From c80b8140b8e7960ce3417f3f3b8c8577c4148af1 Mon Sep 17 00:00:00 2001 From: Blake Blackshear Date: Wed, 31 Jan 2024 06:23:54 -0600 Subject: [PATCH 001/751] increment version --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2cd831670..0cfd9b282 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ default_target: local COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) -VERSION = 0.13.0 +VERSION = 0.14.0 IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) CURRENT_UID := $(shell id -u) From cfda531f5a1779f7836ef87234f9c9b4e3686e46 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 3 Dec 2023 07:16:01 -0700 Subject: [PATCH 002/751] Write a low resolution low fps stream from decoded frames (#8673) * Generate low res low fps previews for recordings viewer * Make sure previews end on the hour * Fix durations and decrase keyframe interval to ensure smooth scrubbing * Ensure minimized resolution is compatible with yuv * Add ability to configure preview quality * Fix * Clean up previews more efficiently * Use iterator --- docs/docs/configuration/reference.md | 5 + frigate/app.py | 14 +- frigate/comms/dispatcher.py | 6 +- frigate/config.py | 17 ++ frigate/const.py | 1 + frigate/ffmpeg_presets.py | 13 ++ frigate/http.py | 63 ++++- frigate/models.py | 9 + frigate/{output.py => output/birdseye.py} | 213 ++++------------- frigate/output/camera.py | 165 ++++++++++++++ frigate/output/output.py | 155 +++++++++++++ frigate/output/preview.py | 265 ++++++++++++++++++++++ frigate/record/cleanup.py | 222 +++++++++++------- frigate/test/test_birdseye.py | 2 +- frigate/util/image.py | 2 +- migrations/021_create_previews_table.py | 35 +++ 16 files changed, 939 insertions(+), 248 deletions(-) rename frigate/{output.py => output/birdseye.py} (81%) create mode 100644 frigate/output/camera.py create mode 100644 frigate/output/output.py create mode 100644 frigate/output/preview.py create mode 100644 migrations/021_create_previews_table.py diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index d500060a7..cb10b7bcb 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -325,6 +325,11 @@ record: # The -r (framerate) dictates how smooth the output video is. # So the args would be -vf setpts=0.02*PTS -r 30 in that case. timelapse_args: "-vf setpts=0.04*PTS -r 30" + # Optional: Recording Preview Settings + preview: + # Optional: Quality of recording preview (default: shown below). + # Options are: very_low, low, medium, high, very_high + quality: medium # Optional: Event recording settings events: # Optional: Number of seconds before the event to include (default: shown below) diff --git a/frigate/app.py b/frigate/app.py index 4a3cf48d6..5aa738d93 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -37,10 +37,17 @@ from frigate.events.external import ExternalEventProcessor from frigate.events.maintainer import EventProcessor from frigate.http import create_app from frigate.log import log_process, root_configurer -from frigate.models import Event, Recordings, RecordingsToDelete, Regions, Timeline +from frigate.models import ( + Event, + Previews, + Recordings, + RecordingsToDelete, + Regions, + Timeline, +) from frigate.object_detection import ObjectDetectProcess from frigate.object_processing import TrackedObjectProcessor -from frigate.output import output_frames +from frigate.output.output import output_frames from frigate.plus import PlusApi from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.ptz.onvif import OnvifController @@ -369,7 +376,7 @@ class FrigateApp: 60, 10 * len([c for c in self.config.cameras.values() if c.enabled]) ), ) - models = [Event, Recordings, RecordingsToDelete, Regions, Timeline] + models = [Event, Recordings, RecordingsToDelete, Previews, Regions, Timeline] self.db.bind(models) def init_stats(self) -> None: @@ -488,6 +495,7 @@ class FrigateApp: args=( self.config, self.video_output_queue, + self.inter_process_queue, self.camera_metrics, ), ) diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 010154bef..d83371c01 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -5,8 +5,8 @@ from abc import ABC, abstractmethod from typing import Any, Callable from frigate.config import BirdseyeModeEnum, FrigateConfig -from frigate.const import INSERT_MANY_RECORDINGS, REQUEST_REGION_GRID -from frigate.models import Recordings +from frigate.const import INSERT_MANY_RECORDINGS, INSERT_PREVIEW, REQUEST_REGION_GRID +from frigate.models import Previews, Recordings from frigate.ptz.onvif import OnvifCommandEnum, OnvifController from frigate.types import CameraMetricsTypes, FeatureMetricsTypes, PTZMetricsTypes from frigate.util.object import get_camera_regions_grid @@ -102,6 +102,8 @@ class Dispatcher: max(self.config.model.width, self.config.model.height), ) ) + elif topic == INSERT_PREVIEW: + Previews.insert(payload).execute() else: self.publish(topic, payload, retain=False) diff --git a/frigate/config.py b/frigate/config.py index 6760ea5e6..59ca519fa 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -260,6 +260,20 @@ class RecordExportConfig(FrigateBaseModel): ) +class RecordQualityEnum(str, Enum): + very_low = "very_low" + low = "low" + medium = "medium" + high = "high" + very_high = "very_high" + + +class RecordPreviewConfig(FrigateBaseModel): + quality: RecordQualityEnum = Field( + default=RecordQualityEnum.medium, title="Quality of recording preview." + ) + + class RecordConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable record on all cameras.") sync_recordings: bool = Field( @@ -278,6 +292,9 @@ class RecordConfig(FrigateBaseModel): export: RecordExportConfig = Field( default_factory=RecordExportConfig, title="Recording Export Config" ) + preview: RecordPreviewConfig = Field( + default_factory=RecordPreviewConfig, title="Recording Preview Config" + ) enabled_in_config: Optional[bool] = Field( title="Keep track of original state of recording." ) diff --git a/frigate/const.py b/frigate/const.py index ebb680333..28bc95f2e 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -59,6 +59,7 @@ MAX_PLAYLIST_SECONDS = 7200 # support 2 hour segments for a single playlist to # Internal Comms Topics INSERT_MANY_RECORDINGS = "insert_many_recordings" +INSERT_PREVIEW = "insert_preview" REQUEST_REGION_GRID = "request_region_grid" # Autotracking diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index bb8848a0c..fe672b25e 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -42,6 +42,11 @@ class LibvaGpuSelector: return "" +FPS_VFR_PARAM = ( + "-fps_mode vfr" + if int(os.getenv("LIBAVFORMAT_VERSION_MAJOR", "59")) >= 59 + else "-vsync 2" +) TIMEOUT_PARAM = ( "-timeout" if int(os.getenv("LIBAVFORMAT_VERSION_MAJOR", "59")) >= 59 @@ -114,6 +119,11 @@ PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = { "default": "ffmpeg -hide_banner {0} -c:v libx264 -preset:v ultrafast -tune:v zerolatency {1}", } +# encoding of previews is only done on CPU due to comparable encode times and better quality from libx264 +PRESETS_HW_ACCEL_ENCODE_PREVIEW = { + "default": "ffmpeg -hide_banner {0} -c:v libx264 -profile:v baseline -preset:v ultrafast {1}", +} + def parse_preset_hardware_acceleration_decode( arg: Any, @@ -153,6 +163,7 @@ def parse_preset_hardware_acceleration_scale( class EncodeTypeEnum(str, Enum): birdseye = "birdseye" + preview = "preview" timelapse = "timelapse" @@ -162,6 +173,8 @@ def parse_preset_hardware_acceleration_encode( """Return the correct scaling preset or default preset if none is set.""" if type == EncodeTypeEnum.birdseye: arg_map = PRESETS_HW_ACCEL_ENCODE_BIRDSEYE + elif type == EncodeTypeEnum.preview: + arg_map = PRESETS_HW_ACCEL_ENCODE_PREVIEW elif type == EncodeTypeEnum.timelapse: arg_map = PRESETS_HW_ACCEL_ENCODE_TIMELAPSE diff --git a/frigate/http.py b/frigate/http.py index d9bd5c29f..c43feee9f 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -43,7 +43,7 @@ from frigate.const import ( RECORD_DIR, ) from frigate.events.external import ExternalEventProcessor -from frigate.models import Event, Recordings, Regions, Timeline +from frigate.models import Event, Previews, Recordings, Regions, Timeline from frigate.object_processing import TrackedObject from frigate.plus import PlusApi from frigate.ptz.onvif import OnvifController @@ -1845,7 +1845,6 @@ def vod_hour_no_timezone(year_month, day, hour, camera_name): ) -# TODO make this nicer when vod module is removed @bp.route("/vod/////") def vod_hour(year_month, day, hour, camera_name, tz_name): parts = year_month.split("-") @@ -1860,6 +1859,66 @@ def vod_hour(year_month, day, hour, camera_name, tz_name): return vod_ts(camera_name, start_ts, end_ts) +@bp.route("/preview//start//end/") +@bp.route("/preview//start//end/") +def preview_ts(camera_name, start_ts, end_ts): + """Get all mp4 previews relevant for time period.""" + previews = ( + Previews.select( + Previews.path, Previews.duration, Previews.start_time, Previews.end_time + ) + .where( + Previews.start_time.between(start_ts, end_ts) + | Previews.end_time.between(start_ts, end_ts) + | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) + ) + .where(Previews.camera == camera_name) + .order_by(Previews.start_time.asc()) + .iterator() + ) + + clips = [] + + preview: Previews + for preview in previews: + clips.append( + { + "src": preview.path.replace("/media/frigate", ""), + "type": "video/mp4", + "start": preview.start_time, + "end": preview.end_time, + } + ) + + if not clips: + logger.error("No previews found for the requested time range") + return make_response( + jsonify( + { + "success": False, + "message": "No previews found.", + } + ), + 404, + ) + + return make_response(jsonify(clips), 200) + + +@bp.route("/preview/////") +def preview_hour(year_month, day, hour, camera_name, tz_name): + parts = year_month.split("-") + start_date = ( + datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc) + - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() + ) + end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) + start_ts = start_date.timestamp() + end_ts = end_date.timestamp() + + return preview_ts(camera_name, start_ts, end_ts) + + @bp.route("/vod/event/") def vod_event(id): try: diff --git a/frigate/models.py b/frigate/models.py index 65cbfbaac..56d429b19 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -76,6 +76,15 @@ class Recordings(Model): # type: ignore[misc] segment_size = FloatField(default=0) # this should be stored as MB +class Previews(Model): # type: ignore[misc] + id = CharField(null=False, primary_key=True, max_length=30) + camera = CharField(index=True, max_length=20) + path = CharField(unique=True) + start_time = DateTimeField() + end_time = DateTimeField() + duration = FloatField() + + # Used for temporary table in record/cleanup.py class RecordingsToDelete(Model): # type: ignore[misc] id = CharField(null=False, primary_key=False, max_length=30) diff --git a/frigate/output.py b/frigate/output/birdseye.py similarity index 81% rename from frigate/output.py rename to frigate/output/birdseye.py index a70e5a804..94830d695 100644 --- a/frigate/output.py +++ b/frigate/output/birdseye.py @@ -1,3 +1,5 @@ +"""Handle outputting birdseye frames via jsmpeg and go2rtc.""" + import datetime import glob import logging @@ -5,23 +7,13 @@ import math import multiprocessing as mp import os import queue -import signal import subprocess as sp import threading import traceback -from wsgiref.simple_server import make_server import cv2 import numpy as np -from setproctitle import setproctitle -from ws4py.server.wsgirefserver import ( - WebSocketWSGIHandler, - WebSocketWSGIRequestHandler, - WSGIServer, -) -from ws4py.server.wsgiutils import WebSocketWSGIApplication -from frigate.comms.ws import WebSocket from frigate.config import BirdseyeModeEnum, FrigateConfig from frigate.const import BASE_DIR, BIRDSEYE_PIPE from frigate.types import CameraMetricsTypes @@ -672,66 +664,19 @@ class BirdsEyeFrameManager: return False -def output_frames( - config: FrigateConfig, - video_output_queue, - camera_metrics: dict[str, CameraMetricsTypes], -): - threading.current_thread().name = "output" - setproctitle("frigate.output") - - stop_event = mp.Event() - - def receiveSignal(signalNumber, frame): - stop_event.set() - - signal.signal(signal.SIGTERM, receiveSignal) - signal.signal(signal.SIGINT, receiveSignal) - - frame_manager = SharedMemoryFrameManager() - previous_frames = {} - - # start a websocket server on 8082 - WebSocketWSGIHandler.http_version = "1.1" - websocket_server = make_server( - "127.0.0.1", - 8082, - server_class=WSGIServer, - handler_class=WebSocketWSGIRequestHandler, - app=WebSocketWSGIApplication(handler_cls=WebSocket), - ) - websocket_server.initialize_websockets_manager() - websocket_thread = threading.Thread(target=websocket_server.serve_forever) - - inputs: dict[str, queue.Queue] = {} - converters = {} - broadcasters = {} - - for camera, cam_config in config.cameras.items(): - inputs[camera] = queue.Queue(maxsize=cam_config.detect.fps) - width = int( - cam_config.live.height - * (cam_config.frame_shape[1] / cam_config.frame_shape[0]) - ) - converters[camera] = FFMpegConverter( - camera, - inputs[camera], - stop_event, - cam_config.frame_shape[1], - cam_config.frame_shape[0], - width, - cam_config.live.height, - cam_config.live.quality, - ) - broadcasters[camera] = BroadcastThread( - camera, converters[camera], websocket_server, stop_event - ) - - if config.birdseye.enabled: - inputs["birdseye"] = queue.Queue(maxsize=10) - converters["birdseye"] = FFMpegConverter( +class Birdseye: + def __init__( + self, + config: FrigateConfig, + camera_metrics: dict[str, CameraMetricsTypes], + stop_event: mp.Event, + websocket_server, + ) -> None: + self.config = config + self.input = queue.Queue(maxsize=10) + self.converter = FFMpegConverter( "birdseye", - inputs["birdseye"], + self.input, stop_event, config.birdseye.width, config.birdseye.height, @@ -740,107 +685,49 @@ def output_frames( config.birdseye.quality, config.birdseye.restream, ) - broadcasters["birdseye"] = BroadcastThread( - "birdseye", - converters["birdseye"], - websocket_server, - stop_event, + self.broadcaster = BroadcastThread( + "birdseye", self.converter, websocket_server, stop_event + ) + frame_manager = SharedMemoryFrameManager() + self.birdseye_manager = BirdsEyeFrameManager( + config, frame_manager, stop_event, camera_metrics ) - websocket_thread.start() + if config.birdseye.restream: + self.birdseye_buffer = frame_manager.create( + "birdseye", + self.birdseye_manager.yuv_shape[0] * self.birdseye_manager.yuv_shape[1], + ) - for t in converters.values(): - t.start() + self.converter.start() + self.broadcaster.start() - for t in broadcasters.values(): - t.start() - - birdseye_manager = BirdsEyeFrameManager( - config, frame_manager, stop_event, camera_metrics - ) - - if config.birdseye.restream: - birdseye_buffer = frame_manager.create( - "birdseye", - birdseye_manager.yuv_shape[0] * birdseye_manager.yuv_shape[1], - ) - - while not stop_event.is_set(): - try: - ( - camera, - frame_time, - current_tracked_objects, - motion_boxes, - regions, - ) = video_output_queue.get(True, 1) - except queue.Empty: - continue - - frame_id = f"{camera}{frame_time}" - - frame = frame_manager.get(frame_id, config.cameras[camera].frame_shape_yuv) - - # send camera frame to ffmpeg process if websockets are connected - if any( - ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager + def write_data( + self, + camera: str, + current_tracked_objects: list[dict[str, any]], + motion_boxes: list[list[int]], + frame_time: float, + frame, + ) -> None: + if self.birdseye_manager.update( + camera, + len([o for o in current_tracked_objects if not o["stationary"]]), + len(motion_boxes), + frame_time, + frame, ): - # write to the converter for the camera if clients are listening to the specific camera + frame_bytes = self.birdseye_manager.frame.tobytes() + + if self.config.birdseye.restream: + self.birdseye_buffer[:] = frame_bytes + try: - inputs[camera].put_nowait(frame.tobytes()) + self.input.put_nowait(frame_bytes) except queue.Full: # drop frames if queue is full pass - if config.birdseye.enabled and ( - config.birdseye.restream - or any( - ws.environ["PATH_INFO"].endswith("birdseye") - for ws in websocket_server.manager - ) - ): - if birdseye_manager.update( - camera, - len([o for o in current_tracked_objects if not o["stationary"]]), - len(motion_boxes), - frame_time, - frame, - ): - frame_bytes = birdseye_manager.frame.tobytes() - - if config.birdseye.restream: - birdseye_buffer[:] = frame_bytes - - try: - inputs["birdseye"].put_nowait(frame_bytes) - except queue.Full: - # drop frames if queue is full - pass - - if camera in previous_frames: - frame_manager.delete(f"{camera}{previous_frames[camera]}") - - previous_frames[camera] = frame_time - - while not video_output_queue.empty(): - ( - camera, - frame_time, - current_tracked_objects, - motion_boxes, - regions, - ) = video_output_queue.get(True, 10) - - frame_id = f"{camera}{frame_time}" - frame = frame_manager.get(frame_id, config.cameras[camera].frame_shape_yuv) - frame_manager.delete(frame_id) - - for b in broadcasters.values(): - b.join() - - websocket_server.manager.close_all() - websocket_server.manager.stop() - websocket_server.manager.join() - websocket_server.shutdown() - websocket_thread.join() - logger.info("exiting output process...") + def stop(self) -> None: + self.converter.join() + self.broadcaster.join() diff --git a/frigate/output/camera.py b/frigate/output/camera.py new file mode 100644 index 000000000..b63e0983a --- /dev/null +++ b/frigate/output/camera.py @@ -0,0 +1,165 @@ +"""Handle outputting individual cameras via jsmpeg.""" + +import logging +import multiprocessing as mp +import queue +import subprocess as sp +import threading + +from frigate.config import CameraConfig + +logger = logging.getLogger(__name__) + + +class FFMpegConverter(threading.Thread): + def __init__( + self, + camera: str, + input_queue: queue.Queue, + stop_event: mp.Event, + in_width: int, + in_height: int, + out_width: int, + out_height: int, + quality: int, + ): + threading.Thread.__init__(self) + self.name = f"{camera}_output_converter" + self.camera = camera + self.input_queue = input_queue + self.stop_event = stop_event + + ffmpeg_cmd = [ + "ffmpeg", + "-f", + "rawvideo", + "-pix_fmt", + "yuv420p", + "-video_size", + f"{in_width}x{in_height}", + "-i", + "pipe:", + "-f", + "mpegts", + "-s", + f"{out_width}x{out_height}", + "-codec:v", + "mpeg1video", + "-q", + f"{quality}", + "-bf", + "0", + "pipe:", + ] + + self.process = sp.Popen( + ffmpeg_cmd, + stdout=sp.PIPE, + stderr=sp.DEVNULL, + stdin=sp.PIPE, + start_new_session=True, + ) + + def __write(self, b) -> None: + self.process.stdin.write(b) + + def read(self, length): + try: + return self.process.stdout.read1(length) + except ValueError: + return False + + def exit(self): + self.process.terminate() + + try: + self.process.communicate(timeout=30) + except sp.TimeoutExpired: + self.process.kill() + self.process.communicate() + + def run(self) -> None: + while not self.stop_event.is_set(): + try: + frame = self.input_queue.get(True, timeout=1) + self.__write(frame) + except queue.Empty: + pass + + self.exit() + + +class BroadcastThread(threading.Thread): + def __init__( + self, + camera: str, + converter: FFMpegConverter, + websocket_server, + stop_event: mp.Event, + ): + super(BroadcastThread, self).__init__() + self.camera = camera + self.converter = converter + self.websocket_server = websocket_server + self.stop_event = stop_event + + def run(self): + while not self.stop_event.is_set(): + buf = self.converter.read(65536) + if buf: + manager = self.websocket_server.manager + with manager.lock: + websockets = manager.websockets.copy() + ws_iter = iter(websockets.values()) + + for ws in ws_iter: + if ( + not ws.terminated + and ws.environ["PATH_INFO"] == f"/{self.camera}" + ): + try: + ws.send(buf, binary=True) + except ValueError: + pass + except (BrokenPipeError, ConnectionResetError) as e: + logger.debug(f"Websocket unexpectedly closed {e}") + elif self.converter.process.poll() is not None: + break + + +class JsmpegCamera: + def __init__( + self, config: CameraConfig, stop_event: mp.Event, websocket_server + ) -> None: + self.config = config + self.input = queue.Queue(maxsize=config.detect.fps) + width = int( + config.live.height * (config.frame_shape[1] / config.frame_shape[0]) + ) + self.converter = FFMpegConverter( + config.name, + self.input, + stop_event, + config.frame_shape[1], + config.frame_shape[0], + width, + config.live.height, + config.live.quality, + ) + self.broadcaster = BroadcastThread( + config.name, self.converter, websocket_server, stop_event + ) + + self.converter.start() + self.broadcaster.start() + + def write_frame(self, frame_bytes) -> None: + try: + self.input.put_nowait(frame_bytes) + except queue.Full: + # drop frames if queue is full + pass + + def stop(self) -> None: + self.converter.join() + self.broadcaster.join() diff --git a/frigate/output/output.py b/frigate/output/output.py new file mode 100644 index 000000000..7284dfa0b --- /dev/null +++ b/frigate/output/output.py @@ -0,0 +1,155 @@ +"""Handle outputting raw frigate frames""" + +import logging +import multiprocessing as mp +import queue +import signal +import threading +from typing import Optional +from wsgiref.simple_server import make_server + +from setproctitle import setproctitle +from ws4py.server.wsgirefserver import ( + WebSocketWSGIHandler, + WebSocketWSGIRequestHandler, + WSGIServer, +) +from ws4py.server.wsgiutils import WebSocketWSGIApplication + +from frigate.comms.ws import WebSocket +from frigate.config import FrigateConfig +from frigate.output.birdseye import Birdseye +from frigate.output.camera import JsmpegCamera +from frigate.output.preview import PreviewRecorder +from frigate.types import CameraMetricsTypes +from frigate.util.image import SharedMemoryFrameManager + +logger = logging.getLogger(__name__) + + +def output_frames( + config: FrigateConfig, + video_output_queue: mp.Queue, + inter_process_queue: mp.Queue, + camera_metrics: dict[str, CameraMetricsTypes], +): + threading.current_thread().name = "output" + setproctitle("frigate.output") + + stop_event = mp.Event() + + def receiveSignal(signalNumber, frame): + stop_event.set() + + signal.signal(signal.SIGTERM, receiveSignal) + signal.signal(signal.SIGINT, receiveSignal) + + frame_manager = SharedMemoryFrameManager() + previous_frames = {} + + # start a websocket server on 8082 + WebSocketWSGIHandler.http_version = "1.1" + websocket_server = make_server( + "127.0.0.1", + 8082, + server_class=WSGIServer, + handler_class=WebSocketWSGIRequestHandler, + app=WebSocketWSGIApplication(handler_cls=WebSocket), + ) + websocket_server.initialize_websockets_manager() + websocket_thread = threading.Thread(target=websocket_server.serve_forever) + + jsmpeg_cameras: dict[str, JsmpegCamera] = {} + birdseye: Optional[Birdseye] = None + preview_recorders: dict[str, PreviewRecorder] = {} + + for camera, cam_config in config.cameras.items(): + if not cam_config.enabled: + continue + + jsmpeg_cameras[camera] = JsmpegCamera(cam_config, stop_event, websocket_server) + preview_recorders[camera] = PreviewRecorder(cam_config, inter_process_queue) + + if config.birdseye.enabled: + birdseye = Birdseye(config, camera_metrics, stop_event, websocket_server) + + websocket_thread.start() + + while not stop_event.is_set(): + try: + ( + camera, + frame_time, + current_tracked_objects, + motion_boxes, + regions, + ) = video_output_queue.get(True, 1) + except queue.Empty: + continue + + frame_id = f"{camera}{frame_time}" + + frame = frame_manager.get(frame_id, config.cameras[camera].frame_shape_yuv) + + # send camera frame to ffmpeg process if websockets are connected + if any( + ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager + ): + # write to the converter for the camera if clients are listening to the specific camera + jsmpeg_cameras[camera].write_frame(frame.tobytes()) + + # send output data to birdseye if websocket is connected or restreaming + if config.birdseye.enabled and ( + config.birdseye.restream + or any( + ws.environ["PATH_INFO"].endswith("birdseye") + for ws in websocket_server.manager + ) + ): + birdseye.write_data( + camera, + current_tracked_objects, + motion_boxes, + frame_time, + frame, + ) + + # send frames for low fps recording + preview_recorders[camera].write_data( + current_tracked_objects, motion_boxes, frame_time, frame + ) + + # delete frames after they have been used for output + if camera in previous_frames: + frame_manager.delete(f"{camera}{previous_frames[camera]}") + + previous_frames[camera] = frame_time + + while not video_output_queue.empty(): + ( + camera, + frame_time, + current_tracked_objects, + motion_boxes, + regions, + ) = video_output_queue.get(True, 10) + + frame_id = f"{camera}{frame_time}" + frame = frame_manager.get(frame_id, config.cameras[camera].frame_shape_yuv) + frame_manager.delete(frame_id) + + for jsmpeg in jsmpeg_cameras.values(): + jsmpeg.stop() + + for preview in preview_recorders.values(): + preview.stop() + + if birdseye is not None: + birdseye.stop() + + websocket_server.manager.close_all() + websocket_server.manager.stop() + websocket_server.manager.join() + websocket_server.shutdown() + websocket_thread.join() + logger.info("exiting output process...") diff --git a/frigate/output/preview.py b/frigate/output/preview.py new file mode 100644 index 000000000..3346274d4 --- /dev/null +++ b/frigate/output/preview.py @@ -0,0 +1,265 @@ +"""Handle outputting low res / fps preview segments from decoded frames.""" + +import datetime +import logging +import multiprocessing as mp +import os +import shutil +import subprocess as sp +import threading +from pathlib import Path + +import cv2 +import numpy as np + +from frigate.config import CameraConfig, RecordQualityEnum +from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW +from frigate.ffmpeg_presets import ( + FPS_VFR_PARAM, + EncodeTypeEnum, + parse_preset_hardware_acceleration_encode, +) +from frigate.models import Previews +from frigate.util.image import copy_yuv_to_position, get_yuv_crop + +logger = logging.getLogger(__name__) + +FOLDER_PREVIEW_FRAMES = "preview_frames" +PREVIEW_OUTPUT_FPS = 1 +PREVIEW_SEGMENT_DURATION = 3600 # one hour +# important to have lower keyframe to maintain scrubbing performance +PREVIEW_KEYFRAME_INTERVAL = 60 +PREVIEW_BIT_RATES = { + RecordQualityEnum.very_low: 4096, + RecordQualityEnum.low: 6144, + RecordQualityEnum.medium: 8192, + RecordQualityEnum.high: 12288, + RecordQualityEnum.very_high: 16384, +} + + +def get_cache_image_name(camera: str, frame_time: float) -> str: + """Get the image name in cache.""" + return os.path.join( + CACHE_DIR, + f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.jpg", + ) + + +class FFMpegConverter(threading.Thread): + """Convert a list of jpg frames into a vfr mp4.""" + + def __init__( + self, + config: CameraConfig, + frame_times: list[float], + inter_process_queue: mp.Queue, + ): + threading.Thread.__init__(self) + self.name = f"{config.name}_preview_converter" + self.config = config + self.frame_times = frame_times + self.inter_process_queue = inter_process_queue + self.path = os.path.join( + CLIPS_DIR, + f"previews/{self.config.name}/{self.frame_times[0]}-{self.frame_times[-1]}.mp4", + ) + + # write a PREVIEW at fps and 1 key frame per clip + self.ffmpeg_cmd = parse_preset_hardware_acceleration_encode( + config.ffmpeg.hwaccel_args, + input="-f concat -y -protocol_whitelist pipe,file -safe 0 -i /dev/stdin", + output=f"-g {PREVIEW_KEYFRAME_INTERVAL} -fpsmax {PREVIEW_OUTPUT_FPS} -bf 0 -b:v {PREVIEW_BIT_RATES[self.config.record.preview.quality]} {FPS_VFR_PARAM} -movflags +faststart -pix_fmt yuv420p {self.path}", + type=EncodeTypeEnum.preview, + ) + + def run(self) -> None: + # generate input list + item_count = len(self.frame_times) + playlist = [] + + for t_idx in range(0, item_count): + if t_idx == item_count - 1: + # last frame does not get a duration + playlist.append( + f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" + ) + continue + + playlist.append( + f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" + ) + playlist.append( + f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}" + ) + + p = sp.run( + self.ffmpeg_cmd.split(" "), + input="\n".join(playlist), + encoding="ascii", + capture_output=True, + ) + + start = self.frame_times[0] + end = self.frame_times[-1] + + if p.returncode == 0: + logger.debug("successfully saved preview") + self.inter_process_queue.put_nowait( + ( + INSERT_PREVIEW, + { + Previews.id: f"{self.config.name}_{end}", + Previews.camera: self.config.name, + Previews.path: self.path, + Previews.start_time: start, + Previews.end_time: end, + Previews.duration: end - start, + }, + ) + ) + else: + logger.error(f"Error saving preview for {self.config.name} :: {p.stderr}") + + # unlink files from cache + # don't delete last frame as it will be used as first frame in next segment + for t in self.frame_times[0:-1]: + Path(get_cache_image_name(self.config.name, t)).unlink(missing_ok=True) + + +class PreviewRecorder: + def __init__(self, config: CameraConfig, inter_process_queue: mp.Queue) -> None: + self.config = config + self.inter_process_queue = inter_process_queue + self.start_time = 0 + self.last_output_time = 0 + self.output_frames = [] + self.out_height = 160 + self.out_width = ( + int((config.detect.width / config.detect.height) * self.out_height) // 4 * 4 + ) + + y, u1, u2, v1, v2 = get_yuv_crop( + self.config.frame_shape_yuv, + ( + 0, + 0, + self.config.frame_shape[1], + self.config.frame_shape[0], + ), + ) + self.channel_dims = { + "y": y, + "u1": u1, + "u2": u2, + "v1": v1, + "v2": v2, + } + + # end segment at end of hour + self.segment_end = ( + (datetime.datetime.now() + datetime.timedelta(hours=1)) + .replace(minute=0, second=0, microsecond=0) + .timestamp() + ) + + Path(os.path.join(CACHE_DIR, "preview_frames")).mkdir(exist_ok=True) + Path(os.path.join(CLIPS_DIR, f"previews/{config.name}")).mkdir( + parents=True, exist_ok=True + ) + + def should_write_frame( + self, + current_tracked_objects: list[dict[str, any]], + motion_boxes: list[list[int]], + frame_time: float, + ) -> bool: + """Decide if this frame should be added to PREVIEW.""" + # limit output to 1 fps + if (frame_time - self.last_output_time) < 1 / PREVIEW_OUTPUT_FPS: + return False + + # send frame if a non-stationary object is in a zone + if any( + (len(o["current_zones"]) > 0 and not o["stationary"]) + for o in current_tracked_objects + ): + self.last_output_time = frame_time + return True + + if len(motion_boxes) > 0: + self.last_output_time = frame_time + return True + + return False + + def write_frame_to_cache(self, frame_time: float, frame) -> None: + # resize yuv frame + small_frame = np.zeros((self.out_height * 3 // 2, self.out_width), np.uint8) + copy_yuv_to_position( + small_frame, + (0, 0), + (self.out_height, self.out_width), + frame, + self.channel_dims, + cv2.INTER_AREA, + ) + small_frame = cv2.cvtColor( + small_frame, + cv2.COLOR_YUV2BGR_I420, + ) + _, jpg = cv2.imencode(".jpg", small_frame) + with open( + get_cache_image_name(self.config.name, frame_time), + "wb", + ) as j: + j.write(jpg.tobytes()) + + def write_data( + self, + current_tracked_objects: list[dict[str, any]], + motion_boxes: list[list[int]], + frame_time: float, + frame, + ) -> None: + # always write the first frame + if self.start_time == 0: + self.start_time = frame_time + self.output_frames.append(frame_time) + self.write_frame_to_cache(frame_time, frame) + return + + if self.should_write_frame(current_tracked_objects, motion_boxes, frame_time): + self.output_frames.append(frame_time) + self.write_frame_to_cache(frame_time, frame) + + # check if PREVIEW clip should be generated and cached frames reset + if frame_time >= self.segment_end: + # save last frame to ensure consistent duration + self.output_frames.append(frame_time) + self.write_frame_to_cache(frame_time, frame) + FFMpegConverter( + self.config, + self.output_frames, + self.inter_process_queue, + ).start() + + # reset frame cache + self.segment_end = ( + (datetime.datetime.now() + datetime.timedelta(hours=1)) + .replace(minute=0, second=0, microsecond=0) + .timestamp() + ) + self.start_time = frame_time + self.last_output_time = frame_time + self.output_frames = [] + + # include first frame to ensure consistent duration + self.output_frames.append(frame_time) + self.write_frame_to_cache(frame_time, frame) + + def stop(self) -> None: + try: + shutil.rmtree(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES)) + except FileNotFoundError: + pass diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index c7aa0e167..c2c7d32e7 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -7,9 +7,9 @@ import threading from multiprocessing.synchronize import Event as MpEvent from pathlib import Path -from frigate.config import FrigateConfig, RetainModeEnum +from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum from frigate.const import CACHE_DIR, RECORD_DIR -from frigate.models import Event, Recordings +from frigate.models import Event, Previews, Recordings from frigate.record.util import remove_empty_directories, sync_recordings from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time @@ -33,10 +33,152 @@ class RecordingCleanup(threading.Thread): logger.debug("Deleting tmp clip.") clear_and_unlink(p) + def expire_existing_camera_recordings( + self, expire_date: float, config: CameraConfig, events: Event + ) -> None: + """Delete recordings for existing camera based on retention config.""" + # Get the timestamp for cutoff of retained days + + # Get recordings to check for expiration + recordings: Recordings = ( + Recordings.select( + Recordings.id, + Recordings.start_time, + Recordings.end_time, + Recordings.path, + Recordings.objects, + Recordings.motion, + ) + .where( + Recordings.camera == config.name, + Recordings.end_time < expire_date, + ) + .order_by(Recordings.start_time) + .namedtuples() + .iterator() + ) + + # loop over recordings and see if they overlap with any non-expired events + # TODO: expire segments based on segment stats according to config + event_start = 0 + deleted_recordings = set() + kept_recordings: list[tuple[float, float]] = [] + for recording in recordings: + keep = False + # Now look for a reason to keep this recording segment + for idx in range(event_start, len(events)): + event: Event = events[idx] + + # if the event starts in the future, stop checking events + # and let this recording segment expire + if event.start_time > recording.end_time: + keep = False + break + + # if the event is in progress or ends after the recording starts, keep it + # and stop looking at events + if event.end_time is None or event.end_time >= recording.start_time: + keep = True + break + + # if the event ends before this recording segment starts, skip + # this event and check the next event for an overlap. + # since the events and recordings are sorted, we can skip events + # that end before the previous recording segment started on future segments + if event.end_time < recording.start_time: + event_start = idx + + # Delete recordings outside of the retention window or based on the retention mode + if ( + not keep + or ( + config.record.events.retain.mode == RetainModeEnum.motion + and recording.motion == 0 + ) + or ( + config.record.events.retain.mode == RetainModeEnum.active_objects + and recording.objects == 0 + ) + ): + Path(recording.path).unlink(missing_ok=True) + deleted_recordings.add(recording.id) + else: + kept_recordings.append((recording.start_time, recording.end_time)) + + # expire recordings + logger.debug(f"Expiring {len(deleted_recordings)} recordings") + # delete up to 100,000 at a time + max_deletes = 100000 + deleted_recordings_list = list(deleted_recordings) + for i in range(0, len(deleted_recordings_list), max_deletes): + Recordings.delete().where( + Recordings.id << deleted_recordings_list[i : i + max_deletes] + ).execute() + + previews: Previews = ( + Previews.select( + Previews.id, + Previews.start_time, + Previews.end_time, + Previews.path, + ) + .where( + Previews.camera == config.name, + Previews.end_time < expire_date, + ) + .order_by(Previews.start_time) + .namedtuples() + .iterator() + ) + + # expire previews + recording_start = 0 + deleted_previews = set() + for preview in previews: + keep = False + # look for a reason to keep this preview + for idx in range(recording_start, len(kept_recordings)): + start_time, end_time = kept_recordings[idx] + + # if the recording starts in the future, stop checking recordings + # and let this preview expire + if start_time > preview.end_time: + keep = False + break + + # if the recording ends after the preview starts, keep it + # and stop looking at recordings + if end_time >= preview.start_time: + keep = True + break + + # if the recording ends before this preview starts, skip + # this recording and check the next recording for an overlap. + # since the kept recordings and previews are sorted, we can skip recordings + # that end before the current preview started + if end_time < preview.start_time: + recording_start = idx + + # Delete previews without any relevant recordings + if not keep: + Path(preview.path).unlink(missing_ok=True) + deleted_previews.add(preview.id) + + # expire previews + logger.debug(f"Expiring {len(deleted_previews)} previews") + # delete up to 100,000 at a time + max_deletes = 100000 + deleted_previews_list = list(deleted_previews) + for i in range(0, len(deleted_previews_list), max_deletes): + Previews.delete().where( + Previews.id << deleted_previews_list[i : i + max_deletes] + ).execute() + def expire_recordings(self) -> None: """Delete recordings based on retention config.""" logger.debug("Start expire recordings.") logger.debug("Start deleted cameras.") + # Handle deleted cameras expire_days = self.config.record.retain.days expire_before = ( @@ -73,31 +215,12 @@ class RecordingCleanup(threading.Thread): logger.debug("Start all cameras.") for camera, config in self.config.cameras.items(): logger.debug(f"Start camera: {camera}.") - # Get the timestamp for cutoff of retained days + expire_days = config.record.retain.days expire_date = ( datetime.datetime.now() - datetime.timedelta(days=expire_days) ).timestamp() - # Get recordings to check for expiration - recordings: Recordings = ( - Recordings.select( - Recordings.id, - Recordings.start_time, - Recordings.end_time, - Recordings.path, - Recordings.objects, - Recordings.motion, - ) - .where( - Recordings.camera == camera, - Recordings.end_time < expire_date, - ) - .order_by(Recordings.start_time) - .namedtuples() - .iterator() - ) - # Get all the events to check against events: Event = ( Event.select( @@ -115,60 +238,7 @@ class RecordingCleanup(threading.Thread): .namedtuples() ) - # loop over recordings and see if they overlap with any non-expired events - # TODO: expire segments based on segment stats according to config - event_start = 0 - deleted_recordings = set() - for recording in recordings: - keep = False - # Now look for a reason to keep this recording segment - for idx in range(event_start, len(events)): - event: Event = events[idx] - - # if the event starts in the future, stop checking events - # and let this recording segment expire - if event.start_time > recording.end_time: - keep = False - break - - # if the event is in progress or ends after the recording starts, keep it - # and stop looking at events - if event.end_time is None or event.end_time >= recording.start_time: - keep = True - break - - # if the event ends before this recording segment starts, skip - # this event and check the next event for an overlap. - # since the events and recordings are sorted, we can skip events - # that end before the previous recording segment started on future segments - if event.end_time < recording.start_time: - event_start = idx - - # Delete recordings outside of the retention window or based on the retention mode - if ( - not keep - or ( - config.record.events.retain.mode == RetainModeEnum.motion - and recording.motion == 0 - ) - or ( - config.record.events.retain.mode - == RetainModeEnum.active_objects - and recording.objects == 0 - ) - ): - Path(recording.path).unlink(missing_ok=True) - deleted_recordings.add(recording.id) - - logger.debug(f"Expiring {len(deleted_recordings)} recordings") - # delete up to 100,000 at a time - max_deletes = 100000 - deleted_recordings_list = list(deleted_recordings) - for i in range(0, len(deleted_recordings_list), max_deletes): - Recordings.delete().where( - Recordings.id << deleted_recordings_list[i : i + max_deletes] - ).execute() - + self.expire_existing_camera_recordings(expire_date, config, events) logger.debug(f"End camera: {camera}.") logger.debug("End all cameras.") diff --git a/frigate/test/test_birdseye.py b/frigate/test/test_birdseye.py index 8c24b48ec..33683f5c4 100644 --- a/frigate/test/test_birdseye.py +++ b/frigate/test/test_birdseye.py @@ -2,7 +2,7 @@ import unittest -from frigate.output import get_canvas_shape +from frigate.output.birdseye import get_canvas_shape class TestBirdseye(unittest.TestCase): diff --git a/frigate/util/image.py b/frigate/util/image.py index 55c745eb0..4fc3c2fd8 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -387,6 +387,7 @@ def copy_yuv_to_position( destination_shape, source_frame=None, source_channel_dim=None, + interpolation=cv2.INTER_LINEAR, ): # get the coordinates of the channels for this position in the layout y, u1, u2, v1, v2 = get_yuv_crop( @@ -435,7 +436,6 @@ def copy_yuv_to_position( uv_y_offset = y_y_offset // 4 uv_x_offset = y_x_offset // 2 - interpolation = cv2.INTER_LINEAR # resize/copy y channel destination_frame[ y[1] + y_y_offset : y[1] + y_y_offset + y_resize_height, diff --git a/migrations/021_create_previews_table.py b/migrations/021_create_previews_table.py new file mode 100644 index 000000000..3ad131e0d --- /dev/null +++ b/migrations/021_create_previews_table.py @@ -0,0 +1,35 @@ +"""Peewee migrations -- 021_create_previews_table.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" +import peewee as pw + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.sql( + 'CREATE TABLE IF NOT EXISTS "previews" ("id" VARCHAR(30) NOT NULL PRIMARY KEY, "camera" VARCHAR(20) NOT NULL, "path" VARCHAR(255) NOT NULL, "start_time" DATETIME NOT NULL, "end_time" DATETIME NOT NULL, "duration" REAL NOT NULL)' + ) + + +def rollback(migrator, database, fake=False, **kwargs): + pass From c716e4b1cb953b99a12ab02fe265280545dbc1c5 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 4 Dec 2023 06:05:55 -0700 Subject: [PATCH 003/751] Ensure final frame in preview is not duplicated --- frigate/output/preview.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 3346274d4..784051f47 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -229,10 +229,6 @@ class PreviewRecorder: self.write_frame_to_cache(frame_time, frame) return - if self.should_write_frame(current_tracked_objects, motion_boxes, frame_time): - self.output_frames.append(frame_time) - self.write_frame_to_cache(frame_time, frame) - # check if PREVIEW clip should be generated and cached frames reset if frame_time >= self.segment_end: # save last frame to ensure consistent duration @@ -257,6 +253,9 @@ class PreviewRecorder: # include first frame to ensure consistent duration self.output_frames.append(frame_time) self.write_frame_to_cache(frame_time, frame) + elif self.should_write_frame(current_tracked_objects, motion_boxes, frame_time): + self.output_frames.append(frame_time) + self.write_frame_to_cache(frame_time, frame) def stop(self) -> None: try: From b1cd5f092632bd7c13acbadfdf593a6b6d6ce878 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 5 Dec 2023 06:04:22 -0700 Subject: [PATCH 004/751] Add external sub label as timeline entry (#8855) * Add external sub label to timeline * Include icon * Update timeline.py Co-authored-by: Sergey Krashevich * Formatting --------- Co-authored-by: Sergey Krashevich --- frigate/timeline.py | 8 ++++++++ web/src/components/TimelineSummary.jsx | 27 ++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/frigate/timeline.py b/frigate/timeline.py index cca0b24b7..c058e0437 100644 --- a/frigate/timeline.py +++ b/frigate/timeline.py @@ -7,6 +7,7 @@ from multiprocessing import Queue from multiprocessing.synchronize import Event as MpEvent from frigate.config import FrigateConfig +from frigate.const import ALL_ATTRIBUTE_LABELS from frigate.events.maintainer import EventTypeEnum from frigate.models import Timeline from frigate.util.builtin import to_relative_box @@ -101,6 +102,13 @@ class TimelineProcessor(threading.Thread): event_data["attributes"].keys() )[0] Timeline.insert(timeline_entry).execute() + elif not prev_event_data.get("sub_label") and event_data.get("sub_label"): + sub_label = event_data["sub_label"][0] + + if sub_label not in ALL_ATTRIBUTE_LABELS: + timeline_entry[Timeline.class_type] = "sub_label" + timeline_entry[Timeline.data]["sub_label"] = sub_label + Timeline.insert(timeline_entry).execute() elif event_type == "end": if event_data["has_clip"] or event_data["has_snapshot"]: timeline_entry[Timeline.class_type] = "gone" diff --git a/web/src/components/TimelineSummary.jsx b/web/src/components/TimelineSummary.jsx index 89b8914c3..62e3b4cb6 100644 --- a/web/src/components/TimelineSummary.jsx +++ b/web/src/components/TimelineSummary.jsx @@ -145,6 +145,13 @@ function getTimelineIcon(timelineItem) { default: return ; } + case 'sub_label': + switch (timelineItem.data.label) { + case 'person': + return ; + case 'car': + return ; + } } } @@ -176,8 +183,24 @@ function getTimelineItemDescription(config, timelineItem, event) { time_style: 'medium', time_format: config.ui.time_format, })}`; - case 'attribute': - return `${timelineItem.data.attribute.replaceAll("_", " ")} detected for ${event.label} at ${formatUnixTimestampToDateTime( + case 'attribute': { + let title = ""; + if (timelineItem.data.attribute == 'face' || timelineItem.data.attribute == 'license_plate') { + title = `${timelineItem.data.attribute.replaceAll("_", " ")} detected for ${event.label}`; + } else { + title = `${event.label} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}` + } + return `${title} at ${formatUnixTimestampToDateTime( + timelineItem.timestamp, + { + date_style: 'short', + time_style: 'medium', + time_format: config.ui.time_format, + } + )}`; + } + case 'sub_label': + return `${event.label} recognized as ${timelineItem.data.sub_label} at ${formatUnixTimestampToDateTime( timelineItem.timestamp, { date_style: 'short', From 696434b36da1462844e7baad88f25f07ade3a1f9 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 8 Dec 2023 07:33:22 -0600 Subject: [PATCH 005/751] Initial framework for new UI with React/Typescript (#8885) * Write a low resolution low fps stream from decoded frames (#8673) * Generate low res low fps previews for recordings viewer * Make sure previews end on the hour * Fix durations and decrase keyframe interval to ensure smooth scrubbing * Ensure minimized resolution is compatible with yuv * Add ability to configure preview quality * Fix * Clean up previews more efficiently * Use iterator * Ensure final frame in preview is not duplicated * initial react/ts framework * fix gitignore glob excluding ts files * ignore folders in web-new * SWRConfig changes for swr 2.x * use frigateConfig type in websocket handlers --------- Co-authored-by: Nicolas Mowen --- .gitignore | 4 +- web-new/.eslintrc.cjs | 57 + web-new/.gitignore | 24 + web-new/README.md | 30 + web-new/components.json | 16 + web-new/images/apple-touch-icon.png | Bin 0 -> 4042 bytes web-new/images/favicon-16x16.png | Bin 0 -> 558 bytes web-new/images/favicon-32x32.png | Bin 0 -> 800 bytes web-new/images/favicon.ico | Bin 0 -> 15086 bytes web-new/images/favicon.png | Bin 0 -> 12564 bytes web-new/images/favicon.svg | 46 + web-new/images/marker.png | Bin 0 -> 534 bytes web-new/images/mstile-150x150.png | Bin 0 -> 2711 bytes web-new/index.html | 33 + web-new/package-lock.json | 7979 +++++++++++++++++ web-new/package.json | 86 + web-new/postcss.config.js | 6 + web-new/public/vite.svg | 1 + web-new/src/App.tsx | 53 + web-new/src/api/baseUrl.ts | 7 + web-new/src/api/index.tsx | 48 + web-new/src/api/ws.tsx | 196 + web-new/src/components/Header.tsx | 305 + web-new/src/components/Logo.tsx | 7 + web-new/src/components/Sidebar.tsx | 101 + web-new/src/components/Wrapper.tsx | 11 + .../src/components/ui/activity-indicator.tsx | 12 + web-new/src/components/ui/alert-dialog.tsx | 139 + web-new/src/components/ui/aspect-ratio.tsx | 5 + web-new/src/components/ui/badge.tsx | 36 + web-new/src/components/ui/button.tsx | 56 + web-new/src/components/ui/calendar.tsx | 64 + web-new/src/components/ui/card.tsx | 79 + web-new/src/components/ui/dialog.tsx | 120 + web-new/src/components/ui/dropdown-menu.tsx | 198 + web-new/src/components/ui/form.tsx | 176 + web-new/src/components/ui/heading.tsx | 73 + web-new/src/components/ui/input.tsx | 25 + web-new/src/components/ui/label.tsx | 24 + web-new/src/components/ui/popover.tsx | 29 + web-new/src/components/ui/radio-group.tsx | 42 + web-new/src/components/ui/select.tsx | 158 + web-new/src/components/ui/sheet.tsx | 138 + web-new/src/components/ui/slider.tsx | 26 + web-new/src/components/ui/switch.tsx | 27 + web-new/src/components/ui/tabs.tsx | 53 + web-new/src/components/ui/text.tsx | 76 + web-new/src/context/providers.tsx | 25 + web-new/src/context/theme-provider.tsx | 136 + web-new/src/context/use-persistence.tsx | 39 + web-new/src/index.css | 24 + web-new/src/lib/MsePlayer.js | 649 ++ web-new/src/lib/formatTimeAgo.ts | 25 + web-new/src/lib/utils.ts | 6 + web-new/src/main.tsx | 10 + web-new/src/pages/ConfigEditor.tsx | 11 + web-new/src/pages/Dashboard.tsx | 81 + web-new/src/pages/Export.tsx | 11 + web-new/src/pages/History.tsx | 11 + web-new/src/pages/Live.tsx | 11 + web-new/src/pages/Logs.tsx | 11 + web-new/src/pages/NoMatch.tsx | 12 + web-new/src/pages/Settings.tsx | 44 + web-new/src/pages/Storage.tsx | 11 + web-new/src/pages/System.tsx | 11 + web-new/src/types/frigateConfig.ts | 402 + web-new/src/vite-env.d.ts | 1 + web-new/tailwind.config.js | 76 + web-new/themes/theme-blue.css | 46 + web-new/themes/theme-default.css | 121 + web-new/themes/theme-gold.css | 61 + web-new/themes/theme-green.css | 46 + web-new/themes/theme-nature.css | 61 + web-new/themes/theme-netflix.css | 61 + web-new/themes/theme-nord.css | 61 + web-new/themes/theme-orange.css | 46 + web-new/themes/theme-red.css | 46 + web-new/tsconfig.json | 29 + web-new/tsconfig.node.json | 10 + web-new/vite.config.ts | 61 + 80 files changed, 12821 insertions(+), 1 deletion(-) create mode 100644 web-new/.eslintrc.cjs create mode 100644 web-new/.gitignore create mode 100644 web-new/README.md create mode 100644 web-new/components.json create mode 100644 web-new/images/apple-touch-icon.png create mode 100644 web-new/images/favicon-16x16.png create mode 100644 web-new/images/favicon-32x32.png create mode 100644 web-new/images/favicon.ico create mode 100644 web-new/images/favicon.png create mode 100644 web-new/images/favicon.svg create mode 100644 web-new/images/marker.png create mode 100644 web-new/images/mstile-150x150.png create mode 100644 web-new/index.html create mode 100644 web-new/package-lock.json create mode 100644 web-new/package.json create mode 100644 web-new/postcss.config.js create mode 100644 web-new/public/vite.svg create mode 100644 web-new/src/App.tsx create mode 100644 web-new/src/api/baseUrl.ts create mode 100644 web-new/src/api/index.tsx create mode 100644 web-new/src/api/ws.tsx create mode 100644 web-new/src/components/Header.tsx create mode 100644 web-new/src/components/Logo.tsx create mode 100644 web-new/src/components/Sidebar.tsx create mode 100644 web-new/src/components/Wrapper.tsx create mode 100644 web-new/src/components/ui/activity-indicator.tsx create mode 100644 web-new/src/components/ui/alert-dialog.tsx create mode 100644 web-new/src/components/ui/aspect-ratio.tsx create mode 100644 web-new/src/components/ui/badge.tsx create mode 100644 web-new/src/components/ui/button.tsx create mode 100644 web-new/src/components/ui/calendar.tsx create mode 100644 web-new/src/components/ui/card.tsx create mode 100644 web-new/src/components/ui/dialog.tsx create mode 100644 web-new/src/components/ui/dropdown-menu.tsx create mode 100644 web-new/src/components/ui/form.tsx create mode 100644 web-new/src/components/ui/heading.tsx create mode 100644 web-new/src/components/ui/input.tsx create mode 100644 web-new/src/components/ui/label.tsx create mode 100644 web-new/src/components/ui/popover.tsx create mode 100644 web-new/src/components/ui/radio-group.tsx create mode 100644 web-new/src/components/ui/select.tsx create mode 100644 web-new/src/components/ui/sheet.tsx create mode 100644 web-new/src/components/ui/slider.tsx create mode 100644 web-new/src/components/ui/switch.tsx create mode 100644 web-new/src/components/ui/tabs.tsx create mode 100644 web-new/src/components/ui/text.tsx create mode 100644 web-new/src/context/providers.tsx create mode 100644 web-new/src/context/theme-provider.tsx create mode 100644 web-new/src/context/use-persistence.tsx create mode 100644 web-new/src/index.css create mode 100644 web-new/src/lib/MsePlayer.js create mode 100644 web-new/src/lib/formatTimeAgo.ts create mode 100644 web-new/src/lib/utils.ts create mode 100644 web-new/src/main.tsx create mode 100644 web-new/src/pages/ConfigEditor.tsx create mode 100644 web-new/src/pages/Dashboard.tsx create mode 100644 web-new/src/pages/Export.tsx create mode 100644 web-new/src/pages/History.tsx create mode 100644 web-new/src/pages/Live.tsx create mode 100644 web-new/src/pages/Logs.tsx create mode 100644 web-new/src/pages/NoMatch.tsx create mode 100644 web-new/src/pages/Settings.tsx create mode 100644 web-new/src/pages/Storage.tsx create mode 100644 web-new/src/pages/System.tsx create mode 100644 web-new/src/types/frigateConfig.ts create mode 100644 web-new/src/vite-env.d.ts create mode 100644 web-new/tailwind.config.js create mode 100644 web-new/themes/theme-blue.css create mode 100644 web-new/themes/theme-default.css create mode 100644 web-new/themes/theme-gold.css create mode 100644 web-new/themes/theme-green.css create mode 100644 web-new/themes/theme-nature.css create mode 100644 web-new/themes/theme-netflix.css create mode 100644 web-new/themes/theme-nord.css create mode 100644 web-new/themes/theme-orange.css create mode 100644 web-new/themes/theme-red.css create mode 100644 web-new/tsconfig.json create mode 100644 web-new/tsconfig.node.json create mode 100644 web-new/vite.config.ts diff --git a/.gitignore b/.gitignore index 33ec9ee24..d12b3834f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,13 +8,15 @@ config/* !config/*.example models *.mp4 -*.ts *.db *.csv frigate/version.py web/build web/node_modules web/coverage +web-new/build +web-new/node_modules +web-new/coverage core !/web/**/*.ts .idea/* \ No newline at end of file diff --git a/web-new/.eslintrc.cjs b/web-new/.eslintrc.cjs new file mode 100644 index 000000000..f22e1f8b6 --- /dev/null +++ b/web-new/.eslintrc.cjs @@ -0,0 +1,57 @@ +module.exports = { + root: true, + env: { browser: true, es2021: true, "vitest-globals/env": true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + "plugin:prettier", + ], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: "latest", + sourceType: "module", + }, + settings: { + jest: { + version: 27, + }, + }, + ignorePatterns: ["*.d.ts"], + plugins: ["react-refresh"], + rules: { + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + "comma-dangle": [ + "error", + { + objects: "always-multiline", + arrays: "always-multiline", + imports: "always-multiline", + }, + ], + "no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "no-console": "error", + }, + overrides: [ + { + files: ["**/*.{ts,tsx}"], + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint"], + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier", + ], + }, + ], +}; diff --git a/web-new/.gitignore b/web-new/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/web-new/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web-new/README.md b/web-new/README.md new file mode 100644 index 000000000..0d6babedd --- /dev/null +++ b/web-new/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/web-new/components.json b/web-new/components.json new file mode 100644 index 000000000..053bbcf62 --- /dev/null +++ b/web-new/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "index.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/web-new/images/apple-touch-icon.png b/web-new/images/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a0ca9e8eb8c42a6ae30ecd774f08944847b6e643 GIT binary patch literal 4042 zcmbtXXH*kiw+=m0BSdx+Nhh4gv`$c_)3+Rk?o&r_?AmDE2lo zj*ETl29dc}>b{N<A_ z%>!r=C{;jqjkF;#25M4=wluRuzEEs_XY}`JNAK$!pNF-6v#+Tk26=4S^~7|rd^F4h zuwKp3;72ILoYipt6ZPkunZ`N}A}Rd3Ns1`b5`Ud1=(FroUhkfV2H3^XR*raaJm zmIVSs&h&b{9gme})P^T>0^+@h(UvJI|2F@jiSeo;XpcU}%DAyt)Z8VUjEZ;Os9S_I zmyNFbFjj4S38oo}Ga0#Yl%t^RV$rbz-Q6uP9d1hxroteet?Ug7NdiH+fYEy72?B8m zolh!lgKO(R=>BS}-8b^h`7;}$$3ccOtdpxtlt5Gz?C{si@?HrsYEuwt@6d$a8oO4z z&+%ppb+VTQ*{9ggCgFl{QILA|DU7(r)T_@)ADK^WMOwwV&7zoRY@MSWrDGG|oY=3s zIst$7Uj37R-}>qQ=ibQd&ekEZce!Pi{By{2qOEL1l`i5wZ5QE7F%#$;9SM}f2MguE z(qO_dAS8sf>p~Be%zYUU0?hD}_CI@hmqOh&6R2qA?(VLssp;;HpLK&np>kfo9(s7x zPOdSL65l2zUCbWZUB!3iDTSOKt##&SWPk~7lF*8Z3Td~Q=%Q%8MCLR*1OmYegi97#k^iR_;{mV>F`%k zWo2b=ubRS=M;Io$kA!JxYz*3)eW7%^ztGjCjH&i}$V?y6_r#AkroQ{u=mzI{^G_5x849;EU=(iA#{kO)3IGig# zQEd@$cCr^|VS9}aW&^l$Wo4xmLsBs`9G#rx8L2Y5VQgYz;^DF3j3+d?Oi7?_zJz|W zNLQexrb_=bHalx!V8A}^DjZw5?SB%*!t3wvuLW0|^Qc`j-)-*e9I1YkAw@63X{5w58k|XPRxb27|UL0>47#J8)JaT#T=n)E)w5S-0 zjr5W$=_7AV{n@2qU@a~y!yTm*JN|1cK{(l+e){xj@hd?&KG>O)tLxD4@c8Nu_34MS zTaSU$O5MGye#lpN0eSPITQgktd2vZepHm|}F=yma#aPGwd>p>xa3w`w@%q}GkN1+0 z2yxN$RUACncj>k1{#;YmfVG&*bknU=L`iHPrbc9Xaxx<|_1f=|m{Lk?EMwh>?nZPK zW_xe%u}E4Z6E|a=x{8X5tE=m;>u8ba7>u62zE!)g$Le@h_-!V@FVz-pE>gS{)ovXU z2~0~J6cpq_LGLqb9tR+0q!qGw32TKK!~Y49uTZ%63)Zy(bxIlg$)-TFA`E z#-O5AVlXx_!Oh8e;k{ze&oG8wVQtY=I4d?$qe%9}-<;5ZCv}(q5lzSd1Eyu8ccrCy znWJi&TFDBRYXwbu!XW5YFUsH9HsZh7&%F%E7gb5hV8Zd{r(@nx$=u{*0ZZ|3+>c3O z*+a+uY!ycD{I-^3(64&?Ok{YX4%Uiw%mIWxe_r=?N6XwLlIYa9*@@@m=4L2+>(^ zl-TJJ9Nb}2!HMUg#8qL;I|4I9o_6mUb4D;m9o)Touz7S6$91dKZ|k4@4*>Q6M9Gl7 zQ{V8&?Kd?^j z^xf1DeIf)qhx*m4%X5k!^50>5d+Dwd#n=@0t<8w78X6j0B4&J++I0|)dzt|O0qIv@XlG}qnEz@!ESY}+^M3X(Az&{y>t&+%>bNY7^&3t%)SUQrZ0vPz zZoF%OLN0QTQkI{r=rFVBao?$aD)?ZDyJF0#5zWBB;3_p8&nvyIbZQ3Vl#|mtjUrUV z*p1hW{Fj*NAEntn8Ygf05iIc&yy%+vLg7Yq)mMjY|DeFYKph<&!0SjP5{3Gz%61Ri z;z9!BJVmd*dUkTSQyil8b$iBhBM9 z@!Q+m0F#G@Q%7_|;BfpXr2$=KQZYvH>PdUXr)9r1y!gBYnIpqr{%qB$@$mRytHPv8 zz(ULGBc2SVk3g2y15sT2L5PTmDE`!vZHd1vEDXTEpw`wBpKikwWn-2$<{+t2&n{nVI8JeI;?`2=W7Ox!fUprd!Z^;^HJPx&HospsHGkn*N$T4DOs;+r$0E*Nd#~ z9jW4Rr#lCMiut!2ZW1i>Q&Vjvq1?C7sp;wISy{1Uh`=?9b3%D}IlwcwJC8TFMwIfi zve?(+8_=%){;kNexVSi7Jv}{L-A}{AmK}jDUmck5Y9Wyc-4vc{&;)BSGgH$&pwhRQ zi&ACx_w^Z@nwpxJ05$7hmnP?lYHzRealA8Om6DP&Iy!nueJERc#8v`eC)XeCEycyO z(?ce*{DJ7BkTB(+y)hh-k&)pDkVQw}!BqV-;6&Q+SavNTm?_?}s=Qo0Q!m^wFDVIS zWd*NiB1eZ|WM*)!9|P6k{vH3RiTI%2w5{p}hl*&&|yh z`NXaODG`7qJZ^Ry8ym|UTGo;XgM4$44A}jhnUT>I0v4xsmQlE>Z)j-vYxq7^LP7$K zMq{|(5$i#zjZ-dkbaX&qyQAIPmKGPyTfMYJo0AurMnMY;{jczr1Ubn`(d3i2M(<9J z_N-c-(!DKvdxzRgdeE9I;V2i9-d##HhVRSGTF+uv~ z)ClZ;s@WZ`z$r+rDdQ!VTf@QzlX-|jQ4R3XY06;Zu`*u2zC3m(aPB+-g~(LPo|Efr zF(7RU3sqj8FZm~6`mM9{N^f`Us}%p=LVoSNo$rPM#?(FGf-n-2lVJCWE9nN6O}EEU zpp(;$>(^t*yr@feB_y8D1?|OHaAt5MOVg(lSd z#7nHFotr#XgT@A8kr8KQkK#21orFS_^hjp)_ADc5HRfSn){G2iQ+}moW$IP5>$vN% zmX;O;&8KRLB49P9Qq03kuk-Uq%9fPQjs<@c<>f&Y5MgW8y>YcsT4>|nr?2Q z`Sxzj(+eBX7R*0stYl0o&ARKCfINEp8kDylNwk#!m~F%H0x1z32o%X!^2cfb3Nh!# ztI5#EO)g(ocRZ!y4(EB4c`8X-4<0PtxfeIL*8h+>gJT@yAel8`6%sUTUzbfWEyv)$ z8yOqFPEM8x;O(}u1AUE@hXIrj$!(cIV8N;ifGaobYt8K4Az3_7h$bIwbpjtUAA_)W z>>_ipbcKFiNo;IvlpH!3|Kh1dn~AAJH#()EN-#tG7j1iz5);3C`O-D!rz~?VeDU3d zsi`SI>>aJGBFjdt#eU)$W+E43zne?gP_p+LG1i8{0g7y!+JnhBqCI9ci6=mO7t*t)v9AHECm@@FT2xWB+f^EXjV2BNao zzBQcdJqrk9wz|GhNI911A{DYZfG9}Ob#}f;#-0=sf_UP~spgKUk+tl!R z7*J#(D(I@+&zX0axJ2Fu9r*x=aVUAFoQOTw7wx_qZEGF6sPE;5Wq{z+PUl#?=7qj+ zftK3X*m!4WCo?nixAbUy+cs_6 zwx}1@OGIm!71t*&%v4tDxYktfD;4}xSgC2&i^z;em3MMCpFU%-*+(H?O*bmA@RiPq z0O4tCA`9LhX+5s9^WlZHwx&yM9!owKVn3B>;B!~@_X{Zz&Kbw1s__0zox1kcul9x) z<^DIX8FW`}Ox@VmztB13+%DhpTWonYKQEbUCB(!=Utbq7<0ofGZ1r|yuG2mzyVH~V zYMyaB*-lqtbZv6EoE><;KT!AhQ9i+YU%uEr;oiqMp+WEH@-42WK<}xRxJHzuB$lLF zB^RXvDF!10BST#SLtR6&5JPh-V-qVA6Kw-SD+7ZCYkfpeH00)|WTsW()^PE|mlZ$_ z;Z-3KB|(Yh3I#>^X_+~x3MG{VsS23|CCLm76>}bc;^8O^)6h8OfBKB)(;x zZee9%@5v&}!U`@8CWlj)l{bedoW618#E~;cWR9?(Ztz&(rN{6}T(IPmlj&5T6%3xP KelF{r5}E)3Qptw^ literal 0 HcmV?d00001 diff --git a/web-new/images/favicon-32x32.png b/web-new/images/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..20e64b28adcab86e2835eba014698faa83028527 GIT binary patch literal 800 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKpuOE zr>`sfQ$_(nC7t|Ld#xB47>|0oIEHu}pL_MJcSxeh@sH>C{0Ng;D&(`JBPv8VsQW~d znb+2yxCNnGI&N*r3jgnB9qsl&$tWW@Pbr4QFMr3%DRD>d~Uc3O1ow-Y@Nwm)B&(S3PUnmMl)XVZ4QTXNg4-xX_p zv1{wq?w7vT;;R=3-8DaUcAu2BZLds>|Dy)h<^?%Mk_xXiW_(kPR=Wiw(zFBCP+r$}cr?qx)xrYpb$}{xX+?@ zk)*-G2|NLxM5j!h%qYS1wU8~%^g*wsiRw(-)eVWPOR{WK%@{?)F zK#IZ0z{pV7z);uFEX2^<%Gkup#6;V`(8|DI!CD^?6b-rgDVb@NxHVk-@MQ&1LwHq4 zL`hI$xk5ovep+TuszOO+L8?M#K}j+LL&coOpLjS5!!$Hb`JX=H`80@uS(#fenOj&{ z*n6@Fv#^3ogUR6(X64Nx3a4*eIdSC75t$?GryD#LcrKqNFn8|z!N=dZ=iPJ9_w36UMO9IC zG-_0YuZ!mI6-9?eQB+r#f3At5Z-}eG`}-xmqv%#%7|A;%i6(>bIoq?tmHDrqdIE-& znJx{l_eI3RFf|QgVrZC1ny+D>7-@St$qop32Ufu@JuN0i{!>WP4$EMt80mX5iDUI6 z$b$MSZR}oz=C-_k`~Og=6k{fi7J+pyt6?})icLW1Ul8;+6W*;-#G`Xgfp*KV1}a6r zbLP?_{a^81Df1^gkEo?czvUSOm0;%~(m6l$efs#qTwFr`AH4Ag^*SwP+X3+#N7^#i(#-ISSHu;yM-MdxtbM?c&jQCai~I{qF1B zo2J>uBW?{8YxmhX)V)Vq?J$I`gi_j#Yk@-`t#%p0N}ba}c|7N&`EO|6&rJjOL8Z#E ziTJ0$GI)kaqp52+*cNZYpAhOl_|?SChP|N|q}2u>Yy!-K@1bySYB&RTgJUAi`%>>R z_65%;3!wPSK*@7lM1$&@S$oK7t#-Getj0vssn{!SnPh@EwG4 zZyULWp98x#F?*;x$)(3y$qbG|K#8hmDFGC<#o)3@h;&j#N}LgKULTZJ znde&XoqEE&lYIo7Z+pU=AHM+4oJpH&<-4#7oTHPrv&oPV5Pbu?CY*VH@_P;F)wy}G z4xgkQL}uZhDnppjl&DQ1H_I;Gbqs+d?IAJ?`F}!qP=cAep1fI*zw3LF{ok@S73A;T z-pP=Zxi_%QL;dSlz%h`7S^v(t8=+8xXB5`~_hQ{`&in2s7J~I3bo@#9Vb}wbFm+h( zb6_PnAF9RnxgMM&uYxPV^UdL~Jy=Jk^^E5{?)cpS;_mC*Gg{`$A@ohpCE;wMrcLl0 zICd7mhrsHe``evomgx|;-pt>6m)bR$VIXYYlM;?=y-8?0-wQ2JsACP!o`sVz%W^vu=HJM(YiCksS(_nf zSVTC<{B4W*1^Ii1irdFY8q4h0q5Q3c$H)3gyhJr5dl^Fh`t##`s_az$t>Qiib7IhN zP1#?HmtY*pobN)u!Cw~db7iNp{1?FoV7rI>y(2mWynji;+^tfR*calie4Q@4Z5HQ=Z@P(pbxE7rV&IkWz&P(IqGcf&3 zh-2nwdgq-u=6#dt=Yf5s9CMGm1Wf-DSf+B!dFl*!6~2O7V1M}k5y%Tg=64PU&m2#| vZE!rqS%>WBqro$l>xy9}8ajTSC-H&z3AaFNJ)07IyEG7n_IfI2DG>KBYCnyd literal 0 HcmV?d00001 diff --git a/web-new/images/favicon.png b/web-new/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..60bf469e87135da98b28e2d3ccf37e0c0e9e23db GIT binary patch literal 12564 zcmZvCc|6qL*Z3WSv5rD06RC(W4V6q;lVqzw3q>YdC8S9qVdRUlmkDK0mP*-LEXB0g z2GNH?DL!RFL@8Ug-+52p=Xw75{o&=#z2}^J&bjBDd+s^!E4004;v%a=5JKYSdv@6( zgeSmXfRG@}Bx|saz&~N%Jq~9Pl2OL~aOSq_f50T}tgYEj^x!pV7=G{{H?}rLs5C)z z{+Iwllso3TjQ0oNzJJtDAswh8&zSkiH<$!;t$Qyo@yP!f@#&XG@8uIdORX^o6FD_z ze|g`UrkhjC-Z^z~URArA2Miv*J~3u5^vmALtE}w62 z)y(xUa2t{B`KFr(>^cLktFPFn}-b9eO`fB!Bco6j%3J zO%KA%eO)U)qG@(pt0mk0yvb2@5SCVOCz|bEBMhTBTRsKx!0iKGjm4X}TY&Z()*bds zr9NVh*evze^{|*gN!az^$Hb%-!xa=QgqZ6_%AME#4vycpBEUJsU}+ra#TIES{}!e9 z`b>(D(*|y>vn-%UkB`X~J`#GmLo+}cq5i$(Jxnij@u!oSvO5LU!vGlzAYw3r9 zMgJ0(wy)N{wk3ZYLW=Ewv~wifr8X?bA$HK}y;6r=R_fl?P}&ac{@5Yg%vSN%ALz@M zPOq0-i_lDpfp_m_ibv)yHQ-IJadCa7^*cL3V$Z8Lw-G9`Pg%J6r@+B^Wre-SAK01W zsAn^z;jRBl9S+TxaO(Z8BH)g_i zCgkn30QT;=-AM$SVOuykZv3t7tPSrjEEwf#tf&g&^gY?o(>E%Hko9ns zUc6{yy{bS#kE{klCw2w~Xj(>O*8A}`Jm9rHQ-wRuHD~c5w6Uvh_vbrhSFGz1+TRlQ z3foWpU7a8OTlYSoeBFwGK^-iL8D^*Vhy}_aRLC4=|FkexL`b)coR4p4JT^=N?dwW* zgnjv{F(m~;?sBps#AjG*tmZ@gd;X*nNjvf`>tDe{XyIsIPg2_Fx}9dCrW==Mf>2AL z;)L#;vbv_;mQDI;sc`g^qzQ9BDq-oQ6~Xfg4Inrw5CJ3*Ve?lrYH@4+O5{n(DYa^YQ17 zOGSty=GE27qSvFWBeKW3>0*dI^k;NjA>hVih4`+U)>d%ra#yMe?_%vsJXYAYqOvBltvg6Asa049M$FIo%MuC~F5=_Gxr?Yi?2z zS&h)P{UXoJN!zOaQ&A)acq0?%myFaUY9tkzBPBUAF{26c zlkF9HQS0_R(SnH8zgkDS__#$o!KV>Gl8dbbIE!Hh3UVnertSzi-fdglSyujV^2(E5 z4foK-4%J|Up5`XVM|WMb_9A=mC9l94Rdd3RIcWbj?WH{x)RD!5-{qlnx22|1;pZ3f z`Ge_#Dg;EUHBnMci(XBwB7Uf-ltaw3@gyf_&icaiEu93!d^-}U7r(RQu~*q9ewCf1 z40luH>o~T?zQ9{U{?<;Av!z&Z;>qara=&Am7Qz7r1E%RZ{HVk;*wNzgl)DK{KM9Xg z_hv0z%Sp1|SEKX6UT0T03ya$K^og^EaXZ0v z4+l$MdL(;k-1V%Ms7D(PU9?vW5_;z&pBCdF%tdIiFkxrOc^%nu3*ciEFe%D(E)aiL zl(3Us!+$X)SgTtQL>O|lD;+IgIYEh5nva3QI@K;Q`ytWn=bDQ1QEJ9LmOnOvb-xH# zN{E6`wy)g%CKv=dq)kP1b53~Qp`Lvre@f2hL)ccg6ESum?>AWK^RJZG4Fm8j_We{6 zYQHtn?tg8;+$5O&4504WiWtvsskpdt^tKY!O-jBMhYsc{PHYhgdul!V?8o~2-2l_x z;KV=C@H7_OjlI$D%2_&nxg{rs>ER1HQmeFNz?t*KLj*xa$>}jzw1c z+Y^cYk}$EL=Kk{}7EA1l4ED-jlW4%O1j;MzA0)LVM&S_s>Yr4}#Hm*HiIy;lEnknq4aQZn%z;GW^(mS#a9&gng>(bcMw zOvE7#TSYs*%A>sKTA~{zUcag1-OD>Iiafe^6I}OjFWA6sF4L%Wo00UF>}S8y=k^b> ztRY-7>xNw|)^u5_#qTStZG+ztzG8QJ*JGhkgN6|f!GI#pz~q)Rr47J^)*s!-{XWUo zUfQ69SZ(D1Im@U4j~<>H#N=bSI)OJAmOln`BMjYv2bTDf)eqMSi*qIyt`3boiU9YO z$JH&p_6$&MWgu^xlIQk5Y2_8UAmMUM*zdOF${lcC)@Q>@dzI6$h3r5Hm*K=-gEUnE zgjz>j|95PCc3W8QR^GJr2nM40-!TTFc^upA<~iMSCz2j3?96+RUU&62;4_Zhh`l;7 zH@F_5EqB0{EW&y6_1118E>zn(0;&KLNC&MaGUa|T>GTW%*<*K7M^zVdKI z%>3U#uh%P5PY4i)a-|z9sPYpO0LqQg7tjvb>ngk)8Exi-AE5>p<`MSZF-)`H$&cE_ zrllLD&xpX9YfZH6nlHztp2*_?NpS+MT3?F=EluEW&$fZNhm&kIZ#St9;Kajz=X$%k zhakjq?(J0T_ZPZZI*jB6$*sUWEnnci-U*8%YC#99AlrFH&KJ;j$`n9M6}{MJFx-nr zMp6m|_X*Y~)eCSa`vN!F)r`eq1+GFKXE&iIcG1sO=IpM}MXHrTS5K&*me0AU*D+<` zj#f0z?vF||j$LF$WTt6r(aBl6s3`S%6VBJJZ?_g*0hU@=_ z7iUfV-<9Znr>V*Ncq_=$-9>=18Nj2@ex=ITsWWe=5!eSTsNDizyG)INDF$+EkO=!S z#K-o+c00-+|FQWf| zjU#7S9c37kaJTum$Tr8Mb}gvpqH-Hhzy5n4StdWVIz?7s1(4D^YG)I8VEuoGFPSVGDgf?J0kON zx?5{5v+qR0c>Te1jWP&J~6!ng;(U`YWST}FifGcR4JpZ#Q$N3Est%pO&OQSk_c|nl?Zh#VhIyrNplq_+f7i;IYu1#I zbkguGd#EVcLw-&odEfv5TDCLuF07Qjtr=!mfIQp)@A{m?`{SMna|8Rk9}`dSY-a7? z`N1{)D_^XSJQn5oR^Dd)6Dc3A;ym(U<0o+)F^3=hUD&D{H#wsDUV!NUR>vZ~nOkf13e(U|;aBb)7`ZIqil|3vBIgX>B2mKs0jJ)}b zh|0k>9x>uWp3QrqcnB`JThHh0 zhK!U1o@3-~<;O$J))~``;t6h>yQ3{+&J z=;`K(eBD5d-O18#hf|R6ery~ny7j#vGZ#Y3R&8ow5@dsB9wyo)Iv*3uX}M)9d&HZN z8JuI0wW8<47#HO02S_?o@}0v4{~ZSxUR@AGD=?)GzcvYejO!l)ajpN*)RXW`5ljp7 z`kjT+__V9IjoHBB=aBI2H&&tjJ3(=`q{GRbLk)-~0OG|Mbb898yBN%|TH}oKgSc!y z*i3NRl|j4bjS3@y3u8skNt3_BFlEz8;M@~-SoJ$+_lJr;l)Vw){enry4y#*6qm7sm zPg8hA8Jke^d6}f}Ro^4$mC^pX0c5ZJWNlaTwY5wU(DSv)oP%wAM&~rt`I1N7r#kT8 z%3_pF(E-R9lkdj3(0Jq^1X;d~I;<~>4uH1u{;cgBdK-waw~U63Y*P4PeF^jy7L>o4 z+<%PAKD-xbHL97Odtavplr{sUe4vC0auYCGU((dgQu+43E?Y}K`MNAiftIx|@~JR- zi*0#ZW5c(dah#ud(KsVWf9d8mB9H!Pq5b=?HHk+c^*3PJ_IK&+LJuEP!4pg%SSOov z(7pIM`7hLq<@uA^ThWRCz{>Nc#JQYTS5}l^k5Vwq5w21!;5>gZbuCYb<*H@BCeZ zhwMx|GZM($Q;3r3K8xCyL142{l)rkUR1~#>ZOHUo?j?djMD~gr9jQ_}o+AJkw=imj zE9>wt%0m}fbQ7+i`?DJ#eojH{(tzD$?zPLOsSTH{20Kg2U;Qw#9c?iM@HcgJ#}P#h zKq4&L-hYv^W7fmQqE(~Q^6fHQ-i^}Ew?P`m76bkT#&k|}ka>un{*nzTd~6MvJ+HGC z&$fbr#|Evfh!+=It;7))1Znq@Qdc4D_Rl`(%+I@{I|~gZ5M>?2@T<=RkuN_0r5WVI zNE$}+cH!J1J|jDTR|y6h{U@+dpCHm*1qgLbiG*Y3RXm9bBc2I1pEdl;i?3vs_NF%t zJn3k7yKwhE>v;?D_rWDJ-W2c_e62nvnjO0{N<}AP;r6n-u_}P+_yDBg9!2rF@(h{e z2lAL!o(~}1WNUkT+BMuN&|<@n0~%BZQP~f;EfAf)zN=Fi*gDK-v>7bPC!Xvi+i-)A zx8TQ6c=r;ro=DyMLaCN13n~NTqNcQNmRP%365OefE!pQX5+d?UQ5>8gl@%-~@$&G( zRds_lo1DKkA+`c$-15w_OJAe~RcV6ag!81~;9O>{5 z_K_?C&wjh5@#HVsTM+ZQT3!F4tCkbmXn&h{6WgAnd^~|~{E&}Dh!naz38{#6C2qP_ zV%89^I8__@R0O>(8$=Z`26NoC!Tp}0q6o#<Nh0O`sU-Jf{cYhQh&7pEb@1d+g=cOZbYL!y0*lZTAzYqX@f~WGbB{YsyZk^pU zSd2(bW;oBzO0y9^e%B=D;scBvG;j>>Sn~Nh|WuS8Hbt($XEs1QoBBhoK8i_IRNlAy9%SLb^h3Np>r2$cJbFC$4xOEUfBKf2F_=nM|d zssOACxz0|HZZe-!Lsr1+NmW5gTomMlDxmHeX?$w(B}9SKsLzvQ>q+x7g;fa@g*x;# zZw-pIqB751+&WMzkow`}uR{&#e6dDUX0`h9l3F2OjfL5JmyoX+2iI$zve4QvjVIY< zEf_{EtwX)sp9mZ^K^51U zaE{In7W-8Mmt?Y)^IEdRX4{mOHlwF040K3cLT2euZ-|(tdfsARd0zs}XFoX0B5*D- zQeNB8--p*QrnVN2n$9TCx$eVNKzOcj+%|CX)2F8*8v81H=100?(2q?V-273)ff!xB zaB!#INz4XK+3}er@zs$n{!ZD781=*! zE<=ZUOwG~3Jyd!V(Y>E0q_9f~`FLz|OIyg~FOXlbna(U8ADjs7J71ZxZT3rMqJLlY z%z}HUzOOh3XZeqN?f#+iUtJT=v{CHI>DME5#mz5=HpzZkYJWLWT_2Jf-1l>T=eMM$Y>! zPU^HHE|cmPSgkeF0SiSPIqTdEV;Uk9M8cr>8tSJi+UBYzd7Va-GYOmt z)g6dY#Uo$&>e2DH0SZzA@q4KK4-_Nl9Q{w*7Z)9mi2`=(b>&@4Vu=&tf()CF=BCk{vpFq22g zPZbotIXh3Bs`mdB{+34U_t^_2%4^71BPweCP~cq2(pXvMt8fFy7zX+hpfeDIC=pJd zd_H}2nhA;4KUo6rzS!eQ3ygKhXOM!M8J?dk2|R>j-T)`hG?=@D*pnm z10f`nQT3(`*T3VpqiVV|R%n%riFWVNO10VHS#^{$w_azp$z}r)Upx0N zXB>9_){e%Zk~N_szEToV7eg0C*l!mP^*W>0k|Lo81#KaR+X>7xRm*5jq?-V472RR^ zMfBy&rRd@49>_z7(_wb*xP>u3@7}VC)_4ebB z1+`~KLO3HMPc_X&_|5W2B_4Uw7q2e}cx+qPB82j3A}@4KryP9_xzM}9UTa!DKh&a7 zVb}S_lrt~4KsCZX$pFfA?J{Q1wbi-IymCJw=DX`IKf18YEBUVC#I*f(eztaihpS8| zQNv6Px1lHHk^#IxO%|^%7dP|041XFp))dE?Tw}zV@6m?N%Nr7HDx{ZF2gS!u9M0CnG%`l576*2J# z8f-ZS`oyVmF>g|@GK@s6gy~L?d-f6QZAD%zJk^xQ$_VYNixTTRxnm&hV!#E#BpR{M z0qTX2>I`?3w|sFiAA0@kGezKp&$+MF$rxl$i zx}J>$k2_DEY8kQqBoo#(SR7?^j^xhKs(dCF(52ISHb7V7{7Fmy@abyxn%cd?mEV@k z#L0_#63*k1^NfE-{GD@Fst{Vfm#Fa;pEGdvc}}kw*E{vq_W`1LzDK z`ZXxYUhZ)470Zh(=;I=f;w`9jkznk{SRm5f$YqvxJBszb)j_1|k1~2q)o@^&%bO#a z5W`U`Pl{W z;%ZeKYK3VFMSn^5;n7dCe|{_+TiKo?W-rKJLgV04hFyD>J@iUh`1*=92{Y6JyYj<*(N^2jN0V!;JXs+`>KTBXExxTJZb(w=y-YI*T)^9aQBYikD)%D z9#$Uzd5k;$D`BvB(=8!HS<9to^qRB1uM^%(82xe0529$wf0A&|8R!so_IF5e3URI> zTK)&oB{|P8R3tG!Y?r>o2=W$}D9F?E8!XOpUkaL|sUG(0?hVpiTB7ltw}FKJ2BYL6 z=;`bE$0i|i6B^(3ZYx;4sB^baR&<{ElU+TqRMY(ZY0!nXx^e%(;^IwD$2%1fU5ASs zdXw0i=xpkfm=&dWr5YNFH@l8I3vuvA)g^Lm&0f1mh#;e1oR_^{RQVazUc8?j#I%^^ za3@iTY+Gj?a4mEb?pd%h#+=tJX(-7ye|1^8?r*vI8`R4E+f*!Y$SBE5*sbU<02Gdc zt%L{0sLhKnc0g5?p?m4K`}+$stnGHu+>^a_2(_~e^7*xWIC1qD>N-G;_KNg_f~s(E zy6*ZfgBm(m8Ta`W6YT@UIpCns5q#kWd7rB@fWPIueE$-P;eON@12Ov@uD6Pqrcxw;Y=Mf|bfzW!I9)otkU(!?#J z+{Rc}_Rm7K!bmxN`}&4P6&(^D?T0FI@cRqdtSMJ$I@7K~P-#3O)g7|!> zdNbyAuLwasoS`0|oHg@mRZ?WUiQJ^sPxHAL{w#Be~LhoUkf(sslF5 z=}9C&%RwiY#dl}ZzR#C!;gyIX)}({cK!tv#-&RO^Q?n~FK*)+y!*@qN&3-pOqEs3r zFEjsGIxL6iF7AhafJXMP&T%!8q1|pZb`NUl=L)0`rN!frN@rc9>m5Vh!ssz8(c5F` z;(^Pc-)oobaWU<&f~0$JpiJ{+n0Q-fzRNaLV;vt#?UpT#-Sh3W1Mf$=`!3s{bZk>C zf7v~vaD%~~9}`nReKzl|msfO(q5junPCrMSrlw{6!nS)yU8F+k4~-4vO3WXYtz%XN zZyWdp7Zl13=o`q6aWQ)K$<0tRU^zm&f7LK(Q*yc@*H(RZf#alHX-;0DAMaw6kts2I zN*a%t4(Ye=+j+X_pRrOy>{n#6`Zt#>++>e?Z7p3Mc%B>-bq-w)TVOdgPcbnsGsDSq z%BD2Y227o!6zY(xr;&b*!UPyZ%QqUaMu^l$o&x?zt;b9z+Dyg>@ePa6fLNyx2I z>aXA|2=JDni0ITGP}3B$c|Hv+X1{w|*d1I04Z^qckG0U+H-1EWlz97WQY5kGx7NzU$nrnET}WOlsKm;*|cZiMQ_WC zF5e;PD5wK`+>9#4V4DJXS>ph+^t#lQy`Sr$5A#)(5ZiPGoJV}_Z2uVa{YD_yMn>f< z{FtZg21)3yU1G4EMqPk9+eP>OW3c%XE>ggNE7U zD3y*4n5m?qk7P)8&>*Nox#$gVENg%8;ofRYb5CEE-I}EKUVI8-IA)WI5=37kRKa4D zkezqKKK=Tv@W~mE#&Y-qBB(p@DKSqj9k2|tS>aChWwJIPV1;0Fbh3BKf&m`J*BB*^ z+`&JA^A~ZbLWN7O4FDZP=8{pSf`2RcNY!b65nFMu)-M&8~2b@l{-kYfem}@ay z_CL3#x-Y|b6eq~Z;aNLRfzp{*Q%Mg`r7Vw?-hqWh^3zW?0J2iM?uGJZpU=Xe3I&u>M1Uk(Rh#Xf2J-KHPn-YfU!XbsJ~X<0xy zX=do!^!XeQ_?!S)eJ*3rU!HmfzKc{`8SOGqTScj{jCN=ZAo|*r>v+pU-zK*MkIR$7 zACCgxQk6v{-PaF~R>MBK3@)_I!2sQxsA2T$c5KgpXsZHax*a3KYY#NNwV^(PP^}jA zL<1R!l1X12oK@1Fp=jqm*v>xU9w$sH2Dnvzu#qqYUyiUXQ~`&58on(+ z{o4**4`&>HL+)fZ*?_p;?lNep3Y}2oLUh|MHNxvI0%ESfz8gUyXEPWj;6x<>Fkfvy zel(oN@dzvY#XoCdpNbJUSl~+IW)q0?16n}vfk>-0C(ODDkmy~Sgot5S(CFq(HB6p_ zEp}o$W}o)4C4xO5nGvvLSt#TodMQkaSjNN`V5c;c&t>Pk!4`#S$>HAMYo?gs*a7U^ zrb(yEgFu_Uk%jx z{~uS6PAgEd5G0tRN|Gxl&C7l5SG5D%(ej`MEdRf8cU?_5 z6`E>rd1*Bp|GTGS7(Rkv3dcI}j_9V}rY{>e49}DDb*9=tp}Vt4EPVobS6U{M*N$o0bc-_~9N( z#Rgkyu)`>$a*Tgk13PzH4-h#6Y|OX|yB)dN(siUTVyRw@c z<6|e6V`-!}AT`|NgWR(7ATb(?+$|6489y}Qw?2!mHH3qNUo?e_-}PrGT;Sr(TYI7i z55C1ylfX^-=?#-Torgi}7jszrTz9XMt=4Yl|&}6CT9x60La1V()UxszXt+=9iGof(tU1D=P`7Roc}7o|M7nqv?6az7r95^ z%Zd8mFdB1H<7V48|L(_3fIoRZ@v%e0?|!gXI-li6LWJcT2Xzz+ zh$IOA{W8h+Fo}`y@w&pzo*_58n`lEUu=k_}By$aRVc+0dx!mDq>ltkd{JST{`>TRc zAmeRGCgcNvFCGTGIPTc-PjeU+(6NDW>BHeLb0LhH3Dy4nZ{F1_c?ygRI}6{4M=U&6 z#qSq61%`zoU1Z1}GuN<1n>#fI-Y; zJW39Kfr&!4KTG}ZT*}sz@XyUKSL+Z&?Zy;+@bq_x8`Cfa5#~dx0w|i{`x?-g@Qk10 zojrVE7ybmg2*`dbxBve4L|Hh1>$b#@@e$bELXA)hz6P#`1mT4B2D&UdfUF9g@x$!2 z?ey<#QP7wXwW2p^)DkSWD3ak@`Mv-kDBHRH3!L9{@G5&@-jH2)f(P*9SX)^w2?D3g zK5E5b?+)G_isSZs^4Kfk<2;}24uY*es}Z`&S5N4jt55_B2DXC>?tF21$h%Q!#0RHk zp-};;Cg)maR@D53S5HCpR_uX(56=p@RxFJP z>HVmmFx?flBw>kwQM~`d;R2BRDwXrw$5Z#cG=}c*HG}jUo4i4a{nFfW4@8A-fn39s z9=xkHxO6@D3qMqTL5M-??1=T{prx<>KEyWebu>$>0d{E5lB}GGP`E`~jwPLJevaKc zH-D_@*e5Xto1S=)WOan70FD7cIxT4*Mu))~_JdI9e0)2W6||>X=ieYb6~mtKYTGt^ zU}3rAqICXLZPZ2Wfm)A?OXG=4uupsJgz%C>eHKX%wA!9-@FfMGk^^BnS7&AI8jt{* zT<-JL5}DY2@K0`R?Zd`95SrTyt?M;~5-{#gj#f)JUWR4yplfGDcY?mN&0YI~G2OKn z8DK`wuGC7Ay#eN9^s@fw(N2v0gNm3P#*gmH5v*2%?nH5AwNK-SQZbKU3)l4cnN#3f zTkf|7G{?Z_Txg4TQAbS3>yRrDGusP+oHqvf-MIe9(cka^no);B-B*Yz{f-VYTXq}I zK+D2oe#m5Nh*zC5_MHY;4Wrr4B@LYoz+aDH zci*Emm6`#6I+sViji iy6tguuwy{Ol0?%f$Fs8Qg|KTCnVat2^ + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/web-new/images/marker.png b/web-new/images/marker.png new file mode 100644 index 0000000000000000000000000000000000000000..3591e0aec0a7a6d419c7518607c3b90b9e87a05b GIT binary patch literal 534 zcmeAS@N?(olHy`uVBq!ia0vp^AwXQl!3HG5__zE4Qk(@Ik;M!Q+`=Ht$S`Y;1W=H% zILO_JVcj{Imp~3nx}&cn1H;CC?mvmFK)KbPE{-7;jBoF3Tzi>8#34}H(7~-i@P>kj z-&v0Nf-^bE)CXHc`v)p{~GQIk|*c%g#Tx$Xj>)_2=_9f7bjfv6?%t zU9@cX-RYIrpOuyEjG5PW{PFh0rIpXGKh4>Hf4}UzFB2AQUT%3HzuEHHaiIA1_NQ;= z&9E=qCn>7=j(F+6Y_qxQ8NHE7!Jh$!YE@V`xv_j$5FNl zhdLPwW1o(OPBO+=N+CHS@y@xBdWz2ZK&)-We|>rG#8ReI7F_5KH!1_%_;aG^>@$s7}l1 z*7A4l#X88RRAq)tkcOyxFAlGN{ZfdoYbZ}<_WoO9RVNmmq14}`wJ55EgASgYdDKj` zU#)?>A~u{c<8(+!+@&#=JbFDC zQeZbxQKCI>x1=}aY#%e@j&9$Obwrk=twgGvtF{Z2(Zy$8I8nDF#gq^4@*FI}6>bVq zI$;*O;c072Lc^2yFJ4imF`<7=EJ8&|S)q%5s&t*pUutPGp_ThxODAuODL?)-Fk2kC;AVqP*&7s?*fig9S%g2+Mfg z#^O-^OsYC^M~~#C>-@dQTVqEx&rVKjM}lWiUaQJY>3hLJg{YtQYF=PUCz^%H{%zCT zyg~7!rmt3?X}pT=KA*!sNz!#(OyLW{@}dBXN0(FBryeHj54Fu2Jp5h}oz6vnk7DoQ z`JEtVG(9eZ>k)1|LhQ^9XBy`5JYM17sXiS8y$+P2B-L+HvfUz0vIwEAAMR;_6ce%+ z$(-0(V)5eiw>O;T@`|8@gJ1cG8#-oyXGi^N;8jhFI=iJau{@v10@H)IeziU-2hqVj zw)a;KjkKMC8N;H2!#Jt`K!?eVu16XJve8Y3 z-uqgRubRvQ<|gP0L&C3^nMJOvM|oAAU_&WblcmWn5{3odO=;qMJMy%v{W$!de{44D zEF)*mv`FZSLMg(l@*G>95?R>M{$wJc>wHo(66RCgSNqspDSoR>561G~&CFUoT9N$m z;w2%Wf9o%~zCTRI-FT(L<9S?3QG2-+u_vDlv3vLA<)6rpxUd3uZ+mf$^*bBUBN_#x z?Bq0d3Kk$!fv}l|q&Qdq#A(HsN1lz!(pwX=yY8H|lhdBfYE53Mf1vi!_p5GF)$hcW z`6g+{2!mJdUvY<~1|IP%0PvZkmi8!c9`Y<=9{fH(M z0=c|{j3O;dOP_SeJtSf+F1og_Vst`l$intiEx9=1Zl@izYRt_FEVoia@`l@{DyP?2 zOM>E;YYLdC$U>+C_R*65z7)r1w$5;=F*nB(gYMUr_up+iX6RQe-dmM%+fLbJoTOAX~Ry*u8VIY{b`Jz$E}4M`N(udtnwWxmE4WDA9zQPb3rVhUTa}TG%i0v zOe|t46K1#1=<$U2qhBJ+TgX-SoRl*qRLscAVC7SA)6&C`ckPpX!Ws+|(H!0DG+bsh zV4NSw_93z?$%iCwo5biA)z_sv=wKPbkhXKnn>J!TiqZ?XLKYeml1ZAb!=I-p}Gdzsl zLwG8(KTj!4{><&59`!hi{5bx~^!QYs>sh;b;r)UL+qnbXqMd9~jCH@2YBT+o`~&gl z(S{4Vt?g-+0-Edsa|Fh{XycQ#a5}b}*}!!$eymt7;JwYejDBhqG2 z+A~qH>c=?fiobr>cZxlgDupWE0ZIGD#YW_&b%xHh=4 zWDQA`gpjm7<#D&-#rC+LzWZHTOo`&3@D5bjDN+tO;x0=qm9OA(2vLDH;X| zs|V1!Kg~@=h4u$d{I?GF&^q3&_yPQd^WUt?oVt@duoX55|{YDN6W|o zfJ{72)|Z(&!T(yR5HGkB1N?}A82`vXeggCn`g+<3J#9S$7riqWBnpEU8@&_Fmbjt~eBCJ_ArAhwkI<;dX( zH+A(PN9M2%4-0^lM0NZ{rIe&D=1GI3;1~Y6zaHot?t}FSF_Wz;ZFPVT XauUQZW~U4IBLJHV4i*p1d{h4oI#j() literal 0 HcmV?d00001 diff --git a/web-new/index.html b/web-new/index.html new file mode 100644 index 000000000..f8f8a6d45 --- /dev/null +++ b/web-new/index.html @@ -0,0 +1,33 @@ + + + + + + + Frigate + + + + + + + + +
+ + + diff --git a/web-new/package-lock.json b/web-new/package-lock.json new file mode 100644 index 000000000..a5cf68b36 --- /dev/null +++ b/web-new/package-lock.json @@ -0,0 +1,7979 @@ +{ + "name": "web-new", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web-new", + "version": "0.0.0", + "dependencies": { + "@cycjimmy/jsmpeg-player": "^6.0.5", + "@hookform/resolvers": "^3.3.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "axios": "^1.6.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "copy-to-clipboard": "^3.3.3", + "date-fns": "^2.30.0", + "idb-keyval": "^6.2.1", + "immer": "^10.0.3", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-day-picker": "^8.9.1", + "react-dom": "^18.2.0", + "react-hook-form": "^7.48.2", + "react-icons": "^4.12.0", + "react-router-dom": "^6.20.1", + "react-use-websocket": "^4.5.0", + "recoil": "^0.7.7", + "sort-by": "^1.2.0", + "strftime": "^0.10.2", + "swr": "^2.2.4", + "tailwind-merge": "^2.1.0", + "tailwindcss-animate": "^1.0.7", + "video.js": "^8.6.1", + "videojs-playlist": "^5.1.0", + "vite-plugin-monaco-editor": "^1.1.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.7", + "@testing-library/jest-dom": "^6.1.5", + "@types/node": "^20.10.3", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@types/react-icons": "^3.0.0", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "@vitest/coverage-v8": "^1.0.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.53.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jest": "^27.6.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "eslint-plugin-vitest-globals": "^1.4.0", + "fake-indexeddb": "^5.0.1", + "jest-websocket-mock": "^2.5.0", + "jsdom": "^23.0.1", + "msw": "^2.0.10", + "postcss": "^8.4.32", + "prettier": "^3.1.0", + "tailwindcss": "^3.3.5", + "typescript": "^5.2.2", + "vite": "^5.0.0", + "vitest": "^1.0.0" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", + "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==", + "dev": true + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.5.tgz", + "integrity": "sha512-hOOqoiNXrmGdFbhgCzu6GiURxUgM27Xwd/aPuu8RfHEZPBzL1Z54okAHAQjXfcQNwvrlkAmAp4SlRTZ45vlthQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", + "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.5.tgz", + "integrity": "sha512-ON5kSOJwVO6xXVRTvOI0eOnWe7VdUcIpsovGo9U/Br4Ie4UVFQTboO2cYnDhAGU6Fp+UxSiT+pMft0SMHfuq6w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.0.tgz", + "integrity": "sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==", + "dev": true, + "dependencies": { + "cookie": "^0.5.0" + } + }, + "node_modules/@bundled-es-modules/js-levenshtein": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/js-levenshtein/-/js-levenshtein-2.0.1.tgz", + "integrity": "sha512-DERMS3yfbAljKsQc0U2wcqGKUWpdFjwqWuoMugEJlqBnKO180/n+4SR/J8MRDt1AN48X1ovgoD9KrdVXcaa3Rg==", + "dev": true, + "dependencies": { + "js-levenshtein": "^1.1.6" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@cycjimmy/jsmpeg-player": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.0.5.tgz", + "integrity": "sha512-bVNHQ7VN9ecKT5AI/6RC7zpW/y4ca68a9txeR5Wiin+jKpUn/7buMe+5NPub89A8NNeNnKPQfrD2+c76ch36mA==" + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.8.tgz", + "integrity": "sha512-31E2lxlGM1KEfivQl8Yf5aYU/mflz9g06H6S15ITUFQueMFtFjESRMoDSkvMo8thYvLBax+VKTPlpnx+sPicOA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.8.tgz", + "integrity": "sha512-B8JbS61bEunhfx8kasogFENgQfr/dIp+ggYXwTqdbMAgGDhRa3AaPpQMuQU0rNxDLECj6FhDzk1cF9WHMVwrtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.8.tgz", + "integrity": "sha512-rdqqYfRIn4jWOp+lzQttYMa2Xar3OK9Yt2fhOhzFXqg0rVWEfSclJvZq5fZslnz6ypHvVf3CT7qyf0A5pM682A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.8.tgz", + "integrity": "sha512-RQw9DemMbIq35Bprbboyf8SmOr4UXsRVxJ97LgB55VKKeJOOdvsIPy0nFyF2l8U+h4PtBx/1kRf0BelOYCiQcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.8.tgz", + "integrity": "sha512-3sur80OT9YdeZwIVgERAysAbwncom7b4bCI2XKLjMfPymTud7e/oY4y+ci1XVp5TfQp/bppn7xLw1n/oSQY3/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.8.tgz", + "integrity": "sha512-WAnPJSDattvS/XtPCTj1tPoTxERjcTpH6HsMr6ujTT+X6rylVe8ggxk8pVxzf5U1wh5sPODpawNicF5ta/9Tmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.8.tgz", + "integrity": "sha512-ICvZyOplIjmmhjd6mxi+zxSdpPTKFfyPPQMQTK/w+8eNK6WV01AjIztJALDtwNNfFhfZLux0tZLC+U9nSyA5Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.8.tgz", + "integrity": "sha512-H4vmI5PYqSvosPaTJuEppU9oz1dq2A7Mr2vyg5TF9Ga+3+MGgBdGzcyBP7qK9MrwFQZlvNyJrvz6GuCaj3OukQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.8.tgz", + "integrity": "sha512-z1zMZivxDLHWnyGOctT9JP70h0beY54xDDDJt4VpTX+iwA77IFsE1vCXWmprajJGa+ZYSqkSbRQ4eyLCpCmiCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.8.tgz", + "integrity": "sha512-1a8suQiFJmZz1khm/rDglOc8lavtzEMRo0v6WhPgxkrjcU0LkHj+TwBrALwoz/OtMExvsqbbMI0ChyelKabSvQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.8.tgz", + "integrity": "sha512-fHZWS2JJxnXt1uYJsDv9+b60WCc2RlvVAy1F76qOLtXRO+H4mjt3Tr6MJ5l7Q78X8KgCFudnTuiQRBhULUyBKQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.8.tgz", + "integrity": "sha512-Wy/z0EL5qZYLX66dVnEg9riiwls5IYnziwuju2oUiuxVc+/edvqXa04qNtbrs0Ukatg5HEzqT94Zs7J207dN5Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.8.tgz", + "integrity": "sha512-ETaW6245wK23YIEufhMQ3HSeHO7NgsLx8gygBVldRHKhOlD1oNeNy/P67mIh1zPn2Hr2HLieQrt6tWrVwuqrxg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.8.tgz", + "integrity": "sha512-T2DRQk55SgoleTP+DtPlMrxi/5r9AeFgkhkZ/B0ap99zmxtxdOixOMI570VjdRCs9pE4Wdkz7JYrsPvsl7eESg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.8.tgz", + "integrity": "sha512-NPxbdmmo3Bk7mbNeHmcCd7R7fptJaczPYBaELk6NcXxy7HLNyWwCyDJ/Xx+/YcNH7Im5dHdx9gZ5xIwyliQCbg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.8.tgz", + "integrity": "sha512-lytMAVOM3b1gPypL2TRmZ5rnXl7+6IIk8uB3eLsV1JwcizuolblXRrc5ShPrO9ls/b+RTp+E6gbsuLWHWi2zGg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.8.tgz", + "integrity": "sha512-hvWVo2VsXz/8NVt1UhLzxwAfo5sioj92uo0bCfLibB0xlOmimU/DeAEsQILlBQvkhrGjamP0/el5HU76HAitGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.8.tgz", + "integrity": "sha512-/7Y7u77rdvmGTxR83PgaSvSBJCC2L3Kb1M/+dmSIvRvQPXXCuC97QAwMugBNG0yGcbEGfFBH7ojPzAOxfGNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.8.tgz", + "integrity": "sha512-9Lc4s7Oi98GqFA4HzA/W2JHIYfnXbUYgekUP/Sm4BG9sfLjyv6GKKHKKVs83SMicBF2JwAX6A1PuOLMqpD001w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.8.tgz", + "integrity": "sha512-rq6WzBGjSzihI9deW3fC2Gqiak68+b7qo5/3kmB6Gvbh/NYPA0sJhrnp7wgV4bNwjqM+R2AApXGxMO7ZoGhIJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.8.tgz", + "integrity": "sha512-AIAbverbg5jMvJznYiGhrd3sumfwWs8572mIJL5NQjJa06P8KfCPWZQ0NwZbPQnbQi9OWSZhFVSUWjjIrn4hSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.8.tgz", + "integrity": "sha512-bfZ0cQ1uZs2PqpulNL5j/3w+GDhP36k1K5c38QdQg+Swy51jFZWWeIkteNsufkQxp986wnqRRsb/bHbY1WQ7TA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.55.0.tgz", + "integrity": "sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.1.tgz", + "integrity": "sha512-QgcKYwzcc8vvZ4n/5uklchy8KVdjJwcOeI+HnnTNclJjs2nYsy23DOCf+sSV1kBwD9yDAoVKCkv/gEPzgQU3Pw==", + "dependencies": { + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", + "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "dependencies": { + "@floating-ui/core": "^1.4.2", + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz", + "integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==", + "dependencies": { + "@floating-ui/dom": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + }, + "node_modules/@hookform/resolvers": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz", + "integrity": "sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", + "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", + "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "dev": true + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mswjs/cookies": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mswjs/cookies/-/cookies-1.1.0.tgz", + "integrity": "sha512-0ZcCVQxifZmhwNBoQIrystCb+2sWBY2Zw8lpfJBPCHGCA/HWqehITeCRVIv4VMy8MPlaHo2w2pTHFV2pFfqKPw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.25.13", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.13.tgz", + "integrity": "sha512-xfjR81WwXPHwhDbqJRHlxYmboJuiSaIKpP4I5TJVFl/EmByOU13jOBT9hmEnxcjR3jvFYoqoNKt7MM9uqerj9A==", + "dev": true, + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.2.1", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, + "node_modules/@pkgr/utils": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", + "integrity": "sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "fast-glob": "^3.3.0", + "is-glob": "^4.0.3", + "open": "^9.1.0", + "picocolors": "^1.0.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", + "integrity": "sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.0.5.tgz", + "integrity": "sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", + "integrity": "sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.0.3.tgz", + "integrity": "sha512-fXR5kbMan9oQqMuacfzlGG/SQMcmMlZ4wrvpckv8SgUulD0MMpspxJrxg/Gp/ISV3JfV1AeSWTYK9GvxA4ySwA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz", + "integrity": "sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-menu": "2.0.6", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.2.tgz", + "integrity": "sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.0.6.tgz", + "integrity": "sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", + "integrity": "sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz", + "integrity": "sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-rect": "1.0.1", + "@radix-ui/react-use-size": "1.0.1", + "@radix-ui/rect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz", + "integrity": "sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", + "integrity": "sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", + "integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz", + "integrity": "sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.0.3.tgz", + "integrity": "sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", + "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz", + "integrity": "sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz", + "integrity": "sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/rect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz", + "integrity": "sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", + "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz", + "integrity": "sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@remix-run/router": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.1.tgz", + "integrity": "sha512-so+DHzZKsoOcoXrILB4rqDkMDy7NLMErRdOxvzvOKb507YINKUP4Di+shbTZDhSE/pBZ+vr7XGIpcOO0VLSA+Q==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.6.1.tgz", + "integrity": "sha512-0WQ0ouLejaUCRsL93GD4uft3rOmB8qoQMU05Kb8CmMtMBe7XUDLAltxVZI1q6byNqEtU7N1ZX1Vw5lIpgulLQA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.6.1.tgz", + "integrity": "sha512-1TKm25Rn20vr5aTGGZqo6E4mzPicCUD79k17EgTLAsXc1zysyi4xXKACfUbwyANEPAEIxkzwue6JZ+stYzWUTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.6.1.tgz", + "integrity": "sha512-cEXJQY/ZqMACb+nxzDeX9IPLAg7S94xouJJCNVE5BJM8JUEP4HeTF+ti3cmxWeSJo+5D+o8Tc0UAWUkfENdeyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.6.1.tgz", + "integrity": "sha512-LoSU9Xu56isrkV2jLldcKspJ7sSXmZWkAxg7sW/RfF7GS4F5/v4EiqKSMCFbZtDu2Nc1gxxFdQdKwkKS4rwxNg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.6.1.tgz", + "integrity": "sha512-EfI3hzYAy5vFNDqpXsNxXcgRDcFHUWSx5nnRSCKwXuQlI5J9dD84g2Usw81n3FLBNsGCegKGwwTVsSKK9cooSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.6.1.tgz", + "integrity": "sha512-9lhc4UZstsegbNLhH0Zu6TqvDfmhGzuCWtcTFXY10VjLLUe4Mr0Ye2L3rrtHaDd/J5+tFMEuo5LTCSCMXWfUKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.6.1.tgz", + "integrity": "sha512-FfoOK1yP5ksX3wwZ4Zk1NgyGHZyuRhf99j64I5oEmirV8EFT7+OhUZEnP+x17lcP/QHJNWGsoJwrz4PJ9fBEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.6.1.tgz", + "integrity": "sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.6.1.tgz", + "integrity": "sha512-RkJVNVRM+piYy87HrKmhbexCHg3A6Z6MU0W9GHnJwBQNBeyhCJG9KDce4SAMdicQnpURggSvtbGo9xAWOfSvIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.6.1.tgz", + "integrity": "sha512-v2FVT6xfnnmTe3W9bJXl6r5KwJglMK/iRlkKiIFfO6ysKs0rDgz7Cwwf3tjldxQUrHL9INT/1r4VA0n9L/F1vQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.6.1.tgz", + "integrity": "sha512-YEeOjxRyEjqcWphH9dyLbzgkF8wZSKAKUkldRY6dgNR5oKs2LZazqGB41cWJ4Iqqcy9/zqYgmzBkRoVz3Q9MLw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.6.1.tgz", + "integrity": "sha512-0zfTlFAIhgz8V2G8STq8toAjsYYA6eci1hnXuyOTUFnymrtJwnS6uGKiv3v5UrPZkBlamLvrLV2iiaeqCKzb0A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@swc/core": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.100.tgz", + "integrity": "sha512-7dKgTyxJjlrMwFZYb1auj3Xq0D8ZBe+5oeIgfMlRU05doXZypYJe0LAk0yjj3WdbwYzpF+T1PLxwTWizI0pckw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@swc/counter": "^0.1.1", + "@swc/types": "^0.1.5" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.3.100", + "@swc/core-darwin-x64": "1.3.100", + "@swc/core-linux-arm64-gnu": "1.3.100", + "@swc/core-linux-arm64-musl": "1.3.100", + "@swc/core-linux-x64-gnu": "1.3.100", + "@swc/core-linux-x64-musl": "1.3.100", + "@swc/core-win32-arm64-msvc": "1.3.100", + "@swc/core-win32-ia32-msvc": "1.3.100", + "@swc/core-win32-x64-msvc": "1.3.100" + }, + "peerDependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.100.tgz", + "integrity": "sha512-XVWFsKe6ei+SsDbwmsuRkYck1SXRpO60Hioa4hoLwR8fxbA9eVp6enZtMxzVVMBi8ej5seZ4HZQeAWepbukiBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.100.tgz", + "integrity": "sha512-KF/MXrnH1nakm1wbt4XV8FS7kvqD9TGmVxeJ0U4bbvxXMvzeYUurzg3AJUTXYmXDhH/VXOYJE5N5RkwZZPs5iA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.100.tgz", + "integrity": "sha512-p8hikNnAEJrw5vHCtKiFT4hdlQxk1V7vqPmvUDgL/qe2menQDK/i12tbz7/3BEQ4UqUPnvwpmVn2d19RdEMNxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.100.tgz", + "integrity": "sha512-BWx/0EeY89WC4q3AaIaBSGfQxkYxIlS3mX19dwy2FWJs/O+fMvF9oLk/CyJPOZzbp+1DjGeeoGFuDYpiNO91JA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.100.tgz", + "integrity": "sha512-XUdGu3dxAkjsahLYnm8WijPfKebo+jHgHphDxaW0ovI6sTdmEGFDew7QzKZRlbYL2jRkUuuKuDGvD6lO5frmhA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.100.tgz", + "integrity": "sha512-PhoXKf+f0OaNW/GCuXjJ0/KfK9EJX7z2gko+7nVnEA0p3aaPtbP6cq1Ubbl6CMoPL+Ci3gZ7nYumDqXNc3CtLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.100.tgz", + "integrity": "sha512-PwLADZN6F9cXn4Jw52FeP/MCLVHm8vwouZZSOoOScDtihjY495SSjdPnlosMaRSR4wJQssGwiD/4MbpgQPqbAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.100.tgz", + "integrity": "sha512-0f6nicKSLlDKlyPRl2JEmkpBV4aeDfRQg6n8mPqgL7bliZIcDahG0ej+HxgNjZfS3e0yjDxsNRa6sAqWU2Z60A==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.100.tgz", + "integrity": "sha512-b7J0rPoMkRTa3XyUGt8PwCaIBuYWsL2DqbirrQKRESzgCvif5iNpqaM6kjIjI/5y5q1Ycv564CB51YDpiS8EtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz", + "integrity": "sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==", + "dev": true + }, + "node_modules/@swc/types": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", + "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", + "dev": true + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz", + "integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==", + "dev": true, + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.1.5.tgz", + "integrity": "sha512-3y04JLW+EceVPy2Em3VwNr95dOKqA8DhR0RJHhHKDZNYXcVXnEK7WIrpj4eYU8SVt/qYZ2aRWt/WgQ+grNES8g==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.3.1", + "@babel/runtime": "^7.9.2", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@jest/globals": ">= 28", + "@types/jest": ">= 28", + "jest": ">= 28", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "@jest/globals": { + "optional": true + }, + "@types/jest": { + "optional": true + }, + "jest": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "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/js-levenshtein": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.3.tgz", + "integrity": "sha512-jd+Q+sD20Qfu9e2aEXogiO3vpOC1PYJOUdyN9gvs4Qrvkg4wF43L5OhqrPeokdv8TL0/mXoYfpkcoGZMNN2pkQ==", + "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==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.10.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.3.tgz", + "integrity": "sha512-XJavIpZqiXID5Yxnxv3RUDKTN5b81ddNC3ecsA0SoFXz/QU8OGBwZGMomiq0zw+uuqbL/krztv/DINAQ/EV4gg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", + "devOptional": true + }, + "node_modules/@types/react": { + "version": "18.2.41", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.41.tgz", + "integrity": "sha512-CwOGr/PiLiNBxEBqpJ7fO3kocP/2SSuC9fpH5K7tusrg4xPSRT/193rzolYwQnTN02We/ATXKnb6GqA5w4fRxw==", + "devOptional": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.17", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.17.tgz", + "integrity": "sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==", + "devOptional": 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", + "integrity": "sha512-Vefs6LkLqF61vfV7AiAqls+vpR94q67gunhMueDznG+msAkrYgRxl7gYjNem/kZ+as2l2mNChmF1jRZzzQQtMg==", + "deprecated": "This is a stub types definition. react-icons provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "react-icons": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "devOptional": true + }, + "node_modules/@types/semver": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", + "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", + "dev": true + }, + "node_modules/@types/statuses": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.4.tgz", + "integrity": "sha512-eqNDvZsCNY49OAXB0Firg/Sc2BgoWsntsLUdybGFOhAfCD6QJ2n9HXUIHGqt5qjrxmMv4wS8WLAw43ZkKcJ8Pw==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.1.tgz", + "integrity": "sha512-5bQDGkXaxD46bPvQt08BUz9YSaO4S0fB1LB5JHQuXTfkGPI3+UUeS387C/e9jRie5GqT8u5kFTrMvAjtX4O5kA==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.13.1", + "@typescript-eslint/type-utils": "6.13.1", + "@typescript-eslint/utils": "6.13.1", + "@typescript-eslint/visitor-keys": "6.13.1", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.1.tgz", + "integrity": "sha512-fs2XOhWCzRhqMmQf0eicLa/CWSaYss2feXsy7xBD/pLyWke/jCIVc2s1ikEAtSW7ina1HNhv7kONoEfVNEcdDQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.13.1", + "@typescript-eslint/types": "6.13.1", + "@typescript-eslint/typescript-estree": "6.13.1", + "@typescript-eslint/visitor-keys": "6.13.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz", + "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.13.1", + "@typescript-eslint/visitor-keys": "6.13.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.13.1.tgz", + "integrity": "sha512-A2qPlgpxx2v//3meMqQyB1qqTg1h1dJvzca7TugM3Yc2USDY+fsRBiojAEo92HO7f5hW5mjAUF6qobOPzlBCBQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.13.1", + "@typescript-eslint/utils": "6.13.1", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz", + "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz", + "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.13.1", + "@typescript-eslint/visitor-keys": "6.13.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz", + "integrity": "sha512-ouPn/zVoan92JgAegesTXDB/oUp6BP1v8WpfYcqh649ejNc9Qv+B4FF2Ff626kO1xg0wWwwG48lAJ4JuesgdOw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.13.1", + "@typescript-eslint/types": "6.13.1", + "@typescript-eslint/typescript-estree": "6.13.1", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz", + "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.13.1", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/@videojs/http-streaming": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.7.0.tgz", + "integrity": "sha512-5uLFKBL8CvD56dxxJyuxqB5CY0tdoa4SE9KbXakeiAy6iFBUEPvTr2YGLKEWvQ8Lojs1wl+FQndLdv+GO7t9Fw==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "4.0.0", + "aes-decrypter": "4.0.1", + "global": "^4.4.0", + "m3u8-parser": "^7.1.0", + "mpd-parser": "^1.2.2", + "mux.js": "7.0.1", + "video.js": "^7 || ^8" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "video.js": "^7 || ^8" + } + }, + "node_modules/@videojs/http-streaming/node_modules/m3u8-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.1.0.tgz", + "integrity": "sha512-7N+pk79EH4oLKPEYdgRXgAsKDyA/VCo0qCHlUwacttQA0WqsjZQYmNfywMvjlY9MpEBVZEt0jKFd73Kv15EBYQ==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "global": "^4.4.0" + } + }, + "node_modules/@videojs/http-streaming/node_modules/m3u8-parser/node_modules/@videojs/vhs-utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", + "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0", + "url-toolkit": "^2.2.1" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/@videojs/http-streaming/node_modules/mux.js": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.0.1.tgz", + "integrity": "sha512-Omz79uHqYpMP1V80JlvEdCiOW1hiw4mBvDh9gaZEpxvB+7WYb2soZSzfuSRrK2Kh9Pm6eugQNrIpY/Bnyhk4hw==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "global": "^4.4.0" + }, + "bin": { + "muxjs-transmux": "bin/transmux.js" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/@videojs/vhs-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.0.0.tgz", + "integrity": "sha512-xJp7Yd4jMLwje2vHCUmi8MOUU76nxiwII3z4Eg3Ucb+6rrkFVGosrXlMgGnaLjq724j3wzNElRZ71D/CKrTtxg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0", + "url-toolkit": "^2.2.1" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/@videojs/xhr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.6.0.tgz", + "integrity": "sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "global": "~4.4.0", + "is-function": "^1.0.1" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.5.0.tgz", + "integrity": "sha512-1PrOvAaDpqlCV+Up8RkAh9qaiUjoDUcjtttyhXDKw53XA6Ve16SOp6cCOpRs8Dj8DqUQs6eTW5YkLcLJjrXAig==", + "dev": true, + "dependencies": { + "@swc/core": "^1.3.96" + }, + "peerDependencies": { + "vite": "^4 || ^5" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.0.0.tgz", + "integrity": "sha512-SiBaQkGqjLzC4mfSNtWoAndHgwiwHJ9XeSqAmy7N0L3zownuqzOUcLwp8yvKvVjqOyvB4bZ9Ek16kAaboDD7Cg==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^4.0.1", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.2", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "^1.0.0-0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.0.0.tgz", + "integrity": "sha512-EbqHCSzQhAY8Su/uLsMCDXkC26LyqQO54kAqJy/DubBqwpRre1iMzvDMPWx+YPfNIN3w7/ydKaJWjH6qRoz0fA==", + "dev": true, + "dependencies": { + "@vitest/spy": "1.0.0", + "@vitest/utils": "1.0.0", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.0.0.tgz", + "integrity": "sha512-1CaYs4knCexozpGxNiT89foiIxidOdU220QpU6CKMN0qU05e3K5XNH8f4pW9KyXH37o1Zin1cLHkoLr/k7NyrQ==", + "dev": true, + "dependencies": { + "@vitest/utils": "1.0.0", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.0.0.tgz", + "integrity": "sha512-kAcQJGsaHMBLrY0QC6kMe7S+JgiMielX2qHqgWFxlUir5IVekJGokJcYTzoOp+MRN1Gue3Q6H5fZD4aC0XHloA==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.0.0.tgz", + "integrity": "sha512-k2gZwSi7nkwcYMj1RNgb45jNUDV/opAGlsVvcmYrRXu2QljMSlyAa0Yut+n3S39XoEKp0I4ggVLABj0xVInynw==", + "dev": true, + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.0.0.tgz", + "integrity": "sha512-r9JhgaP2bUYSnKE9w0aNblCIK8SKpDhXfJgE4TzjDNq3G40Abo5WXJBEKYAteq5p+OWedSFUg6GirNOlH7pN7Q==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.6.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/acorn": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.0.tgz", + "integrity": "sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aes-decrypter": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.1.tgz", + "integrity": "sha512-H1nh/P9VZXUf17AA5NQfJML88CFjVBDuGkp5zDHa7oEhYN9TTpNLJknRY1ie0iSKWlDf6JRnJKaZVDSQdPy6Cg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "global": "^4.4.0", + "pkcs7": "^1.0.4" + } + }, + "node_modules/aes-decrypter/node_modules/@videojs/vhs-utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", + "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0", + "url-toolkit": "^2.2.1" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", + "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/autoprefixer": { + "version": "10.4.16", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", + "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.21.10", + "caniuse-lite": "^1.0.30001538", + "fraction.js": "^4.3.6", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", + "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bplist-parser": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", + "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", + "dev": true, + "dependencies": { + "big-integer": "^1.6.44" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bundle-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", + "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", + "dev": true, + "dependencies": { + "run-applescript": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001566", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001566.tgz", + "integrity": "sha512-ggIhCsTxmITBAMmK8yZjEhCO5/47jKXPu6Dha/wuCS4JePVL+3uiDEBuhu2aIoT+bqTOR8L76Ip1ARL9xYsEJA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "4.3.10", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", + "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "dependencies": { + "clsx": "2.0.0" + }, + "funding": { + "url": "https://joebell.co.uk" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "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", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "devOptional": true + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/default-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", + "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", + "dev": true, + "dependencies": { + "bundle-name": "^3.0.0", + "default-browser-id": "^3.0.0", + "execa": "^7.1.1", + "titleize": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", + "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", + "dev": true, + "dependencies": { + "bplist-parser": "^0.2.0", + "untildify": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/execa": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", + "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/default-browser/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "dev": true, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/default-browser/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, + "node_modules/electron-to-chromium": { + "version": "1.4.601", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.601.tgz", + "integrity": "sha512-SpwUMDWe9tQu8JX5QCO1+p/hChAi9AE9UpoC3rcHVc+gdCGlbT3SGb5I1klgb952HRIyvt9wZhSz9bNBYz9swA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.8.tgz", + "integrity": "sha512-l7iffQpT2OrZfH2rXIp7/FkmaeZM0vxbxN9KfiCwGYuZqzMg/JdvX26R31Zxn/Pxvsrg3Y9N6XTcnknqDyyv4w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.19.8", + "@esbuild/android-arm64": "0.19.8", + "@esbuild/android-x64": "0.19.8", + "@esbuild/darwin-arm64": "0.19.8", + "@esbuild/darwin-x64": "0.19.8", + "@esbuild/freebsd-arm64": "0.19.8", + "@esbuild/freebsd-x64": "0.19.8", + "@esbuild/linux-arm": "0.19.8", + "@esbuild/linux-arm64": "0.19.8", + "@esbuild/linux-ia32": "0.19.8", + "@esbuild/linux-loong64": "0.19.8", + "@esbuild/linux-mips64el": "0.19.8", + "@esbuild/linux-ppc64": "0.19.8", + "@esbuild/linux-riscv64": "0.19.8", + "@esbuild/linux-s390x": "0.19.8", + "@esbuild/linux-x64": "0.19.8", + "@esbuild/netbsd-x64": "0.19.8", + "@esbuild/openbsd-x64": "0.19.8", + "@esbuild/sunos-x64": "0.19.8", + "@esbuild/win32-arm64": "0.19.8", + "@esbuild/win32-ia32": "0.19.8", + "@esbuild/win32-x64": "0.19.8" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz", + "integrity": "sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.55.0", + "@humanwhocodes/config-array": "^0.11.13", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "27.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.6.0.tgz", + "integrity": "sha512-MTlusnnDMChbElsszJvrwD1dN3x6nZl//s4JD23BxB6MgR66TZlL064su24xEIS3VACfAoHV1vgyMgPw8nkdng==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "^5.10.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0", + "eslint": "^7.0.0 || ^8.0.0", + "jest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-jest/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-plugin-jest/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz", + "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.5" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", + "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.5.tgz", + "integrity": "sha512-D53FYKJa+fDmZMtriODxvhwrO+IOqrxoEo21gMA0sjHdU6dPVH4OhyFip9ypl8HOF5RV5KdTo+rBQLvnY2cO8w==", + "dev": true, + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-plugin-vitest-globals": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vitest-globals/-/eslint-plugin-vitest-globals-1.4.0.tgz", + "integrity": "sha512-WE+YlK9X9s4vf5EaYRU0Scw7WItDZStm+PapFSYlg2ABNtaQ4zIG7wEqpoUB3SlfM+SgkhgmzR0TeJOO5k3/Nw==", + "dev": true + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/fake-indexeddb": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-5.0.1.tgz", + "integrity": "sha512-vxybH29Owtc6khV/Usy47B1g+eKwyhFiX8nwpCC4td320jvwrKQDH6vNtcJZgUzVxmfsSIlHzLKQzT76JMCO7A==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "node_modules/globals": { + "version": "13.23.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", + "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/graphql": { + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", + "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/hamt_plus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", + "integrity": "sha512-t2JXKaehnMb9paaYA7J0BX8QQAY8lwfQ9Gjf4pg/mk4krt+cmwmU652HOoWonf+7+EQV97ARPMhhVgU1ra2GhA==" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.2.tgz", + "integrity": "sha512-EWGTfnTqAO2L/j5HZgoM/3z82L7necsJ0pO9Tp0X1wil3PDLrkypTBRgVO2ExehEEvUycejZD3FuRaXpZZc3kw==", + "dev": true + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/http-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", + "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/idb-keyval": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", + "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", + "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", + "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/individual": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz", + "integrity": "sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g==" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/inquirer": { + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-websocket-mock": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/jest-websocket-mock/-/jest-websocket-mock-2.5.0.tgz", + "integrity": "sha512-a+UJGfowNIWvtIKIQBHoEWIUqRxxQHFx4CXT+R5KxxKBtEQ5rS3pPOV/5299sHzqbmeCzxxY5qE4+yfXePePig==", + "dev": true, + "dependencies": { + "jest-diff": "^29.2.0", + "mock-socket": "^9.3.0" + } + }, + "node_modules/jiti": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "23.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.0.1.tgz", + "integrity": "sha512-2i27vgvlUsGEBO9+/kJQRbtqtm+191b5zAZrU/UezVmnC2dlDAFLgDYJvAEi94T4kjsRKkezEtLQTgsNEsW2lQ==", + "dev": true, + "dependencies": { + "cssstyle": "^3.0.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.7", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.14.2", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, + "node_modules/keycode": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz", + "integrity": "sha512-ps3I9jAdNtRpJrbBvQjpzyFbss/skHqzS+eu4RxKLaEAtFqkjZaB6TZMSivPbLxf4K7VI4SjR0P5mRCX5+Q25A==" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/local-pkg": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", + "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "dev": true, + "dependencies": { + "mlly": "^1.4.2", + "pkg-types": "^1.0.3" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lucide-react": { + "version": "0.294.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", + "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/m3u8-parser": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-6.2.0.tgz", + "integrity": "sha512-qlC00JTxYOxawcqg+RB8jbyNwL3foY/nCY61kyWP+RCuJE9APLeqB/nSlTjb4Mg0yRmyERgjswpdQxMvkeoDrg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "global": "^4.4.0" + } + }, + "node_modules/m3u8-parser/node_modules/@videojs/vhs-utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", + "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0", + "url-toolkit": "^2.2.1" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/magic-string": { + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/magicast": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.2.tgz", + "integrity": "sha512-Fjwkl6a0syt9TFN0JSYpOybxiMCkYNEeOTnOTNRbjphirLakznZXAqrXgj/7GG3D1dvETONNwrBfinvAbpunDg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", + "source-map-js": "^1.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mlly": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz", + "integrity": "sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==", + "dev": true, + "dependencies": { + "acorn": "^8.10.0", + "pathe": "^1.1.1", + "pkg-types": "^1.0.3", + "ufo": "^1.3.0" + } + }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/monaco-editor": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz", + "integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q==", + "peer": true + }, + "node_modules/mpd-parser": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.2.2.tgz", + "integrity": "sha512-QCfB1koOoZw6E5La1cx+W/Yd0EZlRhHMqMr4TAJez0eRTuPDzPM5FWoiOqjyo37W+ISPLzmfJACSbJFEBjbL4Q==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "@xmldom/xmldom": "^0.8.3", + "global": "^4.4.0" + }, + "bin": { + "mpd-to-m3u8-json": "bin/parse.js" + } + }, + "node_modules/mpd-parser/node_modules/@videojs/vhs-utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", + "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0", + "url-toolkit": "^2.2.1" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/msw": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.0.10.tgz", + "integrity": "sha512-JhKdzIEuMDSU7qak4CJjiSFW2J0R4Wm5AuLhFzimKs68Wx7PTyqjgnw7+7FpQ3kGi0yY49g/qEFmUmMyLmjb4w==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.0", + "@bundled-es-modules/js-levenshtein": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@mswjs/cookies": "^1.1.0", + "@mswjs/interceptors": "^0.25.13", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.4.1", + "@types/js-levenshtein": "^1.1.1", + "@types/statuses": "^2.0.1", + "chalk": "^4.1.2", + "chokidar": "^3.4.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.1", + "inquirer": "^8.2.0", + "is-node-process": "^1.2.0", + "js-levenshtein": "^1.1.6", + "outvariant": "^1.4.0", + "path-to-regexp": "^6.2.0", + "strict-event-emitter": "^0.5.0", + "type-fest": "^2.19.0", + "yargs": "^17.3.1" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.7.x <= 5.2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "node_modules/mux.js": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.0.2.tgz", + "integrity": "sha512-CM6+QuyDbc0qW1OfEjkd2+jVKzTXF+z5VOKH0eZxtZtnrG/ilkW/U7l7IXGtBNLASF9sKZMcK1u669cq50Qq0A==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "global": "^4.4.0" + }, + "bin": { + "muxjs-transmux": "bin/transmux.js" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", + "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nwsapi": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", + "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-path": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.6.0.tgz", + "integrity": "sha512-fxrwsCFi3/p+LeLOAwo/wyRMODZxdGBtUlWRzsEpsUVrisZbEfZ21arxLGfaWfcnqb8oHPNihIb4XPE8CQPN5A==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", + "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", + "dev": true, + "dependencies": { + "default-browser": "^4.0.0", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.0.tgz", + "integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==", + "dev": true + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz", + "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkcs7": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz", + "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==", + "dependencies": { + "@babel/runtime": "^7.5.5" + }, + "bin": { + "pkcs7": "bin/cli.js" + } + }, + "node_modules/pkg-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", + "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", + "dev": true, + "dependencies": { + "jsonc-parser": "^3.2.0", + "mlly": "^1.2.0", + "pathe": "^1.1.0" + } + }, + "node_modules/postcss": { + "version": "8.4.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", + "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", + "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "engines": { + "node": ">=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz", + "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.9.1.tgz", + "integrity": "sha512-W0SPApKIsYq+XCtfGeMYDoU0KbsG3wfkYtlw8l+vZp6KoBXGOlhzBUp4tNx1XiwiOZwhfdGOlj7NGSCKGSlg5Q==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "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==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.48.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.48.2.tgz", + "integrity": "sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/react-icons": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", + "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", + "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", + "dependencies": { + "react-style-singleton": "^2.2.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.1.tgz", + "integrity": "sha512-ccvLrB4QeT5DlaxSFFYi/KR8UMQ4fcD8zBcR71Zp1kaYTC5oJKYAp1cbavzGrogwxca+ubjkd7XjFZKBW8CxPA==", + "dependencies": { + "@remix-run/router": "1.13.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.1.tgz", + "integrity": "sha512-npzfPWcxfQN35psS7rJgi/EW0Gx6EsNjfdJSAk73U/HqMEJZ2k/8puxfwHFgDQhBGmS3+sjnGbMdMSV45axPQw==", + "dependencies": { + "@remix-run/router": "1.13.1", + "react-router": "6.20.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "dependencies": { + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-use-websocket": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.5.0.tgz", + "integrity": "sha512-oxYVLWM3Lv0InCfjW7hG/Hk0hkE0P1SiLd5/I3d5x0W4riAnDUkD4VEu7qNVAqxNjBF3nU7k0jLMOetLXpwfsA==", + "peerDependencies": { + "react": ">= 18.0.0", + "react-dom": ">= 18.0.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recoil": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.7.7.tgz", + "integrity": "sha512-8Og5KPQW9LwC577Vc7Ug2P0vQshkv1y3zG3tSSkWMqkWSwHmE+by06L8JtnGocjW6gcCvfwB3YtrJG6/tWivNQ==", + "dependencies": { + "hamt_plus": "1.0.2" + }, + "peerDependencies": { + "react": ">=16.13.1" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.6.1.tgz", + "integrity": "sha512-jZHaZotEHQaHLgKr8JnQiDT1rmatjgKlMekyksz+yk9jt/8z9quNjnKNRoaM0wd9DC2QKXjmWWuDYtM3jfF8pQ==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.6.1", + "@rollup/rollup-android-arm64": "4.6.1", + "@rollup/rollup-darwin-arm64": "4.6.1", + "@rollup/rollup-darwin-x64": "4.6.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.6.1", + "@rollup/rollup-linux-arm64-gnu": "4.6.1", + "@rollup/rollup-linux-arm64-musl": "4.6.1", + "@rollup/rollup-linux-x64-gnu": "4.6.1", + "@rollup/rollup-linux-x64-musl": "4.6.1", + "@rollup/rollup-win32-arm64-msvc": "4.6.1", + "@rollup/rollup-win32-ia32-msvc": "4.6.1", + "@rollup/rollup-win32-x64-msvc": "4.6.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, + "node_modules/run-applescript": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", + "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", + "dev": true, + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/run-applescript/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/run-applescript/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/run-applescript/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/run-applescript/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/run-applescript/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rust-result": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz", + "integrity": "sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==", + "dependencies": { + "individual": "^2.0.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-json-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz", + "integrity": "sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==", + "dependencies": { + "rust-result": "^1.0.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sort-by": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/sort-by/-/sort-by-1.2.0.tgz", + "integrity": "sha512-aRyW65r3xMnf4nxJRluCg0H/woJpksU1dQxRtXYzau30sNBOmf5HACpDd9MZDhKh7ALQ5FgSOfMPwZEtUmMqcg==", + "dependencies": { + "object-path": "0.6.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.6.0.tgz", + "integrity": "sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==", + "dev": true + }, + "node_modules/strftime": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/strftime/-/strftime-0.10.2.tgz", + "integrity": "sha512-Y6IZaTVM80chcMe7j65Gl/0nmlNdtt+KWPle5YeCAjmsBfw+id2qdaJ5MDrxUq+OmHKab+jHe7mUjU/aNMSZZg==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", + "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "dev": true, + "dependencies": { + "acorn": "^8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", + "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swr": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.4.tgz", + "integrity": "sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==", + "dependencies": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/synckit": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.6.tgz", + "integrity": "sha512-laHF2savN6sMeHCjLRkheIU4wo3Zg9Ln5YOjOo7sZ5dVQW8yF5pPE5SIw1dsPhq3TRp1jisKRCdPhfs/1WMqDA==", + "dev": true, + "dependencies": { + "@pkgr/utils": "^2.4.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/tailwind-merge": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.1.0.tgz", + "integrity": "sha512-l11VvI4nSwW7MtLSLYT4ldidDEUwQAMWuSHk7l4zcXZDgnCRa0V3OdCwFfM7DCzakVXMNRwAeje9maFFXT71dQ==", + "dependencies": { + "@babel/runtime": "^7.23.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz", + "integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.19.1", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/tinybench": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.1.tgz", + "integrity": "sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==", + "dev": true + }, + "node_modules/tinypool": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.1.tgz", + "integrity": "sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz", + "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/titleize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", + "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, + "node_modules/tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "dev": true, + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", + "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", + "dev": true + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/url-toolkit": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz", + "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==" + }, + "node_modules/use-callback-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", + "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "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/video.js": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.6.1.tgz", + "integrity": "sha512-CNYVJ5WWIZ7bOhbkkfcKqLGoc6WsE3Ft2RfS1lXdQTWk8UiSsPW2Ssk2JzPCA8qnIlUG9os/faCFsYWjyu4JcA==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/http-streaming": "3.7.0", + "@videojs/vhs-utils": "^4.0.0", + "@videojs/xhr": "2.6.0", + "aes-decrypter": "^4.0.1", + "global": "4.4.0", + "keycode": "2.2.0", + "m3u8-parser": "^6.0.0", + "mpd-parser": "^1.0.1", + "mux.js": "^7.0.1", + "safe-json-parse": "4.0.0", + "videojs-contrib-quality-levels": "4.0.0", + "videojs-font": "4.1.0", + "videojs-vtt.js": "0.15.5" + } + }, + "node_modules/videojs-contrib-quality-levels": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.0.0.tgz", + "integrity": "sha512-u5rmd8BjLwANp7XwuQ0Q/me34bMe6zg9PQdHfTS7aXgiVRbNTb4djcmfG7aeSrkpZjg+XCLezFNenlJaCjBHKw==", + "dependencies": { + "global": "^4.4.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6" + }, + "peerDependencies": { + "video.js": "^8" + } + }, + "node_modules/videojs-font": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.1.0.tgz", + "integrity": "sha512-X1LuPfLZPisPLrANIAKCknZbZu5obVM/ylfd1CN+SsCmPZQ3UMDPcvLTpPBJxcBuTpHQq2MO1QCFt7p8spnZ/w==" + }, + "node_modules/videojs-playlist": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/videojs-playlist/-/videojs-playlist-5.1.0.tgz", + "integrity": "sha512-p5ohld6Kom9meYCcEVYj0JVS2MBL2XxMiU+IDB/xKpDOspFAHrERHrZEBoiJZc/mCfHixZBNgj1vWRgYsVVsrw==", + "dependencies": { + "global": "^4.3.2", + "video.js": "^6 || ^7 || ^8" + }, + "engines": { + "node": ">=4.4.0" + } + }, + "node_modules/videojs-vtt.js": { + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz", + "integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==", + "dependencies": { + "global": "^4.3.1" + } + }, + "node_modules/vite": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.5.tgz", + "integrity": "sha512-OekeWqR9Ls56f3zd4CaxzbbS11gqYkEiBtnWFFgYR2WV8oPJRRKq0mpskYy/XaoCL3L7VINDhqqOMNDiYdGvGg==", + "dev": true, + "dependencies": { + "esbuild": "^0.19.3", + "postcss": "^8.4.32", + "rollup": "^4.2.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.0.0.tgz", + "integrity": "sha512-9pGEPYsHy+7Ok7d6FkvniCmMI58IJ4KfFSK0Xq2FHWPQoBRpJKubaNBvMcXm0+uAwS6K2Rh9qJOKijdgqrjN+Q==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0-beta.15 || ^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-plugin-monaco-editor": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.1.0.tgz", + "integrity": "sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==", + "peerDependencies": { + "monaco-editor": ">=0.33.0" + } + }, + "node_modules/vitest": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.0.0.tgz", + "integrity": "sha512-jpablj5+ifiFHV3QGOxPews3uxBuu6rQUzTaQYtEd6ocBpdQBil6AvmmGRQ3Rn0WPgyzb+Ni+JekfMyng+qYng==", + "dev": true, + "dependencies": { + "@vitest/expect": "1.0.0", + "@vitest/runner": "1.0.0", + "@vitest/snapshot": "1.0.0", + "@vitest/spy": "1.0.0", + "@vitest/utils": "1.0.0", + "acorn-walk": "^8.3.0", + "cac": "^6.7.14", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^1.3.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.1", + "vite": "^5.0.0-beta.19 || ^5.0.0", + "vite-node": "1.0.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", + "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/web-new/package.json b/web-new/package.json new file mode 100644 index 000000000..63287105a --- /dev/null +++ b/web-new/package.json @@ -0,0 +1,86 @@ +{ + "name": "web-new", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "tsc && vite build --base=/BASE_PATH/", + "lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .", + "preview": "vite preview", + "prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"", + "test": "vitest", + "coverage": "vitest run --coverage" + }, + "dependencies": { + "@cycjimmy/jsmpeg-player": "^6.0.5", + "@hookform/resolvers": "^3.3.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "axios": "^1.6.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "copy-to-clipboard": "^3.3.3", + "date-fns": "^2.30.0", + "idb-keyval": "^6.2.1", + "immer": "^10.0.3", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-day-picker": "^8.9.1", + "react-dom": "^18.2.0", + "react-hook-form": "^7.48.2", + "react-icons": "^4.12.0", + "react-router-dom": "^6.20.1", + "react-use-websocket": "^4.5.0", + "recoil": "^0.7.7", + "sort-by": "^1.2.0", + "strftime": "^0.10.2", + "swr": "^2.2.4", + "tailwind-merge": "^2.1.0", + "tailwindcss-animate": "^1.0.7", + "video.js": "^8.6.1", + "videojs-playlist": "^5.1.0", + "vite-plugin-monaco-editor": "^1.1.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.7", + "@testing-library/jest-dom": "^6.1.5", + "@types/node": "^20.10.3", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@types/react-icons": "^3.0.0", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "@vitest/coverage-v8": "^1.0.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.53.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jest": "^27.6.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "eslint-plugin-vitest-globals": "^1.4.0", + "fake-indexeddb": "^5.0.1", + "jest-websocket-mock": "^2.5.0", + "jsdom": "^23.0.1", + "msw": "^2.0.10", + "postcss": "^8.4.32", + "prettier": "^3.1.0", + "tailwindcss": "^3.3.5", + "typescript": "^5.2.2", + "vite": "^5.0.0", + "vitest": "^1.0.0" + } +} diff --git a/web-new/postcss.config.js b/web-new/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/web-new/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web-new/public/vite.svg b/web-new/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/web-new/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web-new/src/App.tsx b/web-new/src/App.tsx new file mode 100644 index 000000000..ea71ee5d6 --- /dev/null +++ b/web-new/src/App.tsx @@ -0,0 +1,53 @@ +import Providers from "@/context/providers"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { useState } from "react"; +import Wrapper from "@/components/Wrapper"; +import Sidebar from "@/components/Sidebar"; +import Header from "@/components/Header"; +import Dashboard from "@/pages/Dashboard"; +import Live from "@/pages/Live"; +import History from "@/pages/History"; +import Export from "@/pages/Export"; +import Storage from "@/pages/Storage"; +import System from "@/pages/System"; +import ConfigEditor from "@/pages/ConfigEditor"; +import Logs from "@/pages/Logs"; +import NoMatch from "@/pages/NoMatch"; +import Settings from "@/pages/Settings"; + +function App() { + const [sheetOpen, setSheetOpen] = useState(false); + + const toggleNavbar = () => { + setSheetOpen((prev) => !prev); + }; + + return ( + + + +
+
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ + + + ); +} + +export default App; diff --git a/web-new/src/api/baseUrl.ts b/web-new/src/api/baseUrl.ts new file mode 100644 index 000000000..a47355e97 --- /dev/null +++ b/web-new/src/api/baseUrl.ts @@ -0,0 +1,7 @@ +declare global { + interface Window { + baseUrl?: any; + } + } + +export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`; \ No newline at end of file diff --git a/web-new/src/api/index.tsx b/web-new/src/api/index.tsx new file mode 100644 index 000000000..1f59ed71c --- /dev/null +++ b/web-new/src/api/index.tsx @@ -0,0 +1,48 @@ +import { baseUrl } from "./baseUrl"; +import useSWR, { SWRConfig } from "swr"; +import { WsProvider } from "./ws"; +import axios from "axios"; +import { ReactNode } from "react"; +import { FrigateConfig } from "@/types/frigateConfig"; + +axios.defaults.baseURL = `${baseUrl}api/`; + +type ApiProviderType = { + children?: ReactNode; + options?: Record; +}; + +export function ApiProvider({ children, options }: ApiProviderType) { + axios.defaults.headers.common = { + "X-CSRF-TOKEN": 1, + "X-CACHE-BYPASS": 1, + }; + + return ( + { + const [path, params] = Array.isArray(key) ? key : [key, undefined]; + return axios.get(path, { params }).then((res) => res.data); + }, + ...options, + }} + > + {children} + + ); +} + +type WsWithConfigType = { + children: ReactNode; +}; + +function WsWithConfig({ children }: WsWithConfigType) { + const { data } = useSWR("config"); + + return data ? {children} : children; +} + +export function useApiHost() { + return baseUrl; +} diff --git a/web-new/src/api/ws.tsx b/web-new/src/api/ws.tsx new file mode 100644 index 000000000..3f1cd87e6 --- /dev/null +++ b/web-new/src/api/ws.tsx @@ -0,0 +1,196 @@ +import { baseUrl } from "./baseUrl"; +import { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useReducer, +} from "react"; +import { produce, Draft } from "immer"; +import useWebSocket, { ReadyState } from "react-use-websocket"; +import { FrigateConfig } from "@/types/frigateConfig"; + +type ReducerState = { + [topic: string]: { + lastUpdate: number; + payload: string; + retain: boolean; + }; +}; + +type ReducerAction = { + topic: string; + payload: string; + retain: boolean; +}; + +const initialState: ReducerState = { + _initial_state: { + lastUpdate: 0, + payload: "", + retain: false, + }, +}; + +type WebSocketContextProps = { + state: ReducerState; + readyState: ReadyState; + sendJsonMessage: (message: any) => void; +}; + +export const WS = createContext({ + state: initialState, + readyState: ReadyState.CLOSED, + sendJsonMessage: () => {}, +}); + +export const useWebSocketContext = (): WebSocketContextProps => { + const context = useContext(WS); + if (!context) { + throw new Error( + "useWebSocketContext must be used within a WebSocketProvider" + ); + } + return context; +}; + +function reducer(state: ReducerState, action: ReducerAction): ReducerState { + switch (action.topic) { + default: + return produce(state, (draftState: Draft) => { + let parsedPayload = action.payload; + try { + parsedPayload = action.payload && JSON.parse(action.payload); + } catch (e) {} + draftState[action.topic] = { + lastUpdate: Date.now(), + payload: parsedPayload, + retain: action.retain, + }; + }); + } +} + +type WsProviderType = { + config: FrigateConfig; + children: ReactNode; + wsUrl?: string; +}; + +export function WsProvider({ + config, + children, + wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`, +}: WsProviderType) { + const [state, dispatch] = useReducer(reducer, initialState); + + const { sendJsonMessage, readyState } = useWebSocket(wsUrl, { + onMessage: (event) => { + dispatch(JSON.parse(event.data)); + }, + onOpen: () => dispatch({ topic: "", payload: "", retain: false }), + shouldReconnect: () => true, + }); + + useEffect(() => { + Object.keys(config.cameras).forEach((camera) => { + const { name, record, detect, snapshots, audio } = config.cameras[camera]; + dispatch({ + topic: `${name}/recordings/state`, + payload: record.enabled ? "ON" : "OFF", + retain: false, + }); + dispatch({ + topic: `${name}/detect/state`, + payload: detect.enabled ? "ON" : "OFF", + retain: false, + }); + dispatch({ + topic: `${name}/snapshots/state`, + payload: snapshots.enabled ? "ON" : "OFF", + retain: false, + }); + dispatch({ + topic: `${name}/audio/state`, + payload: audio.enabled ? "ON" : "OFF", + retain: false, + }); + }); + }, [config]); + + return ( + + {children} + + ); +} + +export function useWs(watchTopic: string, publishTopic: string) { + const { state, readyState, sendJsonMessage } = useWebSocketContext(); + + const value = state[watchTopic] || { payload: null }; + + const send = useCallback( + (payload: string, retain = false) => { + if (readyState === ReadyState.OPEN) { + sendJsonMessage({ + topic: publishTopic || watchTopic, + payload, + retain, + }); + } + }, + [sendJsonMessage, readyState, watchTopic, publishTopic] + ); + + return { value, send }; +} + +export function useDetectState(camera: string) { + const { + value: { payload }, + send, + } = useWs(`${camera}/detect/state`, `${camera}/detect/set`); + return { payload, send }; +} + +export function useRecordingsState(camera: string) { + const { + value: { payload }, + send, + } = useWs(`${camera}/recordings/state`, `${camera}/recordings/set`); + return { payload, send }; +} + +export function useSnapshotsState(camera: string) { + const { + value: { payload }, + send, + } = useWs(`${camera}/snapshots/state`, `${camera}/snapshots/set`); + return { payload, send }; +} + +export function useAudioState(camera: string) { + const { + value: { payload }, + send, + } = useWs(`${camera}/audio/state`, `${camera}/audio/set`); + return { payload, send }; +} + +export function usePtzCommand(camera: string) { + const { + value: { payload }, + send, + } = useWs(`${camera}/ptz`, `${camera}/ptz`); + return { payload, send }; +} + +export function useRestart() { + const { + value: { payload }, + send, + } = useWs("restart", "restart"); + return { payload, send }; +} diff --git a/web-new/src/components/Header.tsx b/web-new/src/components/Header.tsx new file mode 100644 index 000000000..3b7a1e6f0 --- /dev/null +++ b/web-new/src/components/Header.tsx @@ -0,0 +1,305 @@ +import { Link } from "react-router-dom"; +import Logo from "@/components/Logo"; +import { + LuActivity, + LuGithub, + LuHardDrive, + LuLifeBuoy, + LuMenu, + LuMoon, + LuMoreVertical, + LuPenSquare, + LuRotateCw, + LuSettings, + LuSun, + LuSunMoon, +} from "react-icons/lu"; +import { IoColorPalette } from "react-icons/io5"; +import { CgDarkMode } from "react-icons/cg"; +import { Button } from "@/components/ui/button"; +import Heading from "./ui/heading"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + colorSchemes, + friendlyColorSchemeName, + useTheme, +} from "@/context/theme-provider"; +import { useEffect, useState } from "react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "./ui/sheet"; +import ActivityIndicator from "./ui/activity-indicator"; +import { useRestart } from "@/api/ws"; + +type HeaderProps = { + onToggleNavbar: () => void; +}; + +function Header({ onToggleNavbar }: HeaderProps) { + const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); + const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const [restartingSheetOpen, setRestartingSheetOpen] = useState(false); + const [countdown, setCountdown] = useState(60); + + const { send: sendRestart } = useRestart(); + + useEffect(() => { + let countdownInterval: NodeJS.Timeout; + + if (restartingSheetOpen) { + countdownInterval = setInterval(() => { + setCountdown((prevCountdown) => prevCountdown - 1); + }, 1000); + } + + return () => { + clearInterval(countdownInterval); + }; + }, [restartingSheetOpen]); + + useEffect(() => { + if (countdown === 0) { + window.location.href = "/"; + } + }, [countdown]); + + const handleForceReload = () => { + window.location.href = "/"; + }; + + return ( +
+
+ + +
+
+ +
+ Frigate +
+ +
+
+ + + + + + System + + + + + + Storage + + + + + + System metrics + + + + + Configuration + + + + + + + Settings + + + + + + Configuration editor + + + Appearance + + + + + Dark Mode + + + + setTheme("light")}> + {theme === "light" ? ( + <> + + Light + + ) : ( + Light + )} + + setTheme("dark")}> + {theme === "dark" ? ( + <> + + Dark + + ) : ( + Dark + )} + + setTheme("system")}> + {theme === "system" ? ( + <> + + System + + ) : ( + System + )} + + + + + + + + Theme + + + + {colorSchemes.map((scheme) => ( + setColorScheme(scheme)} + > + {scheme === colorScheme ? ( + <> + + {friendlyColorSchemeName(scheme)} + + ) : ( + + {friendlyColorSchemeName(scheme)} + + )} + + ))} + + + + + Help + + + + + Documentation + + + + + + GitHub + + + + setRestartDialogOpen(true)}> + + Restart Frigate + + + +
+ {restartDialogOpen && ( + setRestartDialogOpen(false)} + > + + + + Are you sure you want to restart Frigate? + + + + Cancel + { + setRestartingSheetOpen(true); + sendRestart("restart"); + }} + > + Restart + + + + + )} + {restartingSheetOpen && ( + <> + setRestartingSheetOpen(false)} + > + e.preventDefault()} + > +
+ + + + Frigate is Restarting + + +

This page will reload in {countdown} seconds.

+
+
+ +
+
+
+ + )} +
+ ); +} + +export default Header; diff --git a/web-new/src/components/Logo.tsx b/web-new/src/components/Logo.tsx new file mode 100644 index 000000000..f5a220ce9 --- /dev/null +++ b/web-new/src/components/Logo.tsx @@ -0,0 +1,7 @@ +export default function Logo() { + return ( + + + + ); +} diff --git a/web-new/src/components/Sidebar.tsx b/web-new/src/components/Sidebar.tsx new file mode 100644 index 000000000..069f6307c --- /dev/null +++ b/web-new/src/components/Sidebar.tsx @@ -0,0 +1,101 @@ +import { IconType } from "react-icons"; +import { LuFileUp, LuFilm, LuLayoutDashboard, LuVideo } from "react-icons/lu"; +import { NavLink } from "react-router-dom"; +import { Sheet, SheetContent } from "@/components/ui/sheet"; +import Logo from "./Logo"; + +const navbarLinks = [ + { + id: 1, + icon: LuLayoutDashboard, + title: "Dashboard", + url: "/", + }, + { + id: 2, + icon: LuVideo, + title: "Live", + url: "/live", + }, + { + id: 3, + icon: LuFilm, + title: "History", + url: "/history", + }, + { + id: 4, + icon: LuFileUp, + title: "Export", + url: "/export", + }, +]; + +function Sidebar({ + sheetOpen, + setSheetOpen, +}: { + sheetOpen: boolean; + setSheetOpen: (open: boolean) => void; +}) { + const sidebar = ( + + ); + + return ( + <> +
{sidebar}
+ + setSheetOpen(false)} + > + +
+
+ +
+
+ {sidebar} +
+
+ + ); +} + +type SidebarItemProps = { + Icon: IconType; + title: string; + url: string; + onClick?: () => void; +}; + +function SidebarItem({ Icon, title, url, onClick }: SidebarItemProps) { + return ( + + `py-4 px-2 flex flex-col lg:flex-row items-center rounded-lg gap-2 lg:w-full hover:bg-border ${ + isActive ? "font-bold bg-popover text-popover-foreground" : "" + }` + } + > + +
{title}
+
+ ); +} + +export default Sidebar; diff --git a/web-new/src/components/Wrapper.tsx b/web-new/src/components/Wrapper.tsx new file mode 100644 index 000000000..4a156f024 --- /dev/null +++ b/web-new/src/components/Wrapper.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from "react"; + +type TWrapperProps = { + children: ReactNode; +}; + +const Wrapper = ({ children }: TWrapperProps) => { + return
{children}
; +}; + +export default Wrapper; diff --git a/web-new/src/components/ui/activity-indicator.tsx b/web-new/src/components/ui/activity-indicator.tsx new file mode 100644 index 000000000..7aa31152d --- /dev/null +++ b/web-new/src/components/ui/activity-indicator.tsx @@ -0,0 +1,12 @@ +import { LuLoader2 } from "react-icons/lu"; + +export default function ActivityIndicator({ size = 30 }) { + return ( +
+ +
+ ); +} diff --git a/web-new/src/components/ui/alert-dialog.tsx b/web-new/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..cc49f3960 --- /dev/null +++ b/web-new/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/web-new/src/components/ui/aspect-ratio.tsx b/web-new/src/components/ui/aspect-ratio.tsx new file mode 100644 index 000000000..c4abbf37f --- /dev/null +++ b/web-new/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/web-new/src/components/ui/badge.tsx b/web-new/src/components/ui/badge.tsx new file mode 100644 index 000000000..f000e3ef5 --- /dev/null +++ b/web-new/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/web-new/src/components/ui/button.tsx b/web-new/src/components/ui/button.tsx new file mode 100644 index 000000000..de31d9031 --- /dev/null +++ b/web-new/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/web-new/src/components/ui/calendar.tsx b/web-new/src/components/ui/calendar.tsx new file mode 100644 index 000000000..b065f8e0c --- /dev/null +++ b/web-new/src/components/ui/calendar.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/web-new/src/components/ui/card.tsx b/web-new/src/components/ui/card.tsx new file mode 100644 index 000000000..afa13ecfa --- /dev/null +++ b/web-new/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/web-new/src/components/ui/dialog.tsx b/web-new/src/components/ui/dialog.tsx new file mode 100644 index 000000000..bcaf970c6 --- /dev/null +++ b/web-new/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/web-new/src/components/ui/dropdown-menu.tsx b/web-new/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..769ff7aa7 --- /dev/null +++ b/web-new/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/web-new/src/components/ui/form.tsx b/web-new/src/components/ui/form.tsx new file mode 100644 index 000000000..4603f8b3d --- /dev/null +++ b/web-new/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +
diff --git a/web/tailwind.config.js b/web/tailwind.config.js index f87d4538f..d4078c18e 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -91,6 +91,12 @@ module.exports = { DEFAULT: "hsl(var(--severity_motion))", dimmed: "hsl(var(--severity_motion_dimmed))", }, + motion_review: { + DEFAULT: "hsl(var(--motion_review))", + }, + audio_review: { + DEFAULT: "hsl(var(--audio_review))", + }, }, keyframes: { "accordion-down": { diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index 32e9d2288..83f40870f 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -73,6 +73,12 @@ --severity_motion: var(--yellow-400); --severity_motion_dimmed: var(--yellow-200); + + --motion_review: hsl(44, 94%, 50%); + --motion_review: 44, 94%, 50%; + + --audio_review: hsl(228, 94%, 67%); + --audio_review: 228, 94%, 67%; } .dark { From 38e76666e740d59e6a9bbd4e02a68932857e037d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 4 Mar 2024 16:18:27 -0600 Subject: [PATCH 164/751] Motion timeline updates (#10242) * adjust segment math * simplify interp and fix math * fix math and update dependency * push debug * Revert "push debug" This reverts commit 07c171b341814ee5dbe8ee97b76f1230888538af. --- .../timeline/MotionReviewTimeline.tsx | 2 ++ web/src/components/timeline/MotionSegment.tsx | 34 +++++-------------- web/src/hooks/use-motion-segment-utils.ts | 34 +++++++++---------- 3 files changed, 27 insertions(+), 43 deletions(-) diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 4f1da2962..900ceb98d 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -113,6 +113,7 @@ export function MotionReviewTimeline({ minimapStartTime, minimapEndTime, events, + motion_events, ]); const segments = useMemo( @@ -128,6 +129,7 @@ export function MotionReviewTimeline({ minimapStartTime, minimapEndTime, events, + motion_events, ], ); diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index b898611ca..fc184e3d1 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -217,15 +217,10 @@ export function MotionSegment({ className={`h-[2px] rounded-full bg-motion_review`} onClick={segmentClick} style={{ - width: - maxSegmentWidth - - interpolateMotionAudioData( - getMotionSegmentValue(segmentTime - segmentDuration / 2), - 0, - 100, - 1, - maxSegmentWidth, - ), + width: interpolateMotionAudioData( + getMotionSegmentValue(segmentTime + segmentDuration / 2), + maxSegmentWidth, + ), }} >
@@ -236,10 +231,7 @@ export function MotionSegment({ onClick={segmentClick} style={{ width: interpolateMotionAudioData( - getAudioSegmentValue(segmentTime - segmentDuration / 2), - -100, - 0, - 1, + getAudioSegmentValue(segmentTime + segmentDuration / 2), maxSegmentWidth, ), }} @@ -254,15 +246,10 @@ export function MotionSegment({ className={`h-[2px] rounded-full bg-motion_review`} onClick={segmentClick} style={{ - width: - maxSegmentWidth - - interpolateMotionAudioData( - getMotionSegmentValue(segmentTime), - 0, - 100, - 1, - maxSegmentWidth, - ), + width: interpolateMotionAudioData( + getMotionSegmentValue(segmentTime), + maxSegmentWidth, + ), }} >
@@ -274,9 +261,6 @@ export function MotionSegment({ style={{ width: interpolateMotionAudioData( getAudioSegmentValue(segmentTime), - -100, - 0, - 1, maxSegmentWidth, ), }} diff --git a/web/src/hooks/use-motion-segment-utils.ts b/web/src/hooks/use-motion-segment-utils.ts index b9b2ce665..3ec5ee122 100644 --- a/web/src/hooks/use-motion-segment-utils.ts +++ b/web/src/hooks/use-motion-segment-utils.ts @@ -1,41 +1,39 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { MockMotionData } from "@/pages/UIPlayground"; export const useMotionSegmentUtils = ( segmentDuration: number, motion_events: MockMotionData[], ) => { + const halfSegmentDuration = useMemo( + () => segmentDuration / 2, + [segmentDuration], + ); + const getSegmentStart = useCallback( (time: number): number => { - return Math.floor(time / segmentDuration) * segmentDuration; + return Math.floor(time / halfSegmentDuration) * halfSegmentDuration; }, - [segmentDuration], + [halfSegmentDuration], ); const getSegmentEnd = useCallback( (time: number | undefined): number => { if (time) { return ( - Math.floor(time / segmentDuration) * segmentDuration + segmentDuration + Math.floor(time / halfSegmentDuration) * halfSegmentDuration + + halfSegmentDuration ); } else { - return Date.now() / 1000 + segmentDuration; + return Date.now() / 1000 + halfSegmentDuration; } }, - [segmentDuration], + [halfSegmentDuration], ); const interpolateMotionAudioData = useCallback( - ( - value: number, - oldMin: number, - oldMax: number, - newMin: number, - newMax: number, - ): number => { - return ( - ((value - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin - ); + (value: number, newMax: number): number => { + return Math.ceil((Math.abs(value) / 100.0) * newMax) || 1; }, [], ); @@ -45,7 +43,7 @@ export const useMotionSegmentUtils = ( const matchingEvent = motion_events.find((event) => { return ( time >= getSegmentStart(event.start_time) && - time < getSegmentEnd(event.end_time) + time < getSegmentEnd(event.start_time) ); }); @@ -59,7 +57,7 @@ export const useMotionSegmentUtils = ( const matchingEvent = motion_events.find((event) => { return ( time >= getSegmentStart(event.start_time) && - time < getSegmentEnd(event.end_time) + time < getSegmentEnd(event.start_time) ); }); From b4b2162adabffe94ebf18dccd0766372e16096a6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 4 Mar 2024 16:18:30 -0700 Subject: [PATCH 165/751] Camera groups (#10223) * Add camera group config * Add saving of camera group selection * Implement camera groups in config and live view * Fix warnings * Add tooltips to camera group items on desktop * Add camera groups to the filters for events * Fix tooltips and group selection * Cleanup --- frigate/config.py | 13 +++ .../components/filter/CameraGroupSelector.tsx | 102 ++++++++++++++++++ .../components/filter/ReviewFilterGroup.tsx | 39 ++++++- web/src/components/navigation/NavItem.tsx | 47 ++++---- web/src/components/navigation/Sidebar.tsx | 24 +++-- web/src/components/ui/button.tsx | 2 +- web/src/hooks/use-overlay-state.tsx | 16 ++- web/src/pages/Live.tsx | 11 +- web/src/types/frigateConfig.ts | 8 ++ web/src/utils/iconUtil.tsx | 22 ++++ web/src/views/live/LiveDashboardView.tsx | 3 +- 11 files changed, 247 insertions(+), 40 deletions(-) create mode 100644 web/src/components/filter/CameraGroupSelector.tsx diff --git a/frigate/config.py b/frigate/config.py index 4191eafc0..3ab7ea956 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -1003,6 +1003,16 @@ class LoggerConfig(FrigateBaseModel): ) +class CameraGroupConfig(FrigateBaseModel): + """Represents a group of cameras.""" + + cameras: list[str] = Field( + default_factory=list, title="List of cameras in this group." + ) + icon: str = Field(default="generic", title="Icon that represents camera group.") + order: int = Field(default=0, title="Sort order for group.") + + def verify_config_roles(camera_config: CameraConfig) -> None: """Verify that roles are setup in the config correctly.""" assigned_roles = list( @@ -1157,6 +1167,9 @@ class FrigateConfig(FrigateBaseModel): default_factory=DetectConfig, title="Global object tracking configuration." ) cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.") + camera_groups: Dict[str, CameraGroupConfig] = Field( + default_factory=CameraGroupConfig, title="Camera group configuration" + ) timestamp_style: TimestampStyleConfig = Field( default_factory=TimestampStyleConfig, title="Global timestamp style configuration.", diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx new file mode 100644 index 000000000..f975d290c --- /dev/null +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -0,0 +1,102 @@ +import { FrigateConfig } from "@/types/frigateConfig"; +import { isDesktop } from "react-device-detect"; +import useSWR from "swr"; +import { MdHome } from "react-icons/md"; +import useOverlayState from "@/hooks/use-overlay-state"; +import { Button } from "../ui/button"; +import { useNavigate } from "react-router-dom"; +import { useCallback, useMemo, useState } from "react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { getIconForGroup } from "@/utils/iconUtil"; + +type CameraGroupSelectorProps = { + className?: string; +}; +export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { + const { data: config } = useSWR("config"); + const navigate = useNavigate(); + + // tooltip + + const [tooltip, setTooltip] = useState(); + const [timeoutId, setTimeoutId] = useState(); + const showTooltip = useCallback( + (newTooltip: string | undefined) => { + if (!newTooltip) { + setTooltip(newTooltip); + + if (timeoutId) { + clearTimeout(timeoutId); + } + } else { + setTimeoutId(setTimeout(() => setTooltip(newTooltip), 500)); + } + }, + [timeoutId], + ); + + // groups + + const [group, setGroup] = useOverlayState("cameraGroup"); + + const groups = useMemo(() => { + if (!config) { + return []; + } + + return Object.entries(config.camera_groups).sort( + (a, b) => a[1].order - b[1].order, + ); + }, [config]); + + return ( +
+ + + + + + Home + + + {groups.map(([name, config]) => { + return ( + + + + + + {name} + + + ); + })} +
+ ); +} diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 6fb5c7d42..4ecf71c4e 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -2,7 +2,7 @@ import { LuCheck, LuVideo } from "react-icons/lu"; import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; -import { FrigateConfig } from "@/types/frigateConfig"; +import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useMemo, useState } from "react"; import { DropdownMenu, @@ -16,6 +16,8 @@ import { ReviewFilter } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { FaCalendarAlt, FaFilter, FaVideo } from "react-icons/fa"; +import { getIconTypeForGroup } from "@/utils/iconUtil"; +import { IconType } from "react-icons"; const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; @@ -57,6 +59,16 @@ export default function ReviewFilterGroup({ [config, allLabels], ); + const groups = useMemo(() => { + if (!config) { + return []; + } + + return Object.entries(config.camera_groups).sort( + (a, b) => a[1].order - b[1].order, + ); + }, [config]); + // handle updating filters const onUpdateSelectedDay = useCallback( @@ -74,6 +86,7 @@ export default function ReviewFilterGroup({
{ onUpdateFilter({ ...filter, cameras: newCameras }); @@ -102,11 +115,13 @@ export default function ReviewFilterGroup({ type CameraFilterButtonProps = { allCameras: string[]; + groups: [string, CameraGroupConfig][]; selectedCameras: string[] | undefined; updateCameraFilter: (cameras: string[] | undefined) => void; }; function CamerasFilterButton({ allCameras, + groups, selectedCameras, updateCameraFilter, }: CameraFilterButtonProps) { @@ -144,6 +159,24 @@ function CamerasFilterButton({ } }} /> + {groups.length > 0 && ( + <> + + {groups.map(([name, conf]) => { + return ( + { + setCurrentCameras([...conf.cameras]); + }} + /> + ); + })} + + )} {allCameras.map((item) => ( void; }; function FilterCheckBox({ label, + CheckIcon = LuCheck, isChecked, onCheckedChange, }: FilterCheckBoxProps) { @@ -366,7 +401,7 @@ function FilterCheckBox({ onClick={() => onCheckedChange(!isChecked)} > {isChecked ? ( - + ) : (
)} diff --git a/web/src/components/navigation/NavItem.tsx b/web/src/components/navigation/NavItem.tsx index e19ff92a2..37436ffde 100644 --- a/web/src/components/navigation/NavItem.tsx +++ b/web/src/components/navigation/NavItem.tsx @@ -6,7 +6,6 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useState } from "react"; import { isDesktop } from "react-device-detect"; import { TooltipPortal } from "@radix-ui/react-tooltip"; @@ -42,32 +41,36 @@ export default function NavItem({ }: NavItemProps) { const shouldRender = dev ? ENV !== "production" : true; - const [showTooltip, setShowTooltip] = useState(false); + if (!shouldRender) { + return; + } - return ( - shouldRender && ( - - - `${className} flex flex-col justify-center items-center rounded-lg ${ - variants[variant][isActive ? "active" : "inactive"] - }` - } - onMouseEnter={() => (isDesktop ? setShowTooltip(true) : null)} - onMouseLeave={() => (isDesktop ? setShowTooltip(false) : null)} - > - - - - + const content = ( + + `${className} flex flex-col justify-center items-center rounded-lg ${ + variants[variant][isActive ? "active" : "inactive"] + }` + } + > + + + ); + + if (isDesktop) { + return ( + + {content}

{title}

- ) - ); + ); + } + + return content; } diff --git a/web/src/components/navigation/Sidebar.tsx b/web/src/components/navigation/Sidebar.tsx index 1fdea0c41..1956a8562 100644 --- a/web/src/components/navigation/Sidebar.tsx +++ b/web/src/components/navigation/Sidebar.tsx @@ -2,22 +2,30 @@ import Logo from "../Logo"; import { navbarLinks } from "@/pages/site-navigation"; import SettingsNavItems from "../settings/SettingsNavItems"; import NavItem from "./NavItem"; +import { CameraGroupSelector } from "../filter/CameraGroupSelector"; +import { useLocation } from "react-router-dom"; function Sidebar() { + const location = useLocation(); + return ( diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index 37a4d509a..590c98481 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -29,7 +29,7 @@ type DynamicVideoPlayerProps = { timeRange: { start: number; end: number }; cameraPreviews: Preview[]; previewOnly?: boolean; - onControllerReady?: (controller: DynamicVideoController) => void; + onControllerReady: (controller: DynamicVideoController) => void; onClick?: () => void; }; export default function DynamicVideoPlayer({ @@ -86,14 +86,17 @@ export default function DynamicVideoPlayer({ }, [camera, config, previewOnly]); useEffect(() => { - if (!controller) { + if (!playerRef.current && !previewRef.current) { return; } - if (onControllerReady) { + if (controller) { onControllerReady(controller); } - }, [controller, onControllerReady]); + + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playerRef, previewRef]); const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true); @@ -277,10 +280,6 @@ export default function DynamicVideoPlayer({ player.on("ended", () => controller.fireClipChangeEvent("forward"), ); - - if (onControllerReady) { - onControllerReady(controller); - } }} onDispose={() => { playerRef.current = undefined; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index 8e97b5778..60636b470 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -10,6 +10,7 @@ const buttonVariants = cva( variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", + select: "bg-select text-white hover:bg-select/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 9ab090fdd..18d5781e7 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -115,9 +115,7 @@ export default function Events() { // review summary - const { data: reviewSummary, mutate: updateSummary } = useSWR< - ReviewSummary[] - >([ + const { data: reviewSummary, mutate: updateSummary } = useSWR([ "review/summary", { timezone: timezone, @@ -164,7 +162,7 @@ export default function Events() { const markItemAsReviewed = useCallback( async (review: ReviewSegment) => { - const resp = await axios.post(`review/${review.id}/viewed`); + const resp = await axios.post(`reviews/viewed`, { ids: [review.id] }); if (resp.status == 200) { updateSegments( @@ -197,23 +195,30 @@ export default function Events() { ); updateSummary( - (data: ReviewSummary[] | undefined) => { + (data: ReviewSummary | undefined) => { if (!data) { return data; } const day = new Date(review.start_time * 1000); - const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`; - const index = data.findIndex((summary) => summary.day == key); + const today = new Date(); + today.setHours(0, 0, 0, 0); - if (index == -1) { + let key; + if (day.getTime() > today.getTime()) { + key = "last24Hours"; + } else { + key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`; + } + + if (!Object.keys(data).includes(key)) { return data; } - const item = data[index]; - return [ - ...data.slice(0, index), - { + const item = data[key]; + return { + ...data, + [key]: { ...item, reviewed_alert: review.severity == "alert" @@ -228,8 +233,7 @@ export default function Events() { ? item.reviewed_motion + 1 : item.reviewed_motion, }, - ...data.slice(index + 1), - ]; + }; }, { revalidate: false, populateCache: true }, ); @@ -279,6 +283,11 @@ export default function Events() { return undefined; } + // mark item as reviewed since it has been opened + if (!selectedReview?.has_been_reviewed) { + markItemAsReviewed(selectedReview); + } + return { camera: selectedReview.camera, severity: selectedReview.severity, diff --git a/web/src/types/review.ts b/web/src/types/review.ts index 8e52dc4af..7366738ab 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -28,7 +28,7 @@ export type ReviewFilter = { showReviewed?: 0 | 1; }; -export type ReviewSummary = { +type ReviewSummaryDay = { day: string; reviewed_alert: number; reviewed_detection: number; @@ -38,6 +38,10 @@ export type ReviewSummary = { total_motion: number; }; +export type ReviewSummary = { + [day: string]: ReviewSummaryDay; +}; + export type MotionData = { start_time: number; motion: number; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 1b12952ee..1f51e136c 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -35,10 +35,11 @@ import { LuFolderCheck } from "react-icons/lu"; import { MdCircle } from "react-icons/md"; import useSWR from "swr"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; +import { Button } from "@/components/ui/button"; type EventViewProps = { reviewPages?: ReviewSegment[][]; - reviewSummary?: ReviewSummary[]; + reviewSummary?: ReviewSummary; relevantPreviews?: Preview[]; timeRange: { before: number; after: number }; reachedEnd: boolean; @@ -74,17 +75,17 @@ export default function EventView({ // review counts const reviewCounts = useMemo(() => { - if (!reviewSummary || reviewSummary.length == 0) { + if (!reviewSummary) { return { alert: 0, detection: 0, significant_motion: 0 }; } let summary; if (filter?.before == undefined) { - summary = reviewSummary[0]; + summary = reviewSummary["last24Hours"]; } else { const day = new Date(filter.before * 1000); const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`; - summary = reviewSummary.find((check) => check.day == key); + summary = reviewSummary[key]; } if (!summary) { @@ -211,9 +212,11 @@ export default function EventView({ setSeverity(value)} + value={severity} + onValueChange={(value: ReviewSeverity) => + value ? setSeverity(value) : null + } // don't allow the severity to be unselected > -
- Motion ∙ {reviewCounts.significant_motion} -
+
Motion
@@ -303,6 +304,7 @@ type DetectionReviewProps = { detection: ReviewSegment[]; significant_motion: ReviewSegment[]; }; + itemsToReview?: number; relevantPreviews?: Preview[]; pagingObserver: MutableRefObject; selectedReviews: string[]; @@ -320,6 +322,7 @@ function DetectionReview({ contentRef, currentItems, reviewItems, + itemsToReview, relevantPreviews, pagingObserver, selectedReviews, @@ -359,6 +362,17 @@ function DetectionReview({ [isValidating, pagingObserver, reachedEnd, loadNextPage], ); + const markAllReviewed = useCallback(async () => { + if (!currentItems) { + return; + } + + await axios.post(`reviews/viewed`, { + ids: currentItems?.map((seg) => seg.id), + }); + pullLatestData(); + }, [currentItems, pullLatestData]); + // timeline interaction const { alignStartDateToTimeline } = useEventUtils( @@ -453,7 +467,7 @@ function DetectionReview({ /> )} - {!isValidating && currentItems == null && ( + {(itemsToReview == 0 || (currentItems == null && !isValidating)) && (
There are no {severity.replace(/_/g, " ")} items to review @@ -489,13 +503,27 @@ function DetectionReview({ onClick={onSelectReview} />
- {lastRow && !reachedEnd && }
); }) - ) : severity != "alert" ? ( + ) : itemsToReview != 0 ? (
) : null} + {currentItems && ( +
+ {reachedEnd ? ( + + ) : ( + + )} +
+ )}
@@ -574,6 +602,7 @@ function MotionReview({ before: timeRange.before, after: timeRange.after, scale: segmentDuration / 2, + cameras: filter?.cameras?.join(",") ?? null, }, ]); From 55077a0bc9384a7d7d1e5903c767995f2a500f07 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 6 Mar 2024 05:24:21 -0700 Subject: [PATCH 178/751] Add page for submitting to frigate+ (#10273) * Add screen for submitting to frigate+ * Fix sizing --- web/src/App.tsx | 2 + web/src/pages/SubmitPlus.tsx | 95 ++++++++++++++++++++++++++++++++ web/src/pages/site-navigation.ts | 7 +++ 3 files changed, 104 insertions(+) create mode 100644 web/src/pages/SubmitPlus.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 0512d15d3..21e579fc2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import Events from "./pages/Events"; import { isDesktop, isMobile } from "react-device-detect"; import Statusbar from "./components/Statusbar"; import Bottombar from "./components/navigation/Bottombar"; +import SubmitPlus from "./pages/SubmitPlus"; function App() { return ( @@ -34,6 +35,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx new file mode 100644 index 000000000..67dfd5230 --- /dev/null +++ b/web/src/pages/SubmitPlus.tsx @@ -0,0 +1,95 @@ +import { baseUrl } from "@/api/baseUrl"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Event } from "@/types/event"; +import axios from "axios"; +import { useCallback, useState } from "react"; +import useSWR from "swr"; + +export default function SubmitPlus() { + const { data: events, mutate: refresh } = useSWR([ + "events", + { limit: 100, in_progress: 0, is_submitted: 0 }, + ]); + const [upload, setUpload] = useState(); + + const onSubmitToPlus = useCallback( + async (falsePositive: boolean) => { + if (!upload) { + return; + } + + const resp = (await falsePositive) + ? await axios.put(`events/${upload.id}/false_positive`) + : await axios.post(`events/${upload.id}/plus`, { + include_annotation: 1, + }); + + if (resp.status == 200) { + refresh(); + } + }, + [refresh, upload], + ); + + return ( +
+ (!open ? setUpload(undefined) : null)} + > + + + Submit To Frigate+ + + Objects in locations you want to avoid are not false positives. + Submitting them as false positives will confuse the model. + + + {`${upload?.label}`} + + Cancel + onSubmitToPlus(false)} + > + This is a {upload?.label} + + onSubmitToPlus(true)} + > + This is not a {upload?.label} + + + + + + {events?.map((event) => { + return ( +
setUpload(event)} + > + +
+ ); + })} +
+ ); +} diff --git a/web/src/pages/site-navigation.ts b/web/src/pages/site-navigation.ts index e23e09a1a..2810c8371 100644 --- a/web/src/pages/site-navigation.ts +++ b/web/src/pages/site-navigation.ts @@ -1,3 +1,4 @@ +import Logo from "@/components/Logo"; import { FaCompactDisc, FaFlag, FaVideo } from "react-icons/fa"; import { LuConstruction } from "react-icons/lu"; @@ -20,6 +21,12 @@ export const navbarLinks = [ title: "Export", url: "/export", }, + { + id: 5, + icon: Logo, + title: "Frigate+", + url: "/plus", + }, { id: 4, icon: LuConstruction, From fb81e442836fc5851db2c3cb16e41ee5a846c1df Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 6 Mar 2024 13:49:51 -0700 Subject: [PATCH 179/751] UI Tweaks (#10289) * Use different cursor for dragging preview * Improve filters UI and add audio labels * Use switch and adjust colors * Disable chromecast button and remove dimming * Change marked reviewed text --- .../components/filter/ReviewFilterGroup.tsx | 465 +++++++++++------- .../components/player/DynamicVideoPlayer.tsx | 4 +- web/src/components/ui/button.tsx | 2 +- web/src/components/ui/calendar.tsx | 12 +- web/src/components/ui/slider-no-thumb.tsx | 2 +- web/src/components/ui/switch.tsx | 2 +- web/src/views/events/EventView.tsx | 2 +- 7 files changed, 293 insertions(+), 196 deletions(-) diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 4ecf71c4e..489a997fb 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -1,4 +1,4 @@ -import { LuCheck, LuVideo } from "react-icons/lu"; +import { LuCheck } from "react-icons/lu"; import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; @@ -16,8 +16,11 @@ import { ReviewFilter } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { FaCalendarAlt, FaFilter, FaVideo } from "react-icons/fa"; -import { getIconTypeForGroup } from "@/utils/iconUtil"; import { IconType } from "react-icons"; +import { isMobile } from "react-device-detect"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { Switch } from "../ui/switch"; +import { Label } from "../ui/label"; const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; @@ -41,14 +44,21 @@ export default function ReviewFilterGroup({ const cameras = filter?.cameras || Object.keys(config.cameras); cameras.forEach((camera) => { - config.cameras[camera].objects.track.forEach((label) => { + const cameraConfig = config.cameras[camera]; + cameraConfig.objects.track.forEach((label) => { if (!ATTRIBUTES.includes(label)) { labels.add(label); } }); + + if (cameraConfig.audio.enabled_in_config) { + cameraConfig.audio.listen.forEach((label) => { + labels.add(label); + }); + } }); - return [...labels]; + return [...labels].sort(); }, [config, filter]); const filterValues = useMemo( @@ -125,88 +135,123 @@ function CamerasFilterButton({ selectedCameras, updateCameraFilter, }: CameraFilterButtonProps) { + const [open, setOpen] = useState(false); const [currentCameras, setCurrentCameras] = useState( selectedCameras, ); - return ( - { - if (!open) { - updateCameraFilter(currentCameras); - } - }} - > - - - - - Filter Cameras - + const trigger = ( + + ); + const content = ( + <> + + Filter Cameras + + + { + if (isChecked) { + setCurrentCameras(undefined); + } + }} + /> + {groups.length > 0 && ( + <> + + {groups.map(([name, conf]) => { + return ( + { + setCurrentCameras([...conf.cameras]); + }} + /> + ); + })} + + )} + + {allCameras.map((item) => ( { if (isChecked) { - setCurrentCameras(undefined); + const updatedCameras = currentCameras ? [...currentCameras] : []; + + updatedCameras.push(item); + setCurrentCameras(updatedCameras); + } else { + const updatedCameras = currentCameras ? [...currentCameras] : []; + + // can not deselect the last item + if (updatedCameras.length > 1) { + updatedCameras.splice(updatedCameras.indexOf(item), 1); + setCurrentCameras(updatedCameras); + } } }} /> - {groups.length > 0 && ( - <> - - {groups.map(([name, conf]) => { - return ( - { - setCurrentCameras([...conf.cameras]); - }} - /> - ); - })} - - )} - - {allCameras.map((item) => ( - { - if (isChecked) { - const updatedCameras = currentCameras - ? [...currentCameras] - : []; + ))} + +
+ +
+ + ); - updatedCameras.push(item); - setCurrentCameras(updatedCameras); - } else { - const updatedCameras = currentCameras - ? [...currentCameras] - : []; + if (isMobile) { + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } - // can not deselect the last item - if (updatedCameras.length > 1) { - updatedCameras.splice(updatedCameras.indexOf(item), 1); - setCurrentCameras(updatedCameras); - } - } - }} - /> - ))} - -
+ setOpen(open); + }} + > + {trigger} + {content} + + ); + } + + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } + + setOpen(open); + }} + > + {trigger} + {content} ); } @@ -219,6 +264,7 @@ function CalendarFilterButton({ day, updateSelectedDay, }: CalendarFilterButtonProps) { + const [open, setOpen] = useState(false); const [selectedDay, setSelectedDay] = useState(day); const disabledDates = useMemo(() => { const tomorrow = new Date(); @@ -232,32 +278,71 @@ function CalendarFilterButton({ "%b %-d", ); + const trigger = ( + + ); + const content = ( + <> + { + setSelectedDay(day); + }} + /> + +
+ +
+ + ); + + if (isMobile) { + return ( + { + if (!open) { + setSelectedDay(day); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); + } + return ( { if (!open) { - updateSelectedDay(selectedDay); + setSelectedDay(day); } + + setOpen(open); }} > - - - - - { - setSelectedDay(day); - }} - /> - + {trigger} + {content} ); } @@ -276,108 +361,122 @@ function GeneralFilterButton({ showReviewed, setShowReviewed, }: GeneralFilterButtonProps) { - return ( - - - - - -
- - -
-
-
- ); -} - -type LabelFilterButtonProps = { - allLabels: string[]; - selectedLabels: string[] | undefined; - updateLabelFilter: (labels: string[] | undefined) => void; -}; -function LabelsFilterButton({ - allLabels, - selectedLabels, - updateLabelFilter, -}: LabelFilterButtonProps) { + const [open, setOpen] = useState(false); + const [reviewed, setReviewed] = useState(showReviewed ?? 0); const [currentLabels, setCurrentLabels] = useState( selectedLabels, ); - return ( - { - if (!open) { - updateLabelFilter(currentLabels); - } - }} - > - - - - - Filter Labels - + const trigger = ( + + ); + const content = ( + <> +
+ setReviewed(reviewed == 0 ? 1 : 0)} + /> + +
+ + + Filter Labels + + + { + if (isChecked) { + setCurrentLabels(undefined); + } + }} + /> + + {allLabels.map((item) => ( { if (isChecked) { - setCurrentLabels(undefined); + const updatedLabels = currentLabels ? [...currentLabels] : []; + + updatedLabels.push(item); + setCurrentLabels(updatedLabels); + } else { + const updatedLabels = currentLabels ? [...currentLabels] : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setCurrentLabels(updatedLabels); + } } }} /> - - {allLabels.map((item) => ( - { - if (isChecked) { - const updatedLabels = currentLabels ? [...currentLabels] : []; + ))} + +
+ +
+ + ); + + if (isMobile) { + return ( + { + if (!open) { + setReviewed(showReviewed ?? 0); + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); + } + + return ( + { + if (!open) { + setReviewed(showReviewed ?? 0); + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + {content} + ); } diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index 590c98481..4a96bf19c 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -301,6 +301,7 @@ export default function DynamicVideoPlayer({ autoPlay playsInline muted + disableRemotePlayback onSeeked={onPreviewSeeked} onLoadedData={() => controller.previewReady()} > @@ -308,9 +309,6 @@ export default function DynamicVideoPlayer({ )} - {onClick && !hasRecordingAtTime && ( -
- )}
); } diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index 60636b470..0078e3e1c 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -10,7 +10,7 @@ const buttonVariants = cva( variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", - select: "bg-select text-white hover:bg-select/90", + select: "bg-selected text-white hover:bg-opacity-90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx index 850f6f03a..792df0890 100644 --- a/web/src/components/ui/calendar.tsx +++ b/web/src/components/ui/calendar.tsx @@ -25,24 +25,24 @@ function Calendar({ nav: "space-x-1 flex items-center", nav_button: cn( buttonVariants({ variant: "outline" }), - "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", ), nav_button_previous: "absolute left-1", nav_button_next: "absolute right-1", table: "w-full border-collapse space-y-1", - head_row: "flex", + head_row: "flex justify-center", head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", - row: "flex w-full mt-2", + row: "flex w-full mt-2 justify-center", cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", day: cn( buttonVariants({ variant: "ghost" }), - "h-9 w-9 p-0 font-normal aria-selected:opacity-100" + "h-9 w-9 p-0 font-normal aria-selected:opacity-100", ), day_range_end: "day-range-end", day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", + "bg-selected text-white hover:bg-selected hover:text-white focus:bg-selected focus:text-white", + day_today: "bg-muted text-muted-foreground", day_outside: "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", day_disabled: "text-muted-foreground opacity-50", diff --git a/web/src/components/ui/slider-no-thumb.tsx b/web/src/components/ui/slider-no-thumb.tsx index 23f4d4ef1..021e6b806 100644 --- a/web/src/components/ui/slider-no-thumb.tsx +++ b/web/src/components/ui/slider-no-thumb.tsx @@ -18,7 +18,7 @@ const Slider = React.forwardRef< - + )); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx index aa58baa29..343df17d0 100644 --- a/web/src/components/ui/switch.tsx +++ b/web/src/components/ui/switch.tsx @@ -9,7 +9,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( - Mark all items as reviewed + Mark these items as reviewed ) : ( From 90db27e3c80fd7a8a3863cc5c0a6cbba29f122f1 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 6 Mar 2024 15:35:10 -0600 Subject: [PATCH 180/751] Scrolling fixes and motion timeline changes (#10295) * scrolling updates * only scroll by 1 segment on desktop --- web/src/components/timeline/EventSegment.tsx | 6 + .../timeline/MotionReviewTimeline.tsx | 2 +- web/src/components/timeline/MotionSegment.tsx | 89 +++-------- .../components/timeline/ReviewTimeline.tsx | 4 +- web/src/hooks/use-handle-dragging.ts | 140 +++++++++++++----- web/src/hooks/use-motion-segment-utils.ts | 16 ++ web/src/hooks/use-tap-utils.ts | 36 +++++ web/src/views/events/EventView.tsx | 66 +++++---- 8 files changed, 220 insertions(+), 139 deletions(-) create mode 100644 web/src/hooks/use-tap-utils.ts diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 25385fa8d..c91e3cc11 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -17,6 +17,7 @@ import { import { HoverCardPortal } from "@radix-ui/react-hover-card"; import scrollIntoView from "scroll-into-view-if-needed"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; +import useTapUtils from "@/hooks/use-tap-utils"; type EventSegmentProps = { events: ReviewSegment[]; @@ -88,6 +89,8 @@ export function EventSegment({ const apiHost = useApiHost(); + const { handleTouchStart } = useTapUtils(); + const eventThumbnail = useMemo(() => { return getEventThumbnail(segmentTime); }, [getEventThumbnail, segmentTime]); @@ -227,6 +230,9 @@ export function EventSegment({ key={`${segmentKey}_${index}_primary_data`} className={`w-full h-2 bg-gradient-to-r ${roundBottomPrimary ? "rounded-bl-full rounded-br-full" : ""} ${roundTopPrimary ? "rounded-tl-full rounded-tr-full" : ""} ${severityColors[severityValue]}`} onClick={segmentClick} + onTouchStart={(event) => + handleTouchStart(event, segmentClick) + } >
diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 6cacc0deb..4c6c38409 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -97,7 +97,7 @@ export function MotionReviewTimeline({ showMinimap={showMinimap} minimapStartTime={minimapStartTime} minimapEndTime={minimapEndTime} - contentRef={contentRef} + setHandlebarTime={setHandlebarTime} /> ); }); diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 070b66857..2c7138967 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -1,17 +1,12 @@ import { useEventUtils } from "@/hooks/use-event-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { MotionData, ReviewSegment } from "@/types/review"; -import React, { - RefObject, - useCallback, - useEffect, - useMemo, - useRef, -} from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import scrollIntoView from "scroll-into-view-if-needed"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; import { isMobile } from "react-device-detect"; +import useTapUtils from "@/hooks/use-tap-utils"; type MotionSegmentProps = { events: ReviewSegment[]; @@ -22,7 +17,7 @@ type MotionSegmentProps = { showMinimap: boolean; minimapStartTime?: number; minimapEndTime?: number; - contentRef: RefObject; + setHandlebarTime?: React.Dispatch>; }; export function MotionSegment({ @@ -34,7 +29,7 @@ export function MotionSegment({ showMinimap, minimapStartTime, minimapEndTime, - contentRef, + setHandlebarTime, }: MotionSegmentProps) { const severityType = "all"; const { @@ -42,20 +37,18 @@ export function MotionSegment({ getReviewed, displaySeverityType, shouldShowRoundedCorners, - getEventStart, } = useEventSegmentUtils(segmentDuration, events, severityType); - const { - getMotionSegmentValue, - getAudioSegmentValue, - interpolateMotionAudioData, - } = useMotionSegmentUtils(segmentDuration, motion_events); + const { getMotionSegmentValue, interpolateMotionAudioData, getMotionStart } = + useMotionSegmentUtils(segmentDuration, motion_events); const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( events, segmentDuration, ); + const { handleTouchStart } = useTapUtils(); + const severity = useMemo( () => getSeverity(segmentTime, displaySeverityType), // we know that these deps are correct @@ -74,19 +67,19 @@ export function MotionSegment({ ); const startTimestamp = useMemo(() => { - const eventStart = getEventStart(segmentTime); + const eventStart = getMotionStart(segmentTime); if (eventStart) { return alignStartDateToTimeline(eventStart); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getEventStart, segmentTime]); + }, [getMotionStart, segmentTime]); const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); const segmentKey = useMemo(() => segmentTime, [segmentTime]); const maxSegmentWidth = useMemo(() => { - return isMobile ? 15 : 25; + return isMobile ? 30 : 50; }, []); const alignedMinimapStartTime = useMemo( @@ -161,32 +154,10 @@ export function MotionSegment({ }; const segmentClick = useCallback(() => { - if (contentRef.current && startTimestamp) { - const element = contentRef.current.querySelector( - `[data-segment-start="${startTimestamp - segmentDuration}"]`, - ); - if (element instanceof HTMLElement) { - scrollIntoView(element, { - scrollMode: "if-needed", - behavior: "smooth", - }); - element.classList.add( - `outline-severity_${severityType}`, - `shadow-severity_${severityType}`, - ); - element.classList.add("outline-4", "shadow-[0_0_6px_1px]"); - element.classList.remove("outline-0", "shadow-none"); - - // Remove the classes after a short timeout - setTimeout(() => { - element.classList.remove("outline-4", "shadow-[0_0_6px_1px]"); - element.classList.add("outline-0", "shadow-none"); - }, 3000); - } + if (startTimestamp && setHandlebarTime) { + setHandlebarTime(startTimestamp); } - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [startTimestamp]); + }, [startTimestamp, setHandlebarTime]); return (
@@ -210,11 +181,12 @@ export function MotionSegment({
-
+
handleTouchStart(event, segmentClick)} style={{ width: interpolateMotionAudioData( getMotionSegmentValue(segmentTime + segmentDuration / 2), @@ -223,27 +195,15 @@ export function MotionSegment({ }} >
-
-
-
-
+
handleTouchStart(event, segmentClick)} style={{ width: interpolateMotionAudioData( getMotionSegmentValue(segmentTime), @@ -252,19 +212,6 @@ export function MotionSegment({ }} >
-
-
-
diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx index bd5fdf4dd..09b962755 100644 --- a/web/src/components/timeline/ReviewTimeline.tsx +++ b/web/src/components/timeline/ReviewTimeline.tsx @@ -44,7 +44,7 @@ export function ReviewTimeline({ onTouchMove={handleMouseMove} onMouseUp={handleMouseUp} onTouchEnd={handleMouseUp} - className={`relative h-full overflow-y-scroll no-scrollbar bg-secondary ${ + className={`relative h-full overflow-y-auto no-scrollbar bg-secondary ${ isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto" }`} > @@ -64,7 +64,7 @@ export function ReviewTimeline({ >
; @@ -34,15 +34,55 @@ function useDraggableHandler({ isDragging, setIsDragging, }: DragHandlerProps) { + const [clientYPosition, setClientYPosition] = useState(null); + + const draggingAtTopEdge = useMemo(() => { + if (clientYPosition && timelineRef.current) { + return ( + clientYPosition - timelineRef.current.offsetTop < + timelineRef.current.clientHeight * 0.03 && isDragging + ); + } + }, [clientYPosition, timelineRef, isDragging]); + + const draggingAtBottomEdge = useMemo(() => { + if (clientYPosition && timelineRef.current) { + return ( + clientYPosition > + (timelineRef.current.clientHeight + timelineRef.current.offsetTop) * + 0.97 && isDragging + ); + } + }, [clientYPosition, timelineRef, isDragging]); + + const getClientYPosition = useCallback( + ( + e: React.MouseEvent | React.TouchEvent, + ) => { + let clientY; + if (isMobile && e.nativeEvent instanceof TouchEvent) { + clientY = e.nativeEvent.touches[0].clientY; + } else if (e.nativeEvent instanceof MouseEvent) { + clientY = e.nativeEvent.clientY; + } + + if (clientY) { + setClientYPosition(clientY); + } + }, + [setClientYPosition], + ); + const handleMouseDown = useCallback( ( e: React.MouseEvent | React.TouchEvent, ) => { e.preventDefault(); e.stopPropagation(); + getClientYPosition(e); setIsDragging(true); }, - [setIsDragging], + [setIsDragging, getClientYPosition], ); const handleMouseUp = useCallback( @@ -84,7 +124,7 @@ function useDraggableHandler({ ).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", - ...(segmentDuration < 60 && { second: "2-digit" }), + ...(segmentDuration < 60 && isDesktop && { second: "2-digit" }), }); if (scrollTimeline) { scrollIntoView(thumb, { @@ -115,20 +155,24 @@ function useDraggableHandler({ return; } - let clientY; - if (isMobile && e.nativeEvent instanceof TouchEvent) { - clientY = e.nativeEvent.touches[0].clientY; - } else if (e.nativeEvent instanceof MouseEvent) { - clientY = e.nativeEvent.clientY; - } + getClientYPosition(e); + }, - e.preventDefault(); - e.stopPropagation(); + [contentRef, scrollTimeRef, timelineRef, getClientYPosition], + ); - if (showHandlebar && isDragging && clientY) { + useEffect(() => { + let animationFrameId: number | null = null; + + const handleScroll = () => { + if ( + timelineRef.current && + showHandlebar && + isDragging && + clientYPosition + ) { const { scrollHeight: timelineHeight, - clientHeight: visibleTimelineHeight, scrollTop: scrolled, offsetTop: timelineTop, } = timelineRef.current; @@ -139,10 +183,11 @@ function useDraggableHandler({ const parentScrollTop = getCumulativeScrollTop(timelineRef.current); const newHandlePosition = Math.min( - visibleTimelineHeight + parentScrollTop, + segmentHeight * (timelineDuration / segmentDuration) - + segmentHeight * 2, Math.max( segmentHeight + scrolled, - clientY - timelineTop + parentScrollTop, + clientYPosition - timelineTop + parentScrollTop, ), ); @@ -151,14 +196,24 @@ function useDraggableHandler({ timelineStart - segmentIndex * segmentDuration, ); - const scrollTimeline = - clientY < visibleTimelineHeight * 0.1 || - clientY > visibleTimelineHeight * 0.9; + if (draggingAtTopEdge || draggingAtBottomEdge) { + let newPosition = clientYPosition; + + if (draggingAtTopEdge) { + newPosition = scrolled - segmentHeight; + timelineRef.current.scrollTop = newPosition; + } + + if (draggingAtBottomEdge) { + newPosition = scrolled + segmentHeight; + timelineRef.current.scrollTop = newPosition; + } + } updateHandlebarPosition( newHandlePosition - segmentHeight, segmentStartTime, - scrollTimeline, + false, false, ); @@ -168,22 +223,41 @@ function useDraggableHandler({ (newHandlePosition / segmentHeight) * segmentDuration, ); } + + if (draggingAtTopEdge || draggingAtBottomEdge) { + animationFrameId = requestAnimationFrame(handleScroll); + } } - }, + }; + + const startScroll = () => { + if (isDragging) { + handleScroll(); + } + }; + + const stopScroll = () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } + }; + + startScroll(); + + return stopScroll; // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - [ - isDragging, - contentRef, - segmentDuration, - showHandlebar, - timelineDuration, - timelineStart, - updateHandlebarPosition, - alignStartDateToTimeline, - getCumulativeScrollTop, - ], - ); + }, [ + clientYPosition, + isDragging, + segmentDuration, + timelineStart, + timelineDuration, + timelineRef, + draggingAtTopEdge, + draggingAtBottomEdge, + showHandlebar, + ]); useEffect(() => { if ( diff --git a/web/src/hooks/use-motion-segment-utils.ts b/web/src/hooks/use-motion-segment-utils.ts index 7f10069ef..514f91c0c 100644 --- a/web/src/hooks/use-motion-segment-utils.ts +++ b/web/src/hooks/use-motion-segment-utils.ts @@ -66,9 +66,25 @@ export const useMotionSegmentUtils = ( [motion_events, getSegmentStart, getSegmentEnd], ); + const getMotionStart = useCallback( + (time: number): number => { + const matchingEvent = motion_events.find((event) => { + return ( + time >= getSegmentStart(event.start_time) && + time < getSegmentEnd(event.start_time) && + event.motion + ); + }); + + return matchingEvent?.start_time ?? 0; + }, + [motion_events, getSegmentStart, getSegmentEnd], + ); + return { getMotionSegmentValue, getAudioSegmentValue, interpolateMotionAudioData, + getMotionStart, }; }; diff --git a/web/src/hooks/use-tap-utils.ts b/web/src/hooks/use-tap-utils.ts new file mode 100644 index 000000000..6cdb0d40b --- /dev/null +++ b/web/src/hooks/use-tap-utils.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; + +interface TapUtils { + handleTouchStart: ( + event: React.TouchEvent, + onClick: () => void, + ) => void; +} + +const useTapUtils = (): TapUtils => { + const handleTouchStart = useCallback( + (event: React.TouchEvent, onClick: () => void) => { + event.preventDefault(); + + const element = event.target as Element; + const { clientX, clientY } = event.changedTouches[0]; + + // Determine if the touch is within the element's bounds + const rect = element.getBoundingClientRect(); + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + // Call the onClick handler + onClick(); + } + }, + [], + ); + + return { handleTouchStart }; +}; + +export default useTapUtils; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index e36321061..82069cd06 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -658,38 +658,40 @@ function MotionReview({ return ( <> -
- {reviewCameras.map((camera) => { - let grow; - const aspectRatio = camera.detect.width / camera.detect.height; - if (aspectRatio > 2) { - grow = "sm:col-span-2 aspect-wide"; - } else if (aspectRatio < 1) { - grow = "md:row-span-2 md:h-full aspect-tall"; - } else { - grow = "aspect-video"; - } - return ( - { - videoPlayersRef.current[camera.name] = controller; - setPlayerReady(true); - }} - onClick={() => - onSelectReview(`motion,${camera.name},${currentTime}`, false) - } - /> - ); - })} +
+
+ {reviewCameras.map((camera) => { + let grow; + const aspectRatio = camera.detect.width / camera.detect.height; + if (aspectRatio > 2) { + grow = "sm:col-span-2 aspect-wide"; + } else if (aspectRatio < 1) { + grow = "md:row-span-2 md:h-full aspect-tall"; + } else { + grow = "aspect-video"; + } + return ( + { + videoPlayersRef.current[camera.name] = controller; + setPlayerReady(true); + }} + onClick={() => + onSelectReview(`motion,${camera.name},${currentTime}`, false) + } + /> + ); + })} +
Date: Wed, 6 Mar 2024 18:15:50 -0700 Subject: [PATCH 181/751] Ability to add, edit, and delete camera groups in the UI (#10296) * Add dialog for creating new camera group * Support adding of camera groups and dynamically updating the config * Support deleting and edit existing camera groups * Don't show separator if user has no groups * Formatting * fix background --- frigate/api/app.py | 9 +- frigate/util/builtin.py | 11 +- .../components/filter/CameraGroupSelector.tsx | 232 +++++++++++++++++- web/src/components/filter/FilterCheckBox.tsx | 32 +++ .../components/filter/ReviewFilterGroup.tsx | 32 +-- web/src/types/frigateConfig.ts | 4 +- 6 files changed, 282 insertions(+), 38 deletions(-) create mode 100644 web/src/components/filter/FilterCheckBox.tsx diff --git a/frigate/api/app.py b/frigate/api/app.py index f4f513e14..6fdedab90 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -292,7 +292,7 @@ def config_set(): f.close() # Validate the config schema try: - FrigateConfig.parse_raw(new_raw_config) + config_obj = FrigateConfig.parse_raw(new_raw_config) except Exception: with open(config_file, "w") as f: f.write(old_raw_config) @@ -314,6 +314,13 @@ def config_set(): 500, ) + json = request.get_json(silent=True) or {} + + if json.get("requires_restart", 1) == 0: + current_app.frigate_config = FrigateConfig.runtime_config( + config_obj, current_app.plus_api + ) + return make_response( jsonify( { diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 30388251d..aa009aa04 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -204,17 +204,22 @@ def update_yaml_from_url(file_path, url): key_path.pop(i - 1) except ValueError: pass - new_value = new_value_list[0] - update_yaml_file(file_path, key_path, new_value) + + if len(new_value_list) > 1: + update_yaml_file(file_path, key_path, new_value_list) + else: + update_yaml_file(file_path, key_path, new_value_list[0]) def update_yaml_file(file_path, key_path, new_value): yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) with open(file_path, "r") as f: data = yaml.load(f) data = update_yaml(data, key_path, new_value) - + with open("/config/test.yaml", "w") as f: + yaml.dump(data, f) with open(file_path, "w") as f: yaml.dump(data, f) diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index f975d290c..61414281a 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -1,4 +1,8 @@ -import { FrigateConfig } from "@/types/frigateConfig"; +import { + CameraGroupConfig, + FrigateConfig, + GROUP_ICONS, +} from "@/types/frigateConfig"; import { isDesktop } from "react-device-detect"; import useSWR from "swr"; import { MdHome } from "react-icons/md"; @@ -8,6 +12,19 @@ import { useNavigate } from "react-router-dom"; import { useCallback, useMemo, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { getIconForGroup } from "@/utils/iconUtil"; +import { LuPencil, LuPlus, LuTrash } from "react-icons/lu"; +import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; +import { Input } from "../ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import FilterCheckBox from "./FilterCheckBox"; +import axios from "axios"; type CameraGroupSelectorProps = { className?: string; @@ -49,10 +66,20 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { ); }, [config]); + // add group + + const [addGroup, setAddGroup] = useState(false); + return (
+ + + )}
); } + +type NewGroupDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; + currentGroups: [string, CameraGroupConfig][]; +}; +function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + + // add fields + + const [editState, setEditState] = useState<"none" | "add" | "edit">("none"); + const [newTitle, setNewTitle] = useState(""); + const [icon, setIcon] = useState(""); + const [cameras, setCameras] = useState([]); + + // 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(); + } + }, + [updateConfig], + ); + + 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`} +
+
+ + {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), + ]); + } + }} + /> + ))} + +
+ {error &&
{error}
} + + + )} + +
+ ); +} diff --git a/web/src/components/filter/FilterCheckBox.tsx b/web/src/components/filter/FilterCheckBox.tsx new file mode 100644 index 000000000..b23a4e42c --- /dev/null +++ b/web/src/components/filter/FilterCheckBox.tsx @@ -0,0 +1,32 @@ +import { LuCheck } from "react-icons/lu"; +import { Button } from "../ui/button"; +import { IconType } from "react-icons"; + +type FilterCheckBoxProps = { + label: string; + CheckIcon?: IconType; + isChecked: boolean; + onCheckedChange: (isChecked: boolean) => void; +}; + +export default function FilterCheckBox({ + label, + CheckIcon = LuCheck, + isChecked, + onCheckedChange, +}: FilterCheckBoxProps) { + return ( + + ); +} diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 489a997fb..1d862998c 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -1,4 +1,3 @@ -import { LuCheck } from "react-icons/lu"; import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; @@ -16,11 +15,11 @@ import { ReviewFilter } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { FaCalendarAlt, FaFilter, FaVideo } from "react-icons/fa"; -import { IconType } from "react-icons"; import { isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; +import FilterCheckBox from "./FilterCheckBox"; const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; @@ -479,32 +478,3 @@ function GeneralFilterButton({ ); } - -type FilterCheckBoxProps = { - label: string; - CheckIcon?: IconType; - isChecked: boolean; - onCheckedChange: (isChecked: boolean) => void; -}; - -function FilterCheckBox({ - label, - CheckIcon = LuCheck, - isChecked, - onCheckedChange, -}: FilterCheckBoxProps) { - return ( - - ); -} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 7825e7f8f..a6c6b3864 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -204,9 +204,11 @@ export interface CameraConfig { }; } +export const GROUP_ICONS = ["car", "cat", "dog", "leaf"] as const; + export type CameraGroupConfig = { cameras: string[]; - icon: string; + icon: (typeof GROUP_ICONS)[number]; order: number; }; From ccb5e05e3ea0c6e2d34f60edacdd5d985f404045 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 6 Mar 2024 18:17:35 -0700 Subject: [PATCH 182/751] Cleanup overlay state with types and use overlay for severity (#10299) --- web/src/hooks/use-overlay-state.tsx | 11 ++++++----- web/src/pages/Events.tsx | 7 +++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index bcdc2144d..5f39c6311 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -1,15 +1,16 @@ import { useCallback, useMemo } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -export default function useOverlayState( +export default function useOverlayState( key: string, -): [string | undefined, (value: string, replace?: boolean) => void] { + defaultValue: S | undefined = undefined, +): [S | undefined, (value: S, replace?: boolean) => void] { const location = useLocation(); const navigate = useNavigate(); const currentLocationState = location.state; const setOverlayStateValue = useCallback( - (value: string, replace: boolean = false) => { + (value: S, replace: boolean = false) => { const newLocationState = { ...currentLocationState }; newLocationState[key] = value; navigate(location.pathname, { state: newLocationState, replace }); @@ -19,10 +20,10 @@ export default function useOverlayState( [key, navigate], ); - const overlayStateValue = useMemo( + const overlayStateValue = useMemo( () => location.state && location.state[key], [location, key], ); - return [overlayStateValue, setOverlayStateValue]; + return [overlayStateValue ?? defaultValue, setOverlayStateValue]; } diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 18d5781e7..ab45850a7 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -29,7 +29,10 @@ export default function Events() { // recordings viewer - const [severity, setSeverity] = useState("alert"); + const [severity, setSeverity] = useOverlayState( + "severity", + "alert", + ); const [selectedReviewId, setSelectedReviewId] = useOverlayState("review"); // review filter @@ -339,7 +342,7 @@ export default function Events() { reachedEnd={isDone} isValidating={isValidating} filter={reviewFilter} - severity={severity} + severity={severity ?? "alert"} setSeverity={setSeverity} loadNextPage={onLoadNextPage} markItemAsReviewed={markItemAsReviewed} From 8bfc0c98dccc362cbf35b984c68f482f8e844a20 Mon Sep 17 00:00:00 2001 From: Nate Meyer Date: Thu, 7 Mar 2024 06:59:33 -0500 Subject: [PATCH 183/751] Set Compute Level for CI build (#10276) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f187dcb57..90c3d7084 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: tags: ${{ steps.setup.outputs.image-name }}-amd64 cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64 - name: Build and push TensorRT (x86 GPU) + env: + COMPUTE_LEVEL: "50 60 70 80 90" uses: docker/bake-action@v4 with: push: true From b2931bcaa975967dcd99716c85c664f3e0e2cf76 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 7 Mar 2024 07:33:36 -0700 Subject: [PATCH 184/751] Remove paging (#10310) * Remove paging and use reviewed as an internal filter * Fix new reviews not going away --- frigate/api/review.py | 2 +- web/src/components/dynamic/NewReviewData.tsx | 7 +- web/src/pages/Events.tsx | 120 ++++++------------- web/src/views/events/EventView.tsx | 114 ++++++------------ 4 files changed, 78 insertions(+), 165 deletions(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 9f84fb9a1..5807f90ef 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -26,7 +26,7 @@ def review(): cameras = request.args.get("cameras", "all") labels = request.args.get("labels", "all") reviewed = request.args.get("reviewed", type=int, default=0) - limit = request.args.get("limit", 100) + limit = request.args.get("limit", type=int, default=None) severity = request.args.get("severity", None) before = request.args.get("before", type=float, default=datetime.now().timestamp()) diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index b1b094e08..8a4752470 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -8,19 +8,22 @@ type NewReviewDataProps = { className: string; contentRef: MutableRefObject; severity: ReviewSeverity; + hasUpdate: boolean; + setHasUpdate: (update: boolean) => void; pullLatestData: () => void; }; export default function NewReviewData({ className, contentRef, severity, + hasUpdate, + setHasUpdate, pullLatestData, }: NewReviewDataProps) { const { payload: review } = useFrigateReviews(); const startCheckTs = useMemo(() => Date.now() / 1000, []); const [reviewTs, setReviewTs] = useState(startCheckTs); - const [hasUpdate, setHasUpdate] = useState(false); useEffect(() => { if (!review) { @@ -36,7 +39,7 @@ export default function NewReviewData({ if (reviewTs > startCheckTs) { setHasUpdate(true); } - }, [startCheckTs, reviewTs]); + }, [startCheckTs, reviewTs, setHasUpdate]); return (
diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index ab45850a7..3bb199fea 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -19,9 +19,6 @@ import axios from "axios"; import { useCallback, useMemo, useState } from "react"; import { isMobile } from "react-device-detect"; import useSWR from "swr"; -import useSWRInfinite from "swr/infinite"; - -const API_LIMIT = 100; export default function Events() { const { data: config } = useSWR("config"); @@ -41,7 +38,6 @@ export default function Events() { useApiFilter(); const onUpdateFilter = useCallback((newFilter: ReviewFilter) => { - setSize(1); setReviewFilter(newFilter); // we don't want this updating // eslint-disable-next-line react-hooks/exhaustive-deps @@ -69,53 +65,25 @@ export default function Events() { return axios.get(path, { params }).then((res) => res.data); }, []); - const getKey = useCallback( - (index: number, prevData: ReviewSegment[]) => { - if (index > 0) { - const lastDate = prevData[prevData.length - 1].start_time; - reviewSearchParams; - const pagedParams = { - cameras: reviewSearchParams["cameras"], - labels: reviewSearchParams["labels"], - reviewed: reviewSearchParams["showReviewed"], - before: lastDate, - after: reviewSearchParams["after"] || last24Hours.after, - limit: API_LIMIT, - }; - return ["review", pagedParams]; - } + const getKey = useCallback(() => { + const params = { + cameras: reviewSearchParams["cameras"], + labels: reviewSearchParams["labels"], + reviewed: 1, + before: reviewSearchParams["before"] || last24Hours.before, + after: reviewSearchParams["after"] || last24Hours.after, + }; + return ["review", params]; + }, [reviewSearchParams, last24Hours]); - const params = { - cameras: reviewSearchParams["cameras"], - labels: reviewSearchParams["labels"], - reviewed: reviewSearchParams["showReviewed"], - limit: API_LIMIT, - before: reviewSearchParams["before"] || last24Hours.before, - after: reviewSearchParams["after"] || last24Hours.after, - }; - return ["review", params]; + const { data: reviews, mutate: updateSegments } = useSWR( + getKey, + reviewSegmentFetcher, + { + revalidateOnFocus: false, }, - [reviewSearchParams, last24Hours], ); - const { - data: reviewPages, - mutate: updateSegments, - size, - setSize, - isValidating, - } = useSWRInfinite(getKey, reviewSegmentFetcher, { - revalidateOnFocus: false, - persistSize: true, - }); - - const isDone = useMemo( - () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, - [reviewPages], - ); - - const onLoadNextPage = useCallback(() => setSize(size + 1), [size, setSize]); - // review summary const { data: reviewSummary, mutate: updateSummary } = useSWR([ @@ -136,24 +104,20 @@ export default function Events() { // preview videos const previewTimes = useMemo(() => { - if ( - !reviewPages || - reviewPages.length == 0 || - reviewPages.at(-1)?.length == 0 - ) { + if (!reviews || reviews.length == 0) { return undefined; } const startDate = new Date(); startDate.setMinutes(0, 0, 0); - const endDate = new Date(reviewPages.at(-1)?.at(-1)?.end_time || 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, }; - }, [reviewPages]); + }, [reviews]); const { data: allPreviews } = useSWR( previewTimes ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` @@ -169,28 +133,21 @@ export default function Events() { if (resp.status == 200) { updateSegments( - (data: ReviewSegment[][] | undefined) => { + (data: ReviewSegment[] | undefined) => { if (!data) { return data; } - const newData: ReviewSegment[][] = []; + const reviewIndex = data.findIndex((item) => item.id == review.id); + if (reviewIndex == -1) { + return data; + } - data.forEach((page) => { - const reviewIndex = page.findIndex( - (item) => item.id == review.id, - ); - - if (reviewIndex == -1) { - newData.push([...page]); - } else { - newData.push([ - ...page.slice(0, reviewIndex), - { ...page[reviewIndex], has_been_reviewed: true }, - ...page.slice(reviewIndex + 1), - ]); - } - }); + const newData = [ + ...data.slice(0, reviewIndex), + { ...data[reviewIndex], has_been_reviewed: true }, + ...data.slice(reviewIndex + 1), + ]; return newData; }, @@ -252,7 +209,7 @@ export default function Events() { return undefined; } - if (!reviewPages) { + if (!reviews) { return undefined; } @@ -262,8 +219,6 @@ export default function Events() { const allCameras = reviewFilter?.cameras ?? Object.keys(config.cameras); - const allReviews = reviewPages.flat(); - if (selectedReviewId.startsWith("motion")) { const motionData = selectedReviewId.split(","); // format is motion,camera,start_time @@ -272,15 +227,13 @@ export default function Events() { severity: "significant_motion" as ReviewSeverity, start_time: parseFloat(motionData[2]), allCameras: allCameras, - cameraSegments: allReviews.filter((seg) => + cameraSegments: reviews.filter((seg) => allCameras.includes(seg.camera), ), }; } - const selectedReview = allReviews.find( - (item) => item.id == selectedReviewId, - ); + const selectedReview = reviews.find((item) => item.id == selectedReviewId); if (!selectedReview) { return undefined; @@ -296,14 +249,12 @@ export default function Events() { severity: selectedReview.severity, start_time: selectedReview.start_time, allCameras: allCameras, - cameraSegments: allReviews.filter((seg) => - allCameras.includes(seg.camera), - ), + cameraSegments: reviews.filter((seg) => allCameras.includes(seg.camera)), }; // previews will not update after item is selected // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedReviewId, reviewPages]); + }, [selectedReviewId, reviews]); if (!timezone) { return ; @@ -335,16 +286,13 @@ export default function Events() { } else { return ( void; - loadNextPage: () => void; markItemAsReviewed: (review: ReviewSegment) => void; onOpenReview: (reviewId: string) => void; pullLatestData: () => void; updateFilter: (filter: ReviewFilter) => void; }; export default function EventView({ - reviewPages, + reviews, reviewSummary, relevantPreviews, timeRange, - reachedEnd, - isValidating, filter, severity, setSeverity, - loadNextPage, markItemAsReviewed, onOpenReview, pullLatestData, @@ -115,22 +109,20 @@ export default function EventView({ const detections: ReviewSegment[] = []; const motion: ReviewSegment[] = []; - reviewPages?.forEach((page) => { - page.forEach((segment) => { - all.push(segment); + reviews?.forEach((segment) => { + all.push(segment); - switch (segment.severity) { - case "alert": - alerts.push(segment); - break; - case "detection": - detections.push(segment); - break; - default: - motion.push(segment); - break; - } - }); + switch (segment.severity) { + case "alert": + alerts.push(segment); + break; + case "detection": + detections.push(segment); + break; + default: + motion.push(segment); + break; + } }); return { @@ -139,7 +131,7 @@ export default function EventView({ detection: detections, significant_motion: motion, }; - }, [reviewPages]); + }, [reviews]); const currentItems = useMemo(() => { const current = reviewItems[severity]; @@ -151,8 +143,6 @@ export default function EventView({ return current; }, [reviewItems, severity]); - const pagingObserver = useRef(null); - // review interaction const [selectedReviews, setSelectedReviews] = useState([]); @@ -267,14 +257,11 @@ export default function EventView({ currentItems={currentItems} reviewItems={reviewItems} relevantPreviews={relevantPreviews} - pagingObserver={pagingObserver} selectedReviews={selectedReviews} + itemsToReview={reviewCounts[severity]} severity={severity} filter={filter} - isValidating={isValidating} - reachedEnd={reachedEnd} timeRange={timeRange} - loadNextPage={loadNextPage} markItemAsReviewed={markItemAsReviewed} onSelectReview={onSelectReview} pullLatestData={pullLatestData} @@ -306,14 +293,10 @@ type DetectionReviewProps = { }; itemsToReview?: number; relevantPreviews?: Preview[]; - pagingObserver: MutableRefObject; selectedReviews: string[]; severity: ReviewSeverity; filter?: ReviewFilter; - isValidating: boolean; - reachedEnd: boolean; timeRange: { before: number; after: number }; - loadNextPage: () => void; markItemAsReviewed: (review: ReviewSegment) => void; onSelectReview: (id: string, ctrl: boolean) => void; pullLatestData: () => void; @@ -324,14 +307,10 @@ function DetectionReview({ reviewItems, itemsToReview, relevantPreviews, - pagingObserver, selectedReviews, severity, filter, - isValidating, - reachedEnd, timeRange, - loadNextPage, markItemAsReviewed, onSelectReview, pullLatestData, @@ -344,23 +323,7 @@ function DetectionReview({ // review interaction - const lastReviewRef = useCallback( - (node: HTMLElement | null) => { - if (isValidating) return; - if (pagingObserver.current) pagingObserver.current.disconnect(); - try { - pagingObserver.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !reachedEnd) { - loadNextPage(); - } - }); - if (node) pagingObserver.current.observe(node); - } catch (e) { - // no op - } - }, - [isValidating, pagingObserver, reachedEnd, loadNextPage], - ); + const [hasUpdate, setHasUpdate] = useState(false); const markAllReviewed = useCallback(async () => { if (!currentItems) { @@ -370,6 +333,7 @@ function DetectionReview({ await axios.post(`reviews/viewed`, { ids: currentItems?.map((seg) => seg.id), }); + setHasUpdate(false); pullLatestData(); }, [currentItems, pullLatestData]); @@ -463,11 +427,13 @@ function DetectionReview({ className="absolute w-full z-30" contentRef={contentRef} severity={severity} + hasUpdate={hasUpdate} + setHasUpdate={setHasUpdate} pullLatestData={pullLatestData} /> )} - {(itemsToReview == 0 || (currentItems == null && !isValidating)) && ( + {itemsToReview == 0 && (
There are no {severity.replace(/_/g, " ")} items to review @@ -478,15 +444,18 @@ function DetectionReview({ className="w-full m-2 grid sm:grid-cols-2 md:grid-cols-3 3xl:grid-cols-4 gap-2 md:gap-4" ref={contentRef} > - {currentItems ? ( - currentItems.map((value, segIdx) => { - const lastRow = segIdx == currentItems.length - 1; + {currentItems && + currentItems.map((value) => { + if (value.has_been_reviewed && filter?.showReviewed != 1) { + return; + } + const selected = selectedReviews.includes(value.id); return (
); - }) - ) : itemsToReview != 0 ? ( -
- ) : null} - {currentItems && ( + })} + {(itemsToReview ?? 0) > 0 && (
- {reachedEnd ? ( - - ) : ( - - )} +
)}
From 8776cdfd5b5700dd739af23e12cd8c24dc791dbd Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 7 Mar 2024 07:34:11 -0700 Subject: [PATCH 185/751] Tweaks fixes (#10311) * Save numbers as int instead of string * Fix hover logic * Fix delay for new alerts * Fixup dialog and marking item as uploaded * Make preview progress larger and easier to grab * Allow hovering to control preview on desktop --- frigate/util/builtin.py | 7 +- .../player/PreviewThumbnailPlayer.tsx | 114 +++++++++++++----- web/src/components/ui/slider-no-thumb.tsx | 4 +- web/src/pages/SubmitPlus.tsx | 82 +++++++------ web/src/views/live/LiveDashboardView.tsx | 2 +- 5 files changed, 139 insertions(+), 70 deletions(-) diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index aa009aa04..556b91ede 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -208,7 +208,12 @@ def update_yaml_from_url(file_path, url): if len(new_value_list) > 1: update_yaml_file(file_path, key_path, new_value_list) else: - update_yaml_file(file_path, key_path, new_value_list[0]) + value = str(new_value_list[0]) + + if value.isnumeric(): + value = int(value) + + update_yaml_file(file_path, key_path, value) def update_yaml_file(file_path, key_path, new_value): diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index d0f84899b..ca7745849 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -47,14 +47,11 @@ export default function PreviewThumbnailPlayer({ }: PreviewPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); - - const [hoverTimeout, setHoverTimeout] = useState(); - const [playback, setPlayback] = useState(false); - const [ignoreClick, setIgnoreClick] = useState(false); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); // interaction + const [ignoreClick, setIgnoreClick] = useState(false); const handleOnClick = useCallback( (e: React.MouseEvent) => { if (!ignoreClick) { @@ -120,38 +117,39 @@ export default function PreviewThumbnailPlayer({ } }, [allPreviews, review]); + // Hover Playback + + const [hoverTimeout, setHoverTimeout] = useState(); + const [playback, setPlayback] = useState(false); const playingBack = useMemo(() => playback, [playback]); + const [isHovered, setIsHovered] = useState(false); - const onPlayback = useCallback( - (isHovered: boolean) => { - if (isHovered && scrollLock) { - return; + useEffect(() => { + if (isHovered && scrollLock) { + return; + } + + if (isHovered) { + setHoverTimeout( + setTimeout(() => { + setPlayback(true); + setHoverTimeout(null); + }, 500), + ); + } else { + if (hoverTimeout) { + clearTimeout(hoverTimeout); } - if (isHovered) { - setHoverTimeout( - setTimeout(() => { - setPlayback(true); - setHoverTimeout(null); - }, 500), - ); - } else { - if (hoverTimeout) { - clearTimeout(hoverTimeout); - } + setPlayback(false); - setPlayback(false); - - if (onTimeUpdate) { - onTimeUpdate(undefined); - } + if (onTimeUpdate) { + onTimeUpdate(undefined); } - }, - + } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - [hoverTimeout, scrollLock, review], - ); + }, [isHovered, scrollLock]); // date @@ -163,8 +161,8 @@ export default function PreviewThumbnailPlayer({ return (
onPlayback(true)} - onMouseLeave={isMobile ? undefined : () => onPlayback(false)} + onMouseEnter={isMobile ? undefined : () => setIsHovered(true)} + onMouseLeave={isMobile ? undefined : () => setIsHovered(false)} onContextMenu={(e) => { e.preventDefault(); onClick(review.id, true); @@ -294,10 +292,12 @@ function VideoPreview({ onTimeUpdate, }: VideoPreviewProps) { const playerRef = useRef(null); + const sliderRef = useRef(null); // keep track of playback state const [progress, setProgress] = useState(0); + const [hoverTimeout, setHoverTimeout] = useState(); const playerStartTime = useMemo(() => { if (!relevantPreview) { return 0; @@ -458,6 +458,26 @@ function VideoPreview({ }, 500); }, [playerRef, setIgnoreClick]); + const onProgressHover = useCallback( + (event: React.MouseEvent) => { + if (!sliderRef.current) { + return; + } + + const rect = sliderRef.current.getBoundingClientRect(); + const positionX = event.clientX - rect.left; + const width = sliderRef.current.clientWidth; + onManualSeek([Math.round((positionX / width) * 100)]); + + if (hoverTimeout) { + clearTimeout(hoverTimeout); + } + + setHoverTimeout(setTimeout(() => onStopManualSeek(), 500)); + }, + [sliderRef, hoverTimeout, onManualSeek, onStopManualSeek, setHoverTimeout], + ); + return (
); @@ -500,12 +522,14 @@ function InProgressPreview({ onTimeUpdate, }: InProgressPreviewProps) { const apiHost = useApiHost(); + const sliderRef = useRef(null); const { data: previewFrames } = useSWR( `preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${ Math.ceil(review.end_time) + PREVIEW_PADDING }/frames`, ); const [manualFrame, setManualFrame] = useState(false); + const [hoverTimeout, setHoverTimeout] = useState(); const [key, setKey] = useState(0); const handleLoad = useCallback(() => { @@ -577,6 +601,34 @@ function InProgressPreview({ [setManualFrame, setIgnoreClick], ); + const onProgressHover = useCallback( + (event: React.MouseEvent) => { + if (!sliderRef.current || !previewFrames) { + return; + } + + const rect = sliderRef.current.getBoundingClientRect(); + const positionX = event.clientX - rect.left; + const width = sliderRef.current.clientWidth; + const progress = [Math.round((positionX / width) * previewFrames.length)]; + onManualSeek(progress); + + if (hoverTimeout) { + clearTimeout(hoverTimeout); + } + + setHoverTimeout(setTimeout(() => onStopManualSeek(progress), 500)); + }, + [ + sliderRef, + hoverTimeout, + previewFrames, + onManualSeek, + onStopManualSeek, + setHoverTimeout, + ], + ); + if (!previewFrames || previewFrames.length == 0) { return (
); diff --git a/web/src/components/ui/slider-no-thumb.tsx b/web/src/components/ui/slider-no-thumb.tsx index 021e6b806..97f8eba38 100644 --- a/web/src/components/ui/slider-no-thumb.tsx +++ b/web/src/components/ui/slider-no-thumb.tsx @@ -15,10 +15,10 @@ const Slider = React.forwardRef< )} {...props} > - + - + )); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx index 67dfd5230..7581f8cb3 100644 --- a/web/src/pages/SubmitPlus.tsx +++ b/web/src/pages/SubmitPlus.tsx @@ -1,14 +1,13 @@ import { baseUrl } from "@/api/baseUrl"; +import { Button } from "@/components/ui/button"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Event } from "@/types/event"; import axios from "axios"; import { useCallback, useState } from "react"; @@ -27,64 +26,75 @@ export default function SubmitPlus() { return; } - const resp = (await falsePositive) - ? await axios.put(`events/${upload.id}/false_positive`) - : await axios.post(`events/${upload.id}/plus`, { + falsePositive + ? axios.put(`events/${upload.id}/false_positive`) + : axios.post(`events/${upload.id}/plus`, { include_annotation: 1, }); - if (resp.status == 200) { - refresh(); - } + refresh( + (data: Event[] | undefined) => { + if (!data) { + return data; + } + + const index = data.findIndex((e) => e.id == upload.id); + + if (index == -1) { + return data; + } + + return [...data.slice(0, index), ...data.slice(index + 1)]; + }, + { revalidate: false, populateCache: true }, + ); + setUpload(undefined); }, [refresh, upload], ); return (
- (!open ? setUpload(undefined) : null)} > - - - Submit To Frigate+ - + + + Submit To Frigate+ + Objects in locations you want to avoid are not false positives. Submitting them as false positives will confuse the model. - - + + {`${upload?.label}`} - - Cancel - + + + + + + {events?.map((event) => { return (
setUpload(event)} >
diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 880ec1c1d..1dd68420f 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -43,7 +43,7 @@ export default function LiveDashboardView({ // if event is ended and was saved, update events list if (eventUpdate.type == "end" && eventUpdate.review.severity == "alert") { - updateEvents(); + setTimeout(() => updateEvents(), 1000); return; } }, [eventUpdate, updateEvents]); From fc6d6a4e9a80d7846b7269dbefc7e62ecf87d942 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 7 Mar 2024 09:11:24 -0700 Subject: [PATCH 186/751] Fix layout shifting (#10313) * Fix layout shifting * Change value for current data * Fix motion scrubbing lockout --- .../components/player/DynamicVideoPlayer.tsx | 4 +- .../player/PreviewThumbnailPlayer.tsx | 8 ++-- web/src/views/events/EventView.tsx | 38 +++++++++---------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index 4a96bf19c..a7b56964e 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -453,7 +453,7 @@ export class DynamicVideoController { } if (time > this.preview.end) { - if (this.clipChangeLockout) { + if (this.clipChangeLockout && time - this.preview.end < 30) { return; } @@ -470,7 +470,7 @@ export class DynamicVideoController { } if (time < this.preview.start) { - if (this.clipChangeLockout) { + if (this.clipChangeLockout && this.preview.start - time < 30) { return; } diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index ca7745849..e526fa777 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -67,10 +67,10 @@ export default function PreviewThumbnailPlayer({ preventScrollOnSwipe: true, }); - const handleSetReviewed = useCallback( - () => setReviewed(review), - [review, setReviewed], - ); + const handleSetReviewed = useCallback(() => { + review.has_been_reviewed = true; + setReviewed(review); + }, [review, setReviewed]); // playback diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 9e2ddc8c0..830662045 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -133,16 +133,6 @@ export default function EventView({ }; }, [reviews]); - const currentItems = useMemo(() => { - const current = reviewItems[severity]; - - if (!current || current.length == 0) { - return null; - } - - return current; - }, [reviewItems, severity]); - // review interaction const [selectedReviews, setSelectedReviews] = useState([]); @@ -175,7 +165,7 @@ export default function EventView({ const exportReview = useCallback( (id: string) => { - const review = currentItems?.find((seg) => seg.id == id); + const review = reviewItems.all?.find((seg) => seg.id == id); if (!review) { return; @@ -186,7 +176,7 @@ export default function EventView({ { playback: "realtime" }, ); }, - [currentItems], + [reviewItems], ); if (!config) { @@ -254,7 +244,6 @@ export default function EventView({ {severity != "significant_motion" && ( ; - currentItems: ReviewSegment[] | null; reviewItems: { all: ReviewSegment[]; alert: ReviewSegment[]; @@ -303,7 +291,6 @@ type DetectionReviewProps = { }; function DetectionReview({ contentRef, - currentItems, reviewItems, itemsToReview, relevantPreviews, @@ -317,6 +304,23 @@ function DetectionReview({ }: DetectionReviewProps) { const segmentDuration = 60; + // review data + const currentItems = useMemo(() => { + const current = reviewItems[severity]; + + if (!current || current.length == 0) { + return null; + } + + if (filter?.showReviewed != 1) { + return current.filter((seg) => !seg.has_been_reviewed); + } else { + return current; + } + // only refresh when severity or filter changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [severity, filter, reviewItems.all.length]); + // preview const [previewTime, setPreviewTime] = useState(); @@ -446,10 +450,6 @@ function DetectionReview({ > {currentItems && currentItems.map((value) => { - if (value.has_been_reviewed && filter?.showReviewed != 1) { - return; - } - const selected = selectedReviews.includes(value.id); return ( From 90a40d25091f3c772e6534227f19e523f0995d56 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 7 Mar 2024 17:31:43 -0700 Subject: [PATCH 187/751] Fix motion and recording views (#10318) * Fix switching between different hours * Simplify book keeping of different time ranges * Rewrite player to handle changing of previews outside of video player * Simplify switching between cameras * Fix previews causing cameras to not load * Simplify listeners * Always clear existing listener --- .../components/player/DynamicVideoPlayer.tsx | 229 +++++++++--------- web/src/types/playback.ts | 1 + web/src/views/events/EventView.tsx | 39 ++- web/src/views/events/RecordingView.tsx | 115 ++++++--- 4 files changed, 231 insertions(+), 153 deletions(-) diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index a7b56964e..e090c208c 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -12,7 +12,6 @@ import TimelineEventOverlay from "../overlay/TimelineDataOverlay"; import { useApiHost } from "@/api"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; -import ActivityIndicator from "../indicators/activity-indicator"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Recording } from "@/types/record"; import { Preview } from "@/types/preview"; @@ -29,6 +28,7 @@ type DynamicVideoPlayerProps = { timeRange: { start: number; end: number }; cameraPreviews: Preview[]; previewOnly?: boolean; + preloadRecordings: boolean; onControllerReady: (controller: DynamicVideoController) => void; onClick?: () => void; }; @@ -38,6 +38,7 @@ export default function DynamicVideoPlayer({ timeRange, cameraPreviews, previewOnly = false, + preloadRecordings = true, onControllerReady, onClick, }: DynamicVideoPlayerProps) { @@ -64,18 +65,19 @@ export default function DynamicVideoPlayer({ // controlling playback - const playerRef = useRef(undefined); + const [playerRef, setPlayerRef] = useState(undefined); const previewRef = useRef(null); const [isScrubbing, setIsScrubbing] = useState(previewOnly); const [focusedItem, setFocusedItem] = useState( undefined, ); const controller = useMemo(() => { - if (!config) { + if (!config || !playerRef || !previewRef.current) { return undefined; } return new DynamicVideoController( + camera, playerRef, previewRef, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, @@ -83,10 +85,12 @@ export default function DynamicVideoPlayer({ setIsScrubbing, setFocusedItem, ); - }, [camera, config, previewOnly]); + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [camera, config, playerRef, previewRef]); useEffect(() => { - if (!playerRef.current && !previewRef.current) { + if (!controller) { return; } @@ -96,7 +100,29 @@ export default function DynamicVideoPlayer({ // we only want to fire once when players are ready // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playerRef, previewRef]); + }, [controller]); + + const [initPreviewOnly, setInitPreviewOnly] = useState(previewOnly); + + useEffect(() => { + if (!controller || !playerRef) { + return; + } + + if (previewOnly == initPreviewOnly) { + return; + } + + if (previewOnly) { + playerRef.autoplay(false); + } else { + controller.seekToTimestamp(playerRef.currentTime() || 0, true); + } + + setInitPreviewOnly(previewOnly); + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller, previewOnly]); const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true); @@ -107,33 +133,33 @@ export default function DynamicVideoPlayer({ switch (key) { case "ArrowLeft": if (down) { - const currentTime = playerRef.current?.currentTime(); + const currentTime = playerRef?.currentTime(); if (currentTime) { - playerRef.current?.currentTime(Math.max(0, currentTime - 5)); + playerRef?.currentTime(Math.max(0, currentTime - 5)); } } break; case "ArrowRight": if (down) { - const currentTime = playerRef.current?.currentTime(); + const currentTime = playerRef?.currentTime(); if (currentTime) { - playerRef.current?.currentTime(currentTime + 5); + playerRef?.currentTime(currentTime + 5); } } break; case "m": - if (down && !repeat && playerRef.current) { - playerRef.current.muted(!playerRef.current.muted()); + if (down && !repeat && playerRef) { + playerRef.muted(!playerRef.muted()); } break; case " ": - if (down && playerRef.current) { - if (playerRef.current.paused()) { - playerRef.current.play(); + if (down && playerRef) { + if (playerRef.paused()) { + playerRef.play(); } else { - playerRef.current.pause(); + playerRef.pause(); } } break; @@ -236,31 +262,30 @@ export default function DynamicVideoPlayer({ recordings: recordings ?? [], playbackUri, preview, + timeRange, }); // we only want this to change when recordings update // eslint-disable-next-line react-hooks/exhaustive-deps }, [controller, recordings]); - if (!controller) { - return ; - } - return (
- {!previewOnly && ( + {preloadRecordings && (
{ - playerRef.current = player; - player.on("playing", () => setFocusedItem(undefined)); - player.on("timeupdate", () => { - controller.updateProgress(player.currentTime() || 0); - }); - player.on("ended", () => - controller.fireClipChangeEvent("forward"), - ); + setPlayerRef(player); }} onDispose={() => { - playerRef.current = undefined; + setPlayerRef(undefined); }} > {config && focusedItem && ( @@ -303,7 +321,7 @@ export default function DynamicVideoPlayer({ muted disableRemotePlayback onSeeked={onPreviewSeeked} - onLoadedData={() => controller.previewReady()} + onLoadedData={() => controller?.previewReady()} > {currentPreview != undefined && ( @@ -315,35 +333,39 @@ export default function DynamicVideoPlayer({ export class DynamicVideoController { // main state - private playerRef: MutableRefObject; + public camera = ""; + private playerRef: Player; private previewRef: MutableRefObject; private setScrubbing: (isScrubbing: boolean) => void; private setFocusedItem: (timeline: Timeline) => void; private playerMode: PlayerMode = "playback"; + private timeRange: { start: number; end: number } | undefined = undefined; // playback private recordings: Recording[] = []; - private onPlaybackTimestamp: ((time: number) => void) | undefined = undefined; - private onClipChange: ((dir: "forward" | "backward") => void) | undefined = - undefined; private annotationOffset: number; private timeToStart: number | undefined = undefined; - private clipChangeLockout: boolean = true; // preview private preview: Preview | undefined = undefined; private timeToSeek: number | undefined = undefined; private seeking = false; - private readyToScrub = true; + + // listeners + private playerProgressListener: (() => void) | null = null; + private playerEndedListener: (() => void) | null = null; + private canPlayListener: (() => void) | null = null; constructor( - playerRef: MutableRefObject, + camera: string, + playerRef: Player, previewRef: MutableRefObject, annotationOffset: number, defaultMode: PlayerMode, setScrubbing: (isScrubbing: boolean) => void, setFocusedItem: (timeline: Timeline) => void, ) { + this.camera = camera; this.playerRef = playerRef; this.previewRef = previewRef; this.annotationOffset = annotationOffset; @@ -355,7 +377,7 @@ export class DynamicVideoController { newPlayback(newPlayback: DynamicPlayback) { this.recordings = newPlayback.recordings; - this.playerRef.current?.src({ + this.playerRef.src({ src: newPlayback.playbackUri, type: "application/vnd.apple.mpegurl", }); @@ -366,6 +388,13 @@ export class DynamicVideoController { } this.preview = newPlayback.preview; + + if (this.preview) { + this.seeking = false; + this.timeToSeek = undefined; + } + + this.timeRange = newPlayback.timeRange; } seekToTimestamp(time: number, play: boolean = false) { @@ -396,99 +425,86 @@ export class DynamicVideoController { segment.end_time - segment.start_time - (segment.end_time - time); return true; }); - this.playerRef.current?.currentTime(seekSeconds); + this.playerRef.currentTime(seekSeconds); if (play) { - this.playerRef.current?.play(); + this.playerRef.play(); + } else { + this.playerRef.pause(); + } + } + + onCanPlay(listener: (() => void) | null) { + if (listener) { + this.canPlayListener = listener; + this.playerRef.on("canplay", this.canPlayListener); + } else { + if (this.canPlayListener) { + this.playerRef.off("canplay", this.canPlayListener); + this.canPlayListener = null; + } } } seekToTimelineItem(timeline: Timeline) { - this.playerRef.current?.pause(); + this.playerRef.pause(); this.seekToTimestamp(timeline.timestamp + this.annotationOffset); this.setFocusedItem(timeline); } - updateProgress(playerTime: number) { - if (this.onPlaybackTimestamp) { - // take a player time in seconds and convert to timestamp in timeline - let timestamp = 0; - let totalTime = 0; - (this.recordings || []).every((segment) => { - if (totalTime + segment.duration > playerTime) { - // segment is here - timestamp = segment.start_time + (playerTime - totalTime); - return false; - } else { - totalTime += segment.duration; - return true; - } - }); + getProgress(playerTime: number): number { + // take a player time in seconds and convert to timestamp in timeline + let timestamp = 0; + let totalTime = 0; + (this.recordings || []).every((segment) => { + if (totalTime + segment.duration > playerTime) { + // segment is here + timestamp = segment.start_time + (playerTime - totalTime); + return false; + } else { + totalTime += segment.duration; + return true; + } + }); - this.onPlaybackTimestamp(timestamp); - } + return timestamp; } onPlayerTimeUpdate(listener: ((timestamp: number) => void) | undefined) { - this.onPlaybackTimestamp = listener; + if (this.playerProgressListener) { + this.playerRef.off("timeupdate", this.playerProgressListener); + } + + if (listener) { + this.playerProgressListener = () => + listener(this.getProgress(this.playerRef.currentTime() || 0)); + this.playerRef.on("timeupdate", this.playerProgressListener); + } } - onClipChangedEvent(listener: (dir: "forward" | "backward") => void) { - this.onClipChange = listener; - } + onClipChangedEvent(listener: ((dir: "forward") => void) | undefined) { + if (this.playerEndedListener) { + this.playerRef.off("ended", this.playerEndedListener); + } - fireClipChangeEvent(dir: "forward" | "backward") { - if (this.onClipChange) { - this.onClipChange(dir); + if (listener) { + this.playerEndedListener = () => listener("forward"); + this.playerRef.on("ended", this.playerEndedListener); } } scrubToTimestamp(time: number) { - if (!this.preview) { + if (!this.preview || !this.timeRange) { return; } - if (!this.readyToScrub) { - return; - } - - if (time > this.preview.end) { - if (this.clipChangeLockout && time - this.preview.end < 30) { - return; - } - - if (this.playerMode == "scrubbing") { - this.playerMode = "playback"; - this.setScrubbing(false); - this.timeToSeek = undefined; - this.seeking = false; - this.readyToScrub = false; - this.clipChangeLockout = true; - this.fireClipChangeEvent("forward"); - } - return; - } - - if (time < this.preview.start) { - if (this.clipChangeLockout && this.preview.start - time < 30) { - return; - } - - if (this.playerMode == "scrubbing") { - this.playerMode = "playback"; - this.setScrubbing(false); - this.timeToSeek = undefined; - this.seeking = false; - this.readyToScrub = false; - this.clipChangeLockout = true; - this.fireClipChangeEvent("backward"); - } + if (time < this.preview.start || time > this.preview.end) { return; } if (this.playerMode != "scrubbing") { this.playerMode = "scrubbing"; - this.playerRef.current?.pause(); + this.playerRef.pause(); this.setScrubbing(true); } @@ -514,8 +530,6 @@ export class DynamicVideoController { return; } - this.clipChangeLockout = false; - if ( this.timeToSeek && this.timeToSeek != this.previewRef.current?.currentTime @@ -529,7 +543,6 @@ export class DynamicVideoController { previewReady() { this.previewRef.current?.pause(); - this.readyToScrub = true; } hasRecordingAtTime(time: number): boolean { diff --git a/web/src/types/playback.ts b/web/src/types/playback.ts index f787ba2db..29e3cd597 100644 --- a/web/src/types/playback.ts +++ b/web/src/types/playback.ts @@ -5,4 +5,5 @@ export type DynamicPlayback = { recordings: Recording[]; playbackUri: string; preview: Preview | undefined; + timeRange: { end: number; start: number }; }; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 830662045..b0820ba85 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -579,12 +579,17 @@ function MotionReview({ () => getChunkedTimeRange(timeRange.after, lastFullHour), [lastFullHour, timeRange], ); + const [selectedRangeIdx, setSelectedRangeIdx] = useState( timeRangeSegments.ranges.length - 1, ); const [currentTime, setCurrentTime] = useState( timeRangeSegments.ranges[selectedRangeIdx].start, ); + const currentTimeRange = useMemo( + () => timeRangeSegments.ranges[selectedRangeIdx], + [selectedRangeIdx, timeRangeSegments], + ); // move to next clip @@ -600,23 +605,38 @@ function MotionReview({ if (firstController) { firstController.onClipChangedEvent((dir) => { - if ( - dir == "forward" && - selectedRangeIdx < timeRangeSegments.ranges.length - 1 - ) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } else if (selectedRangeIdx > 0) { - setSelectedRangeIdx(selectedRangeIdx - 1); + if (dir == "forward") { + if (selectedRangeIdx < timeRangeSegments.ranges.length - 1) { + setSelectedRangeIdx(selectedRangeIdx + 1); + } } }); } }, [selectedRangeIdx, timeRangeSegments, videoPlayersRef, playerReady]); useEffect(() => { + if ( + currentTime > currentTimeRange.end + 60 || + currentTime < currentTimeRange.start - 60 + ) { + const index = timeRangeSegments.ranges.findIndex( + (seg) => seg.start <= currentTime && seg.end >= currentTime, + ); + + if (index != -1) { + setSelectedRangeIdx(index); + } + return; + } + Object.values(videoPlayersRef.current).forEach((controller) => { controller.scrubToTimestamp(currentTime); }); - }, [currentTime]); + }, [currentTime, currentTimeRange, timeRangeSegments]); + + if (!relevantPreviews) { + return ; + } return ( <> @@ -640,9 +660,10 @@ function MotionReview({ key={camera.name} className={`${grow}`} camera={camera.name} - timeRange={timeRangeSegments.ranges[selectedRangeIdx]} + timeRange={currentTimeRange} cameraPreviews={relevantPreviews || []} previewOnly + preloadRecordings={false} onControllerReady={(controller) => { videoPlayersRef.current[camera.name] = controller; setPlayerReady(true); diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index 30ef6617e..3eb7f776c 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -49,6 +49,10 @@ export function DesktopRecordingView({ return chunk.start <= startTime && chunk.end >= startTime; }), ); + const currentTimeRange = useMemo( + () => timeRange.ranges[selectedRangeIdx], + [selectedRangeIdx, timeRange], + ); // move to next clip useEffect(() => { @@ -59,21 +63,18 @@ export function DesktopRecordingView({ return; } - const firstController = Object.values(videoPlayersRef.current)[0]; + const mainController = videoPlayersRef.current[mainCamera]; - if (firstController) { - firstController.onClipChangedEvent((dir) => { - if ( - dir == "forward" && - selectedRangeIdx < timeRange.ranges.length - 1 - ) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } else if (selectedRangeIdx > 0) { - setSelectedRangeIdx(selectedRangeIdx - 1); + if (mainController) { + mainController.onClipChangedEvent((dir) => { + if (dir == "forward") { + if (selectedRangeIdx < timeRange.ranges.length - 1) { + setSelectedRangeIdx(selectedRangeIdx + 1); + } } }); } - }, [selectedRangeIdx, timeRange, videoPlayersRef, playerReady]); + }, [selectedRangeIdx, timeRange, videoPlayersRef, playerReady, mainCamera]); // scrubbing and timeline state @@ -82,11 +83,25 @@ export function DesktopRecordingView({ useEffect(() => { if (scrubbing) { + if ( + currentTime > currentTimeRange.end + 60 || + currentTime < currentTimeRange.start - 60 + ) { + const index = timeRange.ranges.findIndex( + (seg) => seg.start <= currentTime && seg.end >= currentTime, + ); + + if (index != -1) { + setSelectedRangeIdx(index); + } + return; + } + Object.values(videoPlayersRef.current).forEach((controller) => { controller.scrubToTimestamp(currentTime); }); } - }, [currentTime, scrubbing]); + }, [currentTime, scrubbing, timeRange, currentTimeRange]); useEffect(() => { if (!scrubbing) { @@ -99,22 +114,26 @@ export function DesktopRecordingView({ const onSelectCamera = useCallback( (newCam: string) => { - videoPlayersRef.current[mainCamera].onPlayerTimeUpdate(undefined); - videoPlayersRef.current[mainCamera].scrubToTimestamp(currentTime); - videoPlayersRef.current[newCam].seekToTimestamp(currentTime, true); - videoPlayersRef.current[newCam].onPlayerTimeUpdate( - (timestamp: number) => { - setCurrentTime(timestamp); + const lastController = videoPlayersRef.current[mainCamera]; + const newController = videoPlayersRef.current[newCam]; + lastController.onPlayerTimeUpdate(undefined); + lastController.onClipChangedEvent(undefined); + lastController.scrubToTimestamp(currentTime); + newController.onCanPlay(() => { + newController.seekToTimestamp(currentTime, true); + newController.onCanPlay(null); + }); + newController.onPlayerTimeUpdate((timestamp: number) => { + setCurrentTime(timestamp); - allCameras.forEach((cam) => { - if (cam != newCam) { - videoPlayersRef.current[cam]?.scrubToTimestamp( - Math.floor(timestamp), - ); - } - }); - }, - ); + allCameras.forEach((cam) => { + if (cam != newCam) { + videoPlayersRef.current[cam]?.scrubToTimestamp( + Math.floor(timestamp), + ); + } + }); + }); setMainCamera(newCam); }, [allCameras, currentTime, mainCamera], @@ -155,8 +174,9 @@ export function DesktopRecordingView({ > { videoPlayersRef.current[cam] = controller; setPlayerReady(true); @@ -172,7 +192,10 @@ export function DesktopRecordingView({ }); }); - controller.seekToTimestamp(startTime, true); + controller.onCanPlay(() => { + controller.seekToTimestamp(startTime, true); + controller.onCanPlay(null); + }); }} />
@@ -184,9 +207,10 @@ export function DesktopRecordingView({ { videoPlayersRef.current[cam] = controller; setPlayerReady(true); @@ -265,6 +289,10 @@ export function MobileRecordingView({ return chunk.start <= startTime && chunk.end >= startTime; }), ); + const currentTimeRange = useMemo( + () => timeRange.ranges[selectedRangeIdx], + [selectedRangeIdx, timeRange], + ); // move to next clip useEffect(() => { @@ -273,10 +301,10 @@ export function MobileRecordingView({ } controllerRef.current.onClipChangedEvent((dir) => { - if (dir == "forward" && selectedRangeIdx < timeRange.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } else if (selectedRangeIdx > 0) { - setSelectedRangeIdx(selectedRangeIdx - 1); + if (dir == "forward") { + if (selectedRangeIdx < timeRange.ranges.length - 1) { + setSelectedRangeIdx(selectedRangeIdx + 1); + } } }); }, [playerReady, selectedRangeIdx, timeRange]); @@ -290,9 +318,23 @@ export function MobileRecordingView({ useEffect(() => { if (scrubbing) { + if ( + currentTime > currentTimeRange.end + 60 || + currentTime < currentTimeRange.start - 60 + ) { + const index = timeRange.ranges.findIndex( + (seg) => seg.start <= currentTime && seg.end >= currentTime, + ); + + if (index != -1) { + setSelectedRangeIdx(index); + } + return; + } + controllerRef.current?.scrubToTimestamp(currentTime); } - }, [currentTime, scrubbing]); + }, [currentTime, scrubbing, currentTimeRange, timeRange]); useEffect(() => { if (!scrubbing) { @@ -328,8 +370,9 @@ export function MobileRecordingView({
{ controllerRef.current = controller; setPlayerReady(true); From 507c6afa2c56cc701a8767cce88698f93950bbf3 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 7 Mar 2024 17:32:26 -0700 Subject: [PATCH 188/751] Add filters to plus page and fix layout (#10320) --- web/src/pages/SubmitPlus.tsx | 335 ++++++++++++++++++++++++++++++----- 1 file changed, 288 insertions(+), 47 deletions(-) diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx index 7581f8cb3..699cc313f 100644 --- a/web/src/pages/SubmitPlus.tsx +++ b/web/src/pages/SubmitPlus.tsx @@ -1,4 +1,5 @@ import { baseUrl } from "@/api/baseUrl"; +import FilterCheckBox from "@/components/filter/FilterCheckBox"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -8,15 +9,37 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Event } from "@/types/event"; +import { FrigateConfig } from "@/types/frigateConfig"; import axios from "axios"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { FaList, FaVideo } from "react-icons/fa"; import useSWR from "swr"; export default function SubmitPlus() { + // filters + + const [selectedCameras, setSelectedCameras] = useState(); + const [selectedLabels, setSelectedLabels] = useState(); + + // data + const { data: events, mutate: refresh } = useSWR([ "events", - { limit: 100, in_progress: 0, is_submitted: 0 }, + { + limit: 100, + in_progress: 0, + is_submitted: 0, + cameras: selectedCameras ? selectedCameras.join(",") : null, + labels: selectedLabels ? selectedLabels.join(",") : null, + }, ]); const [upload, setUpload] = useState(); @@ -54,52 +77,270 @@ export default function SubmitPlus() { ); return ( -
- (!open ? setUpload(undefined) : null)} - > - - - Submit To Frigate+ - - Objects in locations you want to avoid are not false positives. - Submitting them as false positives will confuse the model. - - - {`${upload?.label}`} - - - - - - - - - {events?.map((event) => { - return ( -
setUpload(event)} +
+ +
+
+ (!open ? setUpload(undefined) : null)} > - -
- ); - })} + + + Submit To Frigate+ + + Objects in locations you want to avoid are not false + positives. Submitting them as false positives will confuse the + model. + + + {`${upload?.label}`} + + + + + + + + + {events?.map((event) => { + return ( +
setUpload(event)} + > + +
+ ); + })} +
+
+
+ ); +} + +const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; + +type PlusFilterGroupProps = { + selectedCameras: string[] | undefined; + setSelectedCameras: (cameras: string[] | undefined) => void; + selectedLabels: string[] | undefined; + setSelectedLabels: (cameras: string[] | undefined) => void; +}; +function PlusFilterGroup({ + selectedCameras, + setSelectedCameras, + selectedLabels, + setSelectedLabels, +}: PlusFilterGroupProps) { + const { data: config } = useSWR("config"); + + const allCameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.keys(config.cameras); + }, [config]); + const allLabels = useMemo(() => { + if (!config) { + return []; + } + + const labels = new Set(); + const cameras = selectedCameras || Object.keys(config.cameras); + + cameras.forEach((camera) => { + const cameraConfig = config.cameras[camera]; + cameraConfig.objects.track.forEach((label) => { + if (!ATTRIBUTES.includes(label)) { + labels.add(label); + } + }); + }); + + return [...labels].sort(); + }, [config, selectedCameras]); + + const [open, setOpen] = useState<"none" | "camera" | "label">("none"); + const [currentCameras, setCurrentCameras] = useState( + undefined, + ); + const [currentLabels, setCurrentLabels] = useState( + undefined, + ); + + return ( +
+ { + if (!open) { + setCurrentCameras(selectedCameras); + } + setOpen(open ? "camera" : "none"); + }} + > + + + + + + Filter Cameras + + + { + if (isChecked) { + setCurrentCameras(undefined); + } + }} + /> + + {allCameras.map((item) => ( + { + if (isChecked) { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + updatedCameras.push(item); + setCurrentCameras(updatedCameras); + } else { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + // can not deselect the last item + if (updatedCameras.length > 1) { + updatedCameras.splice(updatedCameras.indexOf(item), 1); + setCurrentCameras(updatedCameras); + } + } + }} + /> + ))} + +
+ +
+
+
+ { + if (!open) { + setCurrentLabels(selectedLabels); + } + setOpen(open ? "label" : "none"); + }} + > + + + + + + Filter Labels + + + { + if (isChecked) { + setCurrentLabels(undefined); + } + }} + /> + + {allLabels.map((item) => ( + { + if (isChecked) { + const updatedLabels = currentLabels ? [...currentLabels] : []; + + updatedLabels.push(item); + setCurrentLabels(updatedLabels); + } else { + const updatedLabels = currentLabels ? [...currentLabels] : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setCurrentLabels(updatedLabels); + } + } + }} + /> + ))} + +
+ +
+
+
); } From dfab850b6158afc94ad254e019bafcd3062ba08b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 7 Mar 2024 22:02:29 -0600 Subject: [PATCH 189/751] Better segment clicking (#10321) * better segment clicking on motion segments * move handlebar on click when handlebar is showing * only scroll handlebar if needed --- .../timeline/EventReviewTimeline.tsx | 1 + web/src/components/timeline/EventSegment.tsx | 6 +++ web/src/components/timeline/MotionSegment.tsx | 38 +++++++++++-------- web/src/hooks/use-handle-dragging.ts | 1 + web/src/hooks/use-motion-segment-utils.ts | 2 +- web/src/pages/UIPlayground.tsx | 4 +- 6 files changed, 34 insertions(+), 18 deletions(-) diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 88cf515c1..6b23ac610 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -97,6 +97,7 @@ export function EventReviewTimeline({ minimapEndTime={minimapEndTime} severityType={severityType} contentRef={contentRef} + setHandlebarTime={setHandlebarTime} /> ); }); diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index c91e3cc11..ed86ee66c 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -29,6 +29,7 @@ type EventSegmentProps = { minimapEndTime?: number; severityType: ReviewSeverity; contentRef: RefObject; + setHandlebarTime?: React.Dispatch>; }; export function EventSegment({ @@ -41,6 +42,7 @@ export function EventSegment({ minimapEndTime, severityType, contentRef, + setHandlebarTime, }: EventSegmentProps) { const { getSeverity, @@ -192,6 +194,10 @@ export function EventSegment({ element.classList.add("outline-0", "shadow-none"); }, 3000); } + + if (setHandlebarTime) { + setHandlebarTime(startTimestamp); + } } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 2c7138967..2efdd73d0 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -82,6 +82,19 @@ export function MotionSegment({ return isMobile ? 30 : 50; }, []); + const segmentWidth = useMemo(() => { + return interpolateMotionAudioData( + getMotionSegmentValue(segmentTime + segmentDuration / 2), + maxSegmentWidth, + ); + }, [ + segmentTime, + segmentDuration, + maxSegmentWidth, + getMotionSegmentValue, + interpolateMotionAudioData, + ]); + const alignedMinimapStartTime = useMemo( () => alignStartDateToTimeline(minimapStartTime ?? 0), [minimapStartTime, alignStartDateToTimeline], @@ -154,13 +167,18 @@ export function MotionSegment({ }; const segmentClick = useCallback(() => { - if (startTimestamp && setHandlebarTime) { + if (startTimestamp && setHandlebarTime && segmentWidth > 1) { setHandlebarTime(startTimestamp); } - }, [startTimestamp, setHandlebarTime]); + }, [startTimestamp, setHandlebarTime, segmentWidth]); return ( -
+
handleTouchStart(event, segmentClick)} + > handleTouchStart(event, segmentClick)} style={{ - width: interpolateMotionAudioData( - getMotionSegmentValue(segmentTime + segmentDuration / 2), - maxSegmentWidth, - ), + width: segmentWidth, }} >
@@ -202,13 +215,8 @@ export function MotionSegment({
handleTouchStart(event, segmentClick)} style={{ - width: interpolateMotionAudioData( - getMotionSegmentValue(segmentTime), - maxSegmentWidth, - ), + width: segmentWidth, }} >
diff --git a/web/src/hooks/use-handle-dragging.ts b/web/src/hooks/use-handle-dragging.ts index 5d813528e..c6556189e 100644 --- a/web/src/hooks/use-handle-dragging.ts +++ b/web/src/hooks/use-handle-dragging.ts @@ -130,6 +130,7 @@ function useDraggableHandler({ scrollIntoView(thumb, { block: "center", behavior: "smooth", + scrollMode: "if-needed", }); } } diff --git a/web/src/hooks/use-motion-segment-utils.ts b/web/src/hooks/use-motion-segment-utils.ts index 514f91c0c..4cf0c15e1 100644 --- a/web/src/hooks/use-motion-segment-utils.ts +++ b/web/src/hooks/use-motion-segment-utils.ts @@ -43,7 +43,7 @@ export const useMotionSegmentUtils = ( const matchingEvent = motion_events.find((event) => { return ( time >= getSegmentStart(event.start_time) && - time < getSegmentEnd(event.start_time) + time <= getSegmentEnd(event.start_time) ); }); diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index bbfe211b9..40ddb5d2a 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -124,7 +124,7 @@ function UIPlayground() { const [mockEvents, setMockEvents] = useState([]); const [mockMotionData, setMockMotionData] = useState([]); const [handlebarTime, setHandlebarTime] = useState( - Math.floor(Date.now() / 1000) - 15 * 60, + Math.round((Date.now() / 1000 - 15 * 60) / 60) * 60, ); useMemo(() => { @@ -285,7 +285,7 @@ function UIPlayground() { Date: Fri, 8 Mar 2024 10:13:42 -0600 Subject: [PATCH 190/751] Handlebar dragging fix (#10333) * account for initial click position on handlebar when dragging * fix wrong start time in playground --- web/src/hooks/use-handle-dragging.ts | 17 +++++++++++++++-- web/src/pages/UIPlayground.tsx | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/web/src/hooks/use-handle-dragging.ts b/web/src/hooks/use-handle-dragging.ts index c6556189e..c306705a1 100644 --- a/web/src/hooks/use-handle-dragging.ts +++ b/web/src/hooks/use-handle-dragging.ts @@ -35,6 +35,7 @@ function useDraggableHandler({ setIsDragging, }: DragHandlerProps) { const [clientYPosition, setClientYPosition] = useState(null); + const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); const draggingAtTopEdge = useMemo(() => { if (clientYPosition && timelineRef.current) { @@ -81,8 +82,13 @@ function useDraggableHandler({ e.stopPropagation(); getClientYPosition(e); setIsDragging(true); + + if (scrollTimeRef.current && clientYPosition) { + const handlebarRect = scrollTimeRef.current.getBoundingClientRect(); + setInitialClickAdjustment(clientYPosition - handlebarRect.top); + } }, - [setIsDragging, getClientYPosition], + [setIsDragging, getClientYPosition, scrollTimeRef, clientYPosition], ); const handleMouseUp = useCallback( @@ -93,6 +99,7 @@ function useDraggableHandler({ e.stopPropagation(); if (isDragging) { setIsDragging(false); + setInitialClickAdjustment(0); } }, [isDragging, setIsDragging], @@ -184,11 +191,17 @@ function useDraggableHandler({ const parentScrollTop = getCumulativeScrollTop(timelineRef.current); const newHandlePosition = Math.min( + // end of timeline segmentHeight * (timelineDuration / segmentDuration) - segmentHeight * 2, Math.max( + // start of timeline segmentHeight + scrolled, - clientYPosition - timelineTop + parentScrollTop, + // current Y position + clientYPosition - + timelineTop + + parentScrollTop - + initialClickAdjustment, ), ); diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 40ddb5d2a..be8531e4e 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -285,7 +285,7 @@ function UIPlayground() { Date: Fri, 8 Mar 2024 10:14:58 -0700 Subject: [PATCH 191/751] Rework mobile drawers to have a max height (#10330) --- .../components/filter/ReviewFilterGroup.tsx | 162 ++++++++++-------- web/src/pages/SubmitPlus.tsx | 126 ++++++++------ 2 files changed, 158 insertions(+), 130 deletions(-) diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 1d862998c..b1d74ce23 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -155,56 +155,62 @@ function CamerasFilterButton({ Filter Cameras - { - if (isChecked) { - setCurrentCameras(undefined); - } - }} - /> - {groups.length > 0 && ( - <> - - {groups.map(([name, conf]) => { - return ( - { - setCurrentCameras([...conf.cameras]); - }} - /> - ); - })} - - )} - - {allCameras.map((item) => ( +
{ if (isChecked) { - const updatedCameras = currentCameras ? [...currentCameras] : []; - - updatedCameras.push(item); - setCurrentCameras(updatedCameras); - } else { - const updatedCameras = currentCameras ? [...currentCameras] : []; - - // can not deselect the last item - if (updatedCameras.length > 1) { - updatedCameras.splice(updatedCameras.indexOf(item), 1); - setCurrentCameras(updatedCameras); - } + setCurrentCameras(undefined); } }} /> - ))} + {groups.length > 0 && ( + <> + + {groups.map(([name, conf]) => { + return ( + { + setCurrentCameras([...conf.cameras]); + }} + /> + ); + })} + + )} + + {allCameras.map((item) => ( + { + if (isChecked) { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + updatedCameras.push(item); + setCurrentCameras(updatedCameras); + } else { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + // can not deselect the last item + if (updatedCameras.length > 1) { + updatedCameras.splice(updatedCameras.indexOf(item), 1); + setCurrentCameras(updatedCameras); + } + } + }} + /> + ))} +
Filter Labels - { - if (isChecked) { - setCurrentLabels(undefined); - } - }} - /> - - {allLabels.map((item) => ( +
{ if (isChecked) { - const updatedLabels = currentLabels ? [...currentLabels] : []; - - updatedLabels.push(item); - setCurrentLabels(updatedLabels); - } else { - const updatedLabels = currentLabels ? [...currentLabels] : []; - - // can not deselect the last item - if (updatedLabels.length > 1) { - updatedLabels.splice(updatedLabels.indexOf(item), 1); - setCurrentLabels(updatedLabels); - } + setCurrentLabels(undefined); } }} /> - ))} + + {allLabels.map((item) => ( + { + if (isChecked) { + const updatedLabels = currentLabels ? [...currentLabels] : []; + + updatedLabels.push(item); + setCurrentLabels(updatedLabels); + } else { + const updatedLabels = currentLabels ? [...currentLabels] : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setCurrentLabels(updatedLabels); + } + } + }} + /> + ))} +
- - + + Filter Cameras @@ -229,33 +235,35 @@ function PlusFilterGroup({ }} /> - {allCameras.map((item) => ( - { - if (isChecked) { - const updatedCameras = currentCameras - ? [...currentCameras] - : []; +
+ {allCameras.map((item) => ( + { + if (isChecked) { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; - updatedCameras.push(item); - setCurrentCameras(updatedCameras); - } else { - const updatedCameras = currentCameras - ? [...currentCameras] - : []; - - // can not deselect the last item - if (updatedCameras.length > 1) { - updatedCameras.splice(updatedCameras.indexOf(item), 1); + updatedCameras.push(item); setCurrentCameras(updatedCameras); + } else { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + // can not deselect the last item + if (updatedCameras.length > 1) { + updatedCameras.splice(updatedCameras.indexOf(item), 1); + setCurrentCameras(updatedCameras); + } } - } - }} - /> - ))} + }} + /> + ))} +
-
- - + + { if (!open) { @@ -279,7 +287,7 @@ function PlusFilterGroup({ setOpen(open ? "label" : "none"); }} > - + - - + + Filter Labels @@ -304,29 +312,35 @@ function PlusFilterGroup({ }} /> - {allLabels.map((item) => ( - { - if (isChecked) { - const updatedLabels = currentLabels ? [...currentLabels] : []; +
+ {allLabels.map((item) => ( + { + if (isChecked) { + const updatedLabels = currentLabels + ? [...currentLabels] + : []; - updatedLabels.push(item); - setCurrentLabels(updatedLabels); - } else { - const updatedLabels = currentLabels ? [...currentLabels] : []; - - // can not deselect the last item - if (updatedLabels.length > 1) { - updatedLabels.splice(updatedLabels.indexOf(item), 1); + updatedLabels.push(item); setCurrentLabels(updatedLabels); + } else { + const updatedLabels = currentLabels + ? [...currentLabels] + : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setCurrentLabels(updatedLabels); + } } - } - }} - /> - ))} + }} + /> + ))} +
-
- + +
); } From 3d539c93eb566a0003c8cb39f16e1b459a0b5547 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 8 Mar 2024 10:56:00 -0700 Subject: [PATCH 192/751] Fix motion review (#10329) * Break preview only video player out * Simplify * Load after current preview changes * Clear out waiting for seek state * Start at correct time of hour * Fix layout for tall video --- .../components/player/PreviewVideoPlayer.tsx | 217 ++++++++++++++++++ web/src/types/playback.ts | 5 + web/src/views/events/EventView.tsx | 38 +-- 3 files changed, 230 insertions(+), 30 deletions(-) create mode 100644 web/src/components/player/PreviewVideoPlayer.tsx diff --git a/web/src/components/player/PreviewVideoPlayer.tsx b/web/src/components/player/PreviewVideoPlayer.tsx new file mode 100644 index 000000000..8f3f170d2 --- /dev/null +++ b/web/src/components/player/PreviewVideoPlayer.tsx @@ -0,0 +1,217 @@ +import { + MutableRefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { Preview } from "@/types/preview"; +import { PreviewPlayback } from "@/types/playback"; + +type PreviewVideoPlayerProps = { + className?: string; + camera: string; + timeRange: { start: number; end: number }; + cameraPreviews: Preview[]; + onControllerReady: (controller: PreviewVideoController) => void; + onClick?: () => void; +}; +export default function PreviewVideoPlayer({ + className, + camera, + timeRange, + cameraPreviews, + onControllerReady, + onClick, +}: PreviewVideoPlayerProps) { + const { data: config } = useSWR("config"); + + // controlling playback + + const previewRef = useRef(null); + const controller = useMemo(() => { + if (!config || !previewRef.current) { + return undefined; + } + + return new PreviewVideoController(camera, previewRef); + // we only care when preview is ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [camera, config, previewRef.current]); + + useEffect(() => { + if (!controller) { + return; + } + + if (controller) { + onControllerReady(controller); + } + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller]); + + // initial state + + const initialPreview = useMemo(() => { + return cameraPreviews.find( + (preview) => + preview.camera == camera && + Math.round(preview.start) >= timeRange.start && + Math.floor(preview.end) <= timeRange.end, + ); + + // we only want to calculate this once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [currentPreview, setCurrentPreview] = useState(initialPreview); + + const onPreviewSeeked = useCallback(() => { + if (!controller) { + return; + } + + controller.finishedSeeking(); + }, [controller]); + + useEffect(() => { + if (!controller) { + return; + } + + const preview = cameraPreviews.find( + (preview) => + preview.camera == camera && + Math.round(preview.start) >= timeRange.start && + Math.floor(preview.end) <= timeRange.end, + ); + setCurrentPreview(preview); + + controller.newPlayback({ + preview, + timeRange, + }); + + // we only want this to change when recordings update + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller, timeRange]); + + useEffect(() => { + if (!currentPreview || !previewRef.current) { + return; + } + + previewRef.current.load(); + }, [currentPreview, previewRef]); + + return ( +
+ +
+ ); +} + +export class PreviewVideoController { + // main state + public camera = ""; + private previewRef: MutableRefObject; + private timeRange: { start: number; end: number } | undefined = undefined; + + // preview + private preview: Preview | undefined = undefined; + private timeToSeek: number | undefined = undefined; + private seeking = false; + + constructor( + camera: string, + previewRef: MutableRefObject, + ) { + this.camera = camera; + this.previewRef = previewRef; + } + + newPlayback(newPlayback: PreviewPlayback) { + this.preview = newPlayback.preview; + this.seeking = false; + + this.timeRange = newPlayback.timeRange; + } + + scrubToTimestamp(time: number) { + if (!this.preview || !this.timeRange) { + return; + } + + if (time < this.preview.start || time > this.preview.end) { + return; + } + + if (this.seeking) { + this.timeToSeek = time; + } else { + if (this.previewRef.current) { + this.previewRef.current.currentTime = Math.max( + 0, + time - this.preview.start, + ); + this.seeking = true; + } + } + } + + setNewPreviewStartTime(time: number) { + this.timeToSeek = time; + } + + finishedSeeking() { + if (!this.previewRef.current || !this.preview) { + return; + } + + if ( + this.timeToSeek && + this.timeToSeek != this.previewRef.current?.currentTime + ) { + this.previewRef.current.currentTime = + this.timeToSeek - this.preview.start; + } else { + this.seeking = false; + } + } + + previewReady() { + this.seeking = false; + this.previewRef.current?.pause(); + + if (this.timeToSeek) { + this.finishedSeeking(); + } + } +} diff --git a/web/src/types/playback.ts b/web/src/types/playback.ts index 29e3cd597..ea1d901b7 100644 --- a/web/src/types/playback.ts +++ b/web/src/types/playback.ts @@ -7,3 +7,8 @@ export type DynamicPlayback = { preview: Preview | undefined; timeRange: { end: number; start: number }; }; + +export type PreviewPlayback = { + preview: Preview | undefined; + timeRange: { end: number; start: number }; +}; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index b0820ba85..d801aa487 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -2,9 +2,6 @@ import Logo from "@/components/Logo"; import NewReviewData from "@/components/dynamic/NewReviewData"; import ReviewActionGroup from "@/components/filter/ReviewActionGroup"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; -import DynamicVideoPlayer, { - DynamicVideoController, -} from "@/components/player/DynamicVideoPlayer"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import ActivityIndicator from "@/components/indicators/activity-indicator"; @@ -36,6 +33,9 @@ import { MdCircle } from "react-icons/md"; import useSWR from "swr"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import { Button } from "@/components/ui/button"; +import PreviewVideoPlayer, { + PreviewVideoController, +} from "@/components/player/PreviewVideoPlayer"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -531,7 +531,6 @@ function MotionReview({ }: MotionReviewProps) { const segmentDuration = 30; const { data: config } = useSWR("config"); - const [playerReady, setPlayerReady] = useState(false); const reviewCameras = useMemo(() => { if (!config) { @@ -552,7 +551,7 @@ function MotionReview({ return cameras.sort((a, b) => a.ui.order - b.ui.order); }, [config, filter]); - const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>( + const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>( {}, ); @@ -593,27 +592,6 @@ function MotionReview({ // move to next clip - useEffect(() => { - if ( - !videoPlayersRef.current && - Object.values(videoPlayersRef.current).length > 0 - ) { - return; - } - - const firstController = Object.values(videoPlayersRef.current)[0]; - - if (firstController) { - firstController.onClipChangedEvent((dir) => { - if (dir == "forward") { - if (selectedRangeIdx < timeRangeSegments.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } - } - }); - } - }, [selectedRangeIdx, timeRangeSegments, videoPlayersRef, playerReady]); - useEffect(() => { if ( currentTime > currentTimeRange.end + 60 || @@ -624,6 +602,9 @@ function MotionReview({ ); if (index != -1) { + Object.values(videoPlayersRef.current).forEach((controller) => { + controller.setNewPreviewStartTime(currentTime); + }); setSelectedRangeIdx(index); } return; @@ -656,17 +637,14 @@ function MotionReview({ grow = "aspect-video"; } return ( - { videoPlayersRef.current[camera.name] = controller; - setPlayerReady(true); }} onClick={() => onSelectReview(`motion,${camera.name},${currentTime}`, false) From 05a66ce90d8c6558f51521f070d85cc8751ac5ba Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:49:10 -0600 Subject: [PATCH 193/751] Timeline handlebar tweaks (#10336) * initial offset click for desktop only * align start timeline and update pixel math --- .../timeline/EventReviewTimeline.tsx | 10 +++++-- .../timeline/MotionReviewTimeline.tsx | 14 +++++---- web/src/hooks/use-handle-dragging.ts | 30 +++++++++---------- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 6b23ac610..7e2b60bc3 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -59,6 +59,11 @@ export function EventReviewTimeline({ segmentDuration, ); + const timelineStartAligned = useMemo( + () => alignStartDateToTimeline(timelineStart), + [timelineStart, alignStartDateToTimeline], + ); + const { handleMouseDown, handleMouseUp, handleMouseMove } = useDraggableHandler({ contentRef, @@ -71,7 +76,7 @@ export function EventReviewTimeline({ handlebarTime, setHandlebarTime, timelineDuration, - timelineStart, + timelineStartAligned, isDragging, setIsDragging, handlebarTimeRef, @@ -80,10 +85,9 @@ export function EventReviewTimeline({ // Generate segments for the timeline const generateSegments = useCallback(() => { const segmentCount = timelineDuration / segmentDuration; - const segmentAlignedTime = alignStartDateToTimeline(timelineStart); return Array.from({ length: segmentCount }, (_, index) => { - const segmentTime = segmentAlignedTime - index * segmentDuration; + const segmentTime = timelineStartAligned - index * segmentDuration; return ( alignStartDateToTimeline(timelineStart), + [timelineStart, alignStartDateToTimeline], + ); + const { handleMouseDown, handleMouseUp, handleMouseMove } = useDraggableHandler({ contentRef, @@ -72,7 +77,7 @@ export function MotionReviewTimeline({ handlebarTime, setHandlebarTime, timelineDuration, - timelineStart, + timelineStartAligned, isDragging, setIsDragging, handlebarTimeRef, @@ -81,10 +86,9 @@ export function MotionReviewTimeline({ // Generate segments for the timeline const generateSegments = useCallback(() => { const segmentCount = timelineDuration / segmentDuration; - const segmentAlignedTime = alignStartDateToTimeline(timelineStart); return Array.from({ length: segmentCount }, (_, index) => { - const segmentTime = segmentAlignedTime - index * segmentDuration; + const segmentTime = timelineStartAligned - index * segmentDuration; return ( >; handlebarTimeRef: React.MutableRefObject; timelineDuration: number; - timelineStart: number; + timelineStartAligned: number; isDragging: boolean; setIsDragging: React.Dispatch>; }; @@ -30,7 +30,7 @@ function useDraggableHandler({ setHandlebarTime, handlebarTimeRef, timelineDuration, - timelineStart, + timelineStartAligned, isDragging, setIsDragging, }: DragHandlerProps) { @@ -83,7 +83,7 @@ function useDraggableHandler({ getClientYPosition(e); setIsDragging(true); - if (scrollTimeRef.current && clientYPosition) { + if (scrollTimeRef.current && clientYPosition && isDesktop) { const handlebarRect = scrollTimeRef.current.getBoundingClientRect(); setInitialClickAdjustment(clientYPosition - handlebarRect.top); } @@ -207,7 +207,7 @@ function useDraggableHandler({ const segmentIndex = Math.floor(newHandlePosition / segmentHeight); const segmentStartTime = alignStartDateToTimeline( - timelineStart - segmentIndex * segmentDuration, + timelineStartAligned - segmentIndex * segmentDuration, ); if (draggingAtTopEdge || draggingAtBottomEdge) { @@ -233,8 +233,9 @@ function useDraggableHandler({ if (setHandlebarTime) { setHandlebarTime( - timelineStart - - (newHandlePosition / segmentHeight) * segmentDuration, + timelineStartAligned - + ((newHandlePosition - segmentHeight / 2 - 2) / segmentHeight) * + segmentDuration, ); } @@ -265,7 +266,7 @@ function useDraggableHandler({ clientYPosition, isDragging, segmentDuration, - timelineStart, + timelineStartAligned, timelineDuration, timelineRef, draggingAtTopEdge, @@ -290,20 +291,17 @@ function useDraggableHandler({ const parentScrollTop = getCumulativeScrollTop(timelineRef.current); const newHandlePosition = - ((timelineStart - handlebarTime) / segmentDuration) * segmentHeight + + ((timelineStartAligned - handlebarTime) / segmentDuration) * + segmentHeight + parentScrollTop - - scrolled; + scrolled - + 2; // height of handlebar horizontal line - updateHandlebarPosition( - newHandlePosition - segmentHeight, - handlebarTime, - true, - true, - ); + updateHandlebarPosition(newHandlePosition, handlebarTime, true, true); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handlebarTime, showHandlebar, scrollTimeRef, timelineStart]); + }, [handlebarTime, showHandlebar, scrollTimeRef, timelineStartAligned]); return { handleMouseDown, handleMouseUp, handleMouseMove }; } From 086eb37ece92c571d1840708302ae7f950c26642 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 8 Mar 2024 16:24:12 -0700 Subject: [PATCH 194/751] Cleanup to use new preview video player in dynamic player (#10335) * Cleanup to use new preview video player * Make it so motion persists the selected time * Don't jump when player first starts * Get autoplay working right --- .../components/player/DynamicVideoPlayer.tsx | 353 +++++++----------- .../components/player/PreviewVideoPlayer.tsx | 14 +- web/src/pages/Events.tsx | 6 +- web/src/types/playback.ts | 2 - web/src/views/events/EventView.tsx | 24 +- web/src/views/events/RecordingView.tsx | 18 +- 6 files changed, 172 insertions(+), 245 deletions(-) diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index e090c208c..13911d8c3 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -1,11 +1,4 @@ -import { - MutableRefObject, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import VideoPlayer from "./VideoPlayer"; import Player from "video.js/dist/types/player"; import TimelineEventOverlay from "../overlay/TimelineDataOverlay"; @@ -16,6 +9,9 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Recording } from "@/types/record"; import { Preview } from "@/types/preview"; import { DynamicPlayback } from "@/types/playback"; +import PreviewVideoPlayer, { + PreviewVideoController, +} from "./PreviewVideoPlayer"; type PlayerMode = "playback" | "scrubbing"; @@ -28,7 +24,6 @@ type DynamicVideoPlayerProps = { timeRange: { start: number; end: number }; cameraPreviews: Preview[]; previewOnly?: boolean; - preloadRecordings: boolean; onControllerReady: (controller: DynamicVideoController) => void; onClick?: () => void; }; @@ -38,7 +33,6 @@ export default function DynamicVideoPlayer({ timeRange, cameraPreviews, previewOnly = false, - preloadRecordings = true, onControllerReady, onClick, }: DynamicVideoPlayerProps) { @@ -51,35 +45,36 @@ export default function DynamicVideoPlayer({ ); // playback behavior - const tallVideo = useMemo(() => { + const wideVideo = useMemo(() => { if (!config) { return false; } return ( config.cameras[camera].detect.width / - config.cameras[camera].detect.height < - 1 + config.cameras[camera].detect.height > + 1.7 ); }, [camera, config]); // controlling playback - const [playerRef, setPlayerRef] = useState(undefined); - const previewRef = useRef(null); + const playerRef = useRef(null); + const [previewController, setPreviewController] = + useState(null); const [isScrubbing, setIsScrubbing] = useState(previewOnly); const [focusedItem, setFocusedItem] = useState( undefined, ); const controller = useMemo(() => { - if (!config || !playerRef || !previewRef.current) { + if (!config || !playerRef.current || !previewController) { return undefined; } return new DynamicVideoController( camera, - playerRef, - previewRef, + playerRef.current, + previewController, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, previewOnly ? "scrubbing" : "playback", setIsScrubbing, @@ -87,7 +82,7 @@ export default function DynamicVideoPlayer({ ); // we only want to fire once when players are ready // eslint-disable-next-line react-hooks/exhaustive-deps - }, [camera, config, playerRef, previewRef]); + }, [camera, config, playerRef.current, previewController]); useEffect(() => { if (!controller) { @@ -105,7 +100,7 @@ export default function DynamicVideoPlayer({ const [initPreviewOnly, setInitPreviewOnly] = useState(previewOnly); useEffect(() => { - if (!controller || !playerRef) { + if (!controller || !playerRef.current) { return; } @@ -113,10 +108,8 @@ export default function DynamicVideoPlayer({ return; } - if (previewOnly) { - playerRef.autoplay(false); - } else { - controller.seekToTimestamp(playerRef.currentTime() || 0, true); + if (!previewOnly) { + controller.seekToTimestamp(playerRef.current.currentTime() || 0, true); } setInitPreviewOnly(previewOnly); @@ -124,48 +117,52 @@ export default function DynamicVideoPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [controller, previewOnly]); - const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true); - // keyboard control const onKeyboardShortcut = useCallback( (key: string, down: boolean, repeat: boolean) => { + if (!playerRef.current || previewOnly) { + return; + } + switch (key) { case "ArrowLeft": if (down) { - const currentTime = playerRef?.currentTime(); + const currentTime = playerRef.current.currentTime(); if (currentTime) { - playerRef?.currentTime(Math.max(0, currentTime - 5)); + playerRef.current.currentTime(Math.max(0, currentTime - 5)); } } break; case "ArrowRight": if (down) { - const currentTime = playerRef?.currentTime(); + const currentTime = playerRef.current.currentTime(); if (currentTime) { - playerRef?.currentTime(currentTime + 5); + playerRef.current.currentTime(currentTime + 5); } } break; case "m": if (down && !repeat && playerRef) { - playerRef.muted(!playerRef.muted()); + playerRef.current.muted(!playerRef.current.muted()); } break; case " ": if (down && playerRef) { - if (playerRef.paused()) { - playerRef.play(); + if (playerRef.current.paused()) { + playerRef.current.play(); } else { - playerRef.pause(); + playerRef.current.pause(); } } break; } }, - [playerRef], + // only update when preview only changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [playerRef.current, previewOnly], ); useKeyboardListener( ["ArrowLeft", "ArrowRight", "m", " "], @@ -188,35 +185,6 @@ export default function DynamicVideoPlayer({ // we only want to calculate this once // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const initialPreview = useMemo(() => { - return cameraPreviews.find( - (preview) => - preview.camera == camera && - Math.round(preview.start) >= timeRange.start && - Math.floor(preview.end) <= timeRange.end, - ); - - // we only want to calculate this once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [currentPreview, setCurrentPreview] = useState(initialPreview); - - const onPreviewSeeked = useCallback(() => { - if (!controller) { - return; - } - - controller.finishedSeeking(); - - if (currentPreview && previewOnly && previewRef.current && onClick) { - setHasRecordingAtTime( - controller.hasRecordingAtTime( - currentPreview.start + previewRef.current.currentTime, - ), - ); - } - }, [controller, currentPreview, onClick, previewOnly]); // state of playback player @@ -246,23 +214,9 @@ export default function DynamicVideoPlayer({ ",", )}/master.m3u8`; - const preview = cameraPreviews.find( - (preview) => - preview.camera == camera && - Math.round(preview.start) >= timeRange.start && - Math.floor(preview.end) <= timeRange.end, - ); - setCurrentPreview(preview); - - if (preview && previewRef.current) { - previewRef.current.load(); - } - controller.newPlayback({ recordings: recordings ?? [], playbackUri, - preview, - timeRange, }); // we only want this to change when recordings update @@ -271,62 +225,53 @@ export default function DynamicVideoPlayer({ return (
- {preloadRecordings && ( -
- { - setPlayerRef(player); - }} - onDispose={() => { - setPlayerRef(undefined); - }} - > - {config && focusedItem && ( - - )} - -
- )} -
); } @@ -334,22 +279,17 @@ export default function DynamicVideoPlayer({ export class DynamicVideoController { // main state public camera = ""; - private playerRef: Player; - private previewRef: MutableRefObject; + private playerController: Player; + private previewController: PreviewVideoController; private setScrubbing: (isScrubbing: boolean) => void; private setFocusedItem: (timeline: Timeline) => void; private playerMode: PlayerMode = "playback"; - private timeRange: { start: number; end: number } | undefined = undefined; // playback private recordings: Recording[] = []; private annotationOffset: number; private timeToStart: number | undefined = undefined; - - // preview - private preview: Preview | undefined = undefined; - private timeToSeek: number | undefined = undefined; - private seeking = false; + private canPlay: boolean = false; // listeners private playerProgressListener: (() => void) | null = null; @@ -358,16 +298,16 @@ export class DynamicVideoController { constructor( camera: string, - playerRef: Player, - previewRef: MutableRefObject, + playerController: Player, + previewController: PreviewVideoController, annotationOffset: number, defaultMode: PlayerMode, setScrubbing: (isScrubbing: boolean) => void, setFocusedItem: (timeline: Timeline) => void, ) { this.camera = camera; - this.playerRef = playerRef; - this.previewRef = previewRef; + this.playerController = playerController; + this.previewController = previewController; this.annotationOffset = annotationOffset; this.playerMode = defaultMode; this.setScrubbing = setScrubbing; @@ -376,33 +316,26 @@ export class DynamicVideoController { newPlayback(newPlayback: DynamicPlayback) { this.recordings = newPlayback.recordings; - - this.playerRef.src({ + this.playerController.src({ src: newPlayback.playbackUri, type: "application/vnd.apple.mpegurl", }); + this.canPlay = false; if (this.timeToStart) { this.seekToTimestamp(this.timeToStart); this.timeToStart = undefined; } + } - this.preview = newPlayback.preview; - - if (this.preview) { - this.seeking = false; - this.timeToSeek = undefined; - } - - this.timeRange = newPlayback.timeRange; + autoPlay(play: boolean) { + this.playerController.autoplay(play); } seekToTimestamp(time: number, play: boolean = false) { if (this.playerMode != "playback") { this.playerMode = "playback"; this.setScrubbing(false); - this.timeToSeek = undefined; - this.seeking = false; } if (this.recordings.length == 0) { @@ -425,29 +358,17 @@ export class DynamicVideoController { segment.end_time - segment.start_time - (segment.end_time - time); return true; }); - this.playerRef.currentTime(seekSeconds); + this.playerController.currentTime(seekSeconds); if (play) { - this.playerRef.play(); + this.playerController.play(); } else { - this.playerRef.pause(); - } - } - - onCanPlay(listener: (() => void) | null) { - if (listener) { - this.canPlayListener = listener; - this.playerRef.on("canplay", this.canPlayListener); - } else { - if (this.canPlayListener) { - this.playerRef.off("canplay", this.canPlayListener); - this.canPlayListener = null; - } + this.playerController.pause(); } } seekToTimelineItem(timeline: Timeline) { - this.playerRef.pause(); + this.playerController.pause(); this.seekToTimestamp(timeline.timestamp + this.annotationOffset); this.setFocusedItem(timeline); } @@ -470,79 +391,61 @@ export class DynamicVideoController { return timestamp; } - onPlayerTimeUpdate(listener: ((timestamp: number) => void) | undefined) { - if (this.playerProgressListener) { - this.playerRef.off("timeupdate", this.playerProgressListener); + onCanPlay(listener: (() => void) | null) { + if (this.canPlayListener) { + this.playerController.off("canplay", this.canPlayListener); + this.canPlayListener = null; } if (listener) { - this.playerProgressListener = () => - listener(this.getProgress(this.playerRef.currentTime() || 0)); - this.playerRef.on("timeupdate", this.playerProgressListener); + this.canPlayListener = () => { + this.canPlay = true; + listener(); + }; + this.playerController.on("canplay", this.canPlayListener); } } - onClipChangedEvent(listener: ((dir: "forward") => void) | undefined) { + onPlayerTimeUpdate(listener: ((timestamp: number) => void) | null) { + if (this.playerProgressListener) { + this.playerController.off("timeupdate", this.playerProgressListener); + this.playerProgressListener = null; + } + + if (listener) { + this.playerProgressListener = () => { + if (this.canPlay) { + listener(this.getProgress(this.playerController.currentTime() || 0)); + } + }; + this.playerController.on("timeupdate", this.playerProgressListener); + } + } + + onClipChangedEvent(listener: ((dir: "forward") => void) | null) { if (this.playerEndedListener) { - this.playerRef.off("ended", this.playerEndedListener); + this.playerController.off("ended", this.playerEndedListener); + this.playerEndedListener = null; } if (listener) { this.playerEndedListener = () => listener("forward"); - this.playerRef.on("ended", this.playerEndedListener); + this.playerController.on("ended", this.playerEndedListener); } } - scrubToTimestamp(time: number) { - if (!this.preview || !this.timeRange) { - return; + scrubToTimestamp(time: number, saveIfNotReady: boolean = false) { + const scrubResult = this.previewController.scrubToTimestamp(time); + + if (!scrubResult && saveIfNotReady) { + this.previewController.setNewPreviewStartTime(time); } - if (time < this.preview.start || time > this.preview.end) { - return; - } - - if (this.playerMode != "scrubbing") { + if (scrubResult && this.playerMode != "scrubbing") { this.playerMode = "scrubbing"; - this.playerRef.pause(); + this.playerController.pause(); this.setScrubbing(true); } - - if (this.seeking) { - this.timeToSeek = time; - } else { - if (this.previewRef.current) { - this.previewRef.current.currentTime = Math.max( - 0, - time - this.preview.start, - ); - this.seeking = true; - } - } - } - - finishedSeeking() { - if ( - !this.previewRef.current || - !this.preview || - this.playerMode == "playback" - ) { - return; - } - - if ( - this.timeToSeek && - this.timeToSeek != this.previewRef.current?.currentTime - ) { - this.previewRef.current.currentTime = - this.timeToSeek - this.preview.start; - } else { - this.seeking = false; - } - } - - previewReady() { - this.previewRef.current?.pause(); } hasRecordingAtTime(time: number): boolean { diff --git a/web/src/components/player/PreviewVideoPlayer.tsx b/web/src/components/player/PreviewVideoPlayer.tsx index 8f3f170d2..5d68f0d02 100644 --- a/web/src/components/player/PreviewVideoPlayer.tsx +++ b/web/src/components/player/PreviewVideoPlayer.tsx @@ -16,6 +16,7 @@ type PreviewVideoPlayerProps = { camera: string; timeRange: { start: number; end: number }; cameraPreviews: Preview[]; + startTime?: number; onControllerReady: (controller: PreviewVideoController) => void; onClick?: () => void; }; @@ -24,6 +25,7 @@ export default function PreviewVideoPlayer({ camera, timeRange, cameraPreviews, + startTime, onControllerReady, onClick, }: PreviewVideoPlayerProps) { @@ -128,6 +130,10 @@ export default function PreviewVideoPlayer({ } else { previewRef.current?.pause(); } + + if (previewRef.current && startTime && currentPreview) { + previewRef.current.currentTime = startTime - currentPreview.start; + } }} > {currentPreview != undefined && ( @@ -164,13 +170,13 @@ export class PreviewVideoController { this.timeRange = newPlayback.timeRange; } - scrubToTimestamp(time: number) { + scrubToTimestamp(time: number): boolean { if (!this.preview || !this.timeRange) { - return; + return false; } if (time < this.preview.start || time > this.preview.end) { - return; + return false; } if (this.seeking) { @@ -184,6 +190,8 @@ export class PreviewVideoController { this.seeking = true; } } + + return true; } setNewPreviewStartTime(time: number) { diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 3bb199fea..0d0eae1cc 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -31,6 +31,7 @@ export default function Events() { "alert", ); const [selectedReviewId, setSelectedReviewId] = useOverlayState("review"); + const [startTime, setStartTime] = useState(); // review filter @@ -221,11 +222,13 @@ export default function Events() { if (selectedReviewId.startsWith("motion")) { const motionData = selectedReviewId.split(","); + const motionStart = parseFloat(motionData[2]); + setStartTime(motionStart); // format is motion,camera,start_time return { camera: motionData[1], severity: "significant_motion" as ReviewSeverity, - start_time: parseFloat(motionData[2]), + start_time: motionStart, allCameras: allCameras, cameraSegments: reviews.filter((seg) => allCameras.includes(seg.camera), @@ -292,6 +295,7 @@ export default function Events() { timeRange={selectedTimeRange} filter={reviewFilter} severity={severity ?? "alert"} + startTime={startTime} setSeverity={setSeverity} markItemAsReviewed={markItemAsReviewed} onOpenReview={setSelectedReviewId} diff --git a/web/src/types/playback.ts b/web/src/types/playback.ts index ea1d901b7..b1efeed37 100644 --- a/web/src/types/playback.ts +++ b/web/src/types/playback.ts @@ -4,8 +4,6 @@ import { Recording } from "./record"; export type DynamicPlayback = { recordings: Recording[]; playbackUri: string; - preview: Preview | undefined; - timeRange: { end: number; start: number }; }; export type PreviewPlayback = { diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index d801aa487..776a22d36 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -44,6 +44,7 @@ type EventViewProps = { timeRange: { before: number; after: number }; filter?: ReviewFilter; severity: ReviewSeverity; + startTime?: number; setSeverity: (severity: ReviewSeverity) => void; markItemAsReviewed: (review: ReviewSegment) => void; onOpenReview: (reviewId: string) => void; @@ -57,6 +58,7 @@ export default function EventView({ timeRange, filter, severity, + startTime, setSeverity, markItemAsReviewed, onOpenReview, @@ -262,6 +264,7 @@ export default function EventView({ reviewItems={reviewItems} relevantPreviews={relevantPreviews} timeRange={timeRange} + startTime={startTime} filter={filter} onSelectReview={onSelectReview} /> @@ -518,6 +521,7 @@ type MotionReviewProps = { }; relevantPreviews?: Preview[]; timeRange: { before: number; after: number }; + startTime?: number; filter?: ReviewFilter; onSelectReview: (data: string, ctrl: boolean) => void; }; @@ -526,6 +530,7 @@ function MotionReview({ reviewItems, relevantPreviews, timeRange, + startTime, filter, onSelectReview, }: MotionReviewProps) { @@ -579,11 +584,21 @@ function MotionReview({ [lastFullHour, timeRange], ); - const [selectedRangeIdx, setSelectedRangeIdx] = useState( - timeRangeSegments.ranges.length - 1, - ); + const initialIndex = useMemo(() => { + if (!startTime) { + return timeRangeSegments.ranges.length - 1; + } + + return timeRangeSegments.ranges.findIndex( + (seg) => seg.start <= startTime && seg.end >= startTime, + ); + // only render once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex); const [currentTime, setCurrentTime] = useState( - timeRangeSegments.ranges[selectedRangeIdx].start, + startTime ?? timeRangeSegments.ranges[selectedRangeIdx].start, ); const currentTimeRange = useMemo( () => timeRangeSegments.ranges[selectedRangeIdx], @@ -642,6 +657,7 @@ function MotionReview({ className={`${grow}`} camera={camera.name} timeRange={currentTimeRange} + startTime={startTime} cameraPreviews={relevantPreviews || []} onControllerReady={(controller) => { videoPlayersRef.current[camera.name] = controller; diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index 3eb7f776c..89dff8b40 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -35,7 +35,6 @@ export function DesktopRecordingView({ // controller state - const [playerReady, setPlayerReady] = useState(false); const [mainCamera, setMainCamera] = useState(startCamera); const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>( {}, @@ -74,7 +73,9 @@ export function DesktopRecordingView({ } }); } - }, [selectedRangeIdx, timeRange, videoPlayersRef, playerReady, mainCamera]); + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedRangeIdx, timeRange, videoPlayersRef.current, mainCamera]); // scrubbing and timeline state @@ -116,9 +117,11 @@ export function DesktopRecordingView({ (newCam: string) => { const lastController = videoPlayersRef.current[mainCamera]; const newController = videoPlayersRef.current[newCam]; - lastController.onPlayerTimeUpdate(undefined); - lastController.onClipChangedEvent(undefined); + lastController.onPlayerTimeUpdate(null); + lastController.onClipChangedEvent(null); + lastController.autoPlay(false); lastController.scrubToTimestamp(currentTime); + newController.autoPlay(true); newController.onCanPlay(() => { newController.seekToTimestamp(currentTime, true); newController.onCanPlay(null); @@ -176,10 +179,8 @@ export function DesktopRecordingView({ camera={cam} timeRange={currentTimeRange} cameraPreviews={allPreviews ?? []} - preloadRecordings onControllerReady={(controller) => { videoPlayersRef.current[cam] = controller; - setPlayerReady(true); controller.onPlayerTimeUpdate((timestamp: number) => { setCurrentTime(timestamp); @@ -210,11 +211,9 @@ export function DesktopRecordingView({ timeRange={currentTimeRange} cameraPreviews={allPreviews ?? []} previewOnly - preloadRecordings onControllerReady={(controller) => { videoPlayersRef.current[cam] = controller; - setPlayerReady(true); - controller.scrubToTimestamp(startTime); + controller.scrubToTimestamp(startTime, true); }} onClick={() => onSelectCamera(cam)} /> @@ -372,7 +371,6 @@ export function MobileRecordingView({ camera={startCamera} timeRange={currentTimeRange} cameraPreviews={relevantPreviews || []} - preloadRecordings onControllerReady={(controller) => { controllerRef.current = controller; setPlayerReady(true); From f7122a766e206d5109c0a5cb8582909d0f306608 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 8 Mar 2024 16:24:47 -0700 Subject: [PATCH 195/751] Ensure at least 2 frames are saved per minute in preview video (#10337) * simplify * Formatting --- frigate/output/preview.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/frigate/output/preview.py b/frigate/output/preview.py index de9dccce7..43dd9bb03 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -201,26 +201,18 @@ class PreviewRecorder: frame_time: float, ) -> bool: """Decide if this frame should be added to PREVIEW.""" - preview_output_fps = ( - 2 - if any( - o["label"] == "car" - for o in get_active_objects( - frame_time, self.config, current_tracked_objects - ) - ) - else 1 + active_objs = get_active_objects( + frame_time, self.config, current_tracked_objects ) + preview_output_fps = 2 if any(o["label"] == "car" for o in active_objs) else 1 + # limit output to 1 fps if (frame_time - self.last_output_time) < 1 / preview_output_fps: return False # send frame if a non-stationary object is in a zone - if any( - (len(o["current_zones"]) > 0 and not o["stationary"]) - for o in current_tracked_objects - ): + if len(active_objs > 0): self.last_output_time = frame_time return True @@ -228,6 +220,11 @@ class PreviewRecorder: self.last_output_time = frame_time return True + # ensure that at least 2 frames are written every minute + if frame_time - self.last_output_time > 30: + self.last_output_time = frame_time + return True + return False def write_frame_to_cache(self, frame_time: float, frame) -> None: From c721e7cfa9f9423b027096bcabc1c92704b10b9f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 8 Mar 2024 16:45:42 -0700 Subject: [PATCH 196/751] fix preview comparison (#10338) --- frigate/output/preview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 43dd9bb03..5fd7c5f29 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -212,7 +212,7 @@ class PreviewRecorder: return False # send frame if a non-stationary object is in a zone - if len(active_objs > 0): + if len(active_objs) > 0: self.last_output_time = frame_time return True From a50e955b3e4e8a1c943cfa6c124ed50c6038f2ed Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 9 Mar 2024 05:04:17 -0700 Subject: [PATCH 197/751] Add job to delete old images (#10332) --- .github/workflows/stale.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a9c902f2e..b682012dc 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -24,3 +24,18 @@ jobs: operations-per-run: 120 - name: Print outputs run: echo ${{ join(steps.stale.outputs.*, ',') }} + + clean_ghcr: + name: Delete outdated dev container images + runs-on: ubuntu-latest + steps: + - name: Delete old images + uses: snok/container-retentation-policy@v2 + with: + image-names: dev-* + cut-off: 60 days ago UTC + keep-at-least: 5 + account-type: personal + token: ${{ secrets.GITHUB_TOKEN }} + token-type: github-token + From eeb2187b9789c6e507530f659a5eebc1724886dc Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 9 Mar 2024 05:04:57 -0700 Subject: [PATCH 198/751] Separate extra amd64 builds (#10319) --- .github/workflows/ci.yml | 139 +++++++++++++++++++++------------------ 1 file changed, 76 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90c3d7084..5efec8589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,69 +37,6 @@ jobs: target: frigate tags: ${{ steps.setup.outputs.image-name }}-amd64 cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64 - - name: Build and push TensorRT (x86 GPU) - env: - COMPUTE_LEVEL: "50 60 70 80 90" - uses: docker/bake-action@v4 - with: - push: true - targets: tensorrt - files: docker/tensorrt/trt.hcl - set: | - tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt - *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64 - *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64,mode=max - - name: AMD/ROCm general build - env: - AMDGPU: gfx - HSA_OVERRIDE: 0 - uses: docker/bake-action@v3 - with: - push: true - targets: rocm - files: docker/rocm/rocm.hcl - set: | - rocm.tags=${{ steps.setup.outputs.image-name }}-rocm - *.cache-from=type=gha - - name: AMD/ROCm gfx900 - env: - AMDGPU: gfx900 - HSA_OVERRIDE: 1 - HSA_OVERRIDE_GFX_VERSION: 9.0.0 - uses: docker/bake-action@v3 - with: - push: true - targets: rocm - files: docker/rocm/rocm.hcl - set: | - rocm.tags=${{ steps.setup.outputs.image-name }}-rocm-gfx900 - *.cache-from=type=gha - - name: AMD/ROCm gfx1030 - env: - AMDGPU: gfx1030 - HSA_OVERRIDE: 1 - HSA_OVERRIDE_GFX_VERSION: 10.3.0 - uses: docker/bake-action@v3 - with: - push: true - targets: rocm - files: docker/rocm/rocm.hcl - set: | - rocm.tags=${{ steps.setup.outputs.image-name }}-rocm-gfx1030 - *.cache-from=type=gha - - name: AMD/ROCm gfx1100 - env: - AMDGPU: gfx1100 - HSA_OVERRIDE: 1 - HSA_OVERRIDE_GFX_VERSION: 11.0.0 - uses: docker/bake-action@v3 - with: - push: true - targets: rocm - files: docker/rocm/rocm.hcl - set: | - rocm.tags=${{ steps.setup.outputs.image-name }}-rocm-gfx1100 - *.cache-from=type=gha arm64_build: runs-on: ubuntu-latest name: ARM Build @@ -193,6 +130,82 @@ jobs: tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-jp5 *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5 *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5,mode=max + amd64_extra_builds: + runs-on: ubuntu-latest + name: AMD64 Extra Build + needs: + - amd64_build + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Set up QEMU and Buildx + id: setup + uses: ./.github/actions/setup + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push TensorRT (x86 GPU) + env: + COMPUTE_LEVEL: "50 60 70 80 90" + uses: docker/bake-action@v4 + with: + push: true + targets: tensorrt + files: docker/tensorrt/trt.hcl + set: | + tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt + *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64 + *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64,mode=max + - name: AMD/ROCm general build + env: + AMDGPU: gfx + HSA_OVERRIDE: 0 + uses: docker/bake-action@v3 + with: + push: true + targets: rocm + files: docker/rocm/rocm.hcl + set: | + rocm.tags=${{ steps.setup.outputs.image-name }}-rocm + *.cache-from=type=gha + - name: AMD/ROCm gfx900 + env: + AMDGPU: gfx900 + HSA_OVERRIDE: 1 + HSA_OVERRIDE_GFX_VERSION: 9.0.0 + uses: docker/bake-action@v3 + with: + push: true + targets: rocm + files: docker/rocm/rocm.hcl + set: | + rocm.tags=${{ steps.setup.outputs.image-name }}-rocm-gfx900 + *.cache-from=type=gha + - name: AMD/ROCm gfx1030 + env: + AMDGPU: gfx1030 + HSA_OVERRIDE: 1 + HSA_OVERRIDE_GFX_VERSION: 10.3.0 + uses: docker/bake-action@v3 + with: + push: true + targets: rocm + files: docker/rocm/rocm.hcl + set: | + rocm.tags=${{ steps.setup.outputs.image-name }}-rocm-gfx1030 + *.cache-from=type=gha + - name: AMD/ROCm gfx1100 + env: + AMDGPU: gfx1100 + HSA_OVERRIDE: 1 + HSA_OVERRIDE_GFX_VERSION: 11.0.0 + uses: docker/bake-action@v3 + with: + push: true + targets: rocm + files: docker/rocm/rocm.hcl + set: | + rocm.tags=${{ steps.setup.outputs.image-name }}-rocm-gfx1100 + *.cache-from=type=gha # The majority of users running arm64 are rpi users, so the rpi # build should be the primary arm64 image assemble_default_build: From 62d13024f6d792f88fa6bdb05ed296bb90f75259 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 9 Mar 2024 07:08:06 -0700 Subject: [PATCH 199/751] Adjustments and fixes (#10346) * Increase duration of alerts and detections * Add key * Fix cancel button * Fix motion review when switching days * Add reset buttons and make calendar apply immediately * Adjust apis for motion and audio activity * Write review thumbs as webp and reduce size --- frigate/api/review.py | 76 ++++++++++++++++--- frigate/review/maintainer.py | 13 +++- .../components/filter/CameraGroupSelector.tsx | 2 +- .../components/filter/ReviewFilterGroup.tsx | 59 +++++++------- web/src/pages/SubmitPlus.tsx | 2 +- web/src/types/review.ts | 4 +- web/src/views/events/EventView.tsx | 5 +- 7 files changed, 111 insertions(+), 50 deletions(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 5807f90ef..4170a1a91 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -353,8 +353,8 @@ def delete_reviews(ids: str): return make_response(jsonify({"success": True, "message": "Delete reviews"}), 200) -@ReviewBp.route("/review/activity") -def review_activity(): +@ReviewBp.route("/review/activity/motion") +def motion_activity(): """Get motion and audio activity.""" cameras = request.args.get("cameras", "all") before = request.args.get("before", type=float, default=datetime.now().timestamp()) @@ -374,6 +374,68 @@ def review_activity(): Recordings.duration, Recordings.objects, Recordings.motion, + ) + .where(reduce(operator.and_, clauses)) + .order_by(Recordings.start_time.asc()) + .iterator() + ) + + # format is: { timestamp: segment_start_ts, motion: [0-100], audio: [0 - -100] } + # periods where active objects / audio was detected will cause motion to be scaled down + data: list[dict[str, float]] = [] + + for rec in all_recordings: + data.append( + { + "start_time": rec.start_time, + "motion": rec.motion if rec.objects == 0 else 0, + } + ) + + # get scale in seconds + scale = request.args.get("scale", type=int, default=30) + + # resample data using pandas to get activity on scaled basis + df = pd.DataFrame(data, columns=["start_time", "motion"]) + + # set date as datetime index + df["start_time"] = pd.to_datetime(df["start_time"], unit="s") + df.set_index(["start_time"], inplace=True) + + # normalize data + df = df.resample(f"{scale}S").sum().fillna(0.0) + df["motion"] = ( + (df["motion"] - df["motion"].min()) + / (df["motion"].max() - df["motion"].min()) + * 100 + ) + + # change types for output + df.index = df.index.astype(int) // (10**9) + normalized = df.reset_index().to_dict("records") + return jsonify(normalized) + + +@ReviewBp.route("/review/activity/audio") +def audio_activity(): + """Get motion and audio activity.""" + cameras = request.args.get("cameras", "all") + before = request.args.get("before", type=float, default=datetime.now().timestamp()) + after = request.args.get( + "after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp() + ) + + clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] + + if cameras != "all": + camera_list = cameras.split(",") + clauses.append((Recordings.camera << camera_list)) + + all_recordings: list[Recordings] = ( + Recordings.select( + Recordings.start_time, + Recordings.duration, + Recordings.objects, Recordings.dBFS, ) .where(reduce(operator.and_, clauses)) @@ -382,14 +444,13 @@ def review_activity(): ) # format is: { timestamp: segment_start_ts, motion: [0-100], audio: [0 - -100] } - # periods where active objects / audio was detected will cause motion / audio to be scaled down + # periods where active objects / audio was detected will cause audio to be scaled down data: list[dict[str, float]] = [] for rec in all_recordings: data.append( { "start_time": rec.start_time, - "motion": rec.motion if rec.objects == 0 else 0, "audio": rec.dBFS if rec.objects == 0 else 0, } ) @@ -398,7 +459,7 @@ def review_activity(): scale = request.args.get("scale", type=int, default=30) # resample data using pandas to get activity on scaled basis - df = pd.DataFrame(data, columns=["start_time", "motion", "audio"]) + df = pd.DataFrame(data, columns=["start_time", "audio"]) # set date as datetime index df["start_time"] = pd.to_datetime(df["start_time"], unit="s") @@ -406,11 +467,6 @@ def review_activity(): # normalize data df = df.resample(f"{scale}S").mean().fillna(0.0) - df["motion"] = ( - (df["motion"] - df["motion"].min()) - / (df["motion"].max() - df["motion"].min()) - * 100 - ) df["audio"] = ( (df["audio"] - df["audio"].max()) / (df["audio"].min() - df["audio"].max()) diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 9e0a38210..8da44708f 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -28,6 +28,10 @@ logger = logging.getLogger(__name__) THUMB_HEIGHT = 180 THUMB_WIDTH = 320 +THRESHOLD_ALERT_ACTIVITY = 120 +THRESHOLD_DETECTION_ACTIVITY = 30 +THRESHOLD_MOTION_ACTIVITY = 30 + class SeverityEnum(str, Enum): alert = "alert" @@ -100,7 +104,7 @@ class PendingReviewSegment: path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg") if self.frame is not None: - cv2.imwrite(path, self.frame) + cv2.imwrite(path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]) return { ReviewSegment.id: self.id, @@ -195,15 +199,16 @@ class ReviewSegmentMaintainer(threading.Thread): if len(object["current_zones"]) > 0: segment.zones.update(object["current_zones"]) elif ( - segment.severity == SeverityEnum.signification_motion and len(motion) >= 20 + segment.severity == SeverityEnum.signification_motion + and len(motion) >= THRESHOLD_MOTION_ACTIVITY ): segment.last_update = frame_time else: if segment.severity == SeverityEnum.alert and frame_time > ( - segment.last_update + 60 + segment.last_update + THRESHOLD_ALERT_ACTIVITY ): self.end_segment(segment) - elif frame_time > (segment.last_update + 10): + elif frame_time > (segment.last_update + THRESHOLD_DETECTION_ACTIVITY): self.end_segment(segment) def check_if_new_segment( diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 61414281a..b36406e4d 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -222,7 +222,7 @@ function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) { Camera Groups {currentGroups.map((group) => ( -
+
{group[0]}
+
); @@ -271,8 +280,6 @@ function CalendarFilterButton({ day, updateSelectedDay, }: CalendarFilterButtonProps) { - const [open, setOpen] = useState(false); - const [selectedDay, setSelectedDay] = useState(day); const disabledDates = useMemo(() => { const tomorrow = new Date(); tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); @@ -298,22 +305,21 @@ function CalendarFilterButton({ { - setSelectedDay(day); + updateSelectedDay(day); }} />
@@ -321,16 +327,7 @@ function CalendarFilterButton({ if (isMobile) { return ( - { - if (!open) { - setSelectedDay(day); - } - - setOpen(open); - }} - > + {trigger} {content} @@ -338,16 +335,7 @@ function CalendarFilterButton({ } return ( - { - if (!open) { - setSelectedDay(day); - } - - setOpen(open); - }} - > + {trigger} {content} @@ -433,7 +421,7 @@ function GeneralFilterButton({ ))}
-
+
+
); diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx index 1fc76597d..f0c206846 100644 --- a/web/src/pages/SubmitPlus.tsx +++ b/web/src/pages/SubmitPlus.tsx @@ -107,7 +107,7 @@ export default function SubmitPlus() { alt={`${upload?.label}`} /> - +
@@ -264,6 +265,7 @@ type MobileRecordingViewProps = { severity: ReviewSeverity; reviewItems: ReviewSegment[]; relevantPreviews?: Preview[]; + allCameras: string[]; }; export function MobileRecordingView({ startCamera, @@ -271,6 +273,7 @@ export function MobileRecordingView({ severity, reviewItems, relevantPreviews, + allCameras, }: MobileRecordingViewProps) { const navigate = useNavigate(); const contentRef = useRef(null); @@ -279,6 +282,8 @@ export function MobileRecordingView({ const [playerReady, setPlayerReady] = useState(false); const controllerRef = useRef(undefined); + const [playbackCamera, setPlaybackCamera] = useState(startCamera); + const [playbackStart, setPlaybackStart] = useState(startTime); // timeline time @@ -361,16 +366,45 @@ export function MobileRecordingView({ return (
- +
+ + + + + + + { + setPlaybackStart(currentTime); + setPlaybackCamera(cam); + }} + > + {allCameras.map((cam) => ( + + {cam.replaceAll("_", " ")} + + ))} + + + +
{ controllerRef.current = controller; setPlayerReady(true); From efaa95b742a990fa3cf3dafa1a5796de7b651a0b Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 10 Mar 2024 07:17:48 -0600 Subject: [PATCH 202/751] Fix dst offset calculation (#10357) --- web/src/utils/dateUtil.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index 4a3f7158e..b3814057b 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -258,9 +258,7 @@ const getUTCOffset = (date: Date, timezone: string): number => { } // Otherwise, calculate offset using provided timezone - const utcDate = new Date( - date.getTime() - date.getTimezoneOffset() * 60 * 1000, - ); + const utcDate = new Date(date.getTime()); // locale of en-CA is required for proper locale format let iso = utcDate .toLocaleString("en-CA", { timeZone: timezone, hour12: false }) @@ -274,7 +272,11 @@ const getUTCOffset = (date: Date, timezone: string): number => { target = new Date(`${iso}+000`); } - return (target.getTime() - utcDate.getTime()) / 60 / 1000; + return ( + (target.getTime() - utcDate.getTime() - date.getTimezoneOffset()) / + 60 / + 1000 + ); }; export function getRangeForTimestamp(timestamp: number) { From 70825bc938ef12dc4dba4469cbb041c428fec546 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 10 Mar 2024 08:23:36 -0500 Subject: [PATCH 203/751] rename vars and change which div is absolutely positioned (#10358) --- .../timeline/EventReviewTimeline.tsx | 6 ++--- .../timeline/MotionReviewTimeline.tsx | 6 ++--- .../components/timeline/ReviewTimeline.tsx | 11 ++++++---- web/src/hooks/use-handle-dragging.ts | 22 +++++++++---------- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 7e2b60bc3..5f6c6dc0a 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -46,7 +46,7 @@ export function EventReviewTimeline({ onHandlebarDraggingChange, }: EventReviewTimelineProps) { const [isDragging, setIsDragging] = useState(false); - const scrollTimeRef = useRef(null); + const handlebarRef = useRef(null); const timelineRef = useRef(null); const handlebarTimeRef = useRef(null); const timelineDuration = useMemo( @@ -68,7 +68,7 @@ export function EventReviewTimeline({ useDraggableHandler({ contentRef, timelineRef, - scrollTimeRef, + handlebarRef, alignStartDateToTimeline, alignEndDateToTimeline, segmentDuration, @@ -143,7 +143,7 @@ export function EventReviewTimeline({ return ( (null); + const handlebarRef = useRef(null); const timelineRef = useRef(null); const handlebarTimeRef = useRef(null); const timelineDuration = useMemo( @@ -69,7 +69,7 @@ export function MotionReviewTimeline({ useDraggableHandler({ contentRef, timelineRef, - scrollTimeRef, + handlebarRef, alignStartDateToTimeline, alignEndDateToTimeline, segmentDuration, @@ -145,7 +145,7 @@ export function MotionReviewTimeline({ return ( ; - scrollTimeRef: RefObject; + handlebarRef: RefObject; handlebarTimeRef: RefObject; handleMouseMove: ( e: @@ -27,7 +27,7 @@ export type ReviewTimelineProps = { export function ReviewTimeline({ timelineRef, - scrollTimeRef, + handlebarRef, handlebarTimeRef, handleMouseMove, handleMouseUp, @@ -50,14 +50,17 @@ export function ReviewTimeline({ >
{children}
{showHandlebar && ( -
+
; timelineRef: React.RefObject; - scrollTimeRef: React.RefObject; + handlebarRef: React.RefObject; alignStartDateToTimeline: (time: number) => number; alignEndDateToTimeline: (time: number) => number; segmentDuration: number; @@ -22,7 +22,7 @@ type DragHandlerProps = { function useDraggableHandler({ contentRef, timelineRef, - scrollTimeRef, + handlebarRef, alignStartDateToTimeline, segmentDuration, showHandlebar, @@ -83,12 +83,12 @@ function useDraggableHandler({ getClientYPosition(e); setIsDragging(true); - if (scrollTimeRef.current && clientYPosition && isDesktop) { - const handlebarRect = scrollTimeRef.current.getBoundingClientRect(); + if (handlebarRef.current && clientYPosition && isDesktop) { + const handlebarRect = handlebarRef.current.getBoundingClientRect(); setInitialClickAdjustment(clientYPosition - handlebarRect.top); } }, - [setIsDragging, getClientYPosition, scrollTimeRef, clientYPosition], + [setIsDragging, getClientYPosition, handlebarRef, clientYPosition], ); const handleMouseUp = useCallback( @@ -121,7 +121,7 @@ function useDraggableHandler({ scrollTimeline: boolean, updateHandle: boolean, ) => { - const thumb = scrollTimeRef.current; + const thumb = handlebarRef.current; if (thumb) { requestAnimationFrame(() => { thumb.style.top = `${newHandlePosition}px`; @@ -148,7 +148,7 @@ function useDraggableHandler({ } } }, - [segmentDuration, handlebarTimeRef, scrollTimeRef, setHandlebarTime], + [segmentDuration, handlebarTimeRef, handlebarRef, setHandlebarTime], ); const handleMouseMove = useCallback( @@ -158,7 +158,7 @@ function useDraggableHandler({ if ( !contentRef.current || !timelineRef.current || - !scrollTimeRef.current + !handlebarRef.current ) { return; } @@ -166,7 +166,7 @@ function useDraggableHandler({ getClientYPosition(e); }, - [contentRef, scrollTimeRef, timelineRef, getClientYPosition], + [contentRef, handlebarRef, timelineRef, getClientYPosition], ); useEffect(() => { @@ -277,7 +277,7 @@ function useDraggableHandler({ useEffect(() => { if ( timelineRef.current && - scrollTimeRef.current && + handlebarRef.current && showHandlebar && handlebarTime && !isDragging @@ -301,7 +301,7 @@ function useDraggableHandler({ } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handlebarTime, showHandlebar, scrollTimeRef, timelineStartAligned]); + }, [handlebarTime, showHandlebar, handlebarRef, timelineStartAligned]); return { handleMouseDown, handleMouseUp, handleMouseMove }; } From ee239744d8046ac89a7e31704422ec60ed37b20e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 10 Mar 2024 07:25:16 -0600 Subject: [PATCH 204/751] Redesign exports page (#10359) * Redesign exports page * Cleanup * fix overhange --- web/src/components/card/ExportCard.tsx | 89 +++++++-- web/src/components/indicators/Chip.tsx | 5 +- .../player/PreviewThumbnailPlayer.tsx | 2 +- web/src/pages/Export.tsx | 189 ++++++++---------- 4 files changed, 155 insertions(+), 130 deletions(-) diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 3d374b859..3510cb39d 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -1,32 +1,87 @@ import { baseUrl } from "@/api/baseUrl"; import ActivityIndicator from "../indicators/activity-indicator"; -import { Card } from "../ui/card"; -import { LuPlay, LuTrash } from "react-icons/lu"; +import { LuTrash } from "react-icons/lu"; import { Button } from "../ui/button"; +import { useMemo, useRef, useState } from "react"; +import { isDesktop } from "react-device-detect"; +import { FaPlay } from "react-icons/fa"; +import Chip from "../indicators/Chip"; type ExportProps = { file: { name: string; }; - onSelect: (file: string) => void; onDelete: (file: string) => void; }; -export default function ExportCard({ file, onSelect, onDelete }: ExportProps) { +export default function ExportCard({ file, onDelete }: ExportProps) { + const videoRef = useRef(null); + const [hovered, setHovered] = useState(false); + const [playing, setPlaying] = useState(false); + const inProgress = useMemo( + () => file.name.startsWith("in_progress"), + [file.name], + ); + return ( - - {file.name.startsWith("in_progress") ? ( +
setHovered(true) : undefined + } + onMouseLeave={ + isDesktop && !inProgress ? () => setHovered(false) : undefined + } + onClick={isDesktop || inProgress ? undefined : () => setHovered(!hovered)} + > + {!playing && hovered && ( <> -
- -
-
- {file.name.substring(12, file.name.length - 4)} -
+
+ onDelete(file.name)} + > + + + + )} + {inProgress ? ( + ) : ( - <> -
+ ); +} + +/** + * - - )} - - ); -} + */ diff --git a/web/src/components/indicators/Chip.tsx b/web/src/components/indicators/Chip.tsx index 8d1b6555d..16cfb9bf5 100644 --- a/web/src/components/indicators/Chip.tsx +++ b/web/src/components/indicators/Chip.tsx @@ -3,14 +3,16 @@ import { CSSTransition } from "react-transition-group"; type ChipProps = { className?: string; - children?: ReactNode[]; + children?: ReactNode | ReactNode[]; in?: boolean; + onClick?: () => void; }; export default function Chip({ className, children, in: inProp = true, + onClick, }: ChipProps) { const nodeRef = useRef(null); @@ -30,6 +32,7 @@ export default function Chip({
{children}
diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index e526fa777..4cf7f29b5 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -217,7 +217,7 @@ export default function PreviewThumbnailPlayer({
-
+
{formattedDate}
diff --git a/web/src/pages/Export.tsx b/web/src/pages/Export.tsx index 07abfd268..188382b0b 100644 --- a/web/src/pages/Export.tsx +++ b/web/src/pages/Export.tsx @@ -1,6 +1,5 @@ import { baseUrl } from "@/api/baseUrl"; import ExportCard from "@/components/card/ExportCard"; -import VideoPlayer from "@/components/player/VideoPlayer"; import { AlertDialog, AlertDialogCancel, @@ -12,13 +11,8 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; -import { Card } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { DropdownMenuRadioGroup, DropdownMenuTrigger, @@ -28,18 +22,13 @@ import { DropdownMenuSeparator, DropdownMenuRadioItem, } from "@/components/ui/dropdown-menu"; -import Heading from "@/components/ui/heading"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; import { Toaster } from "@/components/ui/sonner"; import { FrigateConfig } from "@/types/frigateConfig"; import axios from "axios"; import { format } from "date-fns"; import { useCallback, useState } from "react"; import { DateRange } from "react-day-picker"; +import { isDesktop } from "react-device-detect"; import { toast } from "sonner"; import useSWR from "swr"; @@ -67,7 +56,6 @@ function Export() { const [startTime, setStartTime] = useState("00:00:00"); const [endTime, setEndTime] = useState("23:59:59"); - const [selectedClip, setSelectedClip] = useState(); const [deleteClip, setDeleteClip] = useState(); const onHandleExport = () => { @@ -150,8 +138,12 @@ function Export() { }); }, [deleteClip, mutate]); + const Create = isDesktop ? Dialog : Drawer; + const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; + const Content = isDesktop ? DialogContent : DrawerContent; + return ( -
+
- setSelectedClip(undefined)} - > - - - Playback - - - - - -
-
-
- - - - - - Select A Camera - - - {Object.keys(config?.cameras || {}).map((item) => ( - - {item.replaceAll("_", " ")} - - ))} - - - -
+
+ + + + + +
- + + + + Select A Camera + + + + {Object.keys(config?.cameras || {}).map((item) => ( + + {item.replaceAll("_", " ")} + + ))} + + + + + + - + Select A Playback Factor @@ -252,56 +225,54 @@ function Export() {
-
- - - - - - -
- setStartTime(e.target.value)} - /> - setEndTime(e.target.value)} - /> -
-
-
-
- -
-
+ } ${endTime}`} + +
+ + +
+
{exports && ( - - Exports +
{Object.values(exports).map((item) => ( setSelectedClip(file)} onDelete={(file) => setDeleteClip(file)} /> ))} - +
)}
From 020b09216bb2973c08cba9406a280ad9e71f98e3 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 10 Mar 2024 12:18:44 -0600 Subject: [PATCH 205/751] Fix image cleanup (#10364) --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b682012dc..172eaeca6 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Delete old images - uses: snok/container-retentation-policy@v2 + uses: snok/container-retention-policy@v2 with: image-names: dev-* cut-off: 60 days ago UTC From 359e45a7482f2468a88fb7bce56ced979de18242 Mon Sep 17 00:00:00 2001 From: Daniel Harrelson Date: Mon, 11 Mar 2024 08:13:07 -0400 Subject: [PATCH 206/751] Update live.md (#10366) Incorrectly stated tailscale used 100.0.0.0/8 CIDR block. Correct CIDR block for tailscale in 100.64.0.0/10 RFC6598 --- docs/docs/configuration/live.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index 003e7599c..163b16179 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -79,7 +79,7 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req - stun:8555 ``` -- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.0.0.0/8` CIDR block. +- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block. :::tip From cac5bccbe75d24c943cba3d89adef868b31e0ad3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 11 Mar 2024 07:56:36 -0500 Subject: [PATCH 207/751] Fix segments on motion review (#10370) * fix segments on motion review * remove unneeded data attribute --- web/src/components/timeline/MotionSegment.tsx | 31 ++++++++++++++++--- web/src/hooks/use-motion-segment-utils.ts | 14 ++++++--- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 2efdd73d0..c66bbfc0b 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -82,7 +82,19 @@ export function MotionSegment({ return isMobile ? 30 : 50; }, []); - const segmentWidth = useMemo(() => { + const firstHalfSegmentWidth = useMemo(() => { + return interpolateMotionAudioData( + getMotionSegmentValue(segmentTime), + maxSegmentWidth, + ); + }, [ + segmentTime, + maxSegmentWidth, + getMotionSegmentValue, + interpolateMotionAudioData, + ]); + + const secondHalfSegmentWidth = useMemo(() => { return interpolateMotionAudioData( getMotionSegmentValue(segmentTime + segmentDuration / 2), maxSegmentWidth, @@ -167,10 +179,19 @@ export function MotionSegment({ }; const segmentClick = useCallback(() => { - if (startTimestamp && setHandlebarTime && segmentWidth > 1) { + if ( + startTimestamp && + setHandlebarTime && + (firstHalfSegmentWidth > 1 || secondHalfSegmentWidth > 1) + ) { setHandlebarTime(startTimestamp); } - }, [startTimestamp, setHandlebarTime, segmentWidth]); + }, [ + startTimestamp, + setHandlebarTime, + firstHalfSegmentWidth, + secondHalfSegmentWidth, + ]); return (
@@ -216,7 +237,7 @@ export function MotionSegment({ key={`${segmentKey}_motion_data_2`} className={`h-[2px] rounded-full bg-motion_review`} style={{ - width: segmentWidth, + width: firstHalfSegmentWidth, }} >
diff --git a/web/src/hooks/use-motion-segment-utils.ts b/web/src/hooks/use-motion-segment-utils.ts index 4cf0c15e1..dfec48358 100644 --- a/web/src/hooks/use-motion-segment-utils.ts +++ b/web/src/hooks/use-motion-segment-utils.ts @@ -40,14 +40,20 @@ export const useMotionSegmentUtils = ( const getMotionSegmentValue = useCallback( (time: number): number => { - const matchingEvent = motion_events.find((event) => { + const segmentStart = getSegmentStart(time); + const segmentEnd = getSegmentEnd(time); + const matchingEvents = motion_events.filter((event) => { return ( - time >= getSegmentStart(event.start_time) && - time <= getSegmentEnd(event.start_time) + event.start_time >= segmentStart && event.start_time < segmentEnd ); }); - return matchingEvent?.motion ?? 0; + const totalMotion = matchingEvents.reduce( + (acc, curr) => acc + (curr.motion ?? 0), + 0, + ); + + return totalMotion; }, [motion_events, getSegmentStart, getSegmentEnd], ); From 838ef636f8b6b097292faf2458d16dff170f1e8e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 11 Mar 2024 07:05:44 -0600 Subject: [PATCH 208/751] Layout tweaks (#10365) * Cleanup review items * Fix spacing * Fix -1 text * Cleanup scroll * Show activity indicator when review items are null * Show no preview text when not found * Add padding to buttons * Simplify comparisons --- .../components/filter/ReviewFilterGroup.tsx | 6 +-- .../components/player/PreviewVideoPlayer.tsx | 5 ++ web/src/views/events/EventView.tsx | 47 +++++++++++++------ web/src/views/events/RecordingView.tsx | 2 +- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 2a2229ec9..0ebb00a20 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -212,7 +212,7 @@ function CamerasFilterButton({ ))}
-
+
-
+
); } diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 24cf7aa84..eaae92a0f 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -85,7 +85,7 @@ export default function EventView({ } if (!summary) { - return { alert: 0, detection: 0, significant_motion: 0 }; + return { alert: -1, detection: -1, significant_motion: -1 }; } if (filter?.showReviewed == 1) { @@ -106,6 +106,10 @@ export default function EventView({ // review paging const reviewItems = useMemo(() => { + if (!reviews) { + return undefined; + } + const all: ReviewSegment[] = []; const alerts: ReviewSegment[] = []; const detections: ReviewSegment[] = []; @@ -167,7 +171,7 @@ export default function EventView({ const exportReview = useCallback( (id: string) => { - const review = reviewItems.all?.find((seg) => seg.id == id); + const review = reviewItems?.all?.find((seg) => seg.id == id); if (!review) { return; @@ -206,7 +210,9 @@ export default function EventView({ aria-label="Select alerts" > -
Alerts ∙ {reviewCounts.alert}
+
+ Alerts{` ∙ ${reviewCounts.alert > -1 ? reviewCounts.alert : ""}`} +
- Detections ∙ {reviewCounts.detection} + Detections + {` ∙ ${reviewCounts.detection > -1 ? reviewCounts.detection : ""}`}
; - reviewItems: { + reviewItems?: { all: ReviewSegment[]; alert: ReviewSegment[]; detection: ReviewSegment[]; @@ -310,10 +317,14 @@ function DetectionReview({ // review data const currentItems = useMemo(() => { + if (!reviewItems) { + return null; + } + const current = reviewItems[severity]; if (!current || current.length == 0) { - return null; + return []; } if (filter?.showReviewed != 1) { @@ -323,7 +334,7 @@ function DetectionReview({ } // only refresh when severity or filter changes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [severity, filter, reviewItems.all.length]); + }, [severity, filter, reviewItems?.all.length]); // preview @@ -348,7 +359,7 @@ function DetectionReview({ // timeline interaction const { alignStartDateToTimeline } = useEventUtils( - reviewItems.all, + reviewItems?.all ?? [], segmentDuration, ); @@ -441,10 +452,16 @@ function DetectionReview({ /> )} - {itemsToReview == 0 && ( -
+ {!currentItems && ( +
+ +
+ )} + + {currentItems?.length === 0 && ( +
- There are no {severity.replace(/_/g, " ")} items to review + There are no {severity.replace(/_/g, " ")}s to review
)} @@ -479,7 +496,7 @@ function DetectionReview({
); })} - {(itemsToReview ?? 0) > 0 && ( + {(currentItems?.length ?? 0) > 0 && (itemsToReview ?? 0) > 0 && (
-
- {allCameras.map((cam) => { - if (cam == mainCamera) { - return ( -
- { - videoPlayersRef.current[cam] = controller; - controller.onPlayerTimeUpdate((timestamp: number) => { - setCurrentTime(timestamp); - - allCameras.forEach((otherCam) => { - if (cam != otherCam) { - videoPlayersRef.current[otherCam]?.scrubToTimestamp( - Math.floor(timestamp), - ); - } - }); - }); - }} - /> -
- ); - } - - return ( -
+
+
+
+
{ - videoPlayersRef.current[cam] = controller; - controller.scrubToTimestamp(startTime, true); + videoPlayersRef.current[mainCamera] = controller; + controller.onPlayerTimeUpdate((timestamp: number) => { + setCurrentTime(timestamp); + + allCameras.forEach((otherCam) => { + if (mainCamera != otherCam) { + videoPlayersRef.current[otherCam]?.scrubToTimestamp( + Math.floor(timestamp), + ); + } + }); + }); }} - onClick={() => onSelectCamera(cam)} />
- ); - })} -
+
+ {allCameras.map((cam) => { + if (cam !== mainCamera) { + return ( +
+ { + videoPlayersRef.current[cam] = controller; + controller.scrubToTimestamp(startTime, true); + }} + onClick={() => onSelectCamera(cam)} + /> +
+ ); + } + return null; + })} +
+
+
-
- {severity != "significant_motion" ? ( - setScrubbing(scrubbing)} - /> - ) : ( - setScrubbing(scrubbing)} - /> - )} +
+ {severity != "significant_motion" ? ( + setScrubbing(scrubbing)} + /> + ) : ( + setScrubbing(scrubbing)} + /> + )} +
); From 1c5d6765a147bb72dbe647d48d672ea9d000c3ed Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 11 Mar 2024 17:31:05 -0600 Subject: [PATCH 212/751] Preview improvements (#10384) * Write preview frames as webp instead of jpg and ensure webp are cached in nginx * Support preview player that shows current hour images * Update to get preview player working * Use timestamp based recordings check instead of calendar * Start motion review from current time * Adjust layout * Use preview players for previews * remove vite * Cleanup * Fix up the layout --- .../rootfs/usr/local/nginx/conf/nginx.conf | 2 +- frigate/api/media.py | 8 +- frigate/api/preview.py | 6 +- frigate/const.py | 4 + frigate/output/preview.py | 19 +- .../components/player/DynamicVideoPlayer.tsx | 33 +- web/src/components/player/PreviewPlayer.tsx | 458 ++++++++++++++++++ .../player/PreviewThumbnailPlayer.tsx | 3 +- .../components/player/PreviewVideoPlayer.tsx | 230 --------- web/src/utils/timelineUtil.tsx | 2 +- web/src/views/events/EventView.tsx | 23 +- web/src/views/events/RecordingView.tsx | 109 ++--- 12 files changed, 547 insertions(+), 350 deletions(-) create mode 100644 web/src/components/player/PreviewPlayer.tsx delete mode 100644 web/src/components/player/PreviewVideoPlayer.tsx diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index c55a58562..0dc57a26f 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -210,7 +210,7 @@ http { include proxy.conf; } - location ~* /api/.*\.(jpg|jpeg|png)$ { + location ~* /api/.*\.(jpg|jpeg|png|webp)$ { rewrite ^/api/(.*)$ $1 break; proxy_pass http://frigate_api; include proxy.conf; diff --git a/frigate/api/media.py b/frigate/api/media.py index 6e986af0b..2c8f37dc8 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -30,6 +30,7 @@ from frigate.const import ( CLIPS_DIR, EXPORT_DIR, MAX_SEGMENT_DURATION, + PREVIEW_FRAME_TYPE, RECORD_DIR, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment @@ -1173,8 +1174,8 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000): # need to generate from existing images preview_dir = os.path.join(CACHE_DIR, "preview_frames") file_start = f"preview_{camera_name}" - start_file = f"{file_start}-{start_ts}.jpg" - end_file = f"{file_start}-{end_ts}.jpg" + start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" selected_previews = [] for file in sorted(os.listdir(preview_dir)): @@ -1258,8 +1259,9 @@ def review_preview(id: str): @MediaBp.route("/preview//thumbnail.jpg") +@MediaBp.route("/preview//thumbnail.webp") def preview_thumbnail(file_name: str): - """Get a thumbnail from the cached preview jpgs.""" + """Get a thumbnail from the cached preview frames.""" safe_file_name_current = secure_filename(file_name) preview_dir = os.path.join(CACHE_DIR, "preview_frames") diff --git a/frigate/api/preview.py b/frigate/api/preview.py index dd9e5d5a0..46d3d0e82 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -11,7 +11,7 @@ from flask import ( make_response, ) -from frigate.const import CACHE_DIR +from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.models import Previews logger = logging.getLogger(__name__) @@ -97,8 +97,8 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts): """Get list of cached preview frames""" preview_dir = os.path.join(CACHE_DIR, "preview_frames") file_start = f"preview_{camera_name}" - start_file = f"{file_start}-{start_ts}.jpg" - end_file = f"{file_start}-{end_ts}.jpg" + start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" selected_previews = [] for file in sorted(os.listdir(preview_dir)): diff --git a/frigate/const.py b/frigate/const.py index 62a202c37..9b65fe5c9 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -57,6 +57,10 @@ DRIVER_AMD = "radeonsi" DRIVER_INTEL_i965 = "i965" DRIVER_INTEL_iHD = "iHD" +# Preview Values + +PREVIEW_FRAME_TYPE = "webp" + # Record Values CACHE_SEGMENT_FORMAT = "%Y%m%d%H%M%S%z" diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 5fd7c5f29..47b17b188 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -12,7 +12,7 @@ import numpy as np from frigate.comms.inter_process import InterProcessRequestor from frigate.config import CameraConfig, RecordQualityEnum -from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW +from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW, PREVIEW_FRAME_TYPE from frigate.ffmpeg_presets import ( FPS_VFR_PARAM, EncodeTypeEnum, @@ -42,12 +42,12 @@ def get_cache_image_name(camera: str, frame_time: float) -> str: """Get the image name in cache.""" return os.path.join( CACHE_DIR, - f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.jpg", + f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.{PREVIEW_FRAME_TYPE}", ) class FFMpegConverter(threading.Thread): - """Convert a list of jpg frames into a vfr mp4.""" + """Convert a list of still frames into a vfr mp4.""" def __init__( self, @@ -176,7 +176,7 @@ class PreviewRecorder: ) file_start = f"preview_{config.name}" - start_file = f"{file_start}-{start_ts}.jpg" + start_file = f"{file_start}-{start_ts}.webp" for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))): if not file.startswith(file_start): @@ -186,7 +186,7 @@ class PreviewRecorder: os.unlink(os.path.join(PREVIEW_CACHE_DIR, file)) continue - ts = float(file.split("-")[1][:-4]) + ts = float(file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)]) if self.start_time == 0: self.start_time = ts @@ -242,12 +242,11 @@ class PreviewRecorder: small_frame, cv2.COLOR_YUV2BGR_I420, ) - _, jpg = cv2.imencode(".jpg", small_frame) - with open( + cv2.imwrite( get_cache_image_name(self.config.name, frame_time), - "wb", - ) as j: - j.write(jpg.tobytes()) + small_frame, + [int(cv2.IMWRITE_WEBP_QUALITY), 80], + ) def write_data( self, diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index 77eb51ee8..910c8cc79 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -9,9 +9,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Recording } from "@/types/record"; import { Preview } from "@/types/preview"; import { DynamicPlayback } from "@/types/playback"; -import PreviewVideoPlayer, { - PreviewVideoController, -} from "./PreviewVideoPlayer"; +import PreviewPlayer, { PreviewController } from "./PreviewPlayer"; type PlayerMode = "playback" | "scrubbing"; @@ -40,11 +38,6 @@ export default function DynamicVideoPlayer({ }: DynamicVideoPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); - const timezone = useMemo( - () => - config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, - [config], - ); // playback behavior const wideVideo = useMemo(() => { @@ -63,7 +56,7 @@ export default function DynamicVideoPlayer({ const [playerRef, setPlayerRef] = useState(null); const [previewController, setPreviewController] = - useState(null); + useState(null); const [isScrubbing, setIsScrubbing] = useState(previewOnly); const [focusedItem, setFocusedItem] = useState( undefined, @@ -154,14 +147,8 @@ export default function DynamicVideoPlayer({ // initial state const initialPlaybackSource = useMemo(() => { - const date = new Date(timeRange.start * 1000); return { - src: `${apiHost}vod/${date.getFullYear()}-${ - date.getMonth() + 1 - }/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll( - "/", - ",", - )}/master.m3u8`, + src: `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, type: "application/vnd.apple.mpegurl", }; // we only want to calculate this once @@ -224,13 +211,7 @@ export default function DynamicVideoPlayer({ return; } - const date = new Date(timeRange.start * 1000); - const playbackUri = `${apiHost}vod/${date.getFullYear()}-${ - date.getMonth() + 1 - }/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll( - "/", - ",", - )}/master.m3u8`; + const playbackUri = `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`; controller.newPlayback({ recordings: recordings ?? [], @@ -280,7 +261,7 @@ export default function DynamicVideoPlayer({ )}
- void; private setFocusedItem: (timeline: Timeline) => void; private playerMode: PlayerMode = "playback"; @@ -315,7 +296,7 @@ export class DynamicVideoController { constructor( camera: string, playerController: Player, - previewController: PreviewVideoController, + previewController: PreviewController, annotationOffset: number, defaultMode: PlayerMode, setScrubbing: (isScrubbing: boolean) => void, diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx new file mode 100644 index 000000000..bf61698b5 --- /dev/null +++ b/web/src/components/player/PreviewPlayer.tsx @@ -0,0 +1,458 @@ +import { + MutableRefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { Preview } from "@/types/preview"; +import { PreviewPlayback } from "@/types/playback"; +import { isCurrentHour } from "@/utils/dateUtil"; +import { baseUrl } from "@/api/baseUrl"; + +type PreviewPlayerProps = { + className?: string; + camera: string; + timeRange: { start: number; end: number }; + cameraPreviews: Preview[]; + startTime?: number; + onControllerReady: (controller: PreviewController) => void; + onClick?: () => void; +}; +export default function PreviewPlayer({ + className, + camera, + timeRange, + cameraPreviews, + startTime, + onControllerReady, + onClick, +}: PreviewPlayerProps) { + if (isCurrentHour(timeRange.end)) { + return ( + + ); + } + + return ( + + ); +} + +export abstract class PreviewController { + public camera = ""; + + constructor(camera: string) { + this.camera = camera; + } + + abstract scrubToTimestamp(time: number): boolean; + + abstract finishedSeeking(): void; + + abstract setNewPreviewStartTime(time: number): void; +} + +type PreviewVideoPlayerProps = { + className?: string; + camera: string; + timeRange: { start: number; end: number }; + cameraPreviews: Preview[]; + startTime?: number; + onControllerReady: (controller: PreviewVideoController) => void; + onClick?: () => void; +}; +function PreviewVideoPlayer({ + className, + camera, + timeRange, + cameraPreviews, + startTime, + onControllerReady, + onClick, +}: PreviewVideoPlayerProps) { + const { data: config } = useSWR("config"); + + // controlling playback + + const previewRef = useRef(null); + const controller = useMemo(() => { + if (!config || !previewRef.current) { + return undefined; + } + + return new PreviewVideoController(camera, previewRef); + // we only care when preview is ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [camera, config, previewRef.current]); + + useEffect(() => { + if (!controller) { + return; + } + + if (controller) { + onControllerReady(controller); + } + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller]); + + // initial state + + const initialPreview = useMemo(() => { + return cameraPreviews.find( + (preview) => + preview.camera == camera && + Math.round(preview.start) >= timeRange.start && + Math.floor(preview.end) <= timeRange.end, + ); + + // we only want to calculate this once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [currentPreview, setCurrentPreview] = useState(initialPreview); + + const onPreviewSeeked = useCallback(() => { + if (!controller) { + return; + } + + controller.finishedSeeking(); + }, [controller]); + + useEffect(() => { + if (!controller) { + return; + } + + const preview = cameraPreviews.find( + (preview) => + preview.camera == camera && + Math.round(preview.start) >= timeRange.start && + Math.floor(preview.end) <= timeRange.end, + ); + setCurrentPreview(preview); + + controller.newPlayback({ + preview, + timeRange, + }); + + // we only want this to change when recordings update + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller, timeRange]); + + useEffect(() => { + if (!currentPreview || !previewRef.current) { + return; + } + + previewRef.current.load(); + }, [currentPreview, previewRef]); + + return ( +
+ + {cameraPreviews && !currentPreview && ( +
+ No Preview Found +
+ )} +
+ ); +} + +class PreviewVideoController extends PreviewController { + // main state + private previewRef: MutableRefObject; + private timeRange: { start: number; end: number } | undefined = undefined; + + // preview + private preview: Preview | undefined = undefined; + private timeToSeek: number | undefined = undefined; + private seeking = false; + + constructor( + camera: string, + previewRef: MutableRefObject, + ) { + super(camera); + this.previewRef = previewRef; + } + + newPlayback(newPlayback: PreviewPlayback) { + this.preview = newPlayback.preview; + this.seeking = false; + + this.timeRange = newPlayback.timeRange; + } + + override scrubToTimestamp(time: number): boolean { + if (!this.preview || !this.timeRange) { + return false; + } + + if (time < this.preview.start || time > this.preview.end) { + return false; + } + + if (this.seeking) { + this.timeToSeek = time; + } else { + if (this.previewRef.current) { + this.previewRef.current.currentTime = Math.max( + 0, + time - this.preview.start, + ); + this.seeking = true; + } + } + + return true; + } + + override finishedSeeking() { + if (!this.previewRef.current || !this.preview) { + return; + } + + if ( + this.timeToSeek && + this.timeToSeek != this.previewRef.current?.currentTime + ) { + this.previewRef.current.currentTime = + this.timeToSeek - this.preview.start; + } else { + this.seeking = false; + } + } + + override setNewPreviewStartTime(time: number) { + this.timeToSeek = time; + } + + previewReady() { + this.seeking = false; + this.previewRef.current?.pause(); + + if (this.timeToSeek) { + this.finishedSeeking(); + } + } +} + +type PreviewFramesPlayerProps = { + className?: string; + camera: string; + timeRange: { start: number; end: number }; + startTime?: number; + onControllerReady: (controller: PreviewController) => void; + onClick?: () => void; +}; +function PreviewFramesPlayer({ + className, + camera, + timeRange, + startTime, + onControllerReady, + onClick, +}: PreviewFramesPlayerProps) { + // frames data + + const { data: previewFrames } = useSWR( + `preview/${camera}/start/${Math.floor(timeRange.start)}/end/${Math.ceil( + timeRange.end, + )}/frames`, + { revalidateOnFocus: false }, + ); + const frameTimes = useMemo(() => { + if (!previewFrames) { + return undefined; + } + + return previewFrames.map((frame) => + parseFloat(frame.split("-")[1].slice(undefined, -5)), + ); + }, [previewFrames]); + + // controlling frames + + const imgRef = useRef(null); + const controller = useMemo(() => { + if (!frameTimes || !imgRef.current) { + return undefined; + } + + return new PreviewFramesController(camera, imgRef, frameTimes); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [imgRef, frameTimes, imgRef.current]); + + // initial state + + useEffect(() => { + if (!controller) { + return; + } + + if (controller) { + onControllerReady(controller); + } + + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller]); + + const onImageLoaded = useCallback(() => { + if (!controller) { + return; + } + + controller.finishedSeeking(); + }, [controller]); + + useEffect(() => { + if (!controller) { + return; + } + + if (!startTime) { + controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.start); + } else { + controller.scrubToTimestamp(startTime); + } + // we only want to calculate this once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller]); + + return ( +
+ + {previewFrames?.length === 0 && ( +
+ No Preview Found +
+ )} +
+ ); +} + +class PreviewFramesController extends PreviewController { + imgController: MutableRefObject; + frameTimes: number[]; + seeking: boolean = false; + private timeToSeek: number | undefined = undefined; + + constructor( + camera: string, + imgController: MutableRefObject, + frameTimes: number[], + ) { + super(camera); + this.imgController = imgController; + this.frameTimes = frameTimes; + } + + override scrubToTimestamp(time: number): boolean { + if (!this.imgController.current) { + return false; + } + + const frame = this.frameTimes.find((p) => { + return time <= p; + }); + + if (!frame) { + return false; + } + + if (this.seeking) { + this.timeToSeek = frame; + } else { + const newSrc = `${baseUrl}api/preview/preview_${this.camera}-${frame}.webp/thumbnail.webp`; + + if (this.imgController.current.src != newSrc) { + this.imgController.current.src = newSrc; + this.seeking = true; + } + } + + return true; + } + + override finishedSeeking() { + if (!this.imgController.current) { + return false; + } + + if (this.timeToSeek) { + const newSrc = `${baseUrl}api/preview/preview_${this.camera}-${this.timeToSeek}.webp/thumbnail.webp`; + + if (this.imgController.current.src != newSrc) { + this.imgController.current.src = newSrc; + } else { + this.timeToSeek = undefined; + this.seeking = false; + } + } else { + this.seeking = false; + } + } + + override setNewPreviewStartTime(time: number) { + this.timeToSeek = time; + } +} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 4cf7f29b5..12e869877 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -527,6 +527,7 @@ function InProgressPreview({ `preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${ Math.ceil(review.end_time) + PREVIEW_PADDING }/frames`, + { revalidateOnFocus: false }, ); const [manualFrame, setManualFrame] = useState(false); const [hoverTimeout, setHoverTimeout] = useState(); @@ -642,7 +643,7 @@ function InProgressPreview({
void; - onClick?: () => void; -}; -export default function PreviewVideoPlayer({ - className, - camera, - timeRange, - cameraPreviews, - startTime, - onControllerReady, - onClick, -}: PreviewVideoPlayerProps) { - const { data: config } = useSWR("config"); - - // controlling playback - - const previewRef = useRef(null); - const controller = useMemo(() => { - if (!config || !previewRef.current) { - return undefined; - } - - return new PreviewVideoController(camera, previewRef); - // we only care when preview is ready - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [camera, config, previewRef.current]); - - useEffect(() => { - if (!controller) { - return; - } - - if (controller) { - onControllerReady(controller); - } - // we only want to fire once when players are ready - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [controller]); - - // initial state - - const initialPreview = useMemo(() => { - return cameraPreviews.find( - (preview) => - preview.camera == camera && - Math.round(preview.start) >= timeRange.start && - Math.floor(preview.end) <= timeRange.end, - ); - - // we only want to calculate this once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [currentPreview, setCurrentPreview] = useState(initialPreview); - - const onPreviewSeeked = useCallback(() => { - if (!controller) { - return; - } - - controller.finishedSeeking(); - }, [controller]); - - useEffect(() => { - if (!controller) { - return; - } - - const preview = cameraPreviews.find( - (preview) => - preview.camera == camera && - Math.round(preview.start) >= timeRange.start && - Math.floor(preview.end) <= timeRange.end, - ); - setCurrentPreview(preview); - - controller.newPlayback({ - preview, - timeRange, - }); - - // we only want this to change when recordings update - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [controller, timeRange]); - - useEffect(() => { - if (!currentPreview || !previewRef.current) { - return; - } - - previewRef.current.load(); - }, [currentPreview, previewRef]); - - return ( -
- - {cameraPreviews && !currentPreview && ( -
- No Preview Found -
- )} -
- ); -} - -export class PreviewVideoController { - // main state - public camera = ""; - private previewRef: MutableRefObject; - private timeRange: { start: number; end: number } | undefined = undefined; - - // preview - private preview: Preview | undefined = undefined; - private timeToSeek: number | undefined = undefined; - private seeking = false; - - constructor( - camera: string, - previewRef: MutableRefObject, - ) { - this.camera = camera; - this.previewRef = previewRef; - } - - newPlayback(newPlayback: PreviewPlayback) { - this.preview = newPlayback.preview; - this.seeking = false; - - this.timeRange = newPlayback.timeRange; - } - - scrubToTimestamp(time: number): boolean { - if (!this.preview || !this.timeRange) { - return false; - } - - if (time < this.preview.start || time > this.preview.end) { - return false; - } - - if (this.seeking) { - this.timeToSeek = time; - } else { - if (this.previewRef.current) { - this.previewRef.current.currentTime = Math.max( - 0, - time - this.preview.start, - ); - this.seeking = true; - } - } - - return true; - } - - setNewPreviewStartTime(time: number) { - this.timeToSeek = time; - } - - finishedSeeking() { - if (!this.previewRef.current || !this.preview) { - return; - } - - if ( - this.timeToSeek && - this.timeToSeek != this.previewRef.current?.currentTime - ) { - this.previewRef.current.currentTime = - this.timeToSeek - this.preview.start; - } else { - this.seeking = false; - } - } - - previewReady() { - this.seeking = false; - this.previewRef.current?.pause(); - - if (this.timeToSeek) { - this.finishedSeeking(); - } - } -} diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index 291a3fb52..0e0b1d104 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -163,7 +163,7 @@ export function getChunkedTimeRange( while (end < endTimestamp) { startDay.setHours(startDay.getHours() + 1); - if (startDay > endOfThisHour || startDay.getTime() / 1000 > endTimestamp) { + if (startDay > endOfThisHour) { break; } diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index b78a45703..7ed8cea99 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -33,9 +33,9 @@ import { MdCircle } from "react-icons/md"; import useSWR from "swr"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import { Button } from "@/components/ui/button"; -import PreviewVideoPlayer, { - PreviewVideoController, -} from "@/components/player/PreviewVideoPlayer"; +import PreviewPlayer, { + PreviewController, +} from "@/components/player/PreviewPlayer"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -578,9 +578,7 @@ function MotionReview({ return cameras.sort((a, b) => a.ui.order - b.ui.order); }, [config, filter]); - const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>( - {}, - ); + const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({}); // motion data @@ -596,14 +594,9 @@ function MotionReview({ // timeline time - const lastFullHour = useMemo(() => { - const end = new Date(timeRange.before * 1000); - end.setMinutes(0, 0, 0); - return end.getTime() / 1000; - }, [timeRange]); const timeRangeSegments = useMemo( - () => getChunkedTimeRange(timeRange.after, lastFullHour), - [lastFullHour, timeRange], + () => getChunkedTimeRange(timeRange.after, timeRange.before), + [timeRange], ); const initialIndex = useMemo(() => { @@ -620,7 +613,7 @@ function MotionReview({ const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex); const [currentTime, setCurrentTime] = useState( - startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.start, + startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.end, ); const currentTimeRange = useMemo( () => timeRangeSegments.ranges[selectedRangeIdx], @@ -674,7 +667,7 @@ function MotionReview({ grow = "aspect-video"; } return ( - ("config"); const navigate = useNavigate(); const contentRef = useRef(null); // controller state const [mainCamera, setMainCamera] = useState(startCamera); - const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>( - {}, - ); + const mainControllerRef = useRef(null); + const previewRefs = useRef<{ [camera: string]: PreviewController }>({}); const [playbackStart, setPlaybackStart] = useState(startTime); @@ -64,27 +68,20 @@ export function DesktopRecordingView({ // move to next clip useEffect(() => { - if ( - !videoPlayersRef.current && - Object.values(videoPlayersRef.current).length > 0 - ) { + if (!mainControllerRef.current) { return; } - const mainController = videoPlayersRef.current[mainCamera]; - - if (mainController) { - mainController.onClipChangedEvent((dir) => { - if (dir == "forward") { - if (selectedRangeIdx < timeRange.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } + mainControllerRef.current.onClipChangedEvent((dir) => { + if (dir == "forward") { + if (selectedRangeIdx < timeRange.ranges.length - 1) { + setSelectedRangeIdx(selectedRangeIdx + 1); } - }); - } + } + }); // we only want to fire once when players are ready // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedRangeIdx, timeRange, videoPlayersRef.current, mainCamera]); + }, [selectedRangeIdx, timeRange, mainControllerRef.current, mainCamera]); // scrubbing and timeline state @@ -107,7 +104,9 @@ export function DesktopRecordingView({ return; } - Object.values(videoPlayersRef.current).forEach((controller) => { + mainControllerRef.current?.scrubToTimestamp(currentTime); + + Object.values(previewRefs.current).forEach((controller) => { controller.scrubToTimestamp(currentTime); }); } @@ -115,7 +114,7 @@ export function DesktopRecordingView({ useEffect(() => { if (!scrubbing) { - videoPlayersRef.current[mainCamera]?.seekToTimestamp(currentTime, true); + mainControllerRef.current?.seekToTimestamp(currentTime, true); } // we only want to seek when user stops scrubbing @@ -124,27 +123,10 @@ export function DesktopRecordingView({ const onSelectCamera = useCallback( (newCam: string) => { - const lastController = videoPlayersRef.current[mainCamera]; - const newController = videoPlayersRef.current[newCam]; - lastController.onPlayerTimeUpdate(null); - lastController.onClipChangedEvent(null); - lastController.scrubToTimestamp(currentTime); - newController.onPlayerTimeUpdate((timestamp: number) => { - setCurrentTime(timestamp); - - allCameras.forEach((cam) => { - if (cam != newCam) { - videoPlayersRef.current[cam]?.scrubToTimestamp( - Math.floor(timestamp), - ); - } - }); - }); - newController.seekToTimestamp(currentTime, true); - setPlaybackStart(currentTime); setMainCamera(newCam); + setPlaybackStart(currentTime); }, - [allCameras, currentTime, mainCamera], + [currentTime], ); // motion timeline data @@ -162,6 +144,21 @@ export function DesktopRecordingView({ : null, ); + const grow = useMemo(() => { + if (!config) { + return "aspect-video"; + } + + const aspectRatio = + config.cameras[mainCamera].detect.width / + config.cameras[mainCamera].detect.height; + if (aspectRatio > 2) { + return "aspect-wide"; + } else { + return "aspect-video"; + } + }, [config, mainCamera]); + return (
From d882cb0f63934517442d9936e8ace66bc40ba0b4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 15 Mar 2024 05:59:03 -0600 Subject: [PATCH 237/751] Add skeleton for loading exports and improve spacing on bottom sheet (#10463) * Add skeleton for loading exports and improve spacing on bottom sheet * Hide non object frigate plus items --- web/src/components/card/ExportCard.tsx | 26 ++++++-------------------- web/src/pages/Export.tsx | 10 +++++----- web/src/pages/SubmitPlus.tsx | 4 ++++ 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 3510cb39d..8d5d2eef7 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -6,6 +6,7 @@ import { useMemo, useRef, useState } from "react"; import { isDesktop } from "react-device-detect"; import { FaPlay } from "react-icons/fa"; import Chip from "../indicators/Chip"; +import { Skeleton } from "../ui/skeleton"; type ExportProps = { file: { @@ -18,6 +19,7 @@ export default function ExportCard({ file, onDelete }: ExportProps) { const videoRef = useRef(null); const [hovered, setHovered] = useState(false); const [playing, setPlaying] = useState(false); + const [loading, setLoading] = useState(true); const inProgress = useMemo( () => file.name.startsWith("in_progress"), [file.name], @@ -65,10 +67,14 @@ export default function ExportCard({ file, onDelete }: ExportProps) { preload="auto" muted controls={playing} + onLoadedData={() => setLoading(false)} > )} + {loading && ( + + )} {!playing && (
@@ -79,23 +85,3 @@ export default function ExportCard({ file, onDelete }: ExportProps) {
); } - -/** - * -
- {file.name.substring(0, file.name.length - 4)} - - - */ diff --git a/web/src/pages/Export.tsx b/web/src/pages/Export.tsx index 188382b0b..206f3911b 100644 --- a/web/src/pages/Export.tsx +++ b/web/src/pages/Export.tsx @@ -172,16 +172,16 @@ function Export() { -
+
- Select A Camera + Select Camera - Select A Playback Factor + Select Playback {events?.map((event) => { + if (event.data.type != "object") { + return; + } + return (
Date: Fri, 15 Mar 2024 06:52:38 -0600 Subject: [PATCH 238/751] Improve Recordings loading (#10462) * Show skeleton until video players finishes loading * Clean up android logic * Ensure mobile view video is consistent * Cleanup * Only show when not scrubbing * Don't use loading * Start preview at correct time too * Fix react race condition * Be wait for seek to show video player --- web/src/components/player/HlsVideoPlayer.tsx | 3 +++ web/src/components/player/PreviewPlayer.tsx | 27 +++++++++---------- .../player/dynamic/DynamicVideoController.ts | 16 ++++++++++- .../player/dynamic/DynamicVideoPlayer.tsx | 18 ++++++++++--- web/src/views/events/RecordingView.tsx | 17 ++++++++++++ 5 files changed, 63 insertions(+), 18 deletions(-) diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 6d6df7268..0ede0ea3c 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -42,6 +42,7 @@ type HlsVideoPlayerProps = { onClipEnded?: () => void; onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; + onPlaying?: () => void; }; export default function HlsVideoPlayer({ className, @@ -51,6 +52,7 @@ export default function HlsVideoPlayer({ onClipEnded, onPlayerLoaded, onTimeUpdate, + onPlaying, }: HlsVideoPlayerProps) { // playback @@ -183,6 +185,7 @@ export default function HlsVideoPlayer({ setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000)); } }} + onPlaying={onPlaying} onPause={() => { setIsPlaying(false); diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 9206f7e9b..2539faaa2 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -272,26 +272,25 @@ class PreviewVideoController extends PreviewController { } if (this.timeToSeek) { - if ( - Math.round(this.previewRef.current.currentTime + this.preview.start) != - Math.round(this.timeToSeek) - ) { - if (isAndroid) { - const currentTs = - this.previewRef.current.currentTime + this.preview.start; - const diff = this.timeToSeek - currentTs; + const diff = + Math.round(this.timeToSeek) - + Math.round(this.previewRef.current.currentTime + this.preview.start); + if (Math.abs(diff) > 1) { + let seekTime; + if (isAndroid) { if (diff < 30) { - this.previewRef.current.currentTime = - this.previewRef.current.currentTime + diff / 2; + seekTime = Math.round( + this.previewRef.current.currentTime + diff / 2, + ); } else { - this.previewRef.current.currentTime = - this.timeToSeek - this.preview.start; + seekTime = Math.round(this.timeToSeek - this.preview.start); } } else { - this.previewRef.current.currentTime = - this.timeToSeek - this.preview.start; + seekTime = this.timeToSeek - this.preview.start; } + + this.previewRef.current.currentTime = seekTime; } else { this.seeking = false; this.timeToSeek = undefined; diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts index 2b3956db7..d30976447 100644 --- a/web/src/components/player/dynamic/DynamicVideoController.ts +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -81,13 +81,27 @@ export class DynamicVideoController { this.playerController.currentTime = seekSeconds; if (play) { - this.playerController.play(); + this.waitAndPlay(); } else { this.playerController.pause(); } } } + waitAndPlay() { + return new Promise((resolve) => { + const onSeekedHandler = () => { + this.playerController.removeEventListener("seeked", onSeekedHandler); + this.playerController.play(); + resolve(undefined); + }; + + this.playerController.addEventListener("seeked", onSeekedHandler, { + once: true, + }); + }); + } + seekToTimelineItem(timeline: Timeline) { this.playerController.pause(); this.seekToTimestamp(timeline.timestamp + this.annotationOffset); diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index da169a838..3c656a4a7 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -90,12 +90,19 @@ export default function DynamicVideoPlayer({ // initial state + const [isLoading, setIsLoading] = useState(false); const [source, setSource] = useState( `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, ); // start at correct time + useEffect(() => { + if (isScrubbing) { + setIsLoading(true); + } + }, [isScrubbing]); + const onPlayerLoaded = useCallback(() => { if (!controller || !startTimestamp) { return; @@ -140,6 +147,7 @@ export default function DynamicVideoPlayer({ setSource( `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, ); + setIsLoading(true); controller.newPlayback({ recordings: recordings ?? [], @@ -151,14 +159,17 @@ export default function DynamicVideoPlayer({ return (
-
+
setIsLoading(false)} > {config && focusedItem && (
{ setPreviewController(previewController); }} diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index ff5bfbac6..60b8e58a8 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -301,6 +301,7 @@ export function MobileRecordingView({ relevantPreviews, allCameras, }: MobileRecordingViewProps) { + const { data: config } = useSWR("config"); const navigate = useNavigate(); const contentRef = useRef(null); @@ -310,6 +311,21 @@ export function MobileRecordingView({ const [playbackCamera, setPlaybackCamera] = useState(startCamera); const [playbackStart, setPlaybackStart] = useState(startTime); + const grow = useMemo(() => { + if (!config) { + return "aspect-video"; + } + + const aspectRatio = + config.cameras[playbackCamera].detect.width / + config.cameras[playbackCamera].detect.height; + if (aspectRatio > 2) { + return "aspect-wide"; + } else { + return "aspect-video"; + } + }, [config, playbackCamera]); + // timeline time const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]); @@ -453,6 +469,7 @@ export function MobileRecordingView({
Date: Fri, 15 Mar 2024 07:03:14 -0600 Subject: [PATCH 239/751] Add ability to zoom in to live and recordings views (#10475) * Make live view zoomable * Add zooming to full recordings --- web/package-lock.json | 14 ++++ web/package.json | 1 + web/src/components/player/HlsVideoPlayer.tsx | 87 +++++++++++--------- web/src/views/live/LiveCameraView.tsx | 25 +++--- 4 files changed, 77 insertions(+), 50 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 35a75663a..e3bec267b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -52,6 +52,7 @@ "react-tracked": "^1.7.11", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.7.0", + "react-zoom-pan-pinch": "^3.4.3", "recoil": "^0.7.7", "scroll-into-view-if-needed": "^3.1.0", "sonner": "^1.4.0", @@ -6694,6 +6695,19 @@ "react-dom": ">= 18.0.0" } }, + "node_modules/react-zoom-pan-pinch": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.3.tgz", + "integrity": "sha512-x5MFlfAx2D6NTpZu8OISqc2nYn4p+YEaM1p21w7S/VE1wbVzK8vRzTo9Bj1ydufa649MuP7JBRM3vvj1RftFZw==", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/web/package.json b/web/package.json index a9ea40891..0eebdaec1 100644 --- a/web/package.json +++ b/web/package.json @@ -57,6 +57,7 @@ "react-tracked": "^1.7.11", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.7.0", + "react-zoom-pan-pinch": "^3.4.3", "recoil": "^0.7.7", "scroll-into-view-if-needed": "^3.1.0", "sonner": "^1.4.0", diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 0ede0ea3c..4492bae43 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -27,6 +27,7 @@ import { } from "react-icons/md"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Slider } from "../ui/slider-volume"; +import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const; const unsupportedErrorCodes = [ @@ -169,48 +170,54 @@ export default function HlsVideoPlayer({ } onClick={isDesktop ? undefined : () => setControls(!controls)} > -
{camera.onvif.host != "" && }
From df0291db5c9ac0eff6b2585fcc47d90e55654315 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 15 Mar 2024 08:57:58 -0600 Subject: [PATCH 240/751] Fix zoom scaling for live view (#10479) --- web/src/views/live/LiveCameraView.tsx | 243 ++++++++++++++------------ 1 file changed, 127 insertions(+), 116 deletions(-) diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 0ed7c342a..19aff72e0 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -157,131 +157,142 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { }, [cameraAspectRatio, windowAspectRatio, fullscreen]); return ( -
+
- {!fullscreen ? ( - - ) : ( -
- )} - -
- { - if (fullscreen) { - document.exitFullscreen(); - } else { - mainRef.current?.requestFullscreen(); - } - }} - /> - {window.isSecureContext && ( - setMic(!mic)} - /> - )} - setAudio(!audio)} - /> - sendDetect(detectState == "ON" ? "OFF" : "ON")} - /> - sendRecord(recordState == "ON" ? "OFF" : "ON")} - /> - sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} - /> - {camera.audio.enabled_in_config && ( - sendAudio(audioState == "ON" ? "OFF" : "ON")} - /> - )} -
-
-
-
+ {!fullscreen ? ( + + ) : ( +
+ )} + +
+ { + if (fullscreen) { + document.exitFullscreen(); + } else { + mainRef.current?.requestFullscreen(); + } + }} + /> + {window.isSecureContext && ( + setMic(!mic)} + /> + )} + setAudio(!audio)} + /> + sendDetect(detectState == "ON" ? "OFF" : "ON")} + /> + sendRecord(recordState == "ON" ? "OFF" : "ON")} + /> + + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") + } + /> + {camera.audio.enabled_in_config && ( + sendAudio(audioState == "ON" ? "OFF" : "ON")} + /> + )} +
+
+
+ - - - - - -
- {camera.onvif.host != "" && } +
+ +
+ {camera.onvif.host != "" && } +
-
+
); } From c93b186eda76d272d2813b8a06c7b026ac0b0721 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 15 Mar 2024 09:59:41 -0500 Subject: [PATCH 241/751] Fix switching camera group bug (#10478) * give default group a name * use "default" as default value * optional param * fix string * clean up --- .../components/filter/CameraGroupSelector.tsx | 17 ++++++++++------- web/src/pages/Live.tsx | 9 ++++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 7875cc3e0..b8ed05b7b 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -52,7 +52,10 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { // groups - const [group, setGroup] = usePersistedOverlayState("cameraGroup"); + const [group, setGroup] = usePersistedOverlayState( + "cameraGroup", + "default" as string, + ); const groups = useMemo(() => { if (!config) { @@ -78,24 +81,24 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { currentGroups={groups} /> - + - Home + All Cameras {groups.map(([name, config]) => { @@ -109,7 +112,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { : "text-muted-foreground bg-secondary" } size="xs" - onClick={() => setGroup(name, group != undefined)} + onClick={() => setGroup(name, group != "default")} onMouseEnter={() => (isDesktop ? showTooltip(name) : null)} onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)} > diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 4d7b58780..ba64e5680 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -11,10 +11,13 @@ function Live() { const { data: config } = useSWR("config"); const [selectedCameraName, setSelectedCameraName] = useOverlayState("camera"); - const [cameraGroup] = usePersistedOverlayState("cameraGroup"); + const [cameraGroup] = usePersistedOverlayState( + "cameraGroup", + "default" as string, + ); const includesBirdseye = useMemo(() => { - if (config && cameraGroup) { + if (config && cameraGroup && cameraGroup != "default") { return config.camera_groups[cameraGroup].cameras.includes("birdseye"); } else { return false; @@ -26,7 +29,7 @@ function Live() { return []; } - if (cameraGroup) { + if (cameraGroup && cameraGroup != "default") { const group = config.camera_groups[cameraGroup]; return Object.values(config.cameras) .filter((conf) => conf.enabled && group.cameras.includes(conf.name)) From 93260f6cfdcdaa2ea239d78c24cd3d0d7cd1edd9 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 15 Mar 2024 09:29:22 -0600 Subject: [PATCH 242/751] Add region count to database and use for motion activity (#10480) * Add region count to database and use for motion activity * Fix test --- frigate/api/review.py | 11 ++++-- frigate/models.py | 1 + frigate/record/maintainer.py | 15 ++++++-- frigate/test/test_record_retention.py | 16 ++++++--- migrations/006_add_motion_active_objects.py | 3 ++ migrations/023_add_regions.py | 39 +++++++++++++++++++++ 6 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 migrations/023_add_regions.py diff --git a/frigate/api/review.py b/frigate/api/review.py index 7cc3d2695..6d3ba1c3f 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -358,7 +358,6 @@ def motion_activity(): ) clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] - clauses.append((Recordings.motion <= 100)) if cameras != "all": camera_list = cameras.split(",") @@ -367,7 +366,7 @@ def motion_activity(): data: list[Recordings] = ( Recordings.select( Recordings.start_time, - Recordings.motion, + Recordings.regions, ) .where(reduce(operator.and_, clauses)) .order_by(Recordings.start_time.asc()) @@ -379,7 +378,8 @@ def motion_activity(): scale = request.args.get("scale", type=int, default=30) # resample data using pandas to get activity on scaled basis - df = pd.DataFrame(data, columns=["start_time", "motion"]) + df = pd.DataFrame(data, columns=["start_time", "regions"]) + df = df.rename(columns={"regions": "motion"}) # set date as datetime index df["start_time"] = pd.to_datetime(df["start_time"], unit="s") @@ -391,6 +391,11 @@ def motion_activity(): .apply(lambda x: max(x, key=abs, default=0.0)) .fillna(0.0) ) + df["motion"] = ( + (df["motion"] - df["motion"].min()) + / (df["motion"].max() - df["motion"].min()) + * 100 + ) # change types for output df.index = df.index.astype(int) // (10**9) diff --git a/frigate/models.py b/frigate/models.py index 87424e3a8..a9b0f16ca 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -74,6 +74,7 @@ class Recordings(Model): # type: ignore[misc] objects = IntegerField(null=True) dBFS = IntegerField(null=True) segment_size = FloatField(default=0) # this should be stored as MB + regions = IntegerField(null=True) class ReviewSegment(Model): # type: ignore[misc] diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index 71c7a6a3d..89395db12 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -38,10 +38,15 @@ QUEUE_READ_TIMEOUT = 0.00001 # seconds class SegmentInfo: def __init__( - self, motion_area: int, active_object_count: int, average_dBFS: int + self, + motion_area: int, + active_object_count: int, + region_count: int, + average_dBFS: int, ) -> None: self.motion_area = motion_area self.active_object_count = active_object_count + self.region_count = region_count self.average_dBFS = average_dBFS def should_discard_segment(self, retain_mode: RetainModeEnum) -> bool: @@ -298,6 +303,7 @@ class RecordingMaintainer(threading.Thread): ) -> SegmentInfo: video_frame_count = 0 active_count = 0 + region_count = 0 total_motion_area = 0 for frame in self.object_recordings_info[camera]: # frame is after end time of segment @@ -315,8 +321,8 @@ class RecordingMaintainer(threading.Thread): if not o["false_positive"] and o["motionless_count"] == 0 ] ) - total_motion_area += sum([area(box) for box in frame[2]]) + region_count += len(frame[3]) if video_frame_count > 0: normalized_motion_area = min( @@ -350,7 +356,9 @@ class RecordingMaintainer(threading.Thread): average_dBFS = 0 if not audio_values else np.average(audio_values) - return SegmentInfo(normalized_motion_area, active_count, round(average_dBFS)) + return SegmentInfo( + normalized_motion_area, active_count, region_count, round(average_dBFS) + ) async def move_segment( self, @@ -438,6 +446,7 @@ class RecordingMaintainer(threading.Thread): Recordings.motion: segment_info.motion_area, # TODO: update this to store list of active objects at some point Recordings.objects: segment_info.active_object_count, + Recordings.regions: segment_info.region_count, Recordings.dBFS: segment_info.average_dBFS, Recordings.segment_size: segment_size, } diff --git a/frigate/test/test_record_retention.py b/frigate/test/test_record_retention.py index 81230449d..4620c9a3e 100644 --- a/frigate/test/test_record_retention.py +++ b/frigate/test/test_record_retention.py @@ -6,20 +6,28 @@ from frigate.record.maintainer import SegmentInfo class TestRecordRetention(unittest.TestCase): def test_motion_should_keep_motion_not_object(self): - segment_info = SegmentInfo(motion_area=1, active_object_count=0, average_dBFS=0) + segment_info = SegmentInfo( + motion_area=1, active_object_count=0, region_count=0, average_dBFS=0 + ) assert not segment_info.should_discard_segment(RetainModeEnum.motion) assert segment_info.should_discard_segment(RetainModeEnum.active_objects) def test_object_should_keep_object_not_motion(self): - segment_info = SegmentInfo(motion_area=0, active_object_count=1, average_dBFS=0) + segment_info = SegmentInfo( + motion_area=0, active_object_count=1, region_count=0, average_dBFS=0 + ) assert segment_info.should_discard_segment(RetainModeEnum.motion) assert not segment_info.should_discard_segment(RetainModeEnum.active_objects) def test_all_should_keep_all(self): - segment_info = SegmentInfo(motion_area=0, active_object_count=0, average_dBFS=0) + segment_info = SegmentInfo( + motion_area=0, active_object_count=0, region_count=0, average_dBFS=0 + ) assert not segment_info.should_discard_segment(RetainModeEnum.all) def test_should_keep_audio_in_motion_mode(self): - segment_info = SegmentInfo(motion_area=0, active_object_count=0, average_dBFS=1) + segment_info = SegmentInfo( + motion_area=0, active_object_count=0, region_count=0, average_dBFS=1 + ) assert not segment_info.should_discard_segment(RetainModeEnum.motion) assert segment_info.should_discard_segment(RetainModeEnum.active_objects) diff --git a/migrations/006_add_motion_active_objects.py b/migrations/006_add_motion_active_objects.py index 2980b441d..6ab67ee3a 100644 --- a/migrations/006_add_motion_active_objects.py +++ b/migrations/006_add_motion_active_objects.py @@ -34,6 +34,9 @@ def migrate(migrator, database, fake=False, **kwargs): objects=pw.IntegerField(null=True), motion=pw.IntegerField(null=True), ) + migrator.sql( + 'CREATE INDEX "recordings_activity" ON "recordings" ("camera", "start_time" DESC, "regions")' + ) def rollback(migrator, database, fake=False, **kwargs): diff --git a/migrations/023_add_regions.py b/migrations/023_add_regions.py new file mode 100644 index 000000000..17d93962a --- /dev/null +++ b/migrations/023_add_regions.py @@ -0,0 +1,39 @@ +"""Peewee migrations -- 023_add_regions.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw + +from frigate.models import Recordings + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.add_fields( + Recordings, + regions=pw.IntegerField(null=True), + ) + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.remove_fields(Recordings, ["regions"]) From 380b15b28659d85a88a165bf60c247820faa260b Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 15 Mar 2024 12:46:17 -0600 Subject: [PATCH 243/751] WebUI Fixes (#10481) * Update previews on the hour * Allow tap to toggle controls so zooming still works * Use hash location insteaad of state for live camera view * Add typing --- web/src/components/player/LivePlayer.tsx | 3 ++ web/src/components/player/WebRTCPlayer.tsx | 13 ++++++++- web/src/hooks/use-overlay-state.tsx | 30 +++++++++++++++++++- web/src/pages/Events.tsx | 32 ++++++++++++++++++---- web/src/pages/Live.tsx | 5 ++-- web/src/views/live/LiveCameraView.tsx | 32 ++++++++++++---------- 6 files changed, 91 insertions(+), 24 deletions(-) diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index ae982a5ce..69ad60bd3 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -21,6 +21,7 @@ type LivePlayerProps = { windowVisible?: boolean; playAudio?: boolean; micEnabled?: boolean; // only webrtc supports mic + iOSCompatFullScreen?: boolean; onClick?: () => void; }; @@ -32,6 +33,7 @@ export default function LivePlayer({ windowVisible = true, playAudio = false, micEnabled = false, + iOSCompatFullScreen = false, onClick, }: LivePlayerProps) { // camera activity @@ -100,6 +102,7 @@ export default function LivePlayer({ playbackEnabled={cameraActive} audioEnabled={playAudio} microphoneEnabled={micEnabled} + iOSCompatFullScreen={iOSCompatFullScreen} onPlaying={() => setLiveReady(true)} /> ); diff --git a/web/src/components/player/WebRTCPlayer.tsx b/web/src/components/player/WebRTCPlayer.tsx index 4171947f3..a004251d6 100644 --- a/web/src/components/player/WebRTCPlayer.tsx +++ b/web/src/components/player/WebRTCPlayer.tsx @@ -1,5 +1,5 @@ import { baseUrl } from "@/api/baseUrl"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; type WebRtcPlayerProps = { className?: string; @@ -7,6 +7,7 @@ type WebRtcPlayerProps = { playbackEnabled?: boolean; audioEnabled?: boolean; microphoneEnabled?: boolean; + iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element onPlaying?: () => void; }; @@ -16,6 +17,7 @@ export default function WebRtcPlayer({ playbackEnabled = true, audioEnabled = false, microphoneEnabled = false, + iOSCompatFullScreen = false, onPlaying, }: WebRtcPlayerProps) { // metadata @@ -170,14 +172,23 @@ export default function WebRtcPlayer({ microphoneEnabled, ]); + // ios compat + const [iOSCompatControls, setiOSCompatControls] = useState(false); + return (
+
{player}
diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 6787f9143..c29fbed3b 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -3,6 +3,7 @@ import { usePersistedOverlayState, } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; +import LiveBirdseyeView from "@/views/live/LiveBirdseyeView"; import LiveCameraView from "@/views/live/LiveCameraView"; import LiveDashboardView from "@/views/live/LiveDashboardView"; import { useMemo } from "react"; @@ -47,6 +48,10 @@ function Live() { [cameras, selectedCameraName], ); + if (selectedCameraName == "birdseye") { + return ; + } + if (selectedCamera) { return ; } diff --git a/web/src/views/live/LiveBirdseyeView.tsx b/web/src/views/live/LiveBirdseyeView.tsx new file mode 100644 index 000000000..58655e76c --- /dev/null +++ b/web/src/views/live/LiveBirdseyeView.tsx @@ -0,0 +1,189 @@ +import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; +import { Button } from "@/components/ui/button"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useResizeObserver } from "@/hooks/resize-observer"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + isDesktop, + isMobile, + isSafari, + useMobileOrientation, +} from "react-device-detect"; +import { FaCompress, FaExpand } from "react-icons/fa"; +import { IoMdArrowBack } from "react-icons/io"; +import { useNavigate } from "react-router-dom"; +import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; +import useSWR from "swr"; + +export default function LiveBirdseyeView() { + const { data: config } = useSWR("config"); + const navigate = useNavigate(); + const { isPortrait } = useMobileOrientation(); + const mainRef = useRef(null); + const [{ width: windowWidth, height: windowHeight }] = + useResizeObserver(window); + + // fullscreen state + + useEffect(() => { + if (mainRef.current == null) { + return; + } + + const listener = () => { + setFullscreen(document.fullscreenElement != null); + }; + document.addEventListener("fullscreenchange", listener); + + return () => { + document.removeEventListener("fullscreenchange", listener); + }; + }, [mainRef]); + + // playback state + + const [fullscreen, setFullscreen] = useState(false); + + const cameraAspectRatio = useMemo(() => { + if (!config) { + return 16 / 9; + } + + return config.birdseye.width / config.birdseye.height; + }, [config]); + + const growClassName = useMemo(() => { + if (isMobile) { + if (isPortrait) { + return "absolute left-2 right-2 top-[50%] -translate-y-[50%]"; + } else { + if (cameraAspectRatio > 16 / 9) { + return "absolute left-0 top-[50%] -translate-y-[50%]"; + } else { + return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]"; + } + } + } + + if (fullscreen) { + if (cameraAspectRatio > 16 / 9) { + return "absolute inset-x-2 top-[50%] -translate-y-[50%]"; + } else { + return "absolute inset-y-2 left-[50%] -translate-x-[50%]"; + } + } else { + return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]"; + } + }, [cameraAspectRatio, fullscreen, isPortrait]); + + const preferredLiveMode = useMemo(() => { + if (!config || !config.birdseye.restream) { + return "jsmpeg"; + } + + if (isSafari) { + return "webrtc"; + } + + return "mse"; + }, [config]); + + const windowAspectRatio = useMemo(() => { + return windowWidth / windowHeight; + }, [windowWidth, windowHeight]); + + const aspectRatio = useMemo(() => { + if (isMobile || fullscreen) { + return cameraAspectRatio; + } else { + return windowAspectRatio < cameraAspectRatio + ? windowAspectRatio - 0.05 + : cameraAspectRatio - 0.03; + } + }, [cameraAspectRatio, windowAspectRatio, fullscreen]); + + if (!config) { + return ; + } + + return ( + +
+
+ {!fullscreen ? ( + + ) : ( +
+ )} + +
+ { + if (fullscreen) { + document.exitFullscreen(); + } else { + mainRef.current?.requestFullscreen(); + } + }} + /> +
+
+
+ +
+ +
+
+
+ + ); +} diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 013621350..071a9d71e 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -134,6 +134,7 @@ export default function LiveDashboardView({ onSelectCamera("birdseye")} /> )} {cameras.map((camera) => { From c14f3c390263b1d40c4adb0fb4aa552c9ea15c6f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 15 Mar 2024 17:28:57 -0600 Subject: [PATCH 246/751] Fix frigate+ submit and recordings layouts for portrait cameras (#10486) * Fix plus submission dialog * Different layout for portrait recordings * Fix now preview found pulsing * Fix bug with uneven milliseconds * Improve consistency of video scaling --- web/src/components/player/HlsVideoPlayer.tsx | 6 ++- web/src/components/player/PreviewPlayer.tsx | 2 +- .../player/dynamic/DynamicVideoPlayer.tsx | 23 ++++++++---- web/src/pages/Events.tsx | 1 + web/src/pages/SubmitPlus.tsx | 22 ++++++++++- web/src/views/events/RecordingView.tsx | 37 ++++++++++++++----- 6 files changed, 70 insertions(+), 21 deletions(-) diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 4492bae43..fd0e79db0 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -59,6 +59,7 @@ export default function HlsVideoPlayer({ const hlsRef = useRef(); const [useHlsCompat, setUseHlsCompat] = useState(false); + const [loadedMetadata, setLoadedMetadata] = useState(false); useEffect(() => { if (!videoRef.current) { @@ -153,7 +154,7 @@ export default function HlsVideoPlayer({ return (
{ @@ -174,7 +175,7 @@ export default function HlsVideoPlayer({ {!loaded && } {cameraPreviews && !currentPreview && ( -
+
No Preview Found
)} diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 3c656a4a7..c869e516d 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -38,16 +38,23 @@ export default function DynamicVideoPlayer({ const { data: config } = useSWR("config"); // playback behavior - const wideVideo = useMemo(() => { + + const grow = useMemo(() => { if (!config) { - return false; + return "aspect-video"; } - return ( + const aspectRatio = config.cameras[camera].detect.width / - config.cameras[camera].detect.height > - 1.7 - ); + config.cameras[camera].detect.height; + + if (aspectRatio > 2) { + return ""; + } else if (aspectRatio < 16 / 9) { + return "aspect-tall"; + } else { + return "aspect-video"; + } }, [camera, config]); // controlling playback @@ -163,7 +170,7 @@ export default function DynamicVideoPlayer({ className={`w-full relative ${isScrubbing || isLoading ? "hidden" : "visible"}`} >
("config"); + // filters const [selectedCameras, setSelectedCameras] = useState(); @@ -45,6 +47,24 @@ export default function SubmitPlus() { ]); const [upload, setUpload] = useState(); + const grow = useMemo(() => { + if (!config || !upload) { + return ""; + } + + const camera = config.cameras[upload.camera]; + + if (!camera) { + return ""; + } + + if (camera.detect.width / camera.detect.height < 16 / 9) { + return "aspect-video object-contain"; + } + + return ""; + }, [config, upload]); + const onSubmitToPlus = useCallback( async (falsePositive: boolean) => { if (!upload) { @@ -102,7 +122,7 @@ export default function SubmitPlus() { {`${upload?.label}`} diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index 60b8e58a8..d7220c7ab 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -170,21 +170,34 @@ export function DesktopRecordingView({ : null, ); - const grow = useMemo(() => { + const mainCameraAspect = useMemo(() => { if (!config) { - return "aspect-video"; + return "normal"; } const aspectRatio = config.cameras[mainCamera].detect.width / config.cameras[mainCamera].detect.height; + if (aspectRatio > 2) { - return "aspect-wide"; + return "wide"; + } else if (aspectRatio < 16 / 9) { + return "tall"; } else { - return "aspect-video"; + return "normal"; } }, [config, mainCamera]); + const grow = useMemo(() => { + if (mainCameraAspect == "wide") { + return "w-full aspect-wide"; + } else if (mainCameraAspect == "tall") { + return "h-full aspect-tall"; + } else { + return "w-full aspect-video"; + } + }, [mainCameraAspect]); + return (
+ {isMobile && ( + + + + + + { + setPlaybackStart(currentTime); + setMainCamera(cam); + }} + > + {allCameras.map((cam) => ( + + {cam.replaceAll("_", " ")} + + ))} + + + + )} -
+
-
- {allCameras.map((cam) => { - if (cam !== mainCamera) { - return ( -
- { - previewRefs.current[cam] = controller; - controller.scrubToTimestamp(startTime); - }} - onClick={() => onSelectCamera(cam)} - /> -
- ); - } - return null; - })} -
+ {isDesktop && ( +
+ {allCameras.map((cam) => { + if (cam !== mainCamera) { + return ( +
+ { + previewRefs.current[cam] = controller; + controller.scrubToTimestamp(startTime); + }} + onClick={() => onSelectCamera(cam)} + /> +
+ ); + } + return null; + })} +
+ )}
-
+
{severity != "significant_motion" ? ( ); } - -type MobileRecordingViewProps = { - startCamera: string; - startTime: number; - severity: ReviewSeverity; - reviewItems: ReviewSegment[]; - relevantPreviews?: Preview[]; - allCameras: string[]; -}; -export function MobileRecordingView({ - startCamera, - startTime, - severity, - reviewItems, - relevantPreviews, - allCameras, -}: MobileRecordingViewProps) { - const { data: config } = useSWR("config"); - const navigate = useNavigate(); - const contentRef = useRef(null); - - // controller state - - const controllerRef = useRef(undefined); - const [playbackCamera, setPlaybackCamera] = useState(startCamera); - const [playbackStart, setPlaybackStart] = useState(startTime); - - const grow = useMemo(() => { - if (!config) { - return "aspect-video"; - } - - const aspectRatio = - config.cameras[playbackCamera].detect.width / - config.cameras[playbackCamera].detect.height; - if (aspectRatio > 2) { - return "aspect-wide"; - } else { - return "aspect-video"; - } - }, [config, playbackCamera]); - - // timeline time - - const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]); - const [selectedRangeIdx, setSelectedRangeIdx] = useState( - timeRange.ranges.findIndex((chunk) => { - return chunk.start <= startTime && chunk.end >= startTime; - }), - ); - const currentTimeRange = useMemo( - () => timeRange.ranges[selectedRangeIdx], - [selectedRangeIdx, timeRange], - ); - - const mainCameraReviewItems = useMemo( - () => reviewItems.filter((cam) => cam.camera == playbackCamera), - [reviewItems, playbackCamera], - ); - - // handle clip change - - const onClipEnded = useCallback(() => { - if (!controllerRef.current) { - return; - } - - if (selectedRangeIdx < timeRange.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } - }, [selectedRangeIdx, timeRange]); - - // scrubbing and timeline state - - const [scrubbing, setScrubbing] = useState(false); - const [currentTime, setCurrentTime] = useState(startTime); - const [playerTime, setPlayerTime] = useState(startTime); - - const updateSelectedSegment = useCallback( - (currentTime: number, updateStartTime: boolean) => { - const index = timeRange.ranges.findIndex( - (seg) => seg.start <= currentTime && seg.end >= currentTime, - ); - - if (index != -1) { - if (updateStartTime) { - setPlaybackStart(currentTime); - } - - setSelectedRangeIdx(index); - } - }, - [timeRange], - ); - - useEffect(() => { - if (scrubbing) { - if ( - currentTime > currentTimeRange.end + 60 || - currentTime < currentTimeRange.start - 60 - ) { - updateSelectedSegment(currentTime, false); - return; - } - - controllerRef.current?.scrubToTimestamp(currentTime); - } - }, [ - currentTime, - scrubbing, - timeRange, - currentTimeRange, - updateSelectedSegment, - ]); - - useEffect(() => { - if (!scrubbing) { - if (Math.abs(currentTime - playerTime) > 10) { - if ( - currentTimeRange.start <= currentTime && - currentTimeRange.end >= currentTime - ) { - controllerRef.current?.seekToTimestamp(currentTime, true); - } else { - updateSelectedSegment(currentTime, true); - } - } - } - // we only want to seek when current time doesn't match the player update time - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentTime, scrubbing]); - - // motion timeline data - - const { data: motionData } = useSWR( - severity == "significant_motion" - ? [ - "review/activity/motion", - { - before: timeRange.end, - after: timeRange.start, - scale: SEGMENT_DURATION / 2, - cameras: playbackCamera, - }, - ] - : null, - ); - - return ( -
-
- - - - - - - { - setPlaybackStart(currentTime); - setPlaybackCamera(cam); - }} - > - {allCameras.map((cam) => ( - - {cam.replaceAll("_", " ")} - - ))} - - - -
- -
- { - controllerRef.current = controller; - }} - onTimestampUpdate={(timestamp) => { - setPlayerTime(timestamp); - setCurrentTime(timestamp); - }} - onClipEnded={onClipEnded} - isScrubbing={scrubbing} - /> -
- -
- {severity != "significant_motion" ? ( - setScrubbing(scrubbing)} - /> - ) : ( - setScrubbing(scrubbing)} - /> - )} -
-
- ); -} From 4e7808ac0cd3dfd2b20307bb9133d9fec782dd26 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 17 Mar 2024 06:30:39 -0600 Subject: [PATCH 251/751] Normalize motion data in chunks (#10497) --- frigate/api/review.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 008bcdb0b..21eed5297 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -390,11 +390,17 @@ def motion_activity(): .apply(lambda x: max(x, key=abs, default=0.0)) .fillna(0.0) ) - df["motion"] = ( - (df["motion"] - df["motion"].min()) - / (df["motion"].max() - df["motion"].min()) - * 100 - ) + + length = df.shape[0] + chunk = int(60 * (60 / scale)) + + for i in range(0, length, chunk): + part = df.iloc[i : i + chunk] + df.iloc[i : i + chunk, 0] = ( + (part["motion"] - part["motion"].min()) + / (part["motion"].max() - part["motion"].min()) + * 100 + ) # change types for output df.index = df.index.astype(int) // (10**9) From 4a360d8142b7fd87ecba72a48577201d66a86718 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 17 Mar 2024 06:31:02 -0600 Subject: [PATCH 252/751] Fix thumbnails for panoramic cameras (#10499) --- frigate/util/image.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frigate/util/image.py b/frigate/util/image.py index 0afa451b7..ef6c75ae4 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -215,16 +215,19 @@ def calculate_16_9_crop(frame_shape, xmin, ymin, xmax, ymax, multiplier=1.25): min_size = 200 # size is the longest edge and divisible by 4 - x_size = int(xmax - xmin * multiplier) + x_size = int((xmax - xmin) * multiplier) if x_size < min_size: x_size = min_size - y_size = int(ymax - ymin * multiplier) + y_size = int((ymax - ymin) * multiplier) if y_size < min_size: y_size = min_size + if frame_shape[1] / frame_shape[0] > 16 / 9 and x_size / y_size > 4: + return None + # calculate 16x9 using height aspect_y_size = int(9 / 16 * x_size) From e4d0e222e3f3d96e1ac2691c3213d094c361fcde Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 17 Mar 2024 08:28:50 -0500 Subject: [PATCH 253/751] move handlebar timestamp outside of timeline on mobile (#10501) --- web/src/components/timeline/ReviewTimeline.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx index ccd77216e..5f42a0d9e 100644 --- a/web/src/components/timeline/ReviewTimeline.tsx +++ b/web/src/components/timeline/ReviewTimeline.tsx @@ -1,4 +1,5 @@ import { ReactNode, RefObject } from "react"; +import { isIOS, isMobile } from "react-device-detect"; export type ReviewTimelineProps = { timelineRef: RefObject; @@ -55,7 +56,7 @@ export function ReviewTimeline({
{showHandlebar && (
@@ -72,14 +73,16 @@ export function ReviewTimeline({
-
+
From bb6f153e2ec57629f982312fb73f7bbb6bde9d0b Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 17 Mar 2024 17:30:14 -0600 Subject: [PATCH 254/751] Use preview quality setting to set image height and webp quality (#10502) * Use preview quality setting to set image height and webp quality * Increase keyframe interval as well with higher quality * Don't use dynamic height --- frigate/output/preview.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 47b17b188..9e94a690d 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -28,13 +28,21 @@ FOLDER_PREVIEW_FRAMES = "preview_frames" PREVIEW_CACHE_DIR = os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES) PREVIEW_SEGMENT_DURATION = 3600 # one hour # important to have lower keyframe to maintain scrubbing performance -PREVIEW_KEYFRAME_INTERVAL = 60 -PREVIEW_BIT_RATES = { - RecordQualityEnum.very_low: 5120, - RecordQualityEnum.low: 7168, +PREVIEW_KEYFRAME_INTERVAL = 40 +PREVIEW_HEIGHT = 180 +PREVIEW_QUALITY_WEBP = { + RecordQualityEnum.very_low: 70, + RecordQualityEnum.low: 80, + RecordQualityEnum.medium: 80, + RecordQualityEnum.high: 80, + RecordQualityEnum.very_high: 86, +} +PREVIEW_QUALITY_BIT_RATES = { + RecordQualityEnum.very_low: 7168, + RecordQualityEnum.low: 8196, RecordQualityEnum.medium: 9216, - RecordQualityEnum.high: 13312, - RecordQualityEnum.very_high: 17408, + RecordQualityEnum.high: 9864, + RecordQualityEnum.very_high: 10096, } @@ -69,7 +77,7 @@ class FFMpegConverter(threading.Thread): self.ffmpeg_cmd = parse_preset_hardware_acceleration_encode( config.ffmpeg.hwaccel_args, input="-f concat -y -protocol_whitelist pipe,file -safe 0 -i /dev/stdin", - output=f"-g {PREVIEW_KEYFRAME_INTERVAL} -fpsmax 2 -bf 0 -b:v {PREVIEW_BIT_RATES[self.config.record.preview.quality]} {FPS_VFR_PARAM} -movflags +faststart -pix_fmt yuv420p {self.path}", + output=f"-g {PREVIEW_KEYFRAME_INTERVAL} -fpsmax 2 -bf 0 -b:v {PREVIEW_QUALITY_BIT_RATES[self.config.record.preview.quality]} {FPS_VFR_PARAM} -movflags +faststart -pix_fmt yuv420p {self.path}", type=EncodeTypeEnum.preview, ) @@ -131,7 +139,7 @@ class PreviewRecorder: self.start_time = 0 self.last_output_time = 0 self.output_frames = [] - self.out_height = 180 + self.out_height = PREVIEW_HEIGHT self.out_width = ( int((config.detect.width / config.detect.height) * self.out_height) // 4 * 4 ) @@ -245,7 +253,10 @@ class PreviewRecorder: cv2.imwrite( get_cache_image_name(self.config.name, frame_time), small_frame, - [int(cv2.IMWRITE_WEBP_QUALITY), 80], + [ + int(cv2.IMWRITE_WEBP_QUALITY), + PREVIEW_QUALITY_WEBP[self.config.record.preview.quality], + ], ) def write_data( From 880bae1eb2778747b25717e72aaeea244d84c7f5 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 17 Mar 2024 18:53:47 -0600 Subject: [PATCH 255/751] Fix android/chrome seeking on previews (#10512) --- web/src/components/player/PreviewPlayer.tsx | 54 +++++++++++++------ .../player/dynamic/DynamicVideoPlayer.tsx | 5 +- web/src/views/events/EventView.tsx | 4 ++ web/src/views/events/RecordingView.tsx | 3 +- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index fde8f20f3..13d36d0d1 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -12,7 +12,7 @@ import { Preview } from "@/types/preview"; import { PreviewPlayback } from "@/types/playback"; import { isCurrentHour } from "@/utils/dateUtil"; import { baseUrl } from "@/api/baseUrl"; -import { isAndroid } from "react-device-detect"; +import { isAndroid, isChrome, isMobile } from "react-device-detect"; import { Skeleton } from "../ui/skeleton"; type PreviewPlayerProps = { @@ -21,6 +21,7 @@ type PreviewPlayerProps = { timeRange: { start: number; end: number }; cameraPreviews: Preview[]; startTime?: number; + isScrubbing: boolean; onControllerReady: (controller: PreviewController) => void; onClick?: () => void; }; @@ -30,6 +31,7 @@ export default function PreviewPlayer({ timeRange, cameraPreviews, startTime, + isScrubbing, onControllerReady, onClick, }: PreviewPlayerProps) { @@ -53,6 +55,7 @@ export default function PreviewPlayer({ timeRange={timeRange} cameraPreviews={cameraPreviews} startTime={startTime} + isScrubbing={isScrubbing} onControllerReady={onControllerReady} onClick={onClick} /> @@ -79,6 +82,7 @@ type PreviewVideoPlayerProps = { timeRange: { start: number; end: number }; cameraPreviews: Preview[]; startTime?: number; + isScrubbing: boolean; onControllerReady: (controller: PreviewVideoController) => void; onClick?: () => void; }; @@ -88,6 +92,7 @@ function PreviewVideoPlayer({ timeRange, cameraPreviews, startTime, + isScrubbing, onControllerReady, onClick, }: PreviewVideoPlayerProps) { @@ -118,6 +123,14 @@ function PreviewVideoPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [controller]); + useEffect(() => { + if (!controller) { + return; + } + + controller.scrubbing = isScrubbing; + }, [controller, isScrubbing]); + // initial state const [loaded, setLoaded] = useState(false); @@ -140,7 +153,12 @@ function PreviewVideoPlayer({ return; } - controller.finishedSeeking(); + if (isAndroid && isChrome) { + // android/chrome glitches when setting currentTime at the same time as onSeeked + setTimeout(() => controller.finishedSeeking(), 25); + } else { + controller.finishedSeeking(); + } }, [controller]); useEffect(() => { @@ -227,6 +245,7 @@ class PreviewVideoController extends PreviewController { // preview private preview: Preview | undefined = undefined; private timeToSeek: number | undefined = undefined; + public scrubbing = false; private seeking = false; constructor( @@ -253,6 +272,18 @@ class PreviewVideoController extends PreviewController { return false; } + const seekTime = time - this.preview.start; + + if ( + isAndroid && + isChrome && + this.scrubbing && + Math.abs(seekTime - this.previewRef.current.currentTime) > 400 + ) { + // android/chrome has incorrect timestamps sent that are before the expected seek time + return false; + } + if (this.seeking) { this.timeToSeek = time; } else { @@ -276,21 +307,12 @@ class PreviewVideoController extends PreviewController { Math.round(this.timeToSeek) - Math.round(this.previewRef.current.currentTime + this.preview.start); - if (Math.abs(diff) > 1) { - let seekTime; - if (isAndroid) { - if (diff < 30) { - seekTime = Math.round( - this.previewRef.current.currentTime + diff / 2, - ); - } else { - seekTime = Math.round(this.timeToSeek - this.preview.start); - } - } else { - seekTime = this.timeToSeek - this.preview.start; - } + const scrubLimit = isMobile ? 1 : 0.5; - this.previewRef.current.currentTime = seekTime; + if (Math.abs(diff) >= scrubLimit) { + // only seek if there is an appropriate amount of time difference + this.previewRef.current.currentTime = + this.timeToSeek - this.preview.start; } else { this.seeking = false; this.timeToSeek = undefined; diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index c869e516d..6f5462481 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -18,10 +18,10 @@ type DynamicVideoPlayerProps = { timeRange: { start: number; end: number }; cameraPreviews: Preview[]; startTimestamp?: number; + isScrubbing: boolean; onControllerReady: (controller: DynamicVideoController) => void; onTimestampUpdate?: (timestamp: number) => void; onClipEnded?: () => void; - isScrubbing: boolean; }; export default function DynamicVideoPlayer({ className, @@ -29,10 +29,10 @@ export default function DynamicVideoPlayer({ timeRange, cameraPreviews, startTimestamp, + isScrubbing, onControllerReady, onTimestampUpdate, onClipEnded, - isScrubbing, }: DynamicVideoPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); @@ -192,6 +192,7 @@ export default function DynamicVideoPlayer({ timeRange={timeRange} cameraPreviews={cameraPreviews} startTime={startTimestamp} + isScrubbing={isScrubbing} onControllerReady={(previewController) => { setPreviewController(previewController); }} diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 991cab652..c83e0e118 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -616,6 +616,8 @@ function MotionReview({ [selectedRangeIdx, timeRangeSegments], ); + const [scrubbing, setScrubbing] = useState(false); + // move to next clip useEffect(() => { @@ -670,6 +672,7 @@ function MotionReview({ timeRange={currentTimeRange} startTime={startTime} cameraPreviews={relevantPreviews || []} + isScrubbing={scrubbing} onControllerReady={(controller) => { videoPlayersRef.current[camera.name] = controller; }} @@ -694,6 +697,7 @@ function MotionReview({ motion_events={motionData ?? []} severityType="significant_motion" contentRef={contentRef} + onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)} />
diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index a2c64cfb9..4f167400b 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -192,7 +192,7 @@ export function RecordingView({ const grow = useMemo(() => { if (mainCameraAspect == "wide") { return "w-full aspect-wide"; - } else if (mainCameraAspect == "tall") { + } else if (isDesktop && mainCameraAspect == "tall") { return "h-full aspect-tall"; } else { return "w-full aspect-video"; @@ -288,6 +288,7 @@ export function RecordingView({ timeRange={currentTimeRange} cameraPreviews={allPreviews ?? []} startTime={startTime} + isScrubbing={scrubbing} onControllerReady={(controller) => { previewRefs.current[cam] = controller; controller.scrubToTimestamp(startTime); From d249e5b27f7a56152c7cf1da21c5fc00d246c35a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:58:54 -0500 Subject: [PATCH 256/751] Timeline fixes and export handles (#10522) * select an export range from timeline * height tweak --- .../timeline/EventReviewTimeline.tsx | 139 +++++++-- web/src/components/timeline/EventSegment.tsx | 12 +- .../timeline/MotionReviewTimeline.tsx | 139 +++++++-- web/src/components/timeline/MotionSegment.tsx | 10 +- .../components/timeline/ReviewTimeline.tsx | 287 +++++++++++++++++- .../components/timeline/segment-metadata.tsx | 12 +- ...e-dragging.ts => use-draggable-element.ts} | 174 +++++++---- web/src/hooks/use-event-utils.ts | 75 ----- web/src/hooks/use-timeline-utils.ts | 26 ++ web/src/pages/Export.tsx | 24 +- web/src/pages/UIPlayground.tsx | 104 +++++-- web/src/types/draggable-element.ts | 1 + web/src/views/events/EventView.tsx | 7 +- web/themes/theme-default.css | 10 +- 14 files changed, 771 insertions(+), 249 deletions(-) rename web/src/hooks/{use-handle-dragging.ts => use-draggable-element.ts} (58%) delete mode 100644 web/src/hooks/use-event-utils.ts create mode 100644 web/src/hooks/use-timeline-utils.ts create mode 100644 web/src/types/draggable-element.ts diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 5f6c6dc0a..47c43b69a 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -1,4 +1,4 @@ -import useDraggableHandler from "@/hooks/use-handle-dragging"; +import useDraggableElement from "@/hooks/use-draggable-element"; import { useEffect, useCallback, @@ -8,7 +8,7 @@ import { RefObject, } from "react"; import EventSegment from "./EventSegment"; -import { useEventUtils } from "@/hooks/use-event-utils"; +import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { ReviewSegment, ReviewSeverity } from "@/types/review"; import ReviewTimeline from "./ReviewTimeline"; @@ -23,6 +23,11 @@ export type EventReviewTimelineProps = { showMinimap?: boolean; minimapStartTime?: number; minimapEndTime?: number; + showExportHandles?: boolean; + exportStartTime?: number; + exportEndTime?: number; + setExportStartTime?: React.Dispatch>; + setExportEndTime?: React.Dispatch>; events: ReviewSegment[]; severityType: ReviewSeverity; contentRef: RefObject; @@ -40,47 +45,113 @@ export function EventReviewTimeline({ showMinimap = false, minimapStartTime, minimapEndTime, + showExportHandles = false, + exportStartTime, + exportEndTime, + setExportStartTime, + setExportEndTime, events, severityType, contentRef, onHandlebarDraggingChange, }: EventReviewTimelineProps) { const [isDragging, setIsDragging] = useState(false); - const handlebarRef = useRef(null); + const [exportStartPosition, setExportStartPosition] = useState(0); + const [exportEndPosition, setExportEndPosition] = useState(0); + const timelineRef = useRef(null); + const handlebarRef = useRef(null); const handlebarTimeRef = useRef(null); + const exportStartRef = useRef(null); + const exportStartTimeRef = useRef(null); + const exportEndRef = useRef(null); + const exportEndTimeRef = useRef(null); + const timelineDuration = useMemo( () => timelineStart - timelineEnd, [timelineEnd, timelineStart], ); - const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( - events, - segmentDuration, - ); + const { alignStartDateToTimeline, alignEndDateToTimeline } = + useTimelineUtils(segmentDuration); const timelineStartAligned = useMemo( () => alignStartDateToTimeline(timelineStart), [timelineStart, alignStartDateToTimeline], ); - const { handleMouseDown, handleMouseUp, handleMouseMove } = - useDraggableHandler({ - contentRef, - timelineRef, - handlebarRef, - alignStartDateToTimeline, - alignEndDateToTimeline, - segmentDuration, - showHandlebar, - handlebarTime, - setHandlebarTime, - timelineDuration, - timelineStartAligned, - isDragging, - setIsDragging, - handlebarTimeRef, - }); + const paddedExportStartTime = useMemo(() => { + if (exportStartTime) { + return alignStartDateToTimeline(exportStartTime) + segmentDuration; + } + }, [exportStartTime, segmentDuration, alignStartDateToTimeline]); + + const paddedExportEndTime = useMemo(() => { + if (exportEndTime) { + return alignEndDateToTimeline(exportEndTime) - segmentDuration * 2; + } + }, [exportEndTime, segmentDuration, alignEndDateToTimeline]); + + const { + handleMouseDown: handlebarMouseDown, + handleMouseUp: handlebarMouseUp, + handleMouseMove: handlebarMouseMove, + } = useDraggableElement({ + contentRef, + timelineRef, + draggableElementRef: handlebarRef, + segmentDuration, + showDraggableElement: showHandlebar, + draggableElementTime: handlebarTime, + setDraggableElementTime: setHandlebarTime, + timelineDuration, + timelineStartAligned, + isDragging, + setIsDragging, + draggableElementTimeRef: handlebarTimeRef, + }); + + const { + handleMouseDown: exportStartMouseDown, + handleMouseUp: exportStartMouseUp, + handleMouseMove: exportStartMouseMove, + } = useDraggableElement({ + contentRef, + timelineRef, + draggableElementRef: exportStartRef, + segmentDuration, + showDraggableElement: showExportHandles, + draggableElementTime: exportStartTime, + draggableElementLatestTime: paddedExportEndTime, + setDraggableElementTime: setExportStartTime, + timelineDuration, + timelineStartAligned, + isDragging, + setIsDragging, + draggableElementTimeRef: exportStartTimeRef, + setDraggableElementPosition: setExportStartPosition, + }); + + const { + handleMouseDown: exportEndMouseDown, + handleMouseUp: exportEndMouseUp, + handleMouseMove: exportEndMouseMove, + } = useDraggableElement({ + contentRef, + timelineRef, + draggableElementRef: exportEndRef, + segmentDuration, + showDraggableElement: showExportHandles, + draggableElementTime: exportEndTime, + draggableElementEarliestTime: paddedExportStartTime, + setDraggableElementTime: setExportEndTime, + timelineDuration, + timelineStartAligned, + isDragging, + setIsDragging, + draggableElementTimeRef: exportEndTimeRef, + setDraggableElementPosition: setExportEndPosition, + }); // Generate segments for the timeline const generateSegments = useCallback(() => { @@ -145,12 +216,26 @@ export function EventReviewTimeline({ timelineRef={timelineRef} handlebarRef={handlebarRef} handlebarTimeRef={handlebarTimeRef} - handleMouseMove={handleMouseMove} - handleMouseUp={handleMouseUp} - handleMouseDown={handleMouseDown} + handlebarMouseMove={handlebarMouseMove} + handlebarMouseUp={handlebarMouseUp} + handlebarMouseDown={handlebarMouseDown} segmentDuration={segmentDuration} + timelineDuration={timelineDuration} showHandlebar={showHandlebar} isDragging={isDragging} + exportStartMouseMove={exportStartMouseMove} + exportStartMouseUp={exportStartMouseUp} + exportStartMouseDown={exportStartMouseDown} + exportEndMouseMove={exportEndMouseMove} + exportEndMouseUp={exportEndMouseUp} + exportEndMouseDown={exportEndMouseDown} + showExportHandles={showExportHandles} + exportStartRef={exportStartRef} + exportStartTimeRef={exportStartTimeRef} + exportEndRef={exportEndRef} + exportEndTimeRef={exportEndTimeRef} + exportStartPosition={exportStartPosition} + exportEndPosition={exportEndPosition} > {segments} diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index ed86ee66c..08abd377f 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -1,5 +1,5 @@ import { useApiHost } from "@/api"; -import { useEventUtils } from "@/hooks/use-event-utils"; +import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { ReviewSegment, ReviewSeverity } from "@/types/review"; import React, { @@ -53,10 +53,8 @@ export function EventSegment({ getEventThumbnail, } = useEventSegmentUtils(segmentDuration, events, severityType); - const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( - events, - segmentDuration, - ); + const { alignStartDateToTimeline, alignEndDateToTimeline } = + useTimelineUtils(segmentDuration); const severity = useMemo( () => getSeverity(segmentTime, displaySeverityType), @@ -155,7 +153,7 @@ export function EventSegment({ : "" } ${ isFirstSegmentInMinimap || isLastSegmentInMinimap - ? "relative h-2 border-b-2 border-gray-500" + ? "relative h-2 border-b-2 border-neutral-600" : "" }`; @@ -236,7 +234,7 @@ export function EventSegment({ key={`${segmentKey}_${index}_primary_data`} className={`w-full h-2 bg-gradient-to-r ${roundBottomPrimary ? "rounded-bl-full rounded-br-full" : ""} ${roundTopPrimary ? "rounded-tl-full rounded-tr-full" : ""} ${severityColors[severityValue]}`} onClick={segmentClick} - onTouchStart={(event) => + onTouchEnd={(event) => handleTouchStart(event, segmentClick) } >
diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 7ef4b1d73..9a1a7b428 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -1,4 +1,4 @@ -import useDraggableHandler from "@/hooks/use-handle-dragging"; +import useDraggableElement from "@/hooks/use-draggable-element"; import { useEffect, useCallback, @@ -8,7 +8,7 @@ import { RefObject, } from "react"; import MotionSegment from "./MotionSegment"; -import { useEventUtils } from "@/hooks/use-event-utils"; +import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review"; import ReviewTimeline from "./ReviewTimeline"; @@ -23,6 +23,11 @@ export type MotionReviewTimelineProps = { showMinimap?: boolean; minimapStartTime?: number; minimapEndTime?: number; + showExportHandles?: boolean; + exportStartTime?: number; + exportEndTime?: number; + setExportStartTime?: React.Dispatch>; + setExportEndTime?: React.Dispatch>; events: ReviewSegment[]; motion_events: MotionData[]; severityType: ReviewSeverity; @@ -41,47 +46,113 @@ export function MotionReviewTimeline({ showMinimap = false, minimapStartTime, minimapEndTime, + showExportHandles = false, + exportStartTime, + exportEndTime, + setExportStartTime, + setExportEndTime, events, motion_events, contentRef, onHandlebarDraggingChange, }: MotionReviewTimelineProps) { const [isDragging, setIsDragging] = useState(false); - const handlebarRef = useRef(null); + const [exportStartPosition, setExportStartPosition] = useState(0); + const [exportEndPosition, setExportEndPosition] = useState(0); + const timelineRef = useRef(null); + const handlebarRef = useRef(null); const handlebarTimeRef = useRef(null); + const exportStartRef = useRef(null); + const exportStartTimeRef = useRef(null); + const exportEndRef = useRef(null); + const exportEndTimeRef = useRef(null); + const timelineDuration = useMemo( () => timelineStart - timelineEnd + 4 * segmentDuration, [timelineEnd, timelineStart, segmentDuration], ); - const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( - events, - segmentDuration, - ); + const { alignStartDateToTimeline, alignEndDateToTimeline } = + useTimelineUtils(segmentDuration); const timelineStartAligned = useMemo( () => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration, [timelineStart, alignStartDateToTimeline, segmentDuration], ); - const { handleMouseDown, handleMouseUp, handleMouseMove } = - useDraggableHandler({ - contentRef, - timelineRef, - handlebarRef, - alignStartDateToTimeline, - alignEndDateToTimeline, - segmentDuration, - showHandlebar, - handlebarTime, - setHandlebarTime, - timelineDuration, - timelineStartAligned, - isDragging, - setIsDragging, - handlebarTimeRef, - }); + const paddedExportStartTime = useMemo(() => { + if (exportStartTime) { + return alignStartDateToTimeline(exportStartTime) + segmentDuration; + } + }, [exportStartTime, segmentDuration, alignStartDateToTimeline]); + + const paddedExportEndTime = useMemo(() => { + if (exportEndTime) { + return alignEndDateToTimeline(exportEndTime) - segmentDuration * 2; + } + }, [exportEndTime, segmentDuration, alignEndDateToTimeline]); + + const { + handleMouseDown: handlebarMouseDown, + handleMouseUp: handlebarMouseUp, + handleMouseMove: handlebarMouseMove, + } = useDraggableElement({ + contentRef, + timelineRef, + draggableElementRef: handlebarRef, + segmentDuration, + showDraggableElement: showHandlebar, + draggableElementTime: handlebarTime, + setDraggableElementTime: setHandlebarTime, + timelineDuration, + timelineStartAligned, + isDragging, + setIsDragging, + draggableElementTimeRef: handlebarTimeRef, + }); + + const { + handleMouseDown: exportStartMouseDown, + handleMouseUp: exportStartMouseUp, + handleMouseMove: exportStartMouseMove, + } = useDraggableElement({ + contentRef, + timelineRef, + draggableElementRef: exportStartRef, + segmentDuration, + showDraggableElement: showExportHandles, + draggableElementTime: exportStartTime, + draggableElementLatestTime: paddedExportEndTime, + setDraggableElementTime: setExportStartTime, + timelineDuration, + timelineStartAligned, + isDragging, + setIsDragging, + draggableElementTimeRef: exportStartTimeRef, + setDraggableElementPosition: setExportStartPosition, + }); + + const { + handleMouseDown: exportEndMouseDown, + handleMouseUp: exportEndMouseUp, + handleMouseMove: exportEndMouseMove, + } = useDraggableElement({ + contentRef, + timelineRef, + draggableElementRef: exportEndRef, + segmentDuration, + showDraggableElement: showExportHandles, + draggableElementTime: exportEndTime, + draggableElementEarliestTime: paddedExportStartTime, + setDraggableElementTime: setExportEndTime, + timelineDuration, + timelineStartAligned, + isDragging, + setIsDragging, + draggableElementTimeRef: exportEndTimeRef, + setDraggableElementPosition: setExportEndPosition, + }); // Generate segments for the timeline const generateSegments = useCallback(() => { @@ -147,12 +218,26 @@ export function MotionReviewTimeline({ timelineRef={timelineRef} handlebarRef={handlebarRef} handlebarTimeRef={handlebarTimeRef} - handleMouseMove={handleMouseMove} - handleMouseUp={handleMouseUp} - handleMouseDown={handleMouseDown} + handlebarMouseMove={handlebarMouseMove} + handlebarMouseUp={handlebarMouseUp} + handlebarMouseDown={handlebarMouseDown} segmentDuration={segmentDuration} + timelineDuration={timelineDuration} showHandlebar={showHandlebar} isDragging={isDragging} + exportStartMouseMove={exportStartMouseMove} + exportStartMouseUp={exportStartMouseUp} + exportStartMouseDown={exportStartMouseDown} + exportEndMouseMove={exportEndMouseMove} + exportEndMouseUp={exportEndMouseUp} + exportEndMouseDown={exportEndMouseDown} + showExportHandles={showExportHandles} + exportStartRef={exportStartRef} + exportStartTimeRef={exportStartTimeRef} + exportEndRef={exportEndRef} + exportEndTimeRef={exportEndTimeRef} + exportStartPosition={exportStartPosition} + exportEndPosition={exportEndPosition} > {segments} diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index b06d655fb..fb9a92e85 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -1,4 +1,4 @@ -import { useEventUtils } from "@/hooks/use-event-utils"; +import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { MotionData, ReviewSegment } from "@/types/review"; import React, { useCallback, useEffect, useMemo, useRef } from "react"; @@ -42,10 +42,8 @@ export function MotionSegment({ const { getMotionSegmentValue, interpolateMotionAudioData } = useMotionSegmentUtils(segmentDuration, motion_events); - const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( - events, - segmentDuration, - ); + const { alignStartDateToTimeline, alignEndDateToTimeline } = + useTimelineUtils(segmentDuration); const { handleTouchStart } = useTapUtils(); @@ -180,7 +178,7 @@ export function MotionSegment({ key={segmentKey} className={segmentClasses} onClick={segmentClick} - onTouchStart={(event) => handleTouchStart(event, segmentClick)} + onTouchEnd={(event) => handleTouchStart(event, segmentClick)} > ; handlebarRef: RefObject; handlebarTimeRef: RefObject; - handleMouseMove: ( + handlebarMouseMove: ( e: | React.MouseEvent | React.TouchEvent, ) => void; - handleMouseUp: ( + handlebarMouseUp: ( e: | React.MouseEvent | React.TouchEvent, ) => void; - handleMouseDown: ( + handlebarMouseDown: ( e: | React.MouseEvent | React.TouchEvent, ) => void; segmentDuration: number; + timelineDuration: number; showHandlebar: boolean; + showExportHandles: boolean; + exportStartRef: RefObject; + exportStartTimeRef: RefObject; + exportEndRef: RefObject; + exportEndTimeRef: RefObject; + exportStartMouseMove: ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => void; + exportStartMouseUp: ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => void; + exportStartMouseDown: ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => void; + exportEndMouseMove: ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => void; + exportEndMouseUp: ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => void; + exportEndMouseDown: ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => void; isDragging: boolean; + exportStartPosition?: number; + exportEndPosition?: number; children: ReactNode; }; @@ -30,14 +77,156 @@ export function ReviewTimeline({ timelineRef, handlebarRef, handlebarTimeRef, - handleMouseMove, - handleMouseUp, - handleMouseDown, + handlebarMouseMove, + handlebarMouseUp, + handlebarMouseDown, segmentDuration, + timelineDuration, showHandlebar = false, + showExportHandles = false, + exportStartRef, + exportStartTimeRef, + exportEndRef, + exportEndTimeRef, + exportStartMouseMove, + exportStartMouseUp, + exportStartMouseDown, + exportEndMouseMove, + exportEndMouseUp, + exportEndMouseDown, isDragging, + exportStartPosition, + exportEndPosition, children, }: ReviewTimelineProps) { + const exportSectionRef = useRef(null); + + const segmentHeight = useMemo(() => { + if (timelineRef.current) { + const { scrollHeight: timelineHeight } = + timelineRef.current as HTMLDivElement; + + return timelineHeight / (timelineDuration / segmentDuration); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segmentDuration, timelineDuration, timelineRef, showExportHandles]); + + const [draggableElementType, setDraggableElementType] = + useState(); + + const handleHandlebar = useCallback( + ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => { + setDraggableElementType("handlebar"); + handlebarMouseDown(e); + }, + [handlebarMouseDown], + ); + + const handleExportStart = useCallback( + ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => { + setDraggableElementType("export_start"); + exportStartMouseDown(e); + }, + [exportStartMouseDown], + ); + + const handleExportEnd = useCallback( + ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => { + setDraggableElementType("export_end"); + exportEndMouseDown(e); + }, + [exportEndMouseDown], + ); + + const handleMouseMove = useCallback( + ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => { + switch (draggableElementType) { + case "export_start": + exportStartMouseMove(e); + break; + case "export_end": + exportEndMouseMove(e); + break; + case "handlebar": + handlebarMouseMove(e); + break; + + default: + break; + } + }, + [ + draggableElementType, + exportStartMouseMove, + exportEndMouseMove, + handlebarMouseMove, + ], + ); + + const handleMouseUp = useCallback( + ( + e: + | React.MouseEvent + | React.TouchEvent, + ) => { + switch (draggableElementType) { + case "export_start": + exportStartMouseUp(e); + break; + case "export_end": + exportEndMouseUp(e); + break; + case "handlebar": + handlebarMouseUp(e); + break; + + default: + break; + } + }, + [ + draggableElementType, + exportStartMouseUp, + exportEndMouseUp, + handlebarMouseUp, + ], + ); + + useEffect(() => { + if ( + exportSectionRef.current && + segmentHeight && + exportStartPosition && + exportEndPosition + ) { + exportSectionRef.current.style.top = `${exportEndPosition + segmentHeight}px`; + exportSectionRef.current.style.height = `${exportStartPosition - exportEndPosition + segmentHeight / 2}px`; + } + }, [ + showExportHandles, + segmentHeight, + timelineRef, + exportStartPosition, + exportEndPosition, + ]); + return (
@@ -62,8 +253,8 @@ export function ReviewTimeline({ >
)} + {showExportHandles && ( + <> +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + )}
); } diff --git a/web/src/components/timeline/segment-metadata.tsx b/web/src/components/timeline/segment-metadata.tsx index 7aaf3525b..4c85f1971 100644 --- a/web/src/components/timeline/segment-metadata.tsx +++ b/web/src/components/timeline/segment-metadata.tsx @@ -32,7 +32,7 @@ export function MinimapBounds({ <> {isFirstSegmentInMinimap && (
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { @@ -44,7 +44,7 @@ export function MinimapBounds({ )} {isLastSegmentInMinimap && ( -
+
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -61,14 +61,14 @@ export function Tick({ timestamp, timestampSpread }: TickSegmentProps) {
@@ -88,7 +88,7 @@ export function Timestamp({ {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
{timestamp.getMinutes() % timestampSpread === 0 && timestamp.getSeconds() === 0 && diff --git a/web/src/hooks/use-handle-dragging.ts b/web/src/hooks/use-draggable-element.ts similarity index 58% rename from web/src/hooks/use-handle-dragging.ts rename to web/src/hooks/use-draggable-element.ts index 293802996..dfb7826bf 100644 --- a/web/src/hooks/use-handle-dragging.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -1,41 +1,46 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { isDesktop, isMobile } from "react-device-detect"; import scrollIntoView from "scroll-into-view-if-needed"; +import { useTimelineUtils } from "./use-timeline-utils"; -type DragHandlerProps = { +type DraggableElementProps = { contentRef: React.RefObject; timelineRef: React.RefObject; - handlebarRef: React.RefObject; - alignStartDateToTimeline: (time: number) => number; - alignEndDateToTimeline: (time: number) => number; + draggableElementRef: React.RefObject; segmentDuration: number; - showHandlebar: boolean; - handlebarTime?: number; - setHandlebarTime?: React.Dispatch>; - handlebarTimeRef: React.MutableRefObject; + showDraggableElement: boolean; + draggableElementTime?: number; + draggableElementEarliestTime?: number; + draggableElementLatestTime?: number; + setDraggableElementTime?: React.Dispatch>; + draggableElementTimeRef: React.MutableRefObject; timelineDuration: number; timelineStartAligned: number; isDragging: boolean; setIsDragging: React.Dispatch>; + setDraggableElementPosition?: React.Dispatch>; }; -function useDraggableHandler({ +function useDraggableElement({ contentRef, timelineRef, - handlebarRef, - alignStartDateToTimeline, + draggableElementRef, segmentDuration, - showHandlebar, - handlebarTime, - setHandlebarTime, - handlebarTimeRef, + showDraggableElement, + draggableElementTime, + draggableElementEarliestTime, + draggableElementLatestTime, + setDraggableElementTime, + draggableElementTimeRef, timelineDuration, timelineStartAligned, isDragging, setIsDragging, -}: DragHandlerProps) { + setDraggableElementPosition, +}: DraggableElementProps) { const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); + const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration); const draggingAtTopEdge = useMemo(() => { if (clientYPosition && timelineRef.current) { @@ -78,17 +83,32 @@ function useDraggableHandler({ ( e: React.MouseEvent | React.TouchEvent, ) => { - e.preventDefault(); + // prevent default only for mouse events + // to avoid chrome/android issues + if (e.nativeEvent instanceof MouseEvent) { + e.preventDefault(); + } e.stopPropagation(); - getClientYPosition(e); setIsDragging(true); - if (handlebarRef.current && clientYPosition && isDesktop) { - const handlebarRect = handlebarRef.current.getBoundingClientRect(); - setInitialClickAdjustment(clientYPosition - handlebarRect.top); + let clientY; + if (isMobile && e.nativeEvent instanceof TouchEvent) { + clientY = e.nativeEvent.touches[0].clientY; + } else if (e.nativeEvent instanceof MouseEvent) { + clientY = e.nativeEvent.clientY; + } + if (clientY && draggableElementRef.current && isDesktop) { + const draggableElementRect = + draggableElementRef.current.getBoundingClientRect(); + if (!isDragging) { + setInitialClickAdjustment(clientY - draggableElementRect.top); + } + setClientYPosition(clientY); } }, - [setIsDragging, getClientYPosition, handlebarRef, clientYPosition], + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + [setIsDragging, draggableElementRef], ); const handleMouseUp = useCallback( @@ -114,19 +134,36 @@ function useDraggableHandler({ return scrollTop; }, []); - const updateHandlebarPosition = useCallback( + const timestampToPixels = useCallback( + (time: number) => { + const { scrollHeight: timelineHeight } = + timelineRef.current as HTMLDivElement; + + const segmentHeight = + timelineHeight / (timelineDuration / segmentDuration); + + return ((timelineStartAligned - time) / segmentDuration) * segmentHeight; + }, + [segmentDuration, timelineRef, timelineStartAligned, timelineDuration], + ); + + const updateDraggableElementPosition = useCallback( ( - newHandlePosition: number, + newElementPosition: number, segmentStartTime: number, scrollTimeline: boolean, updateHandle: boolean, ) => { - const thumb = handlebarRef.current; + const thumb = draggableElementRef.current; if (thumb) { requestAnimationFrame(() => { - thumb.style.top = `${newHandlePosition}px`; - if (handlebarTimeRef.current) { - handlebarTimeRef.current.textContent = new Date( + thumb.style.top = `${newElementPosition}px`; + if (setDraggableElementPosition) { + setDraggableElementPosition(newElementPosition); + } + + if (draggableElementTimeRef.current) { + draggableElementTimeRef.current.textContent = new Date( segmentStartTime * 1000, ).toLocaleTimeString([], { hour: "2-digit", @@ -143,12 +180,18 @@ function useDraggableHandler({ } }); - if (setHandlebarTime && updateHandle) { - setHandlebarTime(segmentStartTime); + if (setDraggableElementTime && updateHandle) { + setDraggableElementTime(segmentStartTime); } } }, - [segmentDuration, handlebarTimeRef, handlebarRef, setHandlebarTime], + [ + segmentDuration, + draggableElementTimeRef, + draggableElementRef, + setDraggableElementTime, + setDraggableElementPosition, + ], ); const handleMouseMove = useCallback( @@ -158,7 +201,7 @@ function useDraggableHandler({ if ( !contentRef.current || !timelineRef.current || - !handlebarRef.current + !draggableElementRef.current ) { return; } @@ -166,7 +209,7 @@ function useDraggableHandler({ getClientYPosition(e); }, - [contentRef, handlebarRef, timelineRef, getClientYPosition], + [contentRef, draggableElementRef, timelineRef, getClientYPosition], ); useEffect(() => { @@ -175,7 +218,7 @@ function useDraggableHandler({ const handleScroll = () => { if ( timelineRef.current && - showHandlebar && + showDraggableElement && isDragging && clientYPosition ) { @@ -190,13 +233,21 @@ function useDraggableHandler({ const parentScrollTop = getCumulativeScrollTop(timelineRef.current); - const newHandlePosition = Math.min( - // end of timeline - segmentHeight * (timelineDuration / segmentDuration) - - segmentHeight * 2, + // bottom of timeline + const elementEarliest = draggableElementEarliestTime + ? timestampToPixels(draggableElementEarliestTime) + : segmentHeight * (timelineDuration / segmentDuration) - + segmentHeight * 3; + + // top of timeline - default 2 segments added for draggableElement visibility + const elementLatest = draggableElementLatestTime + ? timestampToPixels(draggableElementLatestTime) + : segmentHeight * 2 + scrolled; + + const newElementPosition = Math.min( + elementEarliest, Math.max( - // start of timeline - 2 segments added for handlebar visibility - segmentHeight * 2 + scrolled, + elementLatest, // current Y position clientYPosition - timelineTop + @@ -205,7 +256,7 @@ function useDraggableHandler({ ), ); - const segmentIndex = Math.floor(newHandlePosition / segmentHeight); + const segmentIndex = Math.floor(newElementPosition / segmentHeight); const segmentStartTime = alignStartDateToTimeline( timelineStartAligned - segmentIndex * segmentDuration, ); @@ -224,17 +275,17 @@ function useDraggableHandler({ } } - updateHandlebarPosition( - newHandlePosition - segmentHeight, + updateDraggableElementPosition( + newElementPosition - segmentHeight, segmentStartTime, false, false, ); - if (setHandlebarTime) { - setHandlebarTime( + if (setDraggableElementTime) { + setDraggableElementTime( timelineStartAligned - - ((newHandlePosition - segmentHeight / 2 - 2) / segmentHeight) * + ((newElementPosition - segmentHeight / 2 - 2) / segmentHeight) * segmentDuration, ); } @@ -264,22 +315,21 @@ function useDraggableHandler({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ clientYPosition, - isDragging, segmentDuration, timelineStartAligned, timelineDuration, timelineRef, draggingAtTopEdge, draggingAtBottomEdge, - showHandlebar, + showDraggableElement, ]); useEffect(() => { if ( timelineRef.current && - handlebarRef.current && - showHandlebar && - handlebarTime && + draggableElementRef.current && + showDraggableElement && + draggableElementTime && !isDragging ) { const { scrollHeight: timelineHeight, scrollTop: scrolled } = @@ -290,20 +340,30 @@ function useDraggableHandler({ const parentScrollTop = getCumulativeScrollTop(timelineRef.current); - const newHandlePosition = - ((timelineStartAligned - handlebarTime) / segmentDuration) * + const newElementPosition = + ((timelineStartAligned - draggableElementTime) / segmentDuration) * segmentHeight + parentScrollTop - scrolled - - 2; // height of handlebar horizontal line + 2; // height of draggableElement horizontal line - updateHandlebarPosition(newHandlePosition, handlebarTime, true, true); + updateDraggableElementPosition( + newElementPosition, + draggableElementTime, + true, + true, + ); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handlebarTime, showHandlebar, handlebarRef, timelineStartAligned]); + }, [ + draggableElementTime, + showDraggableElement, + draggableElementRef, + timelineStartAligned, + ]); return { handleMouseDown, handleMouseUp, handleMouseMove }; } -export default useDraggableHandler; +export default useDraggableElement; diff --git a/web/src/hooks/use-event-utils.ts b/web/src/hooks/use-event-utils.ts deleted file mode 100644 index e4d9d3d73..000000000 --- a/web/src/hooks/use-event-utils.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useCallback } from "react"; -import { ReviewSegment } from "@/types/review"; - -export const useEventUtils = ( - events: ReviewSegment[], - segmentDuration: number, -) => { - const isStartOfEvent = useCallback( - (time: number): boolean => { - return events.some((event) => { - const segmentStart = getSegmentStart(event.start_time); - return time >= segmentStart && time < segmentStart + segmentDuration; - }); - }, - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - [events, segmentDuration], - ); - - const isEndOfEvent = useCallback( - (time: number): boolean => { - return events.some((event) => { - if (typeof event.end_time === "number") { - const segmentEnd = getSegmentEnd(event.end_time); - return time >= segmentEnd - segmentDuration && time < segmentEnd; - } - return false; - }); - }, - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - [events, segmentDuration], - ); - - const getSegmentStart = useCallback( - (time: number): number => { - return Math.floor(time / segmentDuration) * segmentDuration; - }, - [segmentDuration], - ); - - const getSegmentEnd = useCallback( - (time: number): number => { - return Math.ceil(time / segmentDuration) * segmentDuration; - }, - [segmentDuration], - ); - - const alignEndDateToTimeline = useCallback( - (time: number): number => { - const remainder = time % segmentDuration; - const adjustment = remainder !== 0 ? segmentDuration - remainder : 0; - return time + adjustment; - }, - [segmentDuration], - ); - - const alignStartDateToTimeline = useCallback( - (time: number): number => { - const remainder = time % segmentDuration; - const adjustment = remainder === 0 ? 0 : -remainder; - return time + adjustment; - }, - [segmentDuration], - ); - - return { - isStartOfEvent, - isEndOfEvent, - getSegmentStart, - getSegmentEnd, - alignEndDateToTimeline, - alignStartDateToTimeline, - }; -}; diff --git a/web/src/hooks/use-timeline-utils.ts b/web/src/hooks/use-timeline-utils.ts new file mode 100644 index 000000000..e03541259 --- /dev/null +++ b/web/src/hooks/use-timeline-utils.ts @@ -0,0 +1,26 @@ +import { useCallback } from "react"; + +export const useTimelineUtils = (segmentDuration: number) => { + const alignEndDateToTimeline = useCallback( + (time: number): number => { + const remainder = time % segmentDuration; + const adjustment = remainder !== 0 ? segmentDuration - remainder : 0; + return time + adjustment; + }, + [segmentDuration], + ); + + const alignStartDateToTimeline = useCallback( + (time: number): number => { + const remainder = time % segmentDuration; + const adjustment = remainder === 0 ? 0 : -remainder; + return time + adjustment; + }, + [segmentDuration], + ); + + return { + alignEndDateToTimeline, + alignStartDateToTimeline, + }; +}; diff --git a/web/src/pages/Export.tsx b/web/src/pages/Export.tsx index 206f3911b..82b982cb7 100644 --- a/web/src/pages/Export.tsx +++ b/web/src/pages/Export.tsx @@ -26,9 +26,10 @@ import { Toaster } from "@/components/ui/sonner"; import { FrigateConfig } from "@/types/frigateConfig"; import axios from "axios"; import { format } from "date-fns"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { DateRange } from "react-day-picker"; import { isDesktop } from "react-device-detect"; +import { useLocation } from "react-router-dom"; import { toast } from "sonner"; import useSWR from "swr"; @@ -42,6 +43,8 @@ function Export() { "exports/", (url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data), ); + const location = useLocation(); + const [dialogOpen, setDialogOpen] = useState(false); // Export States const [camera, setCamera] = useState(); @@ -142,6 +145,23 @@ function Export() { const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; const Content = isDesktop ? DialogContent : DrawerContent; + useEffect(() => { + if (location.state && location.state.start && location.state.end) { + const startTimeString = format( + new Date(location.state.start * 1000), + "HH:mm:ss", + ); + const endTimeString = format( + new Date(location.state.end * 1000), + "HH:mm:ss", + ); + setStartTime(startTimeString); + setEndTime(endTimeString); + + setDialogOpen(true); + } + }, [location.state]); + return (
@@ -167,7 +187,7 @@ function Export() {
- + diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 395378219..8f1faa3fb 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -23,7 +23,9 @@ import { SelectValue, } from "@/components/ui/select"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; -import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { useNavigate } from "react-router-dom"; // Color data const colors = [ @@ -129,6 +131,18 @@ function UIPlayground() { Math.round((Date.now() / 1000 - 15 * 60) / 60) * 60, ); + const [exportStartTime, setExportStartTime] = useState( + Math.round((Date.now() / 1000 - 45 * 60) / 60) * 60, + ); + + const [exportEndTime, setExportEndTime] = useState( + Math.round((Date.now() / 1000 - 43 * 60) / 60) * 60, + ); + + const [showExportHandles, setShowExportHandles] = useState(false); + + const navigate = useNavigate(); + useMemo(() => { const initialEvents = Array.from({ length: 50 }, generateRandomEvent); setMockEvents(initialEvents); @@ -158,8 +172,6 @@ function UIPlayground() { timestampSpread: 15, }); - const videoRef = useRef(null); - const possibleZoomLevels = [ { segmentDuration: 60, timestampSpread: 15 }, { segmentDuration: 30, timestampSpread: 5 }, @@ -223,6 +235,26 @@ function UIPlayground() {

Handlebar is dragging: {isDragging ? "yes" : "no"}

+

+ Export start timestamp: {exportStartTime} -  + {new Date(exportStartTime * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "2-digit", + second: "2-digit", + })} +

+

+ Export end timestamp: {exportEndTime} -  + {new Date(exportEndTime * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "2-digit", + second: "2-digit", + })} +

Timeline type
+
+ { + setShowExportHandles(!showExportHandles); + if (showExportHandles) { + setExportEndTime( + Math.round((Date.now() / 1000 - 43 * 60) / 60) * 60, + ); + setExportStartTime( + Math.round((Date.now() / 1000 - 45 * 60) / 60) * 60, + ); + } + }} + /> + +
+
+ +
-
- {birdseyeConfig && ( - - )} -

+
+ {birdseyeConfig && ( + + )} +
Color scheme @@ -293,14 +357,6 @@ function UIPlayground() {
-
- -
-
{!isEventsReviewTimeline && ( Date: Mon, 18 Mar 2024 22:21:09 -0500 Subject: [PATCH 257/751] ensure div by zero is replaced with 0 after normalization (#10528) --- frigate/api/review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 21eed5297..0ca138e53 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -400,7 +400,7 @@ def motion_activity(): (part["motion"] - part["motion"].min()) / (part["motion"].max() - part["motion"].min()) * 100 - ) + ).fillna(0.0) # change types for output df.index = df.index.astype(int) // (10**9) From 5c3925ab50f5d11ed78f32715078830685fe727c Mon Sep 17 00:00:00 2001 From: Alex Yao <33379584+alexyao2015@users.noreply.github.com> Date: Tue, 19 Mar 2024 07:54:25 -0400 Subject: [PATCH 258/751] Add snapshot-clean.png API endpoint (#10510) * Add snapshot-clean.png API endpoint * fix lint * enable on inprogress event --- docs/docs/integrations/api.md | 9 +++++ frigate/api/media.py | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index 90de38832..c6a832560 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -290,6 +290,14 @@ Returns a thumbnail for the event id optimized for notifications. Works while th Returns the clip for the event id. Works after the event has ended. +### `GET /api/events//snapshot-clean.png` + +Returns the clean snapshot image for the event id. Only works if `snapshots` and `clean_copy` are enabled in the config. + +| param | Type | Description | +| ---------- | ---- | ------------------ | +| `download` | bool | Download the image | + ### `GET /api/events//snapshot.jpg` Returns the snapshot image for the event id. Works while the event is in progress and after completion. @@ -303,6 +311,7 @@ Accepts the following query string parameters, but they are only applied when an | `timestamp` | int | Print the timestamp in the upper left (0 or 1) | | `crop` | int | Crop the snapshot to the (0 or 1) | | `quality` | int | Jpeg encoding quality (0-100). Defaults to 70. | +| `download` | bool | Download the image | ### `POST /api/events//
From f835e86df1506136e44f75c4ee0c6d18df103378 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 19 Mar 2024 07:41:24 -0600 Subject: [PATCH 260/751] Don't use apple icon for android pwa (#10535) * Don't use apple icon for android * Fix dimensions * fix icon name --- web/public/images/maskable-icon.png | Bin 0 -> 3959 bytes web/site.webmanifest | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 web/public/images/maskable-icon.png diff --git a/web/public/images/maskable-icon.png b/web/public/images/maskable-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..eb77fe2be210554056fa7ac1bcaa4fe0d0d4048a GIT binary patch literal 3959 zcmds4i9b}|`=1&6q@;|mb;$C;*v6iHNHVq_C7DQ{ zghaNnZz1uqlPw|fyZZF&^ZWe?zu&p9bD!s)`+nZfdCs}#d0scx#`?@*s1Otc0v$%1 znP31}dC)o8fxCV?yDA{S!I(3~AbN+$9Pn_<3yb!)vH~FhofE_cJ_cerfB+W=ECgcv zse?e~VBz071}ysv0|9|zd_k`QpI?BqSp`l8lYD$4bA7vG7ZEa(!{=IAlP(jxT4&ZZft5II zT$6~6Js-<-)+V)JpPoIlw7j@T|3dg`7eq+Uu6|l@ee)}+u;X*ECEgwt3pt;JOG8K5=GIJ=%v;UC`zU zDC!){TIUq4g@Rz?tSk!Z7KB)_+smP74!7@j9L1~B%)yWnL>4I;bszkHi8^Yrs5Vy( z4BOiI`m8o=)tK(~MoGpxEBU!kr}+!NzKd&9E%&qn$0#$>!(a#%&wFU{I(Q}bR1A0H z0R4j6moMXQ0_n_Wj>>c0PvQ{i6IlKnoCm(~(dFX0Os&AF<g>5`(APe8u6a7w=U1_Dm0a%NQ$*wqC(QK1rTQY_1NTnVRi-OkDkZdV6zeM9dY2 zus5r@vV?dY@hvSSg|FqFbgS5VjYx4O7k_(_nBj+|5r)@fQvz|Ko~`=wWc1TiwVJyc zm(D%KmOtVaGjMTY5EQ>I40K{m{`P@TK^~F2Tjk+*bb~%oL=3KBmE8VP$T5igu+gzT z)gr0rRGAD+*rLFinisbE`C(emj&xLFL-V5+%I3;+d!`o<@ePjZjev=GM*Q$gY`LfE z)eXGwP{k7|85xTz{~=ZW=bzQ067M6)+iUOi8gun)gJ-O@$$PbW;cHT}qp7mC#`o^s z%X52ky}QUkUhYB-iMggJBE{T)FH7Fvy8t)1ChP0#OI#dOu{&fKxuZTSnyX1a|2#il zK&8I7@#}owA*Zpbo2)xxXGGe$^m+k)j|TR2n{T_@>&cUlQ}J#S=)u`m;=7V(%Ie6_qq73ZD~m&F zcEW+fm1yf614YnC0!#sGgOnP5Lniz77T&$s*|GAnOEFRqk>;AfIfi2gn=bX!FUUVT zSE9-jLGZ)vns1!T%g@hU)looX53M|OaEvKh%`qBWMT8fH-q<@0-C#hx31ZPMZxWw@5 zvq@cjP%&e!?Dp4&Ro2O_2l9^eGI90|>sNzX{)5OIwPW}s_kE_FrncU0h;x~HR9lvI zPs2lr?X|zvjz?!XjrMHo$-RWQ+<>{{SSHm(4z?~{v_#YZ8|xjjb%75^5pWX`4ZoDi zfRTQ{EXR<{UkuNEX9|Wa89BE3J6GcG&S*Dbg-*vMKv;(#)3NS<&Qa5s_~{;Ue2SXD zB65;SoL&?uI9FLB9EzrP5ZJw2v8I)Sz?Mk%!h)63l--mBs;=OGgP}Rc@-fBh;R;Ng zIdN%oKrjr|C>5aGrhXPY(D9baDkTJ%glRoTrIIsTAen2nRfM|#(MV(neEN?T}C4a&)%k5*yJ{QCAL zhqnZ|&>}a61?`3}x1*rW7>xkODYGp*SLt@k_w=^2aa6#ualqYte(k?MV*mb-TGD!P z%Jcy~?F5szD3-*^!2>6Sz}mnQI!2C|a3vF(zQs&ie`r6i=a2(UFIZ5yU0XyD7Q5#I z5sk`kzQRfy73z-c@_Yu0+ARF?{o5C>rg*^{j*Mt^w_C#{PGcW<@jNv^F6hV;8Ds>y zVSUDHLM)P>-{jY2dzp{q4k+(tZI73>NK233_Q>-4Q$l4D0Oz?>6DP7itSP}h!R3{1 z88bc>pMTEXXf^|jJ+ZWutslX9Q=w8oc7?|=UFKscaVI^pr{L_jl+Xq)hdMrv<9}GX z8zc&J)zyj^-3sZ3ZDm<*f!}ho2bAHoiBl`uL-(!)j{kLdZ6v%o4&qPT-F+6rYbX;H zKLq^@oTQ4dRnPll@@cP+wP$GDe`;4?4y3U5ThjZ>_SWK7X&B3^#ziXEnp7

g(dy z#p>Gz=r73C2;pRjIqpr9w-I%5Vw8C8{;kzh z3915FM&E@WCj#EIxR|Y zXyzP$!^5Z>nhL{}Vb}(j#J1^R8n>TgO2a9U?*F7HI-e8LA}9bAW_Pk;PHb-%`4}`d z8M;MY5sc0dvf}C8z&rL_mv@(DhA|twzu?b_7+&9H2%Q+qJv>t3Wii+FIEJ*)-`-0f zoPd2VbZ0pBR?fPfQgkV>7M)|}-V7J}T%jj&nynZ-EZB%#M>6wH&RRC}_;3m*KN8jT z?UphcFMspqSLN*pjegsQ_so>^gG0MQqV@NguA6=x%l4`P z!|M4$D+qUytb%DdjtiH4ILVTQ6wov}Rk9nT5rDpk7C4Q)e1;A$}Pm0yaFF8~F(??5VAt1Q z;{4#XVC=i63$GklD#_*r!ky*4&_hc*2-B1JP~)Tz)y$nesUl8KGzS?*V^ zrS{-anh}d5*E{O=;j5B<)jmZO!eCi0jjWwgZDj+W*U^KM0h5}biF)s9vh2fhWNN-0 z*a3yZTqhEV&1SdF7muw$6pk_gkPrf>`nq|8tU7rnT+S#_CK`>#3=J75rhB|qz;G}( z&G_vy2X$BMa=r1K&36jOn+A3e_fH;agt zvOKljo!SZr{?=i1>)l$B=xt{4lLWr`y>fQEl%kjxAxm(r>AGy!7~M)#kZV3!10`wD z+??d))NBte1ooy3iZ9+Y&9}|t&eRL97^=m%nJsNLBWS#S#oResf!T{AjLxZ>Rl0x^ z2C> Date: Tue, 19 Mar 2024 14:56:38 -0600 Subject: [PATCH 261/751] Clean up selected data for recording (#10537) --- .../player/PreviewThumbnailPlayer.tsx | 6 +-- web/src/hooks/use-overlay-state.tsx | 2 +- web/src/pages/Events.tsx | 50 +++++-------------- web/src/types/record.ts | 8 +++ web/src/views/events/EventView.tsx | 35 ++++++++----- 5 files changed, 48 insertions(+), 53 deletions(-) diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index e3676e747..5e9a5c651 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -26,7 +26,7 @@ type PreviewPlayerProps = { scrollLock?: boolean; onTimeUpdate?: (time: number | undefined) => void; setReviewed: (review: ReviewSegment) => void; - onClick: (reviewId: string, ctrl: boolean) => void; + onClick: (review: ReviewSegment, ctrl: boolean) => void; }; type Preview = { @@ -55,7 +55,7 @@ export default function PreviewThumbnailPlayer({ const handleOnClick = useCallback( (e: React.MouseEvent) => { if (!ignoreClick) { - onClick(review.id, e.metaKey); + onClick(review, e.metaKey); } }, [ignoreClick, review, onClick], @@ -165,7 +165,7 @@ export default function PreviewThumbnailPlayer({ onMouseLeave={isMobile ? undefined : () => setIsHovered(false)} onContextMenu={(e) => { e.preventDefault(); - onClick(review.id, true); + onClick(review, true); }} onClick={handleOnClick} {...swipeHandlers} diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index 82fd0ee2d..c2f2a0f85 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { usePersistence } from "./use-persistence"; -export function useOverlayState( +export function useOverlayState( key: string, defaultValue: S | undefined = undefined, ): [S | undefined, (value: S, replace?: boolean) => void] { diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 522027687..3791300b9 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -4,6 +4,7 @@ import { useTimezone } from "@/hooks/use-date-utils"; import { useOverlayState } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; +import { RecordingStartingPoint } from "@/types/record"; import { ReviewFilter, ReviewSegment, @@ -26,7 +27,8 @@ export default function Events() { "severity", "alert", ); - const [selectedReviewId, setSelectedReviewId] = useOverlayState("review"); + const [recording, setRecording] = + useOverlayState("recording"); const [startTime, setStartTime] = useState(); // review filter @@ -257,6 +259,10 @@ export default function Events() { // selected items const selectedReviewData = useMemo(() => { + if (!recording) { + return undefined; + } + if (!config) { return undefined; } @@ -265,50 +271,20 @@ export default function Events() { return undefined; } - if (!selectedReviewId) { - return undefined; - } - + setStartTime(recording.startTime); const allCameras = reviewFilter?.cameras ?? Object.keys(config.cameras); - if (selectedReviewId.startsWith("motion")) { - const motionData = selectedReviewId.split(","); - const motionStart = parseFloat(motionData[2]); - setStartTime(motionStart); - // format is motion,camera,start_time - return { - camera: motionData[1], - severity: "significant_motion" as ReviewSeverity, - start_time: motionStart, - allCameras: allCameras, - cameraSegments: reviews.filter((seg) => - allCameras.includes(seg.camera), - ), - }; - } - - const selectedReview = reviews.find((item) => item.id == selectedReviewId); - - if (!selectedReview) { - return undefined; - } - - // mark item as reviewed since it has been opened - if (!selectedReview?.has_been_reviewed) { - markItemAsReviewed(selectedReview); - } - return { - camera: selectedReview.camera, - severity: selectedReview.severity, - start_time: selectedReview.start_time, + camera: recording.camera, + severity: recording.severity, + start_time: recording.startTime, allCameras: allCameras, cameraSegments: reviews.filter((seg) => allCameras.includes(seg.camera)), }; // previews will not update after item is selected // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedReviewId, reviews]); + }, [recording, reviews]); if (!timezone) { return ; @@ -338,7 +314,7 @@ export default function Events() { setSeverity={setSeverity} markItemAsReviewed={markItemAsReviewed} markAllItemsAsReviewed={markAllItemsAsReviewed} - onOpenReview={setSelectedReviewId} + onOpenRecording={setRecording} pullLatestData={reloadData} updateFilter={onUpdateFilter} /> diff --git a/web/src/types/record.ts b/web/src/types/record.ts index 4522c1814..1efa8565f 100644 --- a/web/src/types/record.ts +++ b/web/src/types/record.ts @@ -1,3 +1,5 @@ +import { ReviewSeverity } from "./review"; + export type Recording = { id: string; camera: string; @@ -30,3 +32,9 @@ type RecordingSegmentActivity = { count: number; hasObjects: boolean; }; + +export type RecordingStartingPoint = { + camera: string; + startTime: number; + severity: ReviewSeverity; +}; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 796ee9964..32cc5283e 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -36,6 +36,7 @@ import { Button } from "@/components/ui/button"; import PreviewPlayer, { PreviewController, } from "@/components/player/PreviewPlayer"; +import { RecordingStartingPoint } from "@/types/record"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -48,7 +49,7 @@ type EventViewProps = { setSeverity: (severity: ReviewSeverity) => void; markItemAsReviewed: (review: ReviewSegment) => void; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; - onOpenReview: (reviewId: string) => void; + onOpenRecording: (recordingInfo: RecordingStartingPoint) => void; pullLatestData: () => void; updateFilter: (filter: ReviewFilter) => void; }; @@ -63,7 +64,7 @@ export default function EventView({ setSeverity, markItemAsReviewed, markAllItemsAsReviewed, - onOpenReview, + onOpenRecording, pullLatestData, updateFilter, }: EventViewProps) { @@ -145,9 +146,9 @@ export default function EventView({ const [selectedReviews, setSelectedReviews] = useState([]); const onSelectReview = useCallback( - (reviewId: string, ctrl: boolean) => { + (review: ReviewSegment, ctrl: boolean) => { if (selectedReviews.length > 0 || ctrl) { - const index = selectedReviews.indexOf(reviewId); + const index = selectedReviews.indexOf(review.id); if (index != -1) { if (selectedReviews.length == 1) { @@ -161,14 +162,20 @@ export default function EventView({ } } else { const copy = [...selectedReviews]; - copy.push(reviewId); + copy.push(review.id); setSelectedReviews(copy); } } else { - onOpenReview(reviewId); + onOpenRecording({ + camera: review.camera, + startTime: review.start_time, + severity: review.severity, + }); + + markItemAsReviewed(review); } }, - [selectedReviews, setSelectedReviews, onOpenReview], + [selectedReviews, setSelectedReviews, onOpenRecording, markItemAsReviewed], ); const exportReview = useCallback( @@ -281,7 +288,7 @@ export default function EventView({ timeRange={timeRange} startTime={startTime} filter={filter} - onSelectReview={onSelectReview} + onOpenRecording={onOpenRecording} /> )}

@@ -305,7 +312,7 @@ type DetectionReviewProps = { timeRange: { before: number; after: number }; markItemAsReviewed: (review: ReviewSegment) => void; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; - onSelectReview: (id: string, ctrl: boolean) => void; + onSelectReview: (review: ReviewSegment, ctrl: boolean) => void; pullLatestData: () => void; }; function DetectionReview({ @@ -552,7 +559,7 @@ type MotionReviewProps = { timeRange: { before: number; after: number }; startTime?: number; filter?: ReviewFilter; - onSelectReview: (data: string, ctrl: boolean) => void; + onOpenRecording: (data: RecordingStartingPoint) => void; }; function MotionReview({ contentRef, @@ -561,7 +568,7 @@ function MotionReview({ timeRange, startTime, filter, - onSelectReview, + onOpenRecording, }: MotionReviewProps) { const segmentDuration = 30; const { data: config } = useSWR("config"); @@ -688,7 +695,11 @@ function MotionReview({ videoPlayersRef.current[camera.name] = controller; }} onClick={() => - onSelectReview(`motion,${camera.name},${currentTime}`, false) + onOpenRecording({ + camera: camera.name, + startTime: currentTime, + severity: "significant_motion", + }) } /> ); From 8589ef50a6141f76f687cd91c338be8a3e26da66 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 19 Mar 2024 14:58:04 -0600 Subject: [PATCH 262/751] Ability to configure loitering time in a zone (#10543) * Add loitering config * Configure loitering * simplify * Add docs * grammar * Formatting --- docs/docs/configuration/reference.md | 2 ++ docs/docs/configuration/zones.md | 13 +++++++++++++ frigate/config.py | 5 +++++ frigate/object_processing.py | 26 ++++++++++++++++++-------- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 2ba42224d..b22c115c9 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -488,6 +488,8 @@ cameras: coordinates: 545,1077,747,939,788,805 # Optional: Number of consecutive frames required for object to be considered present in the zone (default: shown below). inertia: 3 + # Optional: Number of seconds that an object must loiter to be considered in the zone (default: shown below) + loitering_time: 0 # Optional: List of objects that can trigger this zone (default: all tracked objects) objects: - person diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index 40297b048..964b44f17 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -60,6 +60,19 @@ camera: Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. You will get events for person objects that enter anywhere in the yard, and events for cars only if they enter the street. +### Zone Loitering + +Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time before the object will be considered in the zone. + +```yaml +camera: + zones: + sidewalk: + loitering_time: 4 # unit is in seconds + objects: + - person +``` + ### Zone Inertia Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured: diff --git a/frigate/config.py b/frigate/config.py index 85377b2f9..55acc1a44 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -515,6 +515,11 @@ class ZoneConfig(BaseModel): title="Number of consecutive frames required for object to be considered present in the zone.", gt=0, ) + loitering_time: int = Field( + default=0, + ge=0, + title="Number of seconds that an object must loiter to be considered in the zone.", + ) objects: List[str] = Field( default_factory=list, title="List of objects that can trigger the zone.", diff --git a/frigate/object_processing.py b/frigate/object_processing.py index f3bb54adc..4cbaa11d6 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -116,7 +116,8 @@ class TrackedObject: self.colormap = colormap self.camera_config = camera_config self.frame_cache = frame_cache - self.zone_presence = {} + self.zone_presence: dict[str, int] = {} + self.zone_loitering: dict[str, int] = {} self.current_zones = [] self.entered_zones = [] self.attributes = defaultdict(float) @@ -189,19 +190,28 @@ class TrackedObject: if len(zone.objects) > 0 and obj_data["label"] not in zone.objects: continue contour = zone.contour - zone_score = self.zone_presence.get(name, 0) + zone_score = self.zone_presence.get(name, 0) + 1 # check if the object is in the zone if cv2.pointPolygonTest(contour, bottom_center, False) >= 0: # if the object passed the filters once, dont apply again if name in self.current_zones or not zone_filtered(self, zone.filters): - self.zone_presence[name] = zone_score + 1 - # an object is only considered present in a zone if it has a zone inertia of 3+ - if self.zone_presence[name] >= zone.inertia: - current_zones.append(name) + if zone_score >= zone.inertia: + loitering_score = self.zone_loitering.get(name, 0) + 1 - if name not in self.entered_zones: - self.entered_zones.append(name) + # loitering time is configured as seconds, convert to count of frames + if loitering_score >= ( + self.camera_config.zones[name].loitering_time + * self.camera_config.detect.fps + ): + current_zones.append(name) + + if name not in self.entered_zones: + self.entered_zones.append(name) + else: + self.zone_loitering[name] = loitering_score + else: + self.zone_presence[name] = zone_score else: # once an object has a zone inertia of 3+ it is not checked anymore if 0 < zone_score < zone.inertia: From 741f0a51155c3767d2457d5e64036831d29670fd Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 19 Mar 2024 21:06:00 -0600 Subject: [PATCH 263/751] Update Web deps (#10544) * Bump react-icons from 4.12.0 to 5.0.1 in /web Bumps [react-icons](https://github.com/react-icons/react-icons) from 4.12.0 to 5.0.1. - [Release notes](https://github.com/react-icons/react-icons/releases) - [Commits](https://github.com/react-icons/react-icons/compare/v4.12.0...v5.0.1) --- updated-dependencies: - dependency-name: react-icons dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Update jsdom * Update drawer component * Bump eslint * Update more deps * Fix lint --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/package-lock.json | 220 ++++++++----------- web/package.json | 16 +- web/src/components/image/EventThumbnail.tsx | 44 ---- web/src/components/player/HlsVideoPlayer.tsx | 4 +- 4 files changed, 99 insertions(+), 185 deletions(-) delete mode 100644 web/src/components/image/EventThumbnail.tsx diff --git a/web/package-lock.json b/web/package-lock.json index e3bec267b..7aa947a18 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -37,7 +37,7 @@ "hls.js": "^1.5.7", "idb-keyval": "^6.2.1", "immer": "^10.0.3", - "lucide-react": "^0.294.0", + "lucide-react": "^0.359.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.2.1", "react": "^18.2.0", @@ -46,10 +46,10 @@ "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-hook-form": "^7.48.2", - "react-icons": "^4.12.0", + "react-icons": "^5.0.1", "react-router-dom": "^6.22.2", "react-swipeable": "^7.0.1", - "react-tracked": "^1.7.11", + "react-tracked": "^1.7.14", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.7.0", "react-zoom-pan-pinch": "^3.4.3", @@ -61,7 +61,7 @@ "swr": "^2.2.5", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.8.0", + "vaul": "^0.9.0", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.22.4" }, @@ -74,8 +74,8 @@ "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", - "@typescript-eslint/eslint-plugin": "^6.10.0", - "@typescript-eslint/parser": "^6.10.0", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", "@vitejs/plugin-react-swc": "^3.6.0", "@vitest/coverage-v8": "^1.0.0", "autoprefixer": "^10.4.17", @@ -86,9 +86,9 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.4", "eslint-plugin-vitest-globals": "^1.4.0", - "fake-indexeddb": "^5.0.1", + "fake-indexeddb": "^5.0.2", "jest-websocket-mock": "^2.5.0", - "jsdom": "^23.0.1", + "jsdom": "^24.0.0", "msw": "^2.2.1", "postcss": "^8.4.32", "prettier": "^3.2.5", @@ -137,17 +137,6 @@ "node": ">=6.0.0" } }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", - "integrity": "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==", - "dev": true, - "dependencies": { - "bidi-js": "^1.0.3", - "css-tree": "^2.3.1", - "is-potential-custom-element-name": "^1.0.1" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", @@ -2721,16 +2710,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz", + "integrity": "sha512-STEDMVQGww5lhCuNXVSQfbfuNII5E08QWkvAw5Qwf+bj2WT+JkG1uc+5/vXA3AOYMDHVOSpL+9rcbEUiHIm2dw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "7.3.1", + "@typescript-eslint/type-utils": "7.3.1", + "@typescript-eslint/utils": "7.3.1", + "@typescript-eslint/visitor-keys": "7.3.1", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -2739,15 +2728,15 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -2756,26 +2745,26 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.3.1.tgz", + "integrity": "sha512-Rq49+pq7viTRCH48XAbTA+wdLRrB/3sRq4Lpk0oGDm0VmnjBrAOVXH/Laalmwsv2VpekiEfVFwJYVk6/e8uvQw==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "7.3.1", + "@typescript-eslint/types": "7.3.1", + "@typescript-eslint/typescript-estree": "7.3.1", + "@typescript-eslint/visitor-keys": "7.3.1", "debug": "^4.3.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -2784,16 +2773,16 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.3.1.tgz", + "integrity": "sha512-fVS6fPxldsKY2nFvyT7IP78UO1/I2huG+AYu5AMjCT9wtl6JFiDnsv4uad4jQ0GTFzcUV5HShVeN96/17bTBag==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "7.3.1", + "@typescript-eslint/visitor-keys": "7.3.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -2801,25 +2790,25 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.3.1.tgz", + "integrity": "sha512-iFhaysxFsMDQlzJn+vr3OrxN8NmdQkHks4WaqD4QBnt5hsq234wcYdyQ9uquzJJIDAj5W4wQne3yEsYA6OmXGw==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/typescript-estree": "7.3.1", + "@typescript-eslint/utils": "7.3.1", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -2828,12 +2817,12 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.3.1.tgz", + "integrity": "sha512-2tUf3uWggBDl4S4183nivWQ2HqceOZh1U4hhu4p1tPiIJoRRXrab7Y+Y0p+dozYwZVvLPRI6r5wKe9kToF9FIw==", "dev": true, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -2841,13 +2830,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.3.1.tgz", + "integrity": "sha512-tLpuqM46LVkduWP7JO7yVoWshpJuJzxDOPYIVWUUZbW+4dBpgGeUdl/fQkhuV0A8eGnphYw3pp8d2EnvPOfxmQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "7.3.1", + "@typescript-eslint/visitor-keys": "7.3.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2856,7 +2845,7 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -2893,41 +2882,41 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.3.1.tgz", + "integrity": "sha512-jIERm/6bYQ9HkynYlNZvXpzmXWZGhMbrOvq3jJzOSOlKXsVjrrolzWBjDW6/TvT5Q3WqaN4EkmcfdQwi9tDjBQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/scope-manager": "7.3.1", + "@typescript-eslint/types": "7.3.1", + "@typescript-eslint/typescript-estree": "7.3.1", "semver": "^7.5.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.3.1.tgz", + "integrity": "sha512-9RMXwQF8knsZvfv9tdi+4D/j7dMG28X/wMJ8Jj6eOHyHWwDW4ngQJcqEczSsqIKKjFiLFr40Mnr7a5ulDD3vmw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/types": "7.3.1", "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -3326,15 +3315,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -3739,19 +3719,6 @@ "node": ">= 8" } }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "dev": true, - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -5284,12 +5251,11 @@ } }, "node_modules/jsdom": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", - "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.0.0.tgz", + "integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==", "dev": true, "dependencies": { - "@asamuzakjp/dom-selector": "^2.0.1", "cssstyle": "^4.0.1", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", @@ -5298,6 +5264,7 @@ "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.7", "parse5": "^7.1.2", "rrweb-cssom": "^0.6.0", "saxes": "^6.0.0", @@ -5457,9 +5424,9 @@ } }, "node_modules/lucide-react": { - "version": "0.294.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", - "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", + "version": "0.359.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.359.0.tgz", + "integrity": "sha512-bxVL+rM/wacjpT0BKShA6r5IIKb6LCRg+ltFG9pnnIwaRX8kK3hq8v5JwMpT7RC6XeqB5cSaaV6GapPWWmtliw==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } @@ -5502,12 +5469,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", - "dev": true - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -5938,6 +5899,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", + "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", + "dev": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6530,9 +6497,9 @@ } }, "node_modules/react-icons": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", - "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.0.1.tgz", + "integrity": "sha512-WqLZJ4bLzlhmsvme6iFdgO8gfZP17rfjYEJ2m9RsZjZ+cc4k1hTzknEz63YS1MeT50kVzoa1Nz36f4BEx+Wigw==", "peerDependencies": { "react": "*" } @@ -6649,12 +6616,12 @@ } }, "node_modules/react-tracked": { - "version": "1.7.12", - "resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-1.7.12.tgz", - "integrity": "sha512-4HhgUyhUVrq2Bc+Qu4ZloKHgEydR5Ge0YNsTLo/CCLhhk18wsYWOHY5ALrPjaeb5x4mWOQYuUMYe7G2VAfvFkQ==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-1.7.14.tgz", + "integrity": "sha512-6UMlgQeRAGA+uyYzuQGm7kZB6ZQYFhc7sntgP7Oxwwd6M0Ud/POyb4K3QWT1eXvoifSa80nrAWnXWFGpOvbwkw==", "dependencies": { "proxy-compare": "2.6.0", - "use-context-selector": "1.4.2" + "use-context-selector": "1.4.4" }, "peerDependencies": { "react": ">=16.8.0", @@ -6773,15 +6740,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -7876,9 +7834,9 @@ } }, "node_modules/use-context-selector": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.2.tgz", - "integrity": "sha512-OZvd4TWirCMOwIwFlQFnECIyEGb3fvSrd71A8sGWiAPl6SIJTS60Ho+WnAr67T6/JedvlperiReCM5n9mnM3fQ==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.4.tgz", + "integrity": "sha512-pS790zwGxxe59GoBha3QYOwk8AFGp4DN6DOtH+eoqVmgBBRXVx4IlPDhJmmMiNQAgUaLlP+58aqRC3A4rdaSjg==", "peerDependencies": { "react": ">=16.8.0", "react-dom": "*", @@ -7943,9 +7901,9 @@ } }, "node_modules/vaul": { - "version": "0.8.9", - "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.8.9.tgz", - "integrity": "sha512-gpmtmZRWDPP6niQh14JfRIFUYZVyfvAWyA/7rUINOfNlO/2K7uEvI5rLXEXkxZIRFyUZj+TPHLFMirkegPHjrw==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.0.tgz", + "integrity": "sha512-bZSySGbAHiTXmZychprnX/dE0EsSige88xtyyL3/MCRbrFotRPQZo7UdydGXZWw+CKbNOw5Ow8gwAo93/nB/Cg==", "dependencies": { "@radix-ui/react-dialog": "^1.0.4" }, diff --git a/web/package.json b/web/package.json index 0eebdaec1..29a2842dc 100644 --- a/web/package.json +++ b/web/package.json @@ -42,7 +42,7 @@ "hls.js": "^1.5.7", "idb-keyval": "^6.2.1", "immer": "^10.0.3", - "lucide-react": "^0.294.0", + "lucide-react": "^0.359.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.2.1", "react": "^18.2.0", @@ -51,10 +51,10 @@ "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-hook-form": "^7.48.2", - "react-icons": "^4.12.0", + "react-icons": "^5.0.1", "react-router-dom": "^6.22.2", "react-swipeable": "^7.0.1", - "react-tracked": "^1.7.11", + "react-tracked": "^1.7.14", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.7.0", "react-zoom-pan-pinch": "^3.4.3", @@ -66,7 +66,7 @@ "swr": "^2.2.5", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.8.0", + "vaul": "^0.9.0", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.22.4" }, @@ -79,8 +79,8 @@ "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", - "@typescript-eslint/eslint-plugin": "^6.10.0", - "@typescript-eslint/parser": "^6.10.0", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", "@vitejs/plugin-react-swc": "^3.6.0", "@vitest/coverage-v8": "^1.0.0", "autoprefixer": "^10.4.17", @@ -91,9 +91,9 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.4", "eslint-plugin-vitest-globals": "^1.4.0", - "fake-indexeddb": "^5.0.1", + "fake-indexeddb": "^5.0.2", "jest-websocket-mock": "^2.5.0", - "jsdom": "^23.0.1", + "jsdom": "^24.0.0", "msw": "^2.2.1", "postcss": "^8.4.32", "prettier": "^3.2.5", diff --git a/web/src/components/image/EventThumbnail.tsx b/web/src/components/image/EventThumbnail.tsx deleted file mode 100644 index 7c187230c..000000000 --- a/web/src/components/image/EventThumbnail.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { baseUrl } from "@/api/baseUrl"; -import { Event as FrigateEvent } from "@/types/event"; -import { LuStar } from "react-icons/lu"; -import TimeAgo from "../dynamic/TimeAgo"; -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; - -type EventThumbnailProps = { - event: FrigateEvent; - onFavorite?: (e: Event, event: FrigateEvent) => void; -}; -export function EventThumbnail({ event, onFavorite }: EventThumbnailProps) { - return ( - - -
- (onFavorite ? onFavorite(e, event) : null)} - fill={event.retain_indefinitely ? "currentColor" : "none"} - /> -
-
- -
-
-
-
- - {`${event.label} ${ - event.sub_label ? `(${event.sub_label})` : "" - } detected with score of ${(event.data.score * 100).toFixed(0)}% ${ - event.data.sub_label_score - ? `(${event.data.sub_label_score * 100}%)` - : "" - }`} - -
- ); -} diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index fd0e79db0..d1d7186fe 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -255,7 +255,7 @@ function VideoControls({ }, []); const onReplay = useCallback( - (e: React.MouseEvent) => { + (e: React.MouseEvent) => { e.stopPropagation(); const currentTime = video?.currentTime; @@ -270,7 +270,7 @@ function VideoControls({ ); const onSkip = useCallback( - (e: React.MouseEvent) => { + (e: React.MouseEvent) => { e.stopPropagation(); const currentTime = video?.currentTime; From f474bc40371c87af7379cfa21085b6e02d70296d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 19 Mar 2024 21:16:22 -0600 Subject: [PATCH 264/751] Always check previews when refocusing the page (#10546) --- web/src/pages/Events.tsx | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 3791300b9..74652b57c 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -80,6 +80,7 @@ export default function Events() { reviewSegmentFetcher, { revalidateOnFocus: false, + revalidateOnReconnect: false, }, ); @@ -92,7 +93,7 @@ export default function Events() { cameras: reviewSearchParams["cameras"] ?? null, labels: reviewSearchParams["labels"] ?? null, }, - { revalidateOnFocus: false }, + { revalidateOnFocus: false, revalidateOnReconnect: false }, ]); const reloadData = useCallback(() => { @@ -128,22 +129,31 @@ export default function Events() { previewTimes ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` : null, - { revalidateOnFocus: false }, + { revalidateOnFocus: false, revalidateOnReconnect: false }, ); // Set a timeout to update previews on the hour useEffect(() => { - const future = new Date(); - future.setHours(future.getHours() + 1, 0, 30, 0); - const timeoutId = setTimeout( - () => setPreviewKey(10 * Math.random()), - future.getTime() - Date.now(), - ); + 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 () => { - clearTimeout(timeoutId); + document.removeEventListener("focusin", callback); }; - }, []); + }, [allPreviews]); // review status From 5af083cd8a15bc4088762a62bc207dee97c9bbb5 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 20 Mar 2024 07:36:58 -0600 Subject: [PATCH 265/751] Web deps again (#10562) * Bump react-icons from 4.12.0 to 5.0.1 in /web Bumps [react-icons](https://github.com/react-icons/react-icons) from 4.12.0 to 5.0.1. - [Release notes](https://github.com/react-icons/react-icons/releases) - [Commits](https://github.com/react-icons/react-icons/compare/v4.12.0...v5.0.1) --- updated-dependencies: - dependency-name: react-icons dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Update jsdom * Update drawer component * Bump eslint * Update more deps * Fix lint --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 8babe57d635e4689ae7035804d15b77226c8559b Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 20 Mar 2024 19:46:45 -0600 Subject: [PATCH 266/751] UI cleanup (#10567) * Fix selected items text * Use action icons from design and fix spacing * Fix icons for live grid * Fix viewed select api * Setup default theme as system * Make conig editor respect system theme --- .../components/filter/ReviewActionGroup.tsx | 31 ++++++++------ web/src/components/icons/LiveIcons.tsx | 42 +++++++++++++++++++ web/src/context/providers.tsx | 2 +- web/src/context/theme-provider.tsx | 24 +++++++---- web/src/pages/ConfigEditor.tsx | 4 +- web/src/views/live/LiveDashboardView.tsx | 22 +++++----- 6 files changed, 90 insertions(+), 35 deletions(-) create mode 100644 web/src/components/icons/LiveIcons.tsx diff --git a/web/src/components/filter/ReviewActionGroup.tsx b/web/src/components/filter/ReviewActionGroup.tsx index 5e1a93185..c3df52881 100644 --- a/web/src/components/filter/ReviewActionGroup.tsx +++ b/web/src/components/filter/ReviewActionGroup.tsx @@ -1,8 +1,10 @@ -import { LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu"; +import { FaCircleCheck } from "react-icons/fa6"; import { useCallback } from "react"; import axios from "axios"; import { Button } from "../ui/button"; import { isDesktop } from "react-device-detect"; +import { FaCompactDisc } from "react-icons/fa"; +import { HiTrash } from "react-icons/hi"; type ReviewActionGroupProps = { selectedReviews: string[]; @@ -21,8 +23,7 @@ export default function ReviewActionGroup({ }, [setSelectedReviews]); const onMarkAsReviewed = useCallback(async () => { - const idList = selectedReviews.join(","); - await axios.post(`reviews/viewed`, { ids: idList }); + await axios.post(`reviews/viewed`, { ids: selectedReviews }); setSelectedReviews([]); pullLatestData(); }, [selectedReviews, setSelectedReviews, pullLatestData]); @@ -36,16 +37,20 @@ export default function ReviewActionGroup({ return (
-
-
{`${selectedReviews.length} selected | `}
- +
{selectedReviews.length == 1 && ( )}
diff --git a/web/src/components/icons/LiveIcons.tsx b/web/src/components/icons/LiveIcons.tsx new file mode 100644 index 000000000..dc491b612 --- /dev/null +++ b/web/src/components/icons/LiveIcons.tsx @@ -0,0 +1,42 @@ +type LiveIconProps = { + layout?: "list" | "grid"; +}; + +export function LiveGridIcon({ layout }: LiveIconProps) { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export function LiveListIcon({ layout }: LiveIconProps) { + return ( +
+
+
+
+ ); +} diff --git a/web/src/context/providers.tsx b/web/src/context/providers.tsx index efcf2a10b..b239aff47 100644 --- a/web/src/context/providers.tsx +++ b/web/src/context/providers.tsx @@ -13,7 +13,7 @@ function providers({ children }: TProvidersProps) { return ( - + {children} diff --git a/web/src/context/theme-provider.tsx b/web/src/context/theme-provider.tsx index 26d6ef244..70dfaced1 100644 --- a/web/src/context/theme-provider.tsx +++ b/web/src/context/theme-provider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState } from "react"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; type Theme = "dark" | "light" | "system"; type ColorScheme = @@ -41,6 +41,7 @@ type ThemeProviderProps = { type ThemeProviderState = { theme: Theme; + systemTheme?: Theme; colorScheme: ColorScheme; setTheme: (theme: Theme) => void; setColorScheme: (colorScheme: ColorScheme) => void; @@ -48,6 +49,7 @@ type ThemeProviderState = { const initialState: ThemeProviderState = { theme: "system", + systemTheme: undefined, colorScheme: "theme-default", setTheme: () => null, setColorScheme: () => null, @@ -86,6 +88,16 @@ export function ThemeProvider({ } }); + const systemTheme = useMemo(() => { + if (theme != "system") { + return undefined; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + }, [theme]); + useEffect(() => { //localStorage.removeItem(storageKey); //console.log(localStorage.getItem(storageKey)); @@ -95,21 +107,17 @@ export function ThemeProvider({ root.classList.add(theme, colorScheme); - if (theme === "system") { - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light"; - + if (systemTheme) { root.classList.add(systemTheme); return; } root.classList.add(theme); - }, [theme, colorScheme]); + }, [theme, colorScheme, systemTheme]); const value = { theme, + systemTheme, colorScheme, setTheme: (theme: Theme) => { localStorage.setItem(storageKey, JSON.stringify({ theme, colorScheme })); diff --git a/web/src/pages/ConfigEditor.tsx b/web/src/pages/ConfigEditor.tsx index cbfa17a74..eca6b8fc8 100644 --- a/web/src/pages/ConfigEditor.tsx +++ b/web/src/pages/ConfigEditor.tsx @@ -19,7 +19,7 @@ function ConfigEditor() { const { data: config } = useSWR("config/raw"); - const { theme } = useTheme(); + const { theme, systemTheme } = useTheme(); const [error, setError] = useState(); const editorRef = useRef(null); @@ -107,7 +107,7 @@ function ConfigEditor() { language: "yaml", model: modelRef.current, scrollBeyondLastLine: false, - theme: theme == "dark" ? "vs-dark" : "vs-light", + theme: (systemTheme || theme) == "dark" ? "vs-dark" : "vs-light", }); } diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 071a9d71e..d5a7401c5 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -1,6 +1,7 @@ import { useFrigateReviews } from "@/api/ws"; import Logo from "@/components/Logo"; import { CameraGroupSelector } from "@/components/filter/CameraGroupSelector"; +import { LiveGridIcon, LiveListIcon } from "@/components/icons/LiveIcons"; import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import LivePlayer from "@/components/player/LivePlayer"; @@ -12,7 +13,6 @@ import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment } from "@/types/review"; import { useCallback, useEffect, useMemo, useState } from "react"; import { isDesktop, isMobile, isSafari } from "react-device-detect"; -import { CiGrid2H, CiGrid31 } from "react-icons/ci"; import useSWR from "swr"; type LiveDashboardViewProps = { @@ -89,26 +89,26 @@ export default function LiveDashboardView({
From c8fd23caa143694234c9487f16066cd1d51604f9 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 20 Mar 2024 19:56:56 -0600 Subject: [PATCH 267/751] Update web deps (#10564) --- web/package-lock.json | 189 ++++++++++++++++++++---------------------- web/package.json | 16 ++-- 2 files changed, 98 insertions(+), 107 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 7aa947a18..4f57ed8d0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,7 +28,7 @@ "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "apexcharts": "^3.45.1", + "apexcharts": "^3.48.0", "axios": "^1.6.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -47,11 +47,11 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.48.2", "react-icons": "^5.0.1", - "react-router-dom": "^6.22.2", + "react-router-dom": "^6.22.3", "react-swipeable": "^7.0.1", "react-tracked": "^1.7.14", "react-transition-group": "^4.4.5", - "react-use-websocket": "^4.7.0", + "react-use-websocket": "^4.8.1", "react-zoom-pan-pinch": "^3.4.3", "recoil": "^0.7.7", "scroll-into-view-if-needed": "^3.1.0", @@ -77,7 +77,7 @@ "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "@vitejs/plugin-react-swc": "^3.6.0", - "@vitest/coverage-v8": "^1.0.0", + "@vitest/coverage-v8": "^1.4.0", "autoprefixer": "^10.4.17", "eslint": "^8.55.0", "eslint-config-prettier": "^9.1.0", @@ -85,15 +85,15 @@ "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.4", - "eslint-plugin-vitest-globals": "^1.4.0", + "eslint-plugin-vitest-globals": "^1.5.0", "fake-indexeddb": "^5.0.2", "jest-websocket-mock": "^2.5.0", "jsdom": "^24.0.0", - "msw": "^2.2.1", - "postcss": "^8.4.32", + "msw": "^2.2.9", + "postcss": "^8.4.37", "prettier": "^3.2.5", "tailwindcss": "^3.4.1", - "typescript": "^5.2.2", + "typescript": "^5.4.2", "vite": "^5.1.4", "vitest": "^1.3.1" } @@ -807,9 +807,9 @@ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -2135,9 +2135,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.2.tgz", - "integrity": "sha512-+Rnav+CaoTE5QJc4Jcwh5toUpnVLKYbpU6Ys0zqbakqbaLQHeglLVHPfxOiQqdNmUy5C2lXz5dwC6tQNX2JW2Q==", + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", + "integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==", "engines": { "node": ">=14.0.0" } @@ -2942,9 +2942,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz", - "integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.4.0.tgz", + "integrity": "sha512-4hDGyH1SvKpgZnIByr9LhGgCEuF9DKM34IBLCC/fVfy24Z3+PZ+Ii9hsVBsHvY1umM1aGPEjceRkzxCfcQ10wg==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.1", @@ -2952,12 +2952,13 @@ "debug": "^4.3.4", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^4.0.1", + "istanbul-lib-source-maps": "^5.0.4", "istanbul-reports": "^3.1.6", "magic-string": "^0.30.5", "magicast": "^0.3.3", "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" }, @@ -2965,17 +2966,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "1.3.1" + "vitest": "1.4.0" } }, "node_modules/@vitest/expect": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz", - "integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.4.0.tgz", + "integrity": "sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==", "dev": true, "dependencies": { - "@vitest/spy": "1.3.1", - "@vitest/utils": "1.3.1", + "@vitest/spy": "1.4.0", + "@vitest/utils": "1.4.0", "chai": "^4.3.10" }, "funding": { @@ -2983,12 +2984,12 @@ } }, "node_modules/@vitest/runner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz", - "integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.4.0.tgz", + "integrity": "sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==", "dev": true, "dependencies": { - "@vitest/utils": "1.3.1", + "@vitest/utils": "1.4.0", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -3024,9 +3025,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz", - "integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.4.0.tgz", + "integrity": "sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==", "dev": true, "dependencies": { "magic-string": "^0.30.5", @@ -3038,9 +3039,9 @@ } }, "node_modules/@vitest/spy": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz", - "integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.4.0.tgz", + "integrity": "sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==", "dev": true, "dependencies": { "tinyspy": "^2.2.0" @@ -3050,9 +3051,9 @@ } }, "node_modules/@vitest/utils": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz", - "integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.4.0.tgz", + "integrity": "sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==", "dev": true, "dependencies": { "diff-sequences": "^29.6.3", @@ -3196,9 +3197,9 @@ } }, "node_modules/apexcharts": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.46.0.tgz", - "integrity": "sha512-ELAY6vj8JQD7QLktKasTzwm9Wt0qxqfQSo+3QWS7G7I774iK8HCkG1toGsqJH0mkK6PtYBtnSIe66uUcwoCw1w==", + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.48.0.tgz", + "integrity": "sha512-Lhpj1Ij6lKlrUke8gf+P+SE6uGUn+Pe1TnCJ+zqrY0YMvbqM3LMb1lY+eybbTczUyk0RmMZomlTa2NgX2EUs4Q==", "dependencies": { "@yr/monotone-cubic-spline": "^1.0.3", "svg.draggable.js": "^2.2.2", @@ -4346,9 +4347,9 @@ } }, "node_modules/eslint-plugin-vitest-globals": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-vitest-globals/-/eslint-plugin-vitest-globals-1.4.0.tgz", - "integrity": "sha512-WE+YlK9X9s4vf5EaYRU0Scw7WItDZStm+PapFSYlg2ABNtaQ4zIG7wEqpoUB3SlfM+SgkhgmzR0TeJOO5k3/Nw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vitest-globals/-/eslint-plugin-vitest-globals-1.5.0.tgz", + "integrity": "sha512-ZSsVOaOIig0oVLzRTyk8lUfBfqzWxr/J3/NFMfGGRIkGQPejJYmDH3gXmSJxAojts77uzAGB/UmVrwi2DC4LYA==", "dev": true }, "node_modules/eslint-scope": { @@ -5165,14 +5166,14 @@ } }, "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.4.tgz", + "integrity": "sha512-wHOoEsNJTVltaJp8eVkm8w+GVkVNHT2YDYo53YdzQEL2gWm1hBX5cGFR9hQJtuGLebidVX7et3+dmDZrmclduw==", "dev": true, "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "istanbul-lib-coverage": "^3.0.0" }, "engines": { "node": ">=10" @@ -5668,9 +5669,9 @@ "dev": true }, "node_modules/msw": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.2.2.tgz", - "integrity": "sha512-Vn3RGCmp14Oy1Lo9yGJMk4+qV/WdK8opNyHt0jdBnvzQ8OEhFvQ2AeM9EXOgQtGLvzUWzqrrwlfwmsCkFViUlg==", + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.2.9.tgz", + "integrity": "sha512-MLIFufBe6m9c5rZKlmGl6jl1pjn7cTNiDGEgn5v2iVRs0mz+neE2r7lRyYNzvcp6FbdiUEIRp/Y2O2gRMjO8yQ==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -5699,11 +5700,10 @@ "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mswjs" + "url": "https://github.com/sponsors/mswjs" }, "peerDependencies": { - "typescript": ">= 4.7.x <= 5.3.x" + "typescript": ">= 4.7.x" }, "peerDependenciesMeta": { "typescript": { @@ -6157,9 +6157,9 @@ } }, "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "version": "8.4.37", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.37.tgz", + "integrity": "sha512-7iB/v/r7Woof0glKLH8b1SPHrsX7uhdO+Geb41QpF/+mWZHU3uxxSlN+UXGVit1PawOYDToO+AbZzhBzWRDwbQ==", "funding": [ { "type": "opencollective", @@ -6177,7 +6177,7 @@ "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6556,11 +6556,11 @@ } }, "node_modules/react-router": { - "version": "6.22.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.2.tgz", - "integrity": "sha512-YD3Dzprzpcq+tBMHBS822tCjnWD3iIZbTeSXMY9LPSG541EfoBGyZ3bS25KEnaZjLcmQpw2AVLkFyfgXY8uvcw==", + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", + "integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==", "dependencies": { - "@remix-run/router": "1.15.2" + "@remix-run/router": "1.15.3" }, "engines": { "node": ">=14.0.0" @@ -6570,12 +6570,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.22.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.2.tgz", - "integrity": "sha512-WgqxD2qySEIBPZ3w0sHH+PUAiamDeszls9tzqMPBDA1YYVucTBXLU7+gtRfcSnhe92A3glPnvSxK2dhNoAVOIQ==", + "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==", "dependencies": { - "@remix-run/router": "1.15.2", - "react-router": "6.22.2" + "@remix-run/router": "1.15.3", + "react-router": "6.22.3" }, "engines": { "node": ">=14.0.0" @@ -6654,9 +6654,9 @@ } }, "node_modules/react-use-websocket": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.7.0.tgz", - "integrity": "sha512-YjR62jB7vB94IZy5UPBGZSR3c0hxu796q9IuJ0vbNg7InJ7Z84NHOd/LHzVI5nAKtaGy1oqvf8EmjKxX+cNz4A==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.8.1.tgz", + "integrity": "sha512-FTXuG5O+LFozmu1BRfrzl7UIQngECvGJmL7BHsK4TYXuVt+mCizVA8lT0hGSIF0Z0TedF7bOo1nRzOUdginhDw==", "peerDependencies": { "react": ">= 18.0.0", "react-dom": ">= 18.0.0" @@ -7096,19 +7096,10 @@ "object-path": "0.6.0" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "engines": { "node": ">=0.10.0" } @@ -7700,9 +7691,9 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -7968,9 +7959,9 @@ } }, "node_modules/vite-node": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz", - "integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.4.0.tgz", + "integrity": "sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -7998,16 +7989,16 @@ } }, "node_modules/vitest": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz", - "integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.4.0.tgz", + "integrity": "sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==", "dev": true, "dependencies": { - "@vitest/expect": "1.3.1", - "@vitest/runner": "1.3.1", - "@vitest/snapshot": "1.3.1", - "@vitest/spy": "1.3.1", - "@vitest/utils": "1.3.1", + "@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", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", @@ -8021,7 +8012,7 @@ "tinybench": "^2.5.1", "tinypool": "^0.8.2", "vite": "^5.0.0", - "vite-node": "1.3.1", + "vite-node": "1.4.0", "why-is-node-running": "^2.2.2" }, "bin": { @@ -8036,8 +8027,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.3.1", - "@vitest/ui": "1.3.1", + "@vitest/browser": "1.4.0", + "@vitest/ui": "1.4.0", "happy-dom": "*", "jsdom": "*" }, diff --git a/web/package.json b/web/package.json index 29a2842dc..04dd8f158 100644 --- a/web/package.json +++ b/web/package.json @@ -33,7 +33,7 @@ "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "apexcharts": "^3.45.1", + "apexcharts": "^3.48.0", "axios": "^1.6.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -52,11 +52,11 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.48.2", "react-icons": "^5.0.1", - "react-router-dom": "^6.22.2", + "react-router-dom": "^6.22.3", "react-swipeable": "^7.0.1", "react-tracked": "^1.7.14", "react-transition-group": "^4.4.5", - "react-use-websocket": "^4.7.0", + "react-use-websocket": "^4.8.1", "react-zoom-pan-pinch": "^3.4.3", "recoil": "^0.7.7", "scroll-into-view-if-needed": "^3.1.0", @@ -82,7 +82,7 @@ "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "@vitejs/plugin-react-swc": "^3.6.0", - "@vitest/coverage-v8": "^1.0.0", + "@vitest/coverage-v8": "^1.4.0", "autoprefixer": "^10.4.17", "eslint": "^8.55.0", "eslint-config-prettier": "^9.1.0", @@ -90,15 +90,15 @@ "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.4", - "eslint-plugin-vitest-globals": "^1.4.0", + "eslint-plugin-vitest-globals": "^1.5.0", "fake-indexeddb": "^5.0.2", "jest-websocket-mock": "^2.5.0", "jsdom": "^24.0.0", - "msw": "^2.2.1", - "postcss": "^8.4.32", + "msw": "^2.2.9", + "postcss": "^8.4.37", "prettier": "^3.2.5", "tailwindcss": "^3.4.1", - "typescript": "^5.2.2", + "typescript": "^5.4.2", "vite": "^5.1.4", "vitest": "^1.3.1" } From f113acee33397aca84dc860b0cf2049131414435 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 20 Mar 2024 21:56:15 -0500 Subject: [PATCH 268/751] Summary timeline (#10569) * implement summary timeline * implement summary timeline * merge dev * conditionally attach listeners only when dragging * set up listeners with a ref --- web/src/components/dynamic/NewReviewData.tsx | 2 +- .../timeline/EventReviewTimeline.tsx | 12 +- web/src/components/timeline/EventSegment.tsx | 21 +- .../timeline/MotionReviewTimeline.tsx | 49 ++- web/src/components/timeline/MotionSegment.tsx | 7 +- .../components/timeline/ReviewTimeline.tsx | 2 +- .../components/timeline/SummarySegment.tsx | 67 ++++ .../components/timeline/SummaryTimeline.tsx | 317 ++++++++++++++++++ .../components/timeline/segment-metadata.tsx | 8 +- web/src/hooks/use-draggable-element.ts | 12 +- web/src/hooks/use-event-segment-utils.ts | 11 +- web/src/hooks/use-timeline-utils.ts | 10 + web/src/pages/UIPlayground.tsx | 16 +- web/src/views/events/EventView.tsx | 47 ++- web/themes/theme-default.css | 26 +- 15 files changed, 523 insertions(+), 84 deletions(-) create mode 100644 web/src/components/timeline/SummarySegment.tsx create mode 100644 web/src/components/timeline/SummaryTimeline.tsx diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index 8a4752470..c60a08b49 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -43,7 +43,7 @@ export default function NewReviewData({ return (
-
+
)} - - {severityValue !== displaySeverityType && ( -
-
-
- )} ))}
diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 9a1a7b428..1b2a23f0f 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -32,6 +32,7 @@ export type MotionReviewTimelineProps = { motion_events: MotionData[]; severityType: ReviewSeverity; contentRef: RefObject; + timelineRef?: RefObject; onHandlebarDraggingChange?: (isDragging: boolean) => void; }; @@ -54,13 +55,14 @@ export function MotionReviewTimeline({ events, motion_events, contentRef, + timelineRef, onHandlebarDraggingChange, }: MotionReviewTimelineProps) { const [isDragging, setIsDragging] = useState(false); const [exportStartPosition, setExportStartPosition] = useState(0); const [exportEndPosition, setExportEndPosition] = useState(0); - const timelineRef = useRef(null); + const internalTimelineRef = useRef(null); const handlebarRef = useRef(null); const handlebarTimeRef = useRef(null); const exportStartRef = useRef(null); @@ -99,7 +101,7 @@ export function MotionReviewTimeline({ handleMouseMove: handlebarMouseMove, } = useDraggableElement({ contentRef, - timelineRef, + timelineRef: timelineRef || internalTimelineRef, draggableElementRef: handlebarRef, segmentDuration, showDraggableElement: showHandlebar, @@ -118,7 +120,7 @@ export function MotionReviewTimeline({ handleMouseMove: exportStartMouseMove, } = useDraggableElement({ contentRef, - timelineRef, + timelineRef: timelineRef || internalTimelineRef, draggableElementRef: exportStartRef, segmentDuration, showDraggableElement: showExportHandles, @@ -139,7 +141,7 @@ export function MotionReviewTimeline({ handleMouseMove: exportEndMouseMove, } = useDraggableElement({ contentRef, - timelineRef, + timelineRef: timelineRef || internalTimelineRef, draggableElementRef: exportEndRef, segmentDuration, showDraggableElement: showExportHandles, @@ -213,9 +215,46 @@ export function MotionReviewTimeline({ } }, [isDragging, onHandlebarDraggingChange]); + const segmentsObserver = useRef(null); + const selectedTimelineRef = timelineRef || internalTimelineRef; + useEffect(() => { + if (selectedTimelineRef.current && segments) { + segmentsObserver.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const segmentId = entry.target.getAttribute("data-segment-id"); + + const segmentElements = + internalTimelineRef.current?.querySelectorAll( + `[data-segment-id="${segmentId}"] .motion-segment`, + ); + segmentElements?.forEach((segmentElement) => { + segmentElement.classList.remove("hidden"); + segmentElement.classList.add("animate-in"); + }); + } + }); + }, + { threshold: 0 }, + ); + + // Get all segment divs and observe each one + const segmentDivs = + selectedTimelineRef.current.querySelectorAll(".segment.has-data"); + segmentDivs.forEach((segmentDiv) => { + segmentsObserver.current?.observe(segmentDiv); + }); + } + + return () => { + segmentsObserver.current?.disconnect(); + }; + }, [selectedTimelineRef, segments]); + return ( 1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`} onClick={segmentClick} onTouchEnd={(event) => handleTouchStart(event, segmentClick)} > @@ -203,7 +204,7 @@ export function MotionSegment({
1 ? "hidden" : ""} zoom-in-[0.2] ${secondHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`} style={{ width: secondHalfSegmentWidth, }} @@ -215,7 +216,7 @@ export function MotionSegment({
1 ? "hidden" : ""} zoom-in-[0.2] ${firstHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`} style={{ width: firstHalfSegmentWidth, }} diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx index 896fea8e1..b0c1bc16e 100644 --- a/web/src/components/timeline/ReviewTimeline.tsx +++ b/web/src/components/timeline/ReviewTimeline.tsx @@ -234,7 +234,7 @@ export function ReviewTimeline({ onTouchMove={handleMouseMove} onMouseUp={handleMouseUp} onTouchEnd={handleMouseUp} - className={`relative h-full overflow-y-auto no-scrollbar bg-secondary ${ + className={`relative h-full overflow-y-auto no-scrollbar select-none bg-secondary ${ isDragging && (showHandlebar || showExportHandles) ? "cursor-grabbing" : "cursor-auto" diff --git a/web/src/components/timeline/SummarySegment.tsx b/web/src/components/timeline/SummarySegment.tsx new file mode 100644 index 000000000..77f30dbdd --- /dev/null +++ b/web/src/components/timeline/SummarySegment.tsx @@ -0,0 +1,67 @@ +import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; +import { ReviewSegment } from "@/types/review"; +import React, { useMemo } from "react"; +// import useTapUtils from "@/hooks/use-tap-utils"; + +type SummarySegmentProps = { + events: ReviewSegment[]; + segmentTime: number; + segmentDuration: number; + segmentHeight: number; +}; + +export function SummarySegment({ + events, + segmentTime, + segmentDuration, + segmentHeight, +}: SummarySegmentProps) { + const severityType = "all"; + const { getSeverity, getReviewed, displaySeverityType } = + useEventSegmentUtils(segmentDuration, events, severityType); + + const severity = useMemo( + () => getSeverity(segmentTime, displaySeverityType), + [getSeverity, segmentTime, displaySeverityType], + ); + + const reviewed = useMemo( + () => getReviewed(segmentTime), + [getReviewed, segmentTime], + ); + + const segmentKey = useMemo(() => segmentTime, [segmentTime]); + + const severityColors: { [key: number]: string } = { + 1: reviewed ? "bg-severity_motion/50" : "bg-severity_motion", + 2: reviewed ? "bg-severity_detection/50" : "bg-severity_detection", + 3: reviewed ? "bg-severity_alert/50" : "bg-severity_alert", + }; + + return ( +
+ {severity.map((severityValue: number, index: number) => { + return ( + +
+
+
+
+ ); + })} +
+ ); +} + +export default SummarySegment; diff --git a/web/src/components/timeline/SummaryTimeline.tsx b/web/src/components/timeline/SummaryTimeline.tsx new file mode 100644 index 000000000..c2a5fa6d6 --- /dev/null +++ b/web/src/components/timeline/SummaryTimeline.tsx @@ -0,0 +1,317 @@ +import { + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { SummarySegment } from "./SummarySegment"; +import { useTimelineUtils } from "@/hooks/use-timeline-utils"; +import { ReviewSegment } from "@/types/review"; +import { isMobile } from "react-device-detect"; + +export type SummaryTimelineProps = { + reviewTimelineRef: RefObject; + timelineStart: number; + timelineEnd: number; + segmentDuration: number; + events: ReviewSegment[]; +}; + +export function SummaryTimeline({ + reviewTimelineRef, + timelineStart, + timelineEnd, + segmentDuration, + events, +}: SummaryTimelineProps) { + const summaryTimelineRef = useRef(null); + const visibleSectionRef = useRef(null); + const [segmentHeight, setSegmentHeight] = useState(0); + + const [isDragging, setIsDragging] = useState(false); + const [scrollStartPosition, setScrollStartPosition] = useState(0); + const [initialReviewTimelineScrollTop, setInitialReviewTimelineScrollTop] = + useState(0); + + const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration); + + const timelineStartAligned = useMemo( + () => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration, + [timelineStart, alignStartDateToTimeline, segmentDuration], + ); + + const reviewTimelineDuration = useMemo( + () => timelineStart - timelineEnd + 4 * segmentDuration, + [timelineEnd, timelineStart, segmentDuration], + ); + + // Generate segments for the timeline + const generateSegments = useCallback(() => { + const segmentCount = reviewTimelineDuration / segmentDuration; + + if (segmentHeight) { + return Array.from({ length: segmentCount }, (_, index) => { + const segmentTime = timelineStartAligned - index * segmentDuration; + + return ( + + ); + }); + } + }, [ + segmentDuration, + timelineStartAligned, + events, + reviewTimelineDuration, + segmentHeight, + ]); + + const segments = useMemo( + () => generateSegments(), + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + segmentDuration, + segmentHeight, + timelineStartAligned, + events, + reviewTimelineDuration, + segmentHeight, + generateSegments, + ], + ); + + useEffect(() => { + if (reviewTimelineRef.current && summaryTimelineRef.current) { + const content = reviewTimelineRef.current; + const summary = summaryTimelineRef.current; + + const handleScroll = () => { + const { + clientHeight: reviewTimelineVisibleHeight, + scrollHeight: reviewTimelineFullHeight, + scrollTop: scrolled, + } = content; + const { clientHeight: summaryTimelineVisibleHeight } = summary; + + if (visibleSectionRef.current) { + visibleSectionRef.current.style.top = `${summaryTimelineVisibleHeight * (scrolled / reviewTimelineFullHeight)}px`; + visibleSectionRef.current.style.height = `${reviewTimelineVisibleHeight * (reviewTimelineVisibleHeight / reviewTimelineFullHeight)}px`; + } + }; + + content.addEventListener("scroll", handleScroll); + return () => { + content.removeEventListener("scroll", handleScroll); + }; + } + }, [reviewTimelineRef, summaryTimelineRef]); + + useEffect(() => { + if (summaryTimelineRef.current) { + const { clientHeight: summaryTimelineVisibleHeight } = + summaryTimelineRef.current; + + setSegmentHeight( + summaryTimelineVisibleHeight / + (reviewTimelineDuration / segmentDuration), + ); + } + }, [reviewTimelineDuration, summaryTimelineRef, segmentDuration]); + + const timelineClick = useCallback( + ( + e: React.MouseEvent | React.TouchEvent, + ) => { + // prevent default only for mouse events + // to avoid chrome/android issues + if (e.nativeEvent instanceof MouseEvent) { + e.preventDefault(); + } + e.stopPropagation(); + + let clientY; + if (isMobile && e.nativeEvent instanceof TouchEvent) { + clientY = e.nativeEvent.touches[0].clientY; + } else if (e.nativeEvent instanceof MouseEvent) { + clientY = e.nativeEvent.clientY; + } + if ( + clientY && + reviewTimelineRef.current && + summaryTimelineRef.current && + visibleSectionRef.current + ) { + const { clientHeight: summaryTimelineVisibleHeight } = + summaryTimelineRef.current; + + const rect = summaryTimelineRef.current.getBoundingClientRect(); + const summaryTimelineTop = rect.top; + + const { scrollHeight: reviewTimelineHeight } = + reviewTimelineRef.current; + + const { clientHeight: visibleSectionHeight } = + visibleSectionRef.current; + + const visibleSectionOffset = -(visibleSectionHeight / 2); + + const clickPercentage = + (clientY - summaryTimelineTop + visibleSectionOffset) / + summaryTimelineVisibleHeight; + + reviewTimelineRef.current.scrollTo({ + top: Math.floor(reviewTimelineHeight * clickPercentage), + behavior: "smooth", + }); + } + }, + [reviewTimelineRef, summaryTimelineRef, visibleSectionRef], + ); + + const handleMouseDown = useCallback( + ( + e: React.MouseEvent | React.TouchEvent, + ) => { + // prevent default only for mouse events + // to avoid chrome/android issues + if (e.nativeEvent instanceof MouseEvent) { + e.preventDefault(); + } + e.stopPropagation(); + setIsDragging(true); + + let clientY; + if (isMobile && e.nativeEvent instanceof TouchEvent) { + clientY = e.nativeEvent.touches[0].clientY; + } else if (e.nativeEvent instanceof MouseEvent) { + clientY = e.nativeEvent.clientY; + } + if (clientY && summaryTimelineRef.current && reviewTimelineRef.current) { + setScrollStartPosition(clientY); + setInitialReviewTimelineScrollTop(reviewTimelineRef.current.scrollTop); + } + }, + [setIsDragging, summaryTimelineRef, reviewTimelineRef], + ); + + const handleMouseUp = useCallback( + (e: MouseEvent | TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (isDragging) { + setIsDragging(false); + } + }, + [isDragging, setIsDragging], + ); + + const handleMouseMove = useCallback( + (e: MouseEvent | TouchEvent) => { + if ( + summaryTimelineRef.current && + reviewTimelineRef.current && + visibleSectionRef.current + ) { + // prevent default only for mouse events + // to avoid chrome/android issues + if (e instanceof MouseEvent) { + e.preventDefault(); + } + e.stopPropagation(); + let clientY; + if (isMobile && e instanceof TouchEvent) { + clientY = e.touches[0].clientY; + } else if (e instanceof MouseEvent) { + clientY = e.clientY; + } + if (isDragging && clientY) { + const { clientHeight: summaryTimelineVisibleHeight } = + summaryTimelineRef.current; + + const { + scrollHeight: reviewTimelineHeight, + clientHeight: reviewTimelineVisibleHeight, + } = reviewTimelineRef.current; + + const { clientHeight: visibleSectionHeight } = + visibleSectionRef.current; + + const deltaY = + (clientY - scrollStartPosition) * + (summaryTimelineVisibleHeight / visibleSectionHeight); + + const newScrollTop = Math.min( + initialReviewTimelineScrollTop + deltaY, + reviewTimelineHeight - reviewTimelineVisibleHeight, + ); + + reviewTimelineRef.current.scrollTop = newScrollTop; + } + } + }, + [ + initialReviewTimelineScrollTop, + isDragging, + reviewTimelineRef, + scrollStartPosition, + ], + ); + + const documentRef = useRef(document); + useEffect(() => { + const documentInstance = documentRef.current; + + if (isDragging) { + documentInstance?.addEventListener("mousemove", handleMouseMove); + documentInstance?.addEventListener("touchmove", handleMouseMove); + documentInstance?.addEventListener("mouseup", handleMouseUp); + documentInstance?.addEventListener("touchend", handleMouseUp); + } else { + documentInstance?.removeEventListener("mousemove", handleMouseMove); + documentInstance?.removeEventListener("touchmove", handleMouseMove); + documentInstance?.removeEventListener("mouseup", handleMouseUp); + documentInstance?.removeEventListener("touchend", handleMouseUp); + } + return () => { + documentInstance?.removeEventListener("mousemove", handleMouseMove); + documentInstance?.removeEventListener("touchmove", handleMouseMove); + documentInstance?.removeEventListener("mouseup", handleMouseUp); + documentInstance?.removeEventListener("touchend", handleMouseUp); + }; + }, [handleMouseMove, handleMouseUp, isDragging]); + + return ( +
+
+ {segments} +
+
+
+ ); +} + +export default SummaryTimeline; diff --git a/web/src/components/timeline/segment-metadata.tsx b/web/src/components/timeline/segment-metadata.tsx index 4c85f1971..a0f303175 100644 --- a/web/src/components/timeline/segment-metadata.tsx +++ b/web/src/components/timeline/segment-metadata.tsx @@ -32,7 +32,7 @@ export function MinimapBounds({ <> {isFirstSegmentInMinimap && (
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { @@ -44,7 +44,7 @@ export function MinimapBounds({ )} {isLastSegmentInMinimap && ( -
+
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -61,7 +61,7 @@ export function Tick({ timestamp, timestampSpread }: TickSegmentProps) {
{timestamp.getMinutes() % timestampSpread === 0 && timestamp.getSeconds() === 0 && diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index dfb7826bf..1bed502c6 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -40,7 +40,8 @@ function useDraggableElement({ }: DraggableElementProps) { const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); - const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration); + const { alignStartDateToTimeline, getCumulativeScrollTop } = + useTimelineUtils(segmentDuration); const draggingAtTopEdge = useMemo(() => { if (clientYPosition && timelineRef.current) { @@ -125,15 +126,6 @@ function useDraggableElement({ [isDragging, setIsDragging], ); - const getCumulativeScrollTop = useCallback((element: HTMLElement | null) => { - let scrollTop = 0; - while (element) { - scrollTop += element.scrollTop; - element = element.parentElement; - } - return scrollTop; - }, []); - const timestampToPixels = useCallback( (time: number) => { const { scrollHeight: timelineHeight } = diff --git a/web/src/hooks/use-event-segment-utils.ts b/web/src/hooks/use-event-segment-utils.ts index 8901835f0..61bbe2119 100644 --- a/web/src/hooks/use-event-segment-utils.ts +++ b/web/src/hooks/use-event-segment-utils.ts @@ -58,11 +58,12 @@ export const useEventSegmentUtils = ( ); const highestSeverityValue = Math.max(...severityValues); - if ( - severityValues.includes(displaySeverityType) && - displaySeverityType !== highestSeverityValue - ) { - return [displaySeverityType, highestSeverityValue]; + if (severityValues.includes(displaySeverityType)) { + const otherSeverityValues = severityValues.filter( + (severity) => severity !== displaySeverityType, + ); + const highestOtherSeverityValue = Math.max(...otherSeverityValues); + return [displaySeverityType, highestOtherSeverityValue]; } else { return [highestSeverityValue]; } diff --git a/web/src/hooks/use-timeline-utils.ts b/web/src/hooks/use-timeline-utils.ts index e03541259..7b211b528 100644 --- a/web/src/hooks/use-timeline-utils.ts +++ b/web/src/hooks/use-timeline-utils.ts @@ -19,8 +19,18 @@ export const useTimelineUtils = (segmentDuration: number) => { [segmentDuration], ); + const getCumulativeScrollTop = useCallback((element: HTMLElement | null) => { + let scrollTop = 0; + while (element) { + scrollTop += element.scrollTop; + element = element.parentElement; + } + return scrollTop; + }, []); + return { alignEndDateToTimeline, alignStartDateToTimeline, + getCumulativeScrollTop, }; }; diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 8f1faa3fb..c344e0d72 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -26,6 +26,7 @@ import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useNavigate } from "react-router-dom"; +import SummaryTimeline from "@/components/timeline/SummaryTimeline"; // Color data const colors = [ @@ -125,6 +126,7 @@ const generateRandomEvent = (): ReviewSegment => { function UIPlayground() { const { data: config } = useSWR("config"); const contentRef = useRef(null); + const reviewTimelineRef = useRef(null); const [mockEvents, setMockEvents] = useState([]); const [mockMotionData, setMockMotionData] = useState([]); const [handlebarTime, setHandlebarTime] = useState( @@ -144,7 +146,7 @@ function UIPlayground() { const navigate = useNavigate(); useMemo(() => { - const initialEvents = Array.from({ length: 50 }, generateRandomEvent); + const initialEvents = Array.from({ length: 10 }, generateRandomEvent); setMockEvents(initialEvents); setMockMotionData(generateRandomMotionAudioData()); }, []); @@ -403,9 +405,21 @@ function UIPlayground() { events={mockEvents} // events, including new has_been_reviewed and severity properties severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later + timelineRef={reviewTimelineRef} /> )}
+ {isEventsReviewTimeline && ( +
+ +
+ )}
diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 32cc5283e..c3abcb2a9 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -36,6 +36,7 @@ import { Button } from "@/components/ui/button"; import PreviewPlayer, { PreviewController, } from "@/components/player/PreviewPlayer"; +import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { RecordingStartingPoint } from "@/types/record"; type EventViewProps = { @@ -329,6 +330,8 @@ function DetectionReview({ onSelectReview, pullLatestData, }: DetectionReviewProps) { + const reviewTimelineRef = useRef(null); + const segmentDuration = 60; // review data @@ -458,7 +461,7 @@ function DetectionReview({ > {filter?.before == undefined && (
-
- +
+
+ +
+
+ +
); diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index 03cf77e31..19ee09bb3 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -22,16 +22,16 @@ --primary: 0 0% 100%; --primary-foreground: hsl(0, 0%, 0%); - --primary-foreground: 0, 0%, 0%; + --primary-foreground: 0 0% 0%; --secondary: hsl(0, 0%, 96%); - --secondary: 0, 0%, 96%; + --secondary: 0 0% 96%; --secondary-foreground: hsl(0, 0%, 45%); - --secondary-foreground: 0, 0%, 45%; + --secondary-foreground: 0 0% 45%; --secondary-highlight: hsl(0, 0%, 94%); - --secondary-highlight: 0, 0%, 94%; + --secondary-highlight: 0 0% 94%; --muted: hsl(210 40% 96.1%); --muted: 210 40% 96.1%; @@ -104,28 +104,28 @@ --popover-foreground: 210 40% 98%; --primary: hsl(0, 0%, 9%); - --primary: 0, 0%, 9%; + --primary: 0 0% 9%; --primary-foreground: hsl(0, 0%, 100%); - --primary-foreground: 0, 0%, 100%; + --primary-foreground: 0 0% 100%; --secondary: hsl(0, 0%, 15%); - --secondary: 0, 0%, 15%; + --secondary: 0 0% 15%; --secondary-foreground: hsl(0, 0%, 83%); - --secondary-foreground: 0, 0%, 83%; + --secondary-foreground: 0 0% 83%; --secondary-highlight: hsl(0, 0%, 25%); - --secondary-highlight: 0, 0%, 25%; + --secondary-highlight: 0 0% 25%; --muted: hsl(0, 0%, 8%); - --muted: 0, 0%, 8%; + --muted: 0 0% 8%; --muted-foreground: hsl(0, 0%, 32%); - --muted-foreground: 0, 0%, 32%; + --muted-foreground: 0 0% 32%; --accent: hsl(0, 0%, 15%); - --accent: 0, 0%, 15%; + --accent: 0 0% 15%; --accent-foreground: hsl(210 40% 98%); --accent-foreground: 210 40% 98%; @@ -137,7 +137,7 @@ --destructive-foreground: 210 40% 98%; --border: hsl(0, 0%, 32%); - --border: 0, 0%, 32%; + --border: 0 0% 32%; --input: hsl(217.2 32.6% 17.5%); --input: 217.2 32.6% 17.5%; From 865c26ff18ee2847e9879cd10495653243461c72 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 21 Mar 2024 07:43:37 -0600 Subject: [PATCH 269/751] Update recordings view (#10585) * Update recordings view * Fix opening recordings view from gif --- web/src/components/filter/FilterCheckBox.tsx | 6 +- .../components/filter/ReviewFilterGroup.tsx | 2 +- .../image/AnimatedEventThumbnail.tsx | 9 +- web/src/pages/Events.tsx | 25 ++- web/src/views/events/RecordingView.tsx | 184 +++++++++--------- 5 files changed, 124 insertions(+), 102 deletions(-) diff --git a/web/src/components/filter/FilterCheckBox.tsx b/web/src/components/filter/FilterCheckBox.tsx index b23a4e42c..3e8ee21cd 100644 --- a/web/src/components/filter/FilterCheckBox.tsx +++ b/web/src/components/filter/FilterCheckBox.tsx @@ -5,6 +5,7 @@ import { IconType } from "react-icons"; type FilterCheckBoxProps = { label: string; CheckIcon?: IconType; + iconClassName?: string; isChecked: boolean; onCheckedChange: (isChecked: boolean) => void; }; @@ -12,6 +13,7 @@ type FilterCheckBoxProps = { export default function FilterCheckBox({ label, CheckIcon = LuCheck, + iconClassName = "size-6", isChecked, onCheckedChange, }: FilterCheckBoxProps) { @@ -22,9 +24,9 @@ export default function FilterCheckBox({ onClick={() => onCheckedChange(!isChecked)} > {isChecked ? ( - + ) : ( -
+
)}
{label}
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 12434f67e..893fbacc6 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -280,7 +280,7 @@ type CalendarFilterButtonProps = { day?: Date; updateSelectedDay: (day?: Date) => void; }; -function CalendarFilterButton({ +export function CalendarFilterButton({ reviewSummary, day, updateSelectedDay, diff --git a/web/src/components/image/AnimatedEventThumbnail.tsx b/web/src/components/image/AnimatedEventThumbnail.tsx index b990de5b7..dad85116a 100644 --- a/web/src/components/image/AnimatedEventThumbnail.tsx +++ b/web/src/components/image/AnimatedEventThumbnail.tsx @@ -7,6 +7,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment } from "@/types/review"; import { useNavigate } from "react-router-dom"; import { Skeleton } from "../ui/skeleton"; +import { RecordingStartingPoint } from "@/types/record"; type AnimatedEventThumbnailProps = { event: ReviewSegment; @@ -18,7 +19,13 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { const navigate = useNavigate(); const onOpenReview = useCallback(() => { - navigate("events", { state: { review: event.id } }); + navigate("events", { + state: { + camera: event.camera, + startTime: event.start_time, + severity: event.severity, + } as RecordingStartingPoint, + }); }, [navigate, event]); // image behavior diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 74652b57c..0ddecdfea 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -36,11 +36,18 @@ export default function Events() { const [reviewFilter, setReviewFilter, reviewSearchParams] = useApiFilter(); - const onUpdateFilter = useCallback((newFilter: ReviewFilter) => { - setReviewFilter(newFilter); - // we don't want this updating - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const onUpdateFilter = useCallback( + (newFilter: ReviewFilter) => { + setReviewFilter(newFilter); + + // update recording start time if filter + // was changed on recording page + if (recording != undefined && newFilter.after != undefined) { + setRecording({ ...recording, startTime: newFilter.after }, true); + } + }, + [recording, setRecording, setReviewFilter], + ); // review paging @@ -286,10 +293,8 @@ export default function Events() { return { camera: recording.camera, - severity: recording.severity, start_time: recording.startTime, allCameras: allCameras, - cameraSegments: reviews.filter((seg) => allCameras.includes(seg.camera)), }; // previews will not update after item is selected @@ -306,9 +311,11 @@ export default function Events() { startCamera={selectedReviewData.camera} startTime={selectedReviewData.start_time} allCameras={selectedReviewData.allCameras} - severity={selectedReviewData.severity} - reviewItems={selectedReviewData.cameraSegments} + reviewItems={reviews} + reviewSummary={reviewSummary} allPreviews={allPreviews} + filter={reviewFilter} + updateFilter={onUpdateFilter} /> ); } else { diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index 4f167400b..2c5b3fa61 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -1,24 +1,26 @@ +import FilterCheckBox from "@/components/filter/FilterCheckBox"; +import { CalendarFilterButton } from "@/components/filter/ReviewFilterGroup"; import PreviewPlayer, { PreviewController, } from "@/components/player/PreviewPlayer"; import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController"; import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer"; -import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; -import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review"; +import { + MotionData, + ReviewFilter, + ReviewSegment, + ReviewSummary, +} from "@/types/review"; +import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { getChunkedTimeDay } from "@/utils/timelineUtil"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobile } from "react-device-detect"; +import { FaCircle, FaVideo } from "react-icons/fa"; import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; import useSWR from "swr"; @@ -28,18 +30,22 @@ const SEGMENT_DURATION = 30; type RecordingViewProps = { startCamera: string; startTime: number; - severity: ReviewSeverity; - reviewItems: ReviewSegment[]; + reviewItems?: ReviewSegment[]; + reviewSummary?: ReviewSummary; allCameras: string[]; allPreviews?: Preview[]; + filter?: ReviewFilter; + updateFilter: (newFilter: ReviewFilter) => void; }; export function RecordingView({ startCamera, startTime, - severity, reviewItems, + reviewSummary, allCameras, allPreviews, + filter, + updateFilter, }: RecordingViewProps) { const { data: config } = useSWR("config"); const navigate = useNavigate(); @@ -54,7 +60,7 @@ export function RecordingView({ const [playbackStart, setPlaybackStart] = useState(startTime); const mainCameraReviewItems = useMemo( - () => reviewItems.filter((cam) => cam.camera == mainCamera), + () => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [], [reviewItems, mainCamera], ); @@ -157,19 +163,15 @@ export function RecordingView({ // motion timeline data - const { data: motionData } = useSWR( - severity == "significant_motion" - ? [ - "review/activity/motion", - { - before: timeRange.end, - after: timeRange.start, - scale: SEGMENT_DURATION / 2, - cameras: mainCamera, - }, - ] - : null, - ); + const { data: motionData } = useSWR([ + "review/activity/motion", + { + before: timeRange.end, + after: timeRange.start, + scale: SEGMENT_DURATION / 2, + cameras: mainCamera, + }, + ]); const mainCameraAspect = useMemo(() => { if (!config) { @@ -201,41 +203,61 @@ export function RecordingView({ return (
- - {isMobile && ( - - - - - - { - setPlaybackStart(currentTime); - setMainCamera(cam); - }} - > - {allCameras.map((cam) => ( - navigate(-1)}> + + Back + +
+ { + updateFilter({ + ...filter, + after: day == undefined ? undefined : day.getTime() / 1000, + before: + day == undefined ? undefined : getEndOfDayTimestamp(day), + }); + }} + /> + {isMobile && ( + + + + + + {allCameras.map((cam) => ( + { + setPlaybackStart(currentTime); + setMainCamera(cam); + }} + /> + ))} + + + )} +
+
- {severity != "significant_motion" ? ( - setScrubbing(scrubbing)} - /> - ) : ( - setScrubbing(scrubbing)} - /> - )} + setScrubbing(scrubbing)} + />
From 40401911010046c17f6fdecce708b65bc957cb89 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 Mar 2024 09:00:04 -0500 Subject: [PATCH 270/751] fixes and changes (#10587) --- web/src/components/dynamic/NewReviewData.tsx | 2 +- web/src/components/timeline/EventSegment.tsx | 63 +++++++++------- .../timeline/MotionReviewTimeline.tsx | 3 +- web/src/components/timeline/MotionSegment.tsx | 11 ++- .../components/timeline/ReviewTimeline.tsx | 75 ++++++++----------- .../components/timeline/SummarySegment.tsx | 17 +++-- .../components/timeline/SummaryTimeline.tsx | 73 ++++++++++++++---- web/src/hooks/use-draggable-element.ts | 20 ++--- web/src/pages/UIPlayground.tsx | 5 +- web/src/views/events/EventView.tsx | 1 + 10 files changed, 156 insertions(+), 114 deletions(-) diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index c60a08b49..b7b096115 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -43,7 +43,7 @@ export default function NewReviewData({ return (
-
+
+
+ + ); +} diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 1e165d799..56f2f6472 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -236,10 +236,12 @@ export function EventReviewTimeline({ const element = selectedTimelineRef.current?.querySelector( `[data-segment-id="${Math.max(...alignedVisibleTimestamps)}"]`, ); - scrollIntoView(element as HTMLDivElement, { - scrollMode: "if-needed", - behavior: "smooth", - }); + if (element) { + scrollIntoView(element, { + scrollMode: "if-needed", + behavior: "smooth", + }); + } } }, [ selectedTimelineRef, diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 263b3bdbc..3cbf8826b 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -201,7 +201,7 @@ export function EventSegment({
handleTouchStart(event, segmentClick)} > diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 9255df53e..6560e84ae 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -21,6 +21,7 @@ export type MotionReviewTimelineProps = { showHandlebar?: boolean; handlebarTime?: number; setHandlebarTime?: React.Dispatch>; + motionOnly?: boolean; showMinimap?: boolean; minimapStartTime?: number; minimapEndTime?: number; @@ -45,6 +46,7 @@ export function MotionReviewTimeline({ showHandlebar = false, handlebarTime, setHandlebarTime, + motionOnly = false, showMinimap = false, minimapStartTime, minimapEndTime, @@ -113,6 +115,7 @@ export function MotionReviewTimeline({ draggableElementTime: handlebarTime, setDraggableElementTime: setHandlebarTime, timelineDuration, + timelineCollapsed: motionOnly, timelineStartAligned, isDragging, setIsDragging, @@ -176,6 +179,7 @@ export function MotionReviewTimeline({ segmentDuration={segmentDuration} segmentTime={segmentTime} timestampSpread={timestampSpread} + motionOnly={motionOnly} showMinimap={showMinimap} minimapStartTime={minimapStartTime} minimapEndTime={minimapEndTime} @@ -195,6 +199,7 @@ export function MotionReviewTimeline({ minimapEndTime, events, motion_events, + motionOnly, ]); const segments = useMemo( @@ -211,6 +216,7 @@ export function MotionReviewTimeline({ minimapEndTime, events, motion_events, + motionOnly, ], ); diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 21d404859..dd54a03b6 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -14,6 +14,7 @@ type MotionSegmentProps = { segmentTime: number; segmentDuration: number; timestampSpread: number; + motionOnly: boolean; showMinimap: boolean; minimapStartTime?: number; minimapEndTime?: number; @@ -26,6 +27,7 @@ export function MotionSegment({ segmentTime, segmentDuration, timestampSpread, + motionOnly, showMinimap, minimapStartTime, minimapEndTime, @@ -180,79 +182,96 @@ export function MotionSegment({ }, [segmentTime, setHandlebarTime]); return ( -
1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`} - onClick={segmentClick} - onTouchEnd={(event) => handleTouchStart(event, segmentClick)} - > - + <> + {(((firstHalfSegmentWidth > 1 || secondHalfSegmentWidth > 1) && + motionOnly && + severity[0] < 2) || + !motionOnly) && ( +
1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`} + onClick={segmentClick} + onTouchEnd={(event) => handleTouchStart(event, segmentClick)} + > + {!motionOnly && ( + <> + - + - + + + )} -
-
-
-
-
-
- -
-
-
-
-
-
- - {severity.map((severityValue: number, index: number) => { - if (severityValue > 1) { - return ( - -
+
+
+
- - ); - } else { - return null; - } - })} -
+
+ +
+
+
+
+
+
+ + {!motionOnly && + severity.map((severityValue: number, index: number) => { + if (severityValue > 1) { + return ( + +
+
+
+
+ ); + } else { + return null; + } + })} +
+ )} + ); } diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx index 343df17d0..162260f87 100644 --- a/web/src/components/ui/switch.tsx +++ b/web/src/components/ui/switch.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import * as SwitchPrimitives from "@radix-ui/react-switch" +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Switch = React.forwardRef< React.ElementRef, @@ -10,18 +10,18 @@ const Switch = React.forwardRef< -)) -Switch.displayName = SwitchPrimitives.Root.displayName +)); +Switch.displayName = SwitchPrimitives.Root.displayName; -export { Switch } +export { Switch }; diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 1a70658f9..d4cd3e713 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -15,6 +15,7 @@ type DraggableElementProps = { setDraggableElementTime?: React.Dispatch>; draggableElementTimeRef: React.MutableRefObject; timelineDuration: number; + timelineCollapsed?: boolean; timelineStartAligned: number; isDragging: boolean; setIsDragging: React.Dispatch>; @@ -33,6 +34,7 @@ function useDraggableElement({ setDraggableElementTime, draggableElementTimeRef, timelineDuration, + timelineCollapsed, timelineStartAligned, isDragging, setIsDragging, @@ -40,6 +42,7 @@ function useDraggableElement({ }: DraggableElementProps) { const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); + const [segments, setSegments] = useState([]); const { alignStartDateToTimeline, getCumulativeScrollTop } = useTimelineUtils( { segmentDuration: segmentDuration, @@ -101,7 +104,7 @@ function useDraggableElement({ } else if (e.nativeEvent instanceof MouseEvent) { clientY = e.nativeEvent.clientY; } - if (clientY && draggableElementRef.current && isDesktop) { + if (clientY && draggableElementRef.current) { const draggableElementRect = draggableElementRef.current.getBoundingClientRect(); if (!isDragging) { @@ -203,6 +206,12 @@ function useDraggableElement({ [contentRef, draggableElementRef, timelineRef, getClientYPosition], ); + useEffect(() => { + if (timelineRef.current) { + setSegments(Array.from(timelineRef.current.querySelectorAll(".segment"))); + } + }, [timelineRef, segmentDuration, timelineDuration, timelineCollapsed]); + useEffect(() => { let animationFrameId: number | null = null; @@ -211,13 +220,11 @@ function useDraggableElement({ timelineRef.current && showDraggableElement && isDragging && - clientYPosition + clientYPosition && + segments ) { - const { - scrollHeight: timelineHeight, - scrollTop: scrolled, - offsetTop: timelineTop, - } = timelineRef.current; + const { scrollHeight: timelineHeight, scrollTop: scrolled } = + timelineRef.current; const segmentHeight = timelineHeight / (timelineDuration / segmentDuration); @@ -235,22 +242,53 @@ function useDraggableElement({ ? timestampToPixels(draggableElementLatestTime) : segmentHeight * 2 + scrolled; + const timelineRect = timelineRef.current.getBoundingClientRect(); + const timelineTopAbsolute = timelineRect.top; + const newElementPosition = Math.min( elementEarliest, Math.max( elementLatest, // current Y position clientYPosition - - timelineTop + + timelineTopAbsolute + parentScrollTop - initialClickAdjustment, ), ); - const segmentIndex = Math.floor(newElementPosition / segmentHeight); - const segmentStartTime = alignStartDateToTimeline( - timelineStartAligned - segmentIndex * segmentDuration, - ); + if ( + newElementPosition >= elementEarliest || + newElementPosition <= elementLatest + ) { + return; + } + + let targetSegmentId = 0; + let offset = 0; + + segments.forEach((segmentElement: HTMLDivElement) => { + const rect = segmentElement.getBoundingClientRect(); + const segmentTop = + rect.top + scrolled - timelineTopAbsolute - segmentHeight; + const segmentBottom = + rect.bottom + scrolled - timelineTopAbsolute - segmentHeight; + + // Check if handlebar position falls within the segment bounds + if ( + newElementPosition >= segmentTop && + newElementPosition <= segmentBottom + ) { + targetSegmentId = parseFloat( + segmentElement.getAttribute("data-segment-id") || "0", + ); + offset = Math.min( + segmentBottom - newElementPosition, + segmentHeight, + ); + return; + } + }); if (draggingAtTopEdge || draggingAtBottomEdge) { let newPosition = clientYPosition; @@ -267,17 +305,15 @@ function useDraggableElement({ } updateDraggableElementPosition( - newElementPosition - segmentHeight, - segmentStartTime, + newElementPosition, + targetSegmentId, false, false, ); if (setDraggableElementTime) { setDraggableElementTime( - timelineStartAligned - - ((newElementPosition - segmentHeight / 2 - 2) / segmentHeight) * - segmentDuration, + targetSegmentId + segmentDuration * (offset / segmentHeight), ); } @@ -321,7 +357,8 @@ function useDraggableElement({ draggableElementRef.current && showDraggableElement && draggableElementTime && - !isDragging + !isDragging && + segments.length > 0 ) { const { scrollHeight: timelineHeight, scrollTop: scrolled } = timelineRef.current; @@ -329,29 +366,60 @@ function useDraggableElement({ const segmentHeight = timelineHeight / (timelineDuration / segmentDuration); - const parentScrollTop = getCumulativeScrollTop(timelineRef.current); + const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); - const newElementPosition = - ((timelineStartAligned - draggableElementTime) / segmentDuration) * - segmentHeight + - parentScrollTop - - scrolled - - 2; // height of draggableElement horizontal line - - updateDraggableElementPosition( - newElementPosition, - draggableElementTime, - true, - true, + let segmentElement = timelineRef.current.querySelector( + `[data-segment-id="${alignedSegmentTime}"]`, ); + + if (!segmentElement) { + // segment not found, maybe we collapsed over a collapsible segment + let searchTime = alignedSegmentTime; + while (searchTime >= timelineStartAligned - timelineDuration) { + // Decrement currentTime by segmentDuration + searchTime -= segmentDuration; + segmentElement = timelineRef.current.querySelector( + `[data-segment-id="${searchTime}"]`, + ); + + if (segmentElement) { + // segmentElement found + break; + } + } + } + + if (segmentElement) { + const timelineRect = timelineRef.current.getBoundingClientRect(); + const timelineTopAbsolute = timelineRect.top; + const rect = segmentElement.getBoundingClientRect(); + const segmentTop = + rect.top + scrolled - timelineTopAbsolute - segmentHeight / 2; + const offset = + ((draggableElementTime - alignedSegmentTime) / segmentDuration) * + segmentHeight; + const newElementPosition = segmentTop - offset; + + updateDraggableElementPosition( + newElementPosition, + draggableElementTime, + true, + true, + ); + } } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [ draggableElementTime, + timelineDuration, + segmentDuration, showDraggableElement, draggableElementRef, timelineStartAligned, + timelineRef, + timelineCollapsed, + segments, ]); return { handleMouseDown, handleMouseUp, handleMouseMove }; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 15c795713..156d16b2d 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -196,6 +196,8 @@ export default function EventView({ [reviewItems], ); + const [motionOnly, setMotionOnly] = useState(false); + if (!config) { return ; } @@ -253,6 +255,9 @@ export default function EventView({ reviewSummary={reviewSummary} filter={filter} onUpdateFilter={updateFilter} + severity={severity} + motionOnly={motionOnly} + setMotionOnly={setMotionOnly} /> ) : ( )} @@ -603,6 +609,7 @@ type MotionReviewProps = { timeRange: { before: number; after: number }; startTime?: number; filter?: ReviewFilter; + motionOnly?: boolean; onOpenRecording: (data: RecordingStartingPoint) => void; }; function MotionReview({ @@ -612,6 +619,7 @@ function MotionReview({ timeRange, startTime, filter, + motionOnly = false, onOpenRecording, }: MotionReviewProps) { const segmentDuration = 30; @@ -784,6 +792,7 @@ function MotionReview({ timestampSpread={15} timelineStart={timeRangeSegments.end} timelineEnd={timeRangeSegments.start} + motionOnly={motionOnly} showHandlebar handlebarTime={currentTime} setHandlebarTime={setCurrentTime} diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index 19ee09bb3..6dba4474e 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -55,7 +55,7 @@ --border: 214.3 31.8% 91.4%; --input: hsl(214.3 31.8% 91.4%); - --input: 214.3 31.8% 91.4%; + --input: 0 0 85%; --ring: hsl(222.2 84% 4.9%); --ring: 222.2 84% 4.9%; @@ -140,7 +140,7 @@ --border: 0 0% 32%; --input: hsl(217.2 32.6% 17.5%); - --input: 217.2 32.6% 17.5%; + --input: 0 0 25%; --ring: hsl(212.7 26.8% 83.9%); --ring: 212.7 26.8% 83.9%; From 76a114a3cd9725942afe7e3aec9d62daaea08cfa Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 23 Mar 2024 10:11:32 -0600 Subject: [PATCH 280/751] Rewrite events communication to use zmq instead of mp.Queue (#10627) * Move to using zmq for events updating * Use event updater in manual events handler * Formatting --- frigate/app.py | 10 ++----- frigate/comms/events_updater.py | 53 +++++++++++++++++++++++++++++++++ frigate/events/external.py | 24 ++++++++++----- frigate/events/maintainer.py | 39 +++++++++++------------- frigate/events/types.py | 14 +++++++++ frigate/object_processing.py | 24 +++++++++------ 6 files changed, 117 insertions(+), 47 deletions(-) create mode 100644 frigate/comms/events_updater.py create mode 100644 frigate/events/types.py diff --git a/frigate/app.py b/frigate/app.py index 9d02ddeeb..2b1198278 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -195,7 +195,6 @@ class FrigateApp: def init_queues(self) -> None: # Queues for clip processing - self.event_queue: Queue = mp.Queue() self.event_processed_queue: Queue = mp.Queue() # Queue for cameras to push tracked objects to @@ -324,9 +323,7 @@ class FrigateApp: self.db.bind(models) def init_external_event_processor(self) -> None: - self.external_event_processor = ExternalEventProcessor( - self.config, self.event_queue - ) + self.external_event_processor = ExternalEventProcessor(self.config) def init_inter_process_communicator(self) -> None: self.inter_process_communicator = InterProcessCommunicator() @@ -417,7 +414,6 @@ class FrigateApp: self.config, self.dispatcher, self.detected_frames_queue, - self.event_queue, self.event_processed_queue, self.ptz_autotracker_thread, self.stop_event, @@ -515,8 +511,6 @@ class FrigateApp: def start_event_processor(self) -> None: self.event_processor = EventProcessor( self.config, - self.camera_metrics, - self.event_queue, self.event_processed_queue, self.timeline_queue, self.stop_event, @@ -682,6 +676,7 @@ class FrigateApp: self.detection_queue.close() self.detection_queue.join_thread() + self.external_event_processor.stop() self.dispatcher.stop() self.detected_frames_processor.join() self.ptz_autotracker_thread.join() @@ -698,7 +693,6 @@ class FrigateApp: shm.unlink() for queue in [ - self.event_queue, self.event_processed_queue, self.detected_frames_queue, self.log_queue, diff --git a/frigate/comms/events_updater.py b/frigate/comms/events_updater.py new file mode 100644 index 000000000..cb18667a1 --- /dev/null +++ b/frigate/comms/events_updater.py @@ -0,0 +1,53 @@ +"""Facilitates communication between processes.""" + +import zmq + +from frigate.events.types import EventStateEnum, EventTypeEnum + +SOCKET_PUSH_PULL = "ipc:///tmp/cache/events" + + +class EventUpdatePublisher: + """Publishes events (objects, audio, manual).""" + + def __init__(self) -> None: + self.context = zmq.Context() + self.socket = self.context.socket(zmq.PUSH) + self.socket.connect(SOCKET_PUSH_PULL) + + def publish( + self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]] + ) -> None: + """There is no communication back to the processes.""" + self.socket.send_pyobj(payload) + + def stop(self) -> None: + self.socket.close() + self.context.destroy() + + +class EventUpdateSubscriber: + """Receives event updates.""" + + def __init__(self) -> None: + self.context = zmq.Context() + self.socket = self.context.socket(zmq.PULL) + self.socket.bind(SOCKET_PUSH_PULL) + + def check_for_update( + self, timeout=1 + ) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]: + """Returns updated config or None if no update.""" + try: + has_update, _, _ = zmq.select([self.socket], [], [], timeout) + + if has_update: + return self.socket.recv_pyobj() + except zmq.ZMQError: + pass + + return None + + def stop(self) -> None: + self.socket.close() + self.context.destroy() diff --git a/frigate/events/external.py b/frigate/events/external.py index 9c99ef50c..7bae21071 100644 --- a/frigate/events/external.py +++ b/frigate/events/external.py @@ -6,24 +6,24 @@ import logging import os import random import string -from multiprocessing import Queue from typing import Optional import cv2 +from frigate.comms.events_updater import EventUpdatePublisher from frigate.config import CameraConfig, FrigateConfig from frigate.const import CLIPS_DIR -from frigate.events.maintainer import EventTypeEnum +from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.util.image import draw_box_with_label logger = logging.getLogger(__name__) class ExternalEventProcessor: - def __init__(self, config: FrigateConfig, queue: Queue) -> None: + def __init__(self, config: FrigateConfig) -> None: self.config = config - self.queue = queue self.default_thumbnail = None + self.event_sender = EventUpdatePublisher() def create_manual_event( self, @@ -48,10 +48,10 @@ class ExternalEventProcessor: camera_config, label, event_id, draw, snapshot_frame ) - self.queue.put( + self.event_sender.publish( ( EventTypeEnum.api, - "new", + EventStateEnum.start, camera, { "id": event_id, @@ -77,8 +77,13 @@ class ExternalEventProcessor: def finish_manual_event(self, event_id: str, end_time: float) -> None: """Finish external event with indeterminate duration.""" - self.queue.put( - (EventTypeEnum.api, "end", None, {"id": event_id, "end_time": end_time}) + self.event_sender.publish( + ( + EventTypeEnum.api, + EventStateEnum.end, + None, + {"id": event_id, "end_time": end_time}, + ) ) def _write_images( @@ -135,3 +140,6 @@ class ExternalEventProcessor: thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA) ret, jpg = cv2.imencode(".jpg", thumb) return base64.b64encode(jpg.tobytes()).decode("utf-8") + + def stop(self): + self.event_sender.stop() diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index eadf888c9..720022e05 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -1,25 +1,19 @@ import datetime import logging -import queue import threading -from enum import Enum from multiprocessing import Queue from multiprocessing.synchronize import Event as MpEvent from typing import Dict +from frigate.comms.events_updater import EventUpdateSubscriber from frigate.config import EventsConfig, FrigateConfig +from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.models import Event -from frigate.types import CameraMetricsTypes from frigate.util.builtin import to_relative_box logger = logging.getLogger(__name__) -class EventTypeEnum(str, Enum): - api = "api" - tracked_object = "tracked_object" - - def should_update_db(prev_event: Event, current_event: Event) -> bool: """If current_event has updated fields and (clip or snapshot).""" if current_event["has_clip"] or current_event["has_snapshot"]: @@ -58,8 +52,6 @@ class EventProcessor(threading.Thread): def __init__( self, config: FrigateConfig, - camera_processes: dict[str, CameraMetricsTypes], - event_queue: Queue, event_processed_queue: Queue, timeline_queue: Queue, stop_event: MpEvent, @@ -67,13 +59,13 @@ class EventProcessor(threading.Thread): threading.Thread.__init__(self) self.name = "event_processor" self.config = config - self.camera_processes = camera_processes - self.event_queue = event_queue self.event_processed_queue = event_processed_queue self.timeline_queue = timeline_queue self.events_in_process: Dict[str, Event] = {} self.stop_event = stop_event + self.event_receiver = EventUpdateSubscriber() + def run(self) -> None: # set an end_time on events without an end_time on startup Event.update(end_time=Event.start_time + 30).where( @@ -81,13 +73,13 @@ class EventProcessor(threading.Thread): ).execute() while not self.stop_event.is_set(): - try: - source_type, event_type, camera, event_data = self.event_queue.get( - timeout=1 - ) - except queue.Empty: + update = self.event_receiver.check_for_update() + + if update == None: continue + source_type, event_type, camera, event_data = update + logger.debug( f"Event received: {source_type} {event_type} {camera} {event_data['id']}" ) @@ -103,7 +95,7 @@ class EventProcessor(threading.Thread): ) ) - if event_type == "start": + if event_type == EventStateEnum.start: self.events_in_process[event_data["id"]] = event_data continue @@ -125,6 +117,7 @@ class EventProcessor(threading.Thread): Event.update(end_time=datetime.datetime.now().timestamp()).where( Event.end_time == None ).execute() + self.event_receiver.stop() logger.info("Exiting event processor...") def handle_object_detection( @@ -247,12 +240,14 @@ class EventProcessor(threading.Thread): # update the stored copy for comparison on future update messages self.events_in_process[event_data["id"]] = event_data - if event_type == "end": + if event_type == EventStateEnum.end: del self.events_in_process[event_data["id"]] self.event_processed_queue.put((event_data["id"], camera)) - def handle_external_detection(self, event_type: str, event_data: Event) -> None: - if event_type == "new": + def handle_external_detection( + self, event_type: EventStateEnum, event_data: Event + ) -> None: + if event_type == EventStateEnum.start: event = { Event.id: event_data["id"], Event.label: event_data["label"], @@ -271,7 +266,7 @@ class EventProcessor(threading.Thread): }, } Event.insert(event).execute() - elif event_type == "end": + elif event_type == EventStateEnum.end: event = { Event.id: event_data["id"], Event.end_time: event_data["end_time"], diff --git a/frigate/events/types.py b/frigate/events/types.py new file mode 100644 index 000000000..1750b3e7b --- /dev/null +++ b/frigate/events/types.py @@ -0,0 +1,14 @@ +"""Types for event management.""" + +from enum import Enum + + +class EventTypeEnum(str, Enum): + api = "api" + tracked_object = "tracked_object" + + +class EventStateEnum(str, Enum): + start = "start" + update = "update" + end = "end" diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 4cbaa11d6..c5e8101dc 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -14,6 +14,7 @@ import numpy as np from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.dispatcher import Dispatcher +from frigate.comms.events_updater import EventUpdatePublisher from frigate.config import ( CameraConfig, FrigateConfig, @@ -23,7 +24,7 @@ from frigate.config import ( ZoomingModeEnum, ) from frigate.const import CLIPS_DIR -from frigate.events.maintainer import EventTypeEnum +from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.util.image import ( SharedMemoryFrameManager, @@ -826,7 +827,6 @@ class TrackedObjectProcessor(threading.Thread): config: FrigateConfig, dispatcher: Dispatcher, tracked_objects_queue, - event_queue, event_processed_queue, ptz_autotracker_thread, stop_event, @@ -836,7 +836,6 @@ class TrackedObjectProcessor(threading.Thread): self.config = config self.dispatcher = dispatcher self.tracked_objects_queue = tracked_objects_queue - self.event_queue = event_queue self.event_processed_queue = event_processed_queue self.stop_event = stop_event self.camera_states: dict[str, CameraState] = {} @@ -844,10 +843,16 @@ class TrackedObjectProcessor(threading.Thread): self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) + self.event_sender = EventUpdatePublisher() def start(camera, obj: TrackedObject, current_frame_time): - self.event_queue.put( - (EventTypeEnum.tracked_object, "start", camera, obj.to_dict()) + self.event_sender.publish( + ( + EventTypeEnum.tracked_object, + EventStateEnum.start, + camera, + obj.to_dict(), + ) ) def update(camera, obj: TrackedObject, current_frame_time): @@ -861,10 +866,10 @@ class TrackedObjectProcessor(threading.Thread): } self.dispatcher.publish("events", json.dumps(message), retain=False) obj.previous = after - self.event_queue.put( + self.event_sender.publish( ( EventTypeEnum.tracked_object, - "update", + EventStateEnum.update, camera, obj.to_dict(include_thumbnail=True), ) @@ -923,10 +928,10 @@ class TrackedObjectProcessor(threading.Thread): self.dispatcher.publish("events", json.dumps(message), retain=False) self.ptz_autotracker_thread.ptz_autotracker.end_object(camera, obj) - self.event_queue.put( + self.event_sender.publish( ( EventTypeEnum.tracked_object, - "end", + EventStateEnum.end, camera, obj.to_dict(include_thumbnail=True), ) @@ -1215,4 +1220,5 @@ class TrackedObjectProcessor(threading.Thread): self.camera_states[camera].finished(event_id) self.detection_publisher.stop() + self.event_sender.stop() logger.info("Exiting object processor...") From 63bf986e082fd0611a5dfca02fc86104cf3c3ffb Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 23 Mar 2024 10:24:57 -0600 Subject: [PATCH 281/751] Add ability to set playback speed on motion playback (#10628) * Allow control of playback rate on motion page * Apply playback rate --- web/src/components/player/HlsVideoPlayer.tsx | 4 ++++ web/src/components/player/VideoControls.tsx | 21 ++++++++++---------- web/src/views/events/EventView.tsx | 15 +++++++++++--- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index e40754339..9a7814798 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -160,6 +160,7 @@ export default function HlsVideoPlayer({ show={controls} controlsOpen={controlsOpen} setControlsOpen={setControlsOpen} + playbackRate={videoRef.current?.playbackRate ?? 1} onPlayPause={(play) => { if (!videoRef.current) { return; @@ -180,6 +181,9 @@ export default function HlsVideoPlayer({ videoRef.current.currentTime = Math.max(0, currentTime + diff); }} + onSetPlaybackRate={(rate) => + videoRef.current ? (videoRef.current.playbackRate = rate) : null + } /> {children}
diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx index b8044c687..f6fa19d12 100644 --- a/web/src/components/player/VideoControls.tsx +++ b/web/src/components/player/VideoControls.tsx @@ -30,6 +30,7 @@ const CONTROLS_DEFAULT: VideoControls = { seek: true, playbackRate: true, }; +const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; type VideoControlsProps = { className?: string; @@ -38,9 +39,12 @@ type VideoControlsProps = { isPlaying: boolean; show: boolean; controlsOpen?: boolean; + playbackRates?: number[]; + playbackRate: number; setControlsOpen?: (open: boolean) => void; onPlayPause: (play: boolean) => void; onSeek: (diff: number) => void; + onSetPlaybackRate: (rate: number) => void; }; export default function VideoControls({ className, @@ -49,18 +53,13 @@ export default function VideoControls({ isPlaying, show, controlsOpen, + playbackRates = PLAYBACK_RATE_DEFAULT, + playbackRate, setControlsOpen, onPlayPause, onSeek, + onSetPlaybackRate, }: VideoControlsProps) { - const playbackRates = useMemo(() => { - if (isSafari) { - return [0.5, 1, 2]; - } else { - return [0.5, 1, 2, 4, 8, 16]; - } - }, []); - const onReplay = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); @@ -177,7 +176,7 @@ export default function VideoControls({ {features.seek && ( )} - {video && features.playbackRate && ( + {features.playbackRate && ( { @@ -186,10 +185,10 @@ export default function VideoControls({ } }} > - {`${video.playbackRate}x`} + {`${playbackRate}x`} (video.playbackRate = parseFloat(rate))} + onValueChange={(rate) => onSetPlaybackRate(parseFloat(rate))} > {playbackRates.map((rate) => ( diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 156d16b2d..f9b8e2d43 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -716,11 +716,15 @@ function MotionReview({ // playback + const [playbackRate, setPlaybackRate] = useState(8); + const [controlsOpen, setControlsOpen] = useState(false); + useEffect(() => { if (!playing) { return; } + const interval = 500 / playbackRate; const startTime = currentTime; let counter = 0; const intervalId = setInterval(() => { @@ -732,14 +736,14 @@ function MotionReview({ } setCurrentTime(startTime + counter); - }, 60); + }, interval); return () => { clearInterval(intervalId); }; // do not render when current time changes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playing]); + }, [playing, playbackRate]); if (!relevantPreviews) { return ; @@ -815,9 +819,13 @@ function MotionReview({ features={{ volume: false, seek: true, - playbackRate: false, + playbackRate: true, }} isPlaying={playing} + playbackRates={[4, 8, 12, 16]} + playbackRate={playbackRate} + controlsOpen={controlsOpen} + setControlsOpen={setControlsOpen} onPlayPause={setPlaying} onSeek={(diff) => { const wasPlaying = playing; @@ -832,6 +840,7 @@ function MotionReview({ setTimeout(() => setPlaying(true), 100); } }} + onSetPlaybackRate={setPlaybackRate} show={currentTime < timeRange.before - 4} /> From 3a9607e59ba54acdc4c4755f43e0eb0d2f2b418c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 23 Mar 2024 11:53:33 -0500 Subject: [PATCH 282/751] Add relative movement by clicking on camera image for supported ptzs (#10629) --- frigate/comms/dispatcher.py | 3 ++ frigate/ptz/onvif.py | 4 ++ web/src/views/live/LiveCameraView.tsx | 70 ++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index bf551419a..0b466a01c 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -315,6 +315,9 @@ class Dispatcher: if "preset" in payload.lower(): command = OnvifCommandEnum.preset param = payload.lower()[payload.index("_") + 1 :] + elif "move_relative" in payload.lower(): + command = OnvifCommandEnum.move_relative + param = payload.lower()[payload.index("_") + 1 :] else: command = OnvifCommandEnum[payload.lower()] param = "" diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 38b61c2f9..d8af877e9 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -21,6 +21,7 @@ class OnvifCommandEnum(str, Enum): init = "init" move_down = "move_down" move_left = "move_left" + move_relative = "move_relative" move_right = "move_right" move_up = "move_up" preset = "preset" @@ -536,6 +537,9 @@ class OnvifController: self._stop(camera_name) elif command == OnvifCommandEnum.preset: self._move_to_preset(camera_name, param) + elif command == OnvifCommandEnum.move_relative: + _, pan, tilt = param.split("_") + self._move_relative(camera_name, float(pan), float(tilt), 0, 1) elif ( command == OnvifCommandEnum.zoom_in or command == OnvifCommandEnum.zoom_out ): diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index a2f849ff0..5f01e2698 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -45,6 +45,7 @@ import { FaMicrophoneSlash, } from "react-icons/fa"; import { GiSpeaker, GiSpeakerOff } from "react-icons/gi"; +import { HiViewfinderCircle } from "react-icons/hi2"; import { IoMdArrowBack } from "react-icons/io"; import { LuEar, LuEarOff, LuVideo, LuVideoOff } from "react-icons/lu"; import { @@ -82,6 +83,45 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { ); const { payload: audioState, send: sendAudio } = useAudioState(camera.name); + // click overlay for ptzs + + const [clickOverlay, setClickOverlay] = useState(false); + const clickOverlayRef = useRef(null); + const { send: sendPtz } = usePtzCommand(camera.name); + + const handleOverlayClick = useCallback( + ( + e: React.MouseEvent | React.TouchEvent, + ) => { + if (!clickOverlay) { + return; + } + + let clientX; + let clientY; + if (isMobile && e.nativeEvent instanceof TouchEvent) { + clientX = e.nativeEvent.touches[0].clientX; + clientY = e.nativeEvent.touches[0].clientY; + } else if (e.nativeEvent instanceof MouseEvent) { + clientX = e.nativeEvent.clientX; + clientY = e.nativeEvent.clientY; + } + + if (clickOverlayRef.current && clientX && clientY) { + const rect = clickOverlayRef.current.getBoundingClientRect(); + + const normalizedX = (clientX - rect.left) / rect.width; + const normalizedY = (clientY - rect.top) / rect.height; + + const pan = (normalizedX - 0.5) * 2; + const tilt = (0.5 - normalizedY) * 2; + + sendPtz(`move_relative_${pan}_${tilt}`); + } + }, + [clickOverlayRef, clickOverlay, sendPtz], + ); + // fullscreen state useEffect(() => { @@ -277,6 +317,8 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { >
- {camera.onvif.host != "" && } + {camera.onvif.host != "" && ( + + )}
); } -function PtzControlPanel({ camera }: { camera: string }) { +function PtzControlPanel({ + camera, + clickOverlay, + setClickOverlay, +}: { + camera: string; + clickOverlay: boolean; + setClickOverlay: React.Dispatch>; +}) { const { data: ptz } = useSWR(`${camera}/ptz/info`); const { send: sendPtz } = usePtzCommand(camera); @@ -442,6 +498,16 @@ function PtzControlPanel({ camera }: { camera: string }) { )} + {ptz?.features?.includes("pt-r-fov") && ( + <> + + + )} {(ptz?.presets?.length ?? 0) > 0 && ( From c2a32bd6c15eaa3048bb9aa30af25b14344e8329 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 23 Mar 2024 13:45:15 -0600 Subject: [PATCH 283/751] Make saving preview frames on restart more reliable (#10630) * increase priority of saving preview frames * Improve checking for ended recording --- frigate/output/output.py | 4 ++-- frigate/record/maintainer.py | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frigate/output/output.py b/frigate/output/output.py index b7a918cd2..348d4ba7a 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -130,6 +130,8 @@ def output_frames( previous_frames[camera] = frame_time + move_preview_frames("clips") + while True: (topic, data) = detection_subscriber.get_data(timeout=0) @@ -156,8 +158,6 @@ def output_frames( for preview in preview_recorders.values(): preview.stop() - move_preview_frames("clips") - if birdseye is not None: birdseye.stop() diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index 98030814f..5a4fc1e49 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -7,6 +7,7 @@ import os import random import string import threading +import time from collections import defaultdict from multiprocessing.synchronize import Event as MpEvent from pathlib import Path @@ -440,7 +441,12 @@ class RecordingMaintainer(threading.Thread): def run(self) -> None: # Check for new files every 5 seconds wait_time = 0.0 - while not self.stop_event.wait(wait_time): + while not self.stop_event.is_set(): + time.sleep(wait_time) + + if self.stop_event.is_set(): + break + run_start = datetime.datetime.now().timestamp() # check if there is an updated config From bb50b2b6f4b0c9d23c3635ec5b604bd467e1dfb5 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 23 Mar 2024 13:49:31 -0600 Subject: [PATCH 284/751] Respect motion only when playing back (#10632) * Respect motion only when playing back motion * Increase efficiency * Fix import --- .../overlay/TimelineDataOverlay.tsx | 1 + web/src/components/player/LivePlayer.tsx | 2 +- .../player/dynamic/DynamicVideoController.ts | 1 + .../player/dynamic/DynamicVideoPlayer.tsx | 1 + web/src/hooks/use-camera-activity.ts | 58 ++++++++++++++++++- web/src/types/timeline.ts | 11 +--- web/src/utils/timelineUtil.tsx | 1 + web/src/views/events/EventView.tsx | 23 ++++++-- 8 files changed, 82 insertions(+), 16 deletions(-) diff --git a/web/src/components/overlay/TimelineDataOverlay.tsx b/web/src/components/overlay/TimelineDataOverlay.tsx index 3fa9781e6..499cc0745 100644 --- a/web/src/components/overlay/TimelineDataOverlay.tsx +++ b/web/src/components/overlay/TimelineDataOverlay.tsx @@ -1,3 +1,4 @@ +import { Timeline } from "@/types/timeline"; import { useState } from "react"; type TimelineEventOverlayProps = { diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 69ad60bd3..a511bcb5d 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from "react"; import MSEPlayer from "./MsePlayer"; import JSMpegPlayer from "./JSMpegPlayer"; import { MdCircle } from "react-icons/md"; -import useCameraActivity from "@/hooks/use-camera-activity"; +import { useCameraActivity } from "@/hooks/use-camera-activity"; import { useRecordingsState } from "@/api/ws"; import { LivePlayerMode } from "@/types/live"; import useCameraLiveMode from "@/hooks/use-camera-live-mode"; diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts index ce40fa719..7258ddebe 100644 --- a/web/src/components/player/dynamic/DynamicVideoController.ts +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -1,6 +1,7 @@ import { Recording } from "@/types/record"; import { DynamicPlayback } from "@/types/playback"; import { PreviewController } from "../PreviewPlayer"; +import { Timeline } from "@/types/timeline"; type PlayerMode = "playback" | "scrubbing"; diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 62f087cfb..1421954cd 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -8,6 +8,7 @@ import { Preview } from "@/types/preview"; import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; import { DynamicVideoController } from "./DynamicVideoController"; import HlsVideoPlayer from "../HlsVideoPlayer"; +import { Timeline } from "@/types/timeline"; /** * Dynamically switches between video playback and scrubbing preview player. diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index b15697c78..01406d29a 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -4,6 +4,8 @@ import { useMotionActivity, } from "@/api/ws"; import { CameraConfig } from "@/types/frigateConfig"; +import { MotionData, ReviewSegment } from "@/types/review"; +import { TimeRange } from "@/types/timeline"; import { useEffect, useMemo, useState } from "react"; type useCameraActivityReturn = { @@ -12,7 +14,7 @@ type useCameraActivityReturn = { activeAudio: boolean; }; -export default function useCameraActivity( +export function useCameraActivity( camera: CameraConfig, ): useCameraActivityReturn { const [activeObjects, setActiveObjects] = useState([]); @@ -66,3 +68,57 @@ export default function useCameraActivity( : false, }; } + +export function useCameraMotionTimestamps( + timeRange: TimeRange, + motionOnly: boolean, + events: ReviewSegment[], + motion: MotionData[], +) { + const timestamps = useMemo(() => { + const seekableTimestamps = []; + let lastEventIdx = 0; + let lastMotionIdx = 0; + + for (let i = timeRange.after; i <= timeRange.before; i += 0.5) { + if (!motionOnly) { + seekableTimestamps.push(i); + } else { + const relevantEventIdx = events.findIndex((seg, segIdx) => { + if (segIdx < lastEventIdx) { + return false; + } + + return seg.start_time <= i && seg.end_time >= i; + }); + + if (relevantEventIdx != -1) { + lastEventIdx = relevantEventIdx; + continue; + } + + const relevantMotionIdx = motion.findIndex((mot, motIdx) => { + if (motIdx < lastMotionIdx) { + return false; + } + + return mot.start_time <= i && mot.start_time + 15 >= i; + }); + + if (relevantMotionIdx == -1 || motion[relevantMotionIdx].motion == 0) { + if (relevantMotionIdx != -1) { + lastMotionIdx = relevantMotionIdx; + } + + continue; + } + + seekableTimestamps.push(i); + } + } + + return seekableTimestamps; + }, [timeRange, motionOnly, events, motion]); + + return timestamps; +} diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts index 86b364686..b5e746206 100644 --- a/web/src/types/timeline.ts +++ b/web/src/types/timeline.ts @@ -1,4 +1,4 @@ -type Timeline = { +export type Timeline = { camera: string; timestamp: number; data: { @@ -23,11 +23,4 @@ type Timeline = { source: string; }; -// may be used in the future, keep for now for reference -// eslint-disable-next-line @typescript-eslint/no-unused-vars -type HourlyTimeline = { - start: number; - end: number; - count: number; - hours: { [key: string]: Timeline[] }; -}; +export type TimeRange = { before: number; after: number }; diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index 0e0b1d104..5bc19f03d 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -21,6 +21,7 @@ import { } from "react-icons/md"; import { FaBicycle } from "react-icons/fa"; import { endOfHourOrCurrentTime } from "./dateUtil"; +import { Timeline } from "@/types/timeline"; export function getTimelineIcon(timelineItem: Timeline) { switch (timelineItem.class_type) { diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index f9b8e2d43..353760ff7 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -39,6 +39,8 @@ import PreviewPlayer, { import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { RecordingStartingPoint } from "@/types/record"; import VideoControls from "@/components/player/VideoControls"; +import { TimeRange } from "@/types/timeline"; +import { useCameraMotionTimestamps } from "@/hooks/use-camera-activity"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -606,7 +608,7 @@ type MotionReviewProps = { significant_motion: ReviewSegment[]; }; relevantPreviews?: Preview[]; - timeRange: { before: number; after: number }; + timeRange: TimeRange; startTime?: number; filter?: ReviewFilter; motionOnly?: boolean; @@ -718,6 +720,12 @@ function MotionReview({ const [playbackRate, setPlaybackRate] = useState(8); const [controlsOpen, setControlsOpen] = useState(false); + const seekTimestamps = useCameraMotionTimestamps( + timeRange, + motionOnly, + reviewItems?.all ?? [], + motionData ?? [], + ); useEffect(() => { if (!playing) { @@ -725,17 +733,22 @@ function MotionReview({ } const interval = 500 / playbackRate; - const startTime = currentTime; + const startIdx = seekTimestamps.findIndex((time) => time > currentTime); + + if (!startIdx) { + return; + } + let counter = 0; const intervalId = setInterval(() => { - counter += 0.5; + counter += 1; - if (startTime + counter >= timeRange.before) { + if (startIdx + counter >= seekTimestamps.length) { setPlaying(false); return; } - setCurrentTime(startTime + counter); + setCurrentTime(seekTimestamps[startIdx + counter]); }, interval); return () => { From e3a7aa6b6c5135839888a0815e69c97e5ea12d17 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 23 Mar 2024 17:11:35 -0600 Subject: [PATCH 285/751] Smooth transitions between preview scrubbing (#10636) * Use canvas to save video state before switching to smooth transitions between previews * Smooth current hour as well --- web/src/components/player/PreviewPlayer.tsx | 67 +++++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 6c4a5a479..1a58022c4 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -35,6 +35,8 @@ export default function PreviewPlayer({ onControllerReady, onClick, }: PreviewPlayerProps) { + const [currentHourFrame, setCurrentHourFrame] = useState(); + if (isCurrentHour(timeRange.end)) { return ( ); } @@ -56,8 +59,10 @@ export default function PreviewPlayer({ cameraPreviews={cameraPreviews} startTime={startTime} isScrubbing={isScrubbing} + currentHourFrame={currentHourFrame} onControllerReady={onControllerReady} onClick={onClick} + setCurrentHourFrame={setCurrentHourFrame} /> ); } @@ -83,8 +88,10 @@ type PreviewVideoPlayerProps = { cameraPreviews: Preview[]; startTime?: number; isScrubbing: boolean; + currentHourFrame?: string; onControllerReady: (controller: PreviewVideoController) => void; onClick?: () => void; + setCurrentHourFrame: (src: string | undefined) => void; }; function PreviewVideoPlayer({ className, @@ -93,8 +100,10 @@ function PreviewVideoPlayer({ cameraPreviews, startTime, isScrubbing, + currentHourFrame, onControllerReady, onClick, + setCurrentHourFrame, }: PreviewVideoPlayerProps) { const { data: config } = useSWR("config"); @@ -134,6 +143,7 @@ function PreviewVideoPlayer({ // initial state const [loaded, setLoaded] = useState(false); + const [hasCanvas, setHasCanvas] = useState(false); const initialPreview = useMemo(() => { return cameraPreviews.find( (preview) => @@ -187,22 +197,57 @@ function PreviewVideoPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [controller, timeRange]); + // canvas to cover preview transition + + const canvasRef = useRef(null); + const [videoWidth, videoHeight] = useMemo(() => { + if (!previewRef.current) { + return [0, 0]; + } + + return [previewRef.current.videoWidth, previewRef.current.videoHeight]; + // we know the video size will be known on load + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loaded]); + // handle switching sources + useEffect(() => { if (!currentPreview || !previewRef.current) { return; } + if (canvasRef.current) { + canvasRef.current + .getContext("2d") + ?.drawImage(previewRef.current, 0, 0, videoWidth, videoHeight); + setHasCanvas(true); + } + previewRef.current.load(); + // we only want this to change when current preview changes + // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPreview, previewRef]); return (
+ {currentHourFrame && ( + + )} + - {!loaded && } + {!loaded && !hasCanvas && !currentHourFrame && ( + + )} {cameraPreviews && !currentPreview && (
No Preview Found @@ -329,12 +377,14 @@ type PreviewFramesPlayerProps = { startTime?: number; onControllerReady: (controller: PreviewController) => void; onClick?: () => void; + setCurrentHourFrame: (src: string) => void; }; function PreviewFramesPlayer({ className, camera, timeRange, startTime, + setCurrentHourFrame, onControllerReady, onClick, }: PreviewFramesPlayerProps) { @@ -365,7 +415,12 @@ function PreviewFramesPlayer({ return undefined; } - return new PreviewFramesController(camera, imgRef, frameTimes); + return new PreviewFramesController( + camera, + imgRef, + frameTimes, + setCurrentHourFrame, + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [imgRef, frameTimes, imgRef.current]); @@ -430,15 +485,18 @@ class PreviewFramesController extends PreviewController { frameTimes: number[]; seeking: boolean = false; private timeToSeek: number | undefined = undefined; + private setCurrentFrame: (src: string) => void; constructor( camera: string, imgController: MutableRefObject, frameTimes: number[], + setCurrentFrame: (src: string) => void, ) { super(camera); this.imgController = imgController; this.frameTimes = frameTimes; + this.setCurrentFrame = setCurrentFrame; } override scrubToTimestamp(time: number): boolean { @@ -478,6 +536,7 @@ class PreviewFramesController extends PreviewController { if (this.imgController.current.src != newSrc) { this.imgController.current.src = newSrc; + this.setCurrentFrame(newSrc); } else { this.timeToSeek = undefined; this.seeking = false; From d260c6926a78f4016e7f081d04289ea7f8a39b16 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 23 Mar 2024 17:47:25 -0600 Subject: [PATCH 286/751] Fix safari preview transition (#10638) --- web/src/components/player/PreviewPlayer.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 1a58022c4..197986677 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -12,7 +12,7 @@ import { Preview } from "@/types/preview"; import { PreviewPlayback } from "@/types/playback"; import { isCurrentHour } from "@/utils/dateUtil"; import { baseUrl } from "@/api/baseUrl"; -import { isAndroid, isChrome, isMobile } from "react-device-detect"; +import { isAndroid, isChrome, isMobile, isSafari } from "react-device-detect"; import { Skeleton } from "../ui/skeleton"; type PreviewPlayerProps = { @@ -223,7 +223,11 @@ function PreviewVideoPlayer({ setHasCanvas(true); } - previewRef.current.load(); + if (isSafari) { + setTimeout(() => previewRef.current?.load(), 100); + } else { + previewRef.current.load(); + } // we only want this to change when current preview changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPreview, previewRef]); From f91dc37399946a927f5a1ad5d74a00e418984711 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 24 Mar 2024 07:54:34 -0500 Subject: [PATCH 287/751] Add outline for alerts/detections in motion review (#10642) --- web/src/components/player/LivePlayer.tsx | 2 +- web/src/components/timeline/EventSegment.tsx | 56 ++++++++++---------- web/src/views/events/EventView.tsx | 26 +++++++-- 3 files changed, 51 insertions(+), 33 deletions(-) diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index a511bcb5d..8be3f71b9 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -142,7 +142,7 @@ export default function LivePlayer({
{ - element.classList.remove("outline-4", "shadow-[0_0_6px_1px]"); - element.classList.add("outline-0", "shadow-none"); + element.classList.remove("outline-3"); + element.classList.add("outline-0"); }, 3000); } @@ -226,36 +226,36 @@ export function EventSegment({ {severity.map((severityValue: number, index: number) => ( {severityValue === displaySeverityType && ( -
-
-
- -
- + + +
+
+
+
- - - - - - +
- +
-
-
+ + + + + + + )} ))} diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 353760ff7..141766a29 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -519,7 +519,7 @@ function DetectionReview({ )}
{currentItems && @@ -534,7 +534,7 @@ function DetectionReview({ data-segment-start={ alignStartDateToTimeline(value.start_time) - segmentDuration } - className={`review-item outline outline-offset-1 rounded-lg shadow-none transition-all my-1 md:my-0 ${selected ? `outline-4 shadow-[0_0_6px_1px] outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-0 duration-500"}`} + className={`review-item outline outline-offset-1 rounded-lg shadow-none transition-all my-1 md:my-0 ${selected ? `outline-3 outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-0 duration-500"}`} >
{ + if (motionOnly) { + return null; + } + const matchingItem = reviewItems?.all.find( + (item) => + currentTime >= item.start_time && + currentTime <= item.end_time && + item.camera === cameraName, + ); + + return matchingItem ? matchingItem.severity : null; + }, + [reviewItems, currentTime, motionOnly], + ); + if (!relevantPreviews) { return ; } @@ -767,7 +784,7 @@ function MotionReview({
{reviewCameras.map((camera) => { let grow; @@ -779,10 +796,11 @@ function MotionReview({ } else { grow = "aspect-video"; } + const detectionType = getDetectionType(camera.name); return ( Date: Sun, 24 Mar 2024 11:23:39 -0600 Subject: [PATCH 288/751] UI tweaks (#10645) * Use green chip instead of dimming to show that item has been reviewed * Redesign log page to use similar style to events * Use icon only in mobile * Remove unused --- .../player/PreviewThumbnailPlayer.tsx | 7 +- web/src/pages/Logs.tsx | 73 ++++++++----------- 2 files changed, 35 insertions(+), 45 deletions(-) diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 3c1648e9d..52b3e7b53 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -202,7 +202,9 @@ export default function PreviewThumbnailPlayer({
{(review.severity == "alert" || review.severity == "detection") && ( - + {review.data.objects.map((object) => { return getIconForLabel(object, "size-3 text-white"); })} @@ -225,9 +227,6 @@ export default function PreviewThumbnailPlayer({ )}
- {!playingBack && imgLoaded && review.has_been_reviewed && ( -
- )}
); } diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index c09f71ab0..77b9f05f0 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -1,16 +1,8 @@ import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import Heading from "@/components/ui/heading"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import copy from "copy-to-clipboard"; import { useCallback, useMemo, useRef, useState } from "react"; +import { LuCopy } from "react-icons/lu"; import useSWR from "swr"; const logTypes = ["frigate", "go2rtc", "nginx"] as const; @@ -61,38 +53,37 @@ function Logs() { ); return ( -
+
- - Logs - + + value ? setLogService(value) : null + } // don't allow the severity to be unselected + > + {Object.values(logTypes).map((item) => ( + +
{`${item} Logs`}
+
+ ))} +
- - - - - - Select Logs To View - - setLogService(type as LogType)} - > - {Object.values(logTypes).map((item) => ( - - {item} Logs - - ))} - - - - +
@@ -113,7 +104,7 @@ function Logs() {
{logs}
From 37f60f7140d0b357038fd1353d4edfe9ee33649c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 24 Mar 2024 12:39:28 -0500 Subject: [PATCH 289/751] UI changes (#10651) * Check if event time is in segment * conditionally render minimap bounds for event segments --- web/src/components/timeline/EventSegment.tsx | 16 +++++---- web/src/components/timeline/MotionSegment.tsx | 16 +++++---- web/src/hooks/use-event-segment-utils.ts | 35 ++++++++++++------- web/src/pages/UIPlayground.tsx | 10 +++--- web/src/views/events/EventView.tsx | 11 ++++-- 5 files changed, 53 insertions(+), 35 deletions(-) diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 1984d448c..596c9788e 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -205,13 +205,15 @@ export function EventSegment({ onClick={segmentClick} onTouchEnd={(event) => handleTouchStart(event, segmentClick)} > - + {showMinimap && ( + + )} diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index dd54a03b6..748024239 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -196,13 +196,15 @@ export function MotionSegment({ > {!motionOnly && ( <> - + {showMinimap && ( + + )} { - const activeEvents = events?.filter((event) => { + let highestSeverityValue = 0; + let highestOtherSeverityValue = 0; + let hasDisplaySeverityType = false; + + for (const event of events || []) { const segmentStart = getSegmentStart(event.start_time); const segmentEnd = getSegmentEnd(event.end_time); - return time >= segmentStart && time < segmentEnd; - }); - if (activeEvents?.length === 0) return [0]; - const severityValues = activeEvents.map((event) => - mapSeverityToNumber(event.severity), - ); - const highestSeverityValue = Math.max(...severityValues); + if (time >= segmentStart && time < segmentEnd) { + const severity = mapSeverityToNumber(event.severity); - if (severityValues.includes(displaySeverityType)) { - const otherSeverityValues = severityValues.filter( - (severity) => severity !== displaySeverityType, - ); - const highestOtherSeverityValue = Math.max(...otherSeverityValues); + if (severity === displaySeverityType) { + hasDisplaySeverityType = true; + highestOtherSeverityValue = Math.max( + highestOtherSeverityValue, + highestSeverityValue, + ); + } else { + highestSeverityValue = Math.max(highestSeverityValue, severity); + } + } + } + + if (hasDisplaySeverityType) { return [displaySeverityType, highestOtherSeverityValue]; + } else if (highestSeverityValue === 0) { + return [0]; } else { return [highestSeverityValue]; } diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 4f44564ad..bec9828c3 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -89,7 +89,7 @@ function generateRandomMotionAudioData(): MotionData[] { const generateRandomEvent = (): ReviewSegment => { const start_time = - Math.floor(Date.now() / 1000) - 10800 - Math.random() * 60 * 60; + Math.floor(Date.now() / 1000) - 60 * 30 - Math.random() * 60 * 60; const end_time = Math.floor(start_time + Math.random() * 60 * 10); const severities: ReviewSeverity[] = [ "significant_motion", @@ -365,12 +365,12 @@ function UIPlayground() { segmentDuration={zoomSettings.segmentDuration} // seconds per segment timestampSpread={zoomSettings.timestampSpread} // minutes between each major timestamp timelineStart={Math.floor(Date.now() / 1000)} // timestamp start of the timeline - the earlier time - timelineEnd={Math.floor(Date.now() / 1000) - 6 * 60 * 60} // end of timeline - the later time + timelineEnd={Math.floor(Date.now() / 1000) - 24 * 60 * 60} // end of timeline - the later time showHandlebar // show / hide the handlebar handlebarTime={handlebarTime} // set the time of the handlebar setHandlebarTime={setHandlebarTime} // expose handler to set the handlebar time onHandlebarDraggingChange={handleDraggingChange} // function for state of handlebar dragging - showMinimap // show / hide the minimap + showMinimap={false} // show / hide the minimap minimapStartTime={minimapStartTime} // start time of the minimap - the earlier time (eg 1:00pm) minimapEndTime={minimapEndTime} // end of the minimap - the later time (eg 3:00pm) showExportHandles={showExportHandles} @@ -389,7 +389,7 @@ function UIPlayground() { segmentDuration={zoomSettings.segmentDuration} // seconds per segment timestampSpread={zoomSettings.timestampSpread} // minutes between each major timestamp timelineStart={Math.floor(Date.now() / 1000)} // timestamp start of the timeline - the earlier time - timelineEnd={Math.floor(Date.now() / 1000) - 6 * 60 * 60} // end of timeline - the later time + timelineEnd={Math.floor(Date.now() / 1000) - 24 * 60 * 60} // end of timeline - the later time showHandlebar // show / hide the handlebar handlebarTime={handlebarTime} // set the time of the handlebar setHandlebarTime={setHandlebarTime} // expose handler to set the handlebar time @@ -414,7 +414,7 @@ function UIPlayground() { { if (motionOnly) { return null; } + const segmentTime = alignStartDateToTimeline(currentTime); const matchingItem = reviewItems?.all.find( (item) => - currentTime >= item.start_time && - currentTime <= item.end_time && + item.start_time >= segmentTime && + item.end_time <= segmentTime + segmentDuration && item.camera === cameraName, ); return matchingItem ? matchingItem.severity : null; }, - [reviewItems, currentTime, motionOnly], + [reviewItems, currentTime, motionOnly, alignStartDateToTimeline], ); if (!relevantPreviews) { From 24d29dd32c802714475ca74aef8875d711ef21c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Mar 2024 19:55:15 -0600 Subject: [PATCH 290/751] Bump dependabot/fetch-metadata from 1 to 2 (#10607) Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 1 to 2. - [Release notes](https://github.com/dependabot/fetch-metadata/releases) - [Commits](https://github.com/dependabot/fetch-metadata/compare/v1...v2) --- updated-dependencies: - dependency-name: dependabot/fetch-metadata dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dependabot-auto-merge.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependabot-auto-merge.yaml b/.github/workflows/dependabot-auto-merge.yaml index a3eecb1d5..1c047c346 100644 --- a/.github/workflows/dependabot-auto-merge.yaml +++ b/.github/workflows/dependabot-auto-merge.yaml @@ -11,7 +11,7 @@ jobs: steps: - name: Get Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v1 + uses: dependabot/fetch-metadata@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Enable auto-merge for Dependabot PRs From 7b640911281d2fdf0a5094bc9d2c269e7b575e0f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 24 Mar 2024 21:37:44 -0500 Subject: [PATCH 291/751] Motion review playback optimizations (#10659) * handle motion timestamps with ranges * check for overlaps when checking segment for events * rename motion color vars to significant_motion for consistency * safelist significant_motion * rename vars for clarity and use timeout instead of interval --- web/src/components/timeline/EventSegment.tsx | 4 +- web/src/components/timeline/MotionSegment.tsx | 20 +-- .../components/timeline/SummarySegment.tsx | 4 +- web/src/hooks/use-camera-activity.ts | 56 ------- web/src/hooks/use-draggable-element.ts | 50 +++--- web/src/hooks/use-motion-segment-utils.ts | 2 +- web/src/views/events/EventView.tsx | 144 +++++++++++++----- web/tailwind.config.js | 8 +- web/themes/theme-default.css | 4 +- 9 files changed, 163 insertions(+), 129 deletions(-) diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 596c9788e..9fa2d025d 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -155,8 +155,8 @@ export function EventSegment({ const severityColors: { [key: number]: string } = { 1: reviewed - ? "from-severity_motion-dimmed/50 to-severity_motion/50" - : "from-severity_motion-dimmed to-severity_motion", + ? "from-severity_significant_motion-dimmed/50 to-severity_significant_motion/50" + : "from-severity_significant_motion-dimmed to-severity_significant_motion", 2: reviewed ? "from-severity_detection-dimmed/50 to-severity_detection/50" : "from-severity_detection-dimmed to-severity_detection", diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 748024239..279f69a9a 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -158,15 +158,15 @@ export function MotionSegment({ : "" }`; - const animationClassesSecondHalf = `motion-segment ${secondHalfSegmentWidth > 1 ? "hidden" : ""} + const animationClassesSecondHalf = `motion-segment ${secondHalfSegmentWidth > 0 ? "hidden" : ""} zoom-in-[0.2] ${secondHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"}`; - const animationClassesFirstHalf = `motion-segment ${firstHalfSegmentWidth > 1 ? "hidden" : ""} + const animationClassesFirstHalf = `motion-segment ${firstHalfSegmentWidth > 0 ? "hidden" : ""} zoom-in-[0.2] ${firstHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"}`; const severityColors: { [key: number]: string } = { 1: reviewed - ? "from-severity_motion-dimmed/50 to-severity_motion/50" - : "from-severity_motion-dimmed to-severity_motion", + ? "from-severity_significant_motion-dimmed/50 to-severity_significant_motion/50" + : "from-severity_significant_motion-dimmed to-severity_significant_motion", 2: reviewed ? "from-severity_detection-dimmed/50 to-severity_detection/50" : "from-severity_detection-dimmed to-severity_detection", @@ -183,14 +183,14 @@ export function MotionSegment({ return ( <> - {(((firstHalfSegmentWidth > 1 || secondHalfSegmentWidth > 1) && + {(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) && motionOnly && severity[0] < 2) || !motionOnly) && (
1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`} + className={`segment ${firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0 ? "has-data" : ""} ${segmentClasses}`} onClick={segmentClick} onTouchEnd={(event) => handleTouchStart(event, segmentClick)} > @@ -228,9 +228,10 @@ export function MotionSegment({
@@ -240,9 +241,10 @@ export function MotionSegment({
@@ -251,7 +253,7 @@ export function MotionSegment({ {!motionOnly && severity.map((severityValue: number, index: number) => { - if (severityValue > 1) { + if (severityValue > 0) { return (
diff --git a/web/src/components/timeline/SummarySegment.tsx b/web/src/components/timeline/SummarySegment.tsx index 9c8e8bca9..34e4b6621 100644 --- a/web/src/components/timeline/SummarySegment.tsx +++ b/web/src/components/timeline/SummarySegment.tsx @@ -34,7 +34,9 @@ export function SummarySegment({ const segmentKey = useMemo(() => segmentTime, [segmentTime]); const severityColors: { [key: number]: string } = { - 1: reviewed ? "bg-severity_motion/50" : "bg-severity_motion", + 1: reviewed + ? "bg-severity_significant_motion/50" + : "bg-severity_significant_motion", 2: reviewed ? "bg-severity_detection/50" : "bg-severity_detection", 3: reviewed ? "bg-severity_alert/50" : "bg-severity_alert", }; diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index 01406d29a..d2fa49671 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -4,8 +4,6 @@ import { useMotionActivity, } from "@/api/ws"; import { CameraConfig } from "@/types/frigateConfig"; -import { MotionData, ReviewSegment } from "@/types/review"; -import { TimeRange } from "@/types/timeline"; import { useEffect, useMemo, useState } from "react"; type useCameraActivityReturn = { @@ -68,57 +66,3 @@ export function useCameraActivity( : false, }; } - -export function useCameraMotionTimestamps( - timeRange: TimeRange, - motionOnly: boolean, - events: ReviewSegment[], - motion: MotionData[], -) { - const timestamps = useMemo(() => { - const seekableTimestamps = []; - let lastEventIdx = 0; - let lastMotionIdx = 0; - - for (let i = timeRange.after; i <= timeRange.before; i += 0.5) { - if (!motionOnly) { - seekableTimestamps.push(i); - } else { - const relevantEventIdx = events.findIndex((seg, segIdx) => { - if (segIdx < lastEventIdx) { - return false; - } - - return seg.start_time <= i && seg.end_time >= i; - }); - - if (relevantEventIdx != -1) { - lastEventIdx = relevantEventIdx; - continue; - } - - const relevantMotionIdx = motion.findIndex((mot, motIdx) => { - if (motIdx < lastMotionIdx) { - return false; - } - - return mot.start_time <= i && mot.start_time + 15 >= i; - }); - - if (relevantMotionIdx == -1 || motion[relevantMotionIdx].motion == 0) { - if (relevantMotionIdx != -1) { - lastMotionIdx = relevantMotionIdx; - } - - continue; - } - - seekableTimestamps.push(i); - } - } - - return seekableTimestamps; - }, [timeRange, motionOnly, events, motion]); - - return timestamps; -} diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index d4cd3e713..49499900f 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -368,27 +368,10 @@ function useDraggableElement({ const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); - let segmentElement = timelineRef.current.querySelector( + const segmentElement = timelineRef.current.querySelector( `[data-segment-id="${alignedSegmentTime}"]`, ); - if (!segmentElement) { - // segment not found, maybe we collapsed over a collapsible segment - let searchTime = alignedSegmentTime; - while (searchTime >= timelineStartAligned - timelineDuration) { - // Decrement currentTime by segmentDuration - searchTime -= segmentDuration; - segmentElement = timelineRef.current.querySelector( - `[data-segment-id="${searchTime}"]`, - ); - - if (segmentElement) { - // segmentElement found - break; - } - } - } - if (segmentElement) { const timelineRect = timelineRef.current.getBoundingClientRect(); const timelineTopAbsolute = timelineRect.top; @@ -422,6 +405,37 @@ function useDraggableElement({ segments, ]); + useEffect(() => { + if (timelineRef.current && draggableElementTime && timelineCollapsed) { + const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); + + let segmentElement = timelineRef.current.querySelector( + `[data-segment-id="${alignedSegmentTime}"]`, + ); + + if (!segmentElement) { + // segment not found, maybe we collapsed over a collapsible segment + let searchTime = alignedSegmentTime; + while (searchTime >= timelineStartAligned - timelineDuration) { + searchTime -= segmentDuration; + segmentElement = timelineRef.current.querySelector( + `[data-segment-id="${searchTime}"]`, + ); + + if (segmentElement) { + // found, set time + if (setDraggableElementTime) { + setDraggableElementTime(searchTime); + } + break; + } + } + } + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timelineCollapsed]); + return { handleMouseDown, handleMouseUp, handleMouseMove }; } diff --git a/web/src/hooks/use-motion-segment-utils.ts b/web/src/hooks/use-motion-segment-utils.ts index dfec48358..0482e776e 100644 --- a/web/src/hooks/use-motion-segment-utils.ts +++ b/web/src/hooks/use-motion-segment-utils.ts @@ -33,7 +33,7 @@ export const useMotionSegmentUtils = ( const interpolateMotionAudioData = useCallback( (value: number, newMax: number): number => { - return Math.ceil((Math.abs(value) / 100.0) * newMax) || 1; + return Math.ceil((Math.abs(value) / 100.0) * newMax) || 0; }, [], ); diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 41101807d..2dfb957d7 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -40,7 +40,6 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { RecordingStartingPoint } from "@/types/record"; import VideoControls from "@/components/player/VideoControls"; import { TimeRange } from "@/types/timeline"; -import { useCameraMotionTimestamps } from "@/hooks/use-camera-activity"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -247,7 +246,7 @@ export default function EventView({ value="significant_motion" aria-label="Select motion" > - +
Motion
@@ -720,43 +719,111 @@ function MotionReview({ const [playbackRate, setPlaybackRate] = useState(8); const [controlsOpen, setControlsOpen] = useState(false); - const seekTimestamps = useCameraMotionTimestamps( - timeRange, - motionOnly, - reviewItems?.all ?? [], - motionData ?? [], - ); + + const noMotionRanges = useMemo(() => { + if (!motionData || !reviewItems) { + return; + } + + if (!motionOnly) { + return []; + } + + const ranges = []; + let currentSegmentStart = null; + let currentSegmentEnd = null; + + for (let i = 0; i < motionData.length; i = i + segmentDuration / 15) { + const motionStart = motionData[i].start_time; + const motionEnd = motionStart + segmentDuration; + + const segmentMotion = motionData + .slice(i, i + segmentDuration / 15) + .some(({ motion }) => motion !== undefined && motion > 0); + const overlappingReviewItems = reviewItems.all.some( + (item) => + (item.start_time >= motionStart && item.start_time < motionEnd) || + (item.end_time > motionStart && item.end_time <= motionEnd) || + (item.start_time <= motionStart && item.end_time >= motionEnd), + ); + + if (!segmentMotion || overlappingReviewItems) { + if (currentSegmentStart === null) { + currentSegmentStart = motionStart; + } + currentSegmentEnd = motionEnd; + } else { + if (currentSegmentStart !== null) { + ranges.push([currentSegmentStart, currentSegmentEnd]); + currentSegmentStart = null; + currentSegmentEnd = null; + } + } + } + + if (currentSegmentStart !== null) { + ranges.push([currentSegmentStart, currentSegmentEnd]); + } + + return ranges; + }, [motionData, reviewItems, motionOnly]); + + const nextTimestamp = useMemo(() => { + if (!noMotionRanges) { + return; + } + let currentRange = 0; + let nextTimestamp = currentTime + 0.5; + + while (currentRange < noMotionRanges.length) { + const [start, end] = noMotionRanges[currentRange]; + + if (start && end) { + // If the current time is before the start of the current range + if (currentTime < start) { + // The next timestamp is either the start of the current range or currentTime + 0.5, whichever is smaller + nextTimestamp = Math.min(start, nextTimestamp); + break; + } + // If the current time is within the current range + else if (currentTime >= start && currentTime < end) { + // The next timestamp is the end of the current range + nextTimestamp = end; + currentRange++; + } + // If the current time is past the end of the current range + else { + currentRange++; + } + } + } + + return nextTimestamp; + }, [currentTime, noMotionRanges]); + + const timeoutIdRef = useRef(null); useEffect(() => { - if (!playing) { - return; - } - - const interval = 500 / playbackRate; - const startIdx = seekTimestamps.findIndex((time) => time > currentTime); - - if (!startIdx) { - return; - } - - let counter = 0; - const intervalId = setInterval(() => { - counter += 1; - - if (startIdx + counter >= seekTimestamps.length) { - setPlaying(false); + if (nextTimestamp) { + if (!playing && timeoutIdRef.current != null) { + clearTimeout(timeoutIdRef.current); return; } - setCurrentTime(seekTimestamps[startIdx + counter]); - }, interval); + const handleTimeout = () => { + setCurrentTime(nextTimestamp); + timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate); + }; - return () => { - clearInterval(intervalId); - }; - // do not render when current time changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playing, playbackRate]); + timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate); + + return () => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + }; + } + }, [playing, playbackRate, nextTimestamp]); const { alignStartDateToTimeline } = useTimelineUtils({ segmentDuration, @@ -767,11 +834,16 @@ function MotionReview({ if (motionOnly) { return null; } - const segmentTime = alignStartDateToTimeline(currentTime); + const segmentStartTime = alignStartDateToTimeline(currentTime); + const segmentEndTime = segmentStartTime + segmentDuration; const matchingItem = reviewItems?.all.find( (item) => - item.start_time >= segmentTime && - item.end_time <= segmentTime + segmentDuration && + ((item.start_time >= segmentStartTime && + item.start_time < segmentEndTime) || + (item.end_time > segmentStartTime && + item.end_time <= segmentEndTime) || + (item.start_time <= segmentStartTime && + item.end_time >= segmentEndTime)) && item.camera === cameraName, ); diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 58ce1b5fe..916e625d4 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -9,7 +9,7 @@ module.exports = { ], safelist: [ { - pattern: /(outline|shadow)-severity_(alert|detection|motion)/, + pattern: /(outline|shadow)-severity_(alert|detection|significant_motion)/, }, ], theme: { @@ -87,9 +87,9 @@ module.exports = { DEFAULT: "hsl(var(--severity_detection))", dimmed: "hsl(var(--severity_detection_dimmed))", }, - severity_motion: { - DEFAULT: "hsl(var(--severity_motion))", - dimmed: "hsl(var(--severity_motion_dimmed))", + severity_significant_motion: { + DEFAULT: "hsl(var(--severity_significant_motion))", + dimmed: "hsl(var(--severity_significant_motion_dimmed))", }, motion_review: { DEFAULT: "hsl(var(--motion_review))", diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index 6dba4474e..b2a0126ba 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -71,8 +71,8 @@ --severity_detection: var(--orange-600); --severity_detection_dimmed: var(--orange-400); - --severity_motion: var(--yellow-400); - --severity_motion_dimmed: var(--yellow-200); + --severity_significant_motion: var(--yellow-400); + --severity_significant_motion_dimmed: var(--yellow-200); --motion_review: hsl(44, 94%, 50%); --motion_review: 44 94% 50%; From 258cd5b6d7c7df673aae36b2ae3ce46c05ce62d6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 25 Mar 2024 10:00:28 -0600 Subject: [PATCH 292/751] Include which cameras detected motion during aggregated data (#10663) --- frigate/api/review.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 0ca138e53..5074d6df2 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -358,6 +358,7 @@ def motion_activity(): ) clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] + clauses.append((Recordings.motion > 0)) if cameras != "all": camera_list = cameras.split(",") @@ -365,6 +366,7 @@ def motion_activity(): data: list[Recordings] = ( Recordings.select( + Recordings.camera, Recordings.start_time, Recordings.motion, ) @@ -378,18 +380,22 @@ def motion_activity(): scale = request.args.get("scale", type=int, default=30) # resample data using pandas to get activity on scaled basis - df = pd.DataFrame(data, columns=["start_time", "motion"]) + df = pd.DataFrame(data, columns=["start_time", "motion", "camera"]) # set date as datetime index df["start_time"] = pd.to_datetime(df["start_time"], unit="s") df.set_index(["start_time"], inplace=True) # normalize data - df = ( - df.resample(f"{scale}S") + motion = ( + df["motion"] + .resample(f"{scale}S") .apply(lambda x: max(x, key=abs, default=0.0)) .fillna(0.0) + .to_frame() ) + cameras = df["camera"].resample(f"{scale}S").agg(lambda x: ",".join(set(x))) + df = motion.join(cameras) length = df.shape[0] chunk = int(60 * (60 / scale)) From 51db63e42b2bfb555b796749c9491d871b79f114 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 25 Mar 2024 11:19:55 -0500 Subject: [PATCH 293/751] Motion review changes (#10667) * Add outlines in motion only mode * fix playground --- web/src/hooks/use-camera-activity.ts | 123 ++++++++++++++++++++++ web/src/hooks/use-draggable-element.ts | 2 +- web/src/pages/UIPlayground.tsx | 2 + web/src/types/review.ts | 1 + web/src/views/events/EventView.tsx | 139 ++++++++----------------- 5 files changed, 170 insertions(+), 97 deletions(-) diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index d2fa49671..9ddcfa692 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -4,7 +4,9 @@ import { useMotionActivity, } from "@/api/ws"; import { CameraConfig } from "@/types/frigateConfig"; +import { MotionData, ReviewSegment } from "@/types/review"; import { useEffect, useMemo, useState } from "react"; +import { useTimelineUtils } from "./use-timeline-utils"; type useCameraActivityReturn = { activeTracking: boolean; @@ -66,3 +68,124 @@ export function useCameraActivity( : false, }; } + +export function useCameraMotionNextTimestamp( + timeRangeSegmentEnd: number, + segmentDuration: number, + motionOnly: boolean, + reviewItems: ReviewSegment[], + motionData: MotionData[], + currentTime: number, +) { + const { alignStartDateToTimeline } = useTimelineUtils({ + segmentDuration, + }); + + const noMotionRanges = useMemo(() => { + if (!motionData || !reviewItems) { + return; + } + + if (!motionOnly) { + return []; + } + + const ranges = []; + let currentSegmentStart = null; + let currentSegmentEnd = null; + + // align motion start to timeline start + const offset = + (motionData[0].start_time - + alignStartDateToTimeline(timeRangeSegmentEnd)) % + segmentDuration; + + const startIndex = + offset > 0 ? Math.floor(offset / (segmentDuration / 15)) : 0; + + for ( + let i = startIndex; + i < motionData.length; + i = i + segmentDuration / 15 + ) { + const motionStart = motionData[i].start_time; + const motionEnd = motionStart + segmentDuration; + + const segmentMotion = motionData + .slice(i, i + segmentDuration / 15) + .some(({ motion }) => motion !== undefined && motion > 0); + const overlappingReviewItems = reviewItems.some( + (item) => + (item.start_time >= motionStart && item.start_time < motionEnd) || + (item.end_time > motionStart && item.end_time <= motionEnd) || + (item.start_time <= motionStart && item.end_time >= motionEnd), + ); + + if (!segmentMotion || overlappingReviewItems) { + if (currentSegmentStart === null) { + currentSegmentStart = motionStart; + } + currentSegmentEnd = motionEnd; + } else { + if (currentSegmentStart !== null) { + ranges.push([currentSegmentStart, currentSegmentEnd]); + currentSegmentStart = null; + currentSegmentEnd = null; + } + } + } + + if (currentSegmentStart !== null) { + ranges.push([currentSegmentStart, currentSegmentEnd]); + } + + return ranges; + }, [ + motionData, + reviewItems, + motionOnly, + alignStartDateToTimeline, + segmentDuration, + timeRangeSegmentEnd, + ]); + + const nextTimestamp = useMemo(() => { + if (!noMotionRanges) { + return; + } + + if (!motionOnly) { + return currentTime + 0.5; + } + + let currentRange = 0; + let nextTimestamp = currentTime + 0.5; + + while (currentRange < noMotionRanges.length) { + const [start, end] = noMotionRanges[currentRange]; + + if (start && end) { + // If the current time is before the start of the current range + if (currentTime < start) { + // The next timestamp is either the start of the current range or currentTime + 0.5, whichever is smaller + nextTimestamp = Math.min(start, nextTimestamp); + break; + } + // If the current time is within the current range + else if (currentTime >= start && currentTime < end) { + // The next timestamp is the end of the current range + nextTimestamp = end; + currentRange++; + } + // If the current time is past the end of the current range + else { + currentRange++; + } + } + } + + return nextTimestamp; + }, [currentTime, noMotionRanges, motionOnly]); + + return nextTimestamp; +} diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 49499900f..1457dd480 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -235,7 +235,7 @@ function useDraggableElement({ const elementEarliest = draggableElementEarliestTime ? timestampToPixels(draggableElementEarliestTime) : segmentHeight * (timelineDuration / segmentDuration) - - segmentHeight * 3; + segmentHeight * 3.5; // top of timeline - default 2 segments added for draggableElement visibility const elementLatest = draggableElementLatestTime diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index bec9828c3..62f47fb57 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -77,10 +77,12 @@ function generateRandomMotionAudioData(): MotionData[] { ) { const motion = Math.floor(Math.random() * 101); // Random number between 0 and 100 const audio = Math.random() * -100; // Random negative value between -100 and 0 + const camera = "test_camera"; data.push({ start_time: startTimestamp, motion, audio, + camera, }); } diff --git a/web/src/types/review.ts b/web/src/types/review.ts index e88174e14..ffd33b620 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -46,4 +46,5 @@ export type MotionData = { start_time: number; motion?: number; audio?: number; + camera: string; }; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 2dfb957d7..0f5561b92 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -40,6 +40,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { RecordingStartingPoint } from "@/types/record"; import VideoControls from "@/components/player/VideoControls"; import { TimeRange } from "@/types/timeline"; +import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -720,86 +721,14 @@ function MotionReview({ const [playbackRate, setPlaybackRate] = useState(8); const [controlsOpen, setControlsOpen] = useState(false); - const noMotionRanges = useMemo(() => { - if (!motionData || !reviewItems) { - return; - } - - if (!motionOnly) { - return []; - } - - const ranges = []; - let currentSegmentStart = null; - let currentSegmentEnd = null; - - for (let i = 0; i < motionData.length; i = i + segmentDuration / 15) { - const motionStart = motionData[i].start_time; - const motionEnd = motionStart + segmentDuration; - - const segmentMotion = motionData - .slice(i, i + segmentDuration / 15) - .some(({ motion }) => motion !== undefined && motion > 0); - const overlappingReviewItems = reviewItems.all.some( - (item) => - (item.start_time >= motionStart && item.start_time < motionEnd) || - (item.end_time > motionStart && item.end_time <= motionEnd) || - (item.start_time <= motionStart && item.end_time >= motionEnd), - ); - - if (!segmentMotion || overlappingReviewItems) { - if (currentSegmentStart === null) { - currentSegmentStart = motionStart; - } - currentSegmentEnd = motionEnd; - } else { - if (currentSegmentStart !== null) { - ranges.push([currentSegmentStart, currentSegmentEnd]); - currentSegmentStart = null; - currentSegmentEnd = null; - } - } - } - - if (currentSegmentStart !== null) { - ranges.push([currentSegmentStart, currentSegmentEnd]); - } - - return ranges; - }, [motionData, reviewItems, motionOnly]); - - const nextTimestamp = useMemo(() => { - if (!noMotionRanges) { - return; - } - let currentRange = 0; - let nextTimestamp = currentTime + 0.5; - - while (currentRange < noMotionRanges.length) { - const [start, end] = noMotionRanges[currentRange]; - - if (start && end) { - // If the current time is before the start of the current range - if (currentTime < start) { - // The next timestamp is either the start of the current range or currentTime + 0.5, whichever is smaller - nextTimestamp = Math.min(start, nextTimestamp); - break; - } - // If the current time is within the current range - else if (currentTime >= start && currentTime < end) { - // The next timestamp is the end of the current range - nextTimestamp = end; - currentRange++; - } - // If the current time is past the end of the current range - else { - currentRange++; - } - } - } - - return nextTimestamp; - }, [currentTime, noMotionRanges]); + const nextTimestamp = useCameraMotionNextTimestamp( + timeRangeSegments.end, + segmentDuration, + motionOnly, + reviewItems?.all ?? [], + motionData ?? [], + currentTime, + ); const timeoutIdRef = useRef(null); @@ -832,24 +761,42 @@ function MotionReview({ const getDetectionType = useCallback( (cameraName: string) => { if (motionOnly) { - return null; - } - const segmentStartTime = alignStartDateToTimeline(currentTime); - const segmentEndTime = segmentStartTime + segmentDuration; - const matchingItem = reviewItems?.all.find( - (item) => - ((item.start_time >= segmentStartTime && - item.start_time < segmentEndTime) || - (item.end_time > segmentStartTime && - item.end_time <= segmentEndTime) || - (item.start_time <= segmentStartTime && - item.end_time >= segmentEndTime)) && - item.camera === cameraName, - ); + const segmentStartTime = alignStartDateToTimeline(currentTime); + const segmentEndTime = segmentStartTime + segmentDuration; + const matchingItem = motionData?.find((item) => { + const cameras = item.camera.split(",").map((camera) => camera.trim()); + return ( + item.start_time >= segmentStartTime && + item.start_time < segmentEndTime && + cameras.includes(cameraName) + ); + }); - return matchingItem ? matchingItem.severity : null; + return matchingItem ? "significant_motion" : null; + } else { + const segmentStartTime = alignStartDateToTimeline(currentTime); + const segmentEndTime = segmentStartTime + segmentDuration; + const matchingItem = reviewItems?.all.find( + (item) => + ((item.start_time >= segmentStartTime && + item.start_time < segmentEndTime) || + (item.end_time > segmentStartTime && + item.end_time <= segmentEndTime) || + (item.start_time <= segmentStartTime && + item.end_time >= segmentEndTime)) && + item.camera === cameraName, + ); + + return matchingItem ? matchingItem.severity : null; + } }, - [reviewItems, currentTime, motionOnly, alignStartDateToTimeline], + [ + reviewItems, + motionData, + currentTime, + motionOnly, + alignStartDateToTimeline, + ], ); if (!relevantPreviews) { From 6dd6ca5de596ae6071f3c01ed97119e03fcd7af2 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 25 Mar 2024 14:56:13 -0600 Subject: [PATCH 294/751] Only allow visible cameras to go live on dashboard (#10671) * Only show live cameras that are currently visible * Add back black background * fix --- web/src/components/player/LivePlayer.tsx | 17 ++++--- web/src/components/player/PreviewPlayer.tsx | 6 +-- web/src/views/live/LiveDashboardView.tsx | 54 ++++++++++++++++++++- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 8be3f71b9..a8fe5d3b1 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -10,10 +10,10 @@ import { useCameraActivity } from "@/hooks/use-camera-activity"; import { useRecordingsState } from "@/api/ws"; import { LivePlayerMode } from "@/types/live"; import useCameraLiveMode from "@/hooks/use-camera-live-mode"; -import { isDesktop } from "react-device-detect"; import CameraActivityIndicator from "../indicators/CameraActivityIndicator"; type LivePlayerProps = { + cameraRef?: (ref: HTMLDivElement | null) => void; className?: string; cameraConfig: CameraConfig; preferredLiveMode?: LivePlayerMode; @@ -26,6 +26,7 @@ type LivePlayerProps = { }; export default function LivePlayer({ + cameraRef = undefined, className, cameraConfig, preferredLiveMode, @@ -140,6 +141,8 @@ export default function LivePlayer({ return (
- {isDesktop && ( -
- {recording == "ON" && ( - - )} -
- )} +
+ {recording == "ON" && ( + + )} +
); } diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 197986677..76a16fe9d 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -234,7 +234,7 @@ function PreviewVideoPlayer({ return (
{currentHourFrame && ( @@ -247,7 +247,7 @@ function PreviewVideoPlayer({ ref={canvasRef} width={videoWidth} height={videoHeight} - className={`absolute h-full left-1/2 -translate-x-1/2 bg-black ${!loaded && hasCanvas ? "" : "hidden"}`} + className={`absolute h-full left-1/2 -translate-x-1/2 ${!loaded && hasCanvas ? "" : "hidden"}`} />
+
No Preview Found
)} diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index d5a7401c5..649e102b5 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -11,7 +11,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { usePersistence } from "@/hooks/use-persistence"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment } from "@/types/review"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobile, isSafari } from "react-device-detect"; import useSWR from "swr"; @@ -79,6 +79,53 @@ export default function LiveDashboardView({ }; }, [visibilityListener]); + const [visibleCameras, setVisibleCameras] = useState([]); + const visibleCameraObserver = useRef(null); + useEffect(() => { + const visibleCameras = new Set(); + visibleCameraObserver.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const camera = (entry.target as HTMLElement).dataset.camera; + + if (!camera) { + return; + } + + if (entry.isIntersecting) { + visibleCameras.add(camera); + } else { + visibleCameras.delete(camera); + } + + setVisibleCameras([...visibleCameras]); + }); + }, + { threshold: 0.5 }, + ); + + return () => { + visibleCameraObserver.current?.disconnect(); + }; + }, []); + + const cameraRef = useCallback( + (node: HTMLElement | null) => { + if (!visibleCameraObserver.current) { + return; + } + + try { + if (node) visibleCameraObserver.current.observe(node); + } catch (e) { + // no op + } + }, + // we need to listen on the value of the ref + // eslint-disable-next-line react-hooks/exhaustive-deps + [visibleCameraObserver.current], + ); + const birdseyeConfig = useMemo(() => config?.birdseye, [config]); return ( @@ -149,9 +196,12 @@ export default function LiveDashboardView({ } return ( onSelectCamera(camera.name)} From 71c7504de535e55925f0ba5d06f8312d343557d8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 25 Mar 2024 15:56:20 -0500 Subject: [PATCH 295/751] fix timeline upward scrolling limit (#10673) --- web/src/hooks/use-draggable-element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 1457dd480..11242cdf4 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -240,7 +240,7 @@ function useDraggableElement({ // top of timeline - default 2 segments added for draggableElement visibility const elementLatest = draggableElementLatestTime ? timestampToPixels(draggableElementLatestTime) - : segmentHeight * 2 + scrolled; + : segmentHeight * 1.5; const timelineRect = timelineRef.current.getBoundingClientRect(); const timelineTopAbsolute = timelineRect.top; From 7b7d3c56ccf8aa3a521ea0826898ab676fb80eff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Mar 2024 18:08:31 -0600 Subject: [PATCH 296/751] Bump pytz from 2023.3.post1 to 2024.1 in /docker/main (#9592) --- docker/main/requirements-wheels.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 8466d9f13..69da2a0e3 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -15,7 +15,7 @@ psutil == 5.9.* pydantic == 2.6.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml PyYAML == 6.0.* -pytz == 2023.3.post1 +pytz == 2024.1 pyzmq == 25.1.* ruamel.yaml == 0.18.* tzlocal == 5.2 From 30d93e1d90aa9368a9f345dfe3f2b0cc4adf45c7 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 25 Mar 2024 20:25:06 -0600 Subject: [PATCH 297/751] Improve refreshing of review items (#10675) --- web/src/components/dynamic/NewReviewData.tsx | 39 ++++++-------------- web/src/pages/Events.tsx | 27 ++++++++++---- web/src/views/events/EventView.tsx | 10 +---- 3 files changed, 32 insertions(+), 44 deletions(-) diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index b7b096115..0e021f634 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -1,45 +1,29 @@ -import { useFrigateReviews } from "@/api/ws"; -import { ReviewSeverity } from "@/types/review"; +import { ReviewSegment } from "@/types/review"; import { Button } from "../ui/button"; import { LuRefreshCcw } from "react-icons/lu"; -import { MutableRefObject, useEffect, useMemo, useState } from "react"; +import { MutableRefObject, useMemo } from "react"; type NewReviewDataProps = { className: string; contentRef: MutableRefObject; - severity: ReviewSeverity; - hasUpdate: boolean; - setHasUpdate: (update: boolean) => void; + reviewItems?: ReviewSegment[] | null; + itemsToReview?: number; pullLatestData: () => void; }; export default function NewReviewData({ className, contentRef, - severity, - hasUpdate, - setHasUpdate, + reviewItems, + itemsToReview, pullLatestData, }: NewReviewDataProps) { - const { payload: review } = useFrigateReviews(); - - const startCheckTs = useMemo(() => Date.now() / 1000, []); - const [reviewTs, setReviewTs] = useState(startCheckTs); - - useEffect(() => { - if (!review) { - return; + const hasUpdate = useMemo(() => { + if (!reviewItems || !itemsToReview) { + return false; } - if (review.type == "end" && review.review.severity == severity) { - setReviewTs(review.review.start_time); - } - }, [review, severity]); - - useEffect(() => { - if (reviewTs > startCheckTs) { - setHasUpdate(true); - } - }, [startCheckTs, reviewTs, setHasUpdate]); + return reviewItems.length != itemsToReview; + }, [reviewItems, itemsToReview]); return (
@@ -52,7 +36,6 @@ export default function NewReviewData({ } text-center mt-5 mx-auto bg-gray-400 text-white`} variant="secondary" onClick={() => { - setHasUpdate(false); pullLatestData(); if (contentRef.current) { contentRef.current.scrollTo({ diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 0ddecdfea..53f74a5e3 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -18,7 +18,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import useSWR from "swr"; export default function Events() { - const { data: config } = useSWR("config"); + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); const timezone = useTimezone(config); // recordings viewer @@ -66,6 +68,9 @@ export default function Events() { }; }, [last24Hours, reviewSearchParams]); + // we want to update the items whenever the severity changes + useEffect(() => setBeforeTs(Date.now() / 1000), [severity]); + const reviewSegmentFetcher = useCallback((key: Array | string) => { const [path, params] = Array.isArray(key) ? key : [key, undefined]; return axios.get(path, { params }).then((res) => res.data); @@ -93,15 +98,21 @@ export default function Events() { // review summary - const { data: reviewSummary, mutate: updateSummary } = useSWR([ - "review/summary", + const { data: reviewSummary, mutate: updateSummary } = useSWR( + [ + "review/summary", + { + timezone: timezone, + cameras: reviewSearchParams["cameras"] ?? null, + labels: reviewSearchParams["labels"] ?? null, + }, + ], { - timezone: timezone, - cameras: reviewSearchParams["cameras"] ?? null, - labels: reviewSearchParams["labels"] ?? null, + revalidateOnFocus: true, + refreshInterval: 30000, + revalidateOnReconnect: false, }, - { revalidateOnFocus: false, revalidateOnReconnect: false }, - ]); + ); const reloadData = useCallback(() => { setBeforeTs(Date.now() / 1000); diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 0f5561b92..c0f532a17 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -382,10 +382,6 @@ function DetectionReview({ [previewTime, setPreviewTime], ); - // review interaction - - const [hasUpdate, setHasUpdate] = useState(false); - // timeline interaction const timelineDuration = useMemo( @@ -498,9 +494,8 @@ function DetectionReview({ )} @@ -555,7 +550,6 @@ function DetectionReview({ className="text-white" variant="select" onClick={() => { - setHasUpdate(false); markAllItemsAsReviewed(currentItems ?? []); }} > From bc6b4c38b892ab40a5fb76d09b74448da9afb39a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 25 Mar 2024 21:29:42 -0500 Subject: [PATCH 298/751] improve timeline scrolling with dynamic speed (#10677) --- web/src/hooks/use-draggable-element.ts | 47 ++++++++++++++++++-------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 11242cdf4..493252714 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -42,6 +42,7 @@ function useDraggableElement({ }: DraggableElementProps) { const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); + const [scrollEdgeSize, setScrollEdgeSize] = useState(); const [segments, setSegments] = useState([]); const { alignStartDateToTimeline, getCumulativeScrollTop } = useTimelineUtils( { @@ -52,23 +53,24 @@ function useDraggableElement({ ); const draggingAtTopEdge = useMemo(() => { - if (clientYPosition && timelineRef.current) { + if (clientYPosition && timelineRef.current && scrollEdgeSize) { return ( - clientYPosition - timelineRef.current.offsetTop < - timelineRef.current.clientHeight * 0.03 && isDragging + clientYPosition - timelineRef.current.offsetTop < scrollEdgeSize && + isDragging ); } - }, [clientYPosition, timelineRef, isDragging]); + }, [clientYPosition, timelineRef, isDragging, scrollEdgeSize]); const draggingAtBottomEdge = useMemo(() => { - if (clientYPosition && timelineRef.current) { + if (clientYPosition && timelineRef.current && scrollEdgeSize) { return ( clientYPosition > - (timelineRef.current.clientHeight + timelineRef.current.offsetTop) * - 0.97 && isDragging + timelineRef.current.clientHeight + + timelineRef.current.offsetTop - + scrollEdgeSize && isDragging ); } - }, [clientYPosition, timelineRef, isDragging]); + }, [clientYPosition, timelineRef, isDragging, scrollEdgeSize]); const getClientYPosition = useCallback( (e: MouseEvent | TouchEvent) => { @@ -290,17 +292,26 @@ function useDraggableElement({ } }); - if (draggingAtTopEdge || draggingAtBottomEdge) { - let newPosition = clientYPosition; - + if ((draggingAtTopEdge || draggingAtBottomEdge) && scrollEdgeSize) { if (draggingAtTopEdge) { - newPosition = scrolled - segmentHeight; - timelineRef.current.scrollTop = newPosition; + const intensity = Math.max( + 0, + (scrollEdgeSize - (clientYPosition - timelineTopAbsolute)) / + scrollEdgeSize, + ); + timelineRef.current.scrollTop -= segmentHeight * intensity; } if (draggingAtBottomEdge) { - newPosition = scrolled + segmentHeight; - timelineRef.current.scrollTop = newPosition; + const intensity = Math.max( + 0, + (clientYPosition - + timelineTopAbsolute - + (timelineRef.current.getBoundingClientRect().height - + scrollEdgeSize)) / + scrollEdgeSize, + ); + timelineRef.current.scrollTop += segmentHeight * intensity; } } @@ -436,6 +447,12 @@ function useDraggableElement({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [timelineCollapsed]); + useEffect(() => { + if (timelineRef.current) { + setScrollEdgeSize(timelineRef.current.clientHeight * 0.03); + } + }, [timelineRef]); + return { handleMouseDown, handleMouseUp, handleMouseMove }; } From 6fbd272acfc230fab710a9f7010abafb41fa337b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 26 Mar 2024 08:07:49 -0500 Subject: [PATCH 299/751] Add tooltips for review item chips (#10685) * tooltips for review item chips * use flexbox instead of margins --- .../player/PreviewThumbnailPlayer.tsx | 73 +++++++++++++------ 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 52b3e7b53..12e4bd706 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -19,6 +19,7 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useImageLoaded from "@/hooks/use-image-loaded"; import { Skeleton } from "../ui/skeleton"; import { useSwipeable } from "react-swipeable"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; type PreviewPlayerProps = { review: ReviewSegment; @@ -121,7 +122,11 @@ export default function PreviewThumbnailPlayer({ const [hoverTimeout, setHoverTimeout] = useState(); const [playback, setPlayback] = useState(false); - const playingBack = useMemo(() => playback, [playback]); + const [tooltipHovering, setTooltipHovering] = useState(false); + const playingBack = useMemo( + () => playback && !tooltipHovering, + [playback, tooltipHovering], + ); const [isHovered, setIsHovered] = useState(false); useEffect(() => { @@ -129,7 +134,7 @@ export default function PreviewThumbnailPlayer({ return; } - if (isHovered) { + if (isHovered && !tooltipHovering) { setHoverTimeout( setTimeout(() => { setPlayback(true); @@ -149,7 +154,7 @@ export default function PreviewThumbnailPlayer({ } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isHovered, scrollLock]); + }, [isHovered, scrollLock, tooltipHovering]); // date @@ -196,28 +201,50 @@ export default function PreviewThumbnailPlayer({ }} /> +
+ +
setTooltipHovering(true)} + onMouseLeave={() => setTooltipHovering(false)} + > + +
+ {(review.severity == "alert" || + review.severity == "detection") && ( + <> + + {review.data.objects.map((object) => { + return getIconForLabel(object, "size-3 text-white"); + })} + {review.data.audio.map((audio) => { + return getIconForLabel(audio, "size-3 text-white"); + })} + {review.data.sub_labels?.map((sub) => { + return getIconForSubLabel(sub, "size-3 text-white"); + })} + + + )} +
+
+
+ + {[ + ...(review.data.objects || []), + ...(review.data.audio || []), + ...(review.data.sub_labels || []), + ] + .filter((item) => item !== undefined) + .join(", ")} + +
+
{!playingBack && ( <> -
-
- {(review.severity == "alert" || - review.severity == "detection") && ( - - {review.data.objects.map((object) => { - return getIconForLabel(object, "size-3 text-white"); - })} - {review.data.audio.map((audio) => { - return getIconForLabel(audio, "size-3 text-white"); - })} - {review.data.sub_labels?.map((sub) => { - return getIconForSubLabel(sub, "size-3 text-white"); - })} - - )} -
-
+
From 1cd374d3ad42348d665376332ffd42694dc728df Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 26 Mar 2024 11:29:07 -0500 Subject: [PATCH 300/751] add option to only scrollintoview initially (#10689) --- web/src/components/timeline/MotionReviewTimeline.tsx | 3 +++ web/src/hooks/use-draggable-element.ts | 10 +++++++++- web/src/views/events/RecordingView.tsx | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 6560e84ae..7d2d5371f 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -21,6 +21,7 @@ export type MotionReviewTimelineProps = { showHandlebar?: boolean; handlebarTime?: number; setHandlebarTime?: React.Dispatch>; + onlyInitialHandlebarScroll?: boolean; motionOnly?: boolean; showMinimap?: boolean; minimapStartTime?: number; @@ -46,6 +47,7 @@ export function MotionReviewTimeline({ showHandlebar = false, handlebarTime, setHandlebarTime, + onlyInitialHandlebarScroll = false, motionOnly = false, showMinimap = false, minimapStartTime, @@ -114,6 +116,7 @@ export function MotionReviewTimeline({ showDraggableElement: showHandlebar, draggableElementTime: handlebarTime, setDraggableElementTime: setHandlebarTime, + initialScrollIntoViewOnly: onlyInitialHandlebarScroll, timelineDuration, timelineCollapsed: motionOnly, timelineStartAligned, diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 493252714..f68ea5ebe 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -13,6 +13,7 @@ type DraggableElementProps = { draggableElementEarliestTime?: number; draggableElementLatestTime?: number; setDraggableElementTime?: React.Dispatch>; + initialScrollIntoViewOnly?: boolean; draggableElementTimeRef: React.MutableRefObject; timelineDuration: number; timelineCollapsed?: boolean; @@ -32,6 +33,7 @@ function useDraggableElement({ draggableElementEarliestTime, draggableElementLatestTime, setDraggableElementTime, + initialScrollIntoViewOnly, draggableElementTimeRef, timelineDuration, timelineCollapsed, @@ -42,6 +44,7 @@ function useDraggableElement({ }: DraggableElementProps) { const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); + const [elementScrollIntoView, setElementScrollIntoView] = useState(true); const [scrollEdgeSize, setScrollEdgeSize] = useState(); const [segments, setSegments] = useState([]); const { alignStartDateToTimeline, getCumulativeScrollTop } = useTimelineUtils( @@ -397,9 +400,13 @@ function useDraggableElement({ updateDraggableElementPosition( newElementPosition, draggableElementTime, - true, + elementScrollIntoView, true, ); + + if (initialScrollIntoViewOnly) { + setElementScrollIntoView(false); + } } } // we know that these deps are correct @@ -413,6 +420,7 @@ function useDraggableElement({ timelineStartAligned, timelineRef, timelineCollapsed, + initialScrollIntoViewOnly, segments, ]); diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index f1a414be0..e9981c1be 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -344,6 +344,7 @@ export function RecordingView({ showHandlebar handlebarTime={currentTime} setHandlebarTime={setCurrentTime} + onlyInitialHandlebarScroll={true} events={mainCameraReviewItems} motion_events={motionData ?? []} severityType="significant_motion" From 1377d33e2519744e72e4ae4d1ce87e68292b3730 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 26 Mar 2024 15:03:58 -0600 Subject: [PATCH 301/751] Redesign Recordings View (#10690) * Use full width top bar * Make each item in review filter group optional * Remove export creation from export page * Consolidate packages and fix opening recording from event * Use common type for time range * Move timeline to separate component * Add events list view to recordings view * Fix loading of images * Fix incorrect labels * use overlay state for selected timeline type * Fix up for mobile view for now * replace overlay state * fix comparison * remove unused --- frigate/review/maintainer.py | 9 +- .../AnimatedEventCard.tsx} | 15 +- web/src/components/card/ReviewCard.tsx | 73 ++++++ web/src/components/dynamic/NewReviewData.tsx | 2 +- web/src/components/dynamic/TimeAgo.tsx | 5 +- .../components/filter/ReviewFilterGroup.tsx | 57 ++-- .../indicators/ImageLoadingIndicator.tsx | 20 ++ web/src/components/player/PreviewPlayer.tsx | 25 +- .../player/PreviewThumbnailPlayer.tsx | 21 +- .../player/dynamic/DynamicVideoPlayer.tsx | 12 +- web/src/pages/Export.tsx | 220 +--------------- web/src/types/playback.ts | 3 +- web/src/utils/timelineUtil.tsx | 14 +- web/src/views/events/EventView.tsx | 16 +- web/src/views/events/RecordingView.tsx | 245 +++++++++++++----- web/src/views/live/LiveDashboardView.tsx | 4 +- 16 files changed, 378 insertions(+), 363 deletions(-) rename web/src/components/{image/AnimatedEventThumbnail.tsx => card/AnimatedEventCard.tsx} (87%) create mode 100644 web/src/components/card/ReviewCard.tsx create mode 100644 web/src/components/indicators/ImageLoadingIndicator.tsx diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 8da44708f..9728d19dd 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -265,7 +265,14 @@ class ReviewSegmentMaintainer(threading.Thread): self.frame_manager.close(frame_id) elif len(motion) >= 20: self.active_review_segments[camera] = PendingReviewSegment( - camera, frame_time, SeverityEnum.signification_motion, motion=motion + camera, + frame_time, + SeverityEnum.signification_motion, + detections=set(), + objects=set(), + sub_labels=set(), + motion=motion, + zones=set(), ) def run(self) -> None: diff --git a/web/src/components/image/AnimatedEventThumbnail.tsx b/web/src/components/card/AnimatedEventCard.tsx similarity index 87% rename from web/src/components/image/AnimatedEventThumbnail.tsx rename to web/src/components/card/AnimatedEventCard.tsx index dad85116a..2944e6c33 100644 --- a/web/src/components/image/AnimatedEventThumbnail.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -8,11 +8,12 @@ import { ReviewSegment } from "@/types/review"; import { useNavigate } from "react-router-dom"; import { Skeleton } from "../ui/skeleton"; import { RecordingStartingPoint } from "@/types/record"; +import axios from "axios"; -type AnimatedEventThumbnailProps = { +type AnimatedEventCardProps = { event: ReviewSegment; }; -export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { +export function AnimatedEventCard({ event }: AnimatedEventCardProps) { const { data: config } = useSWR("config"); // interaction @@ -21,11 +22,15 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { const onOpenReview = useCallback(() => { navigate("events", { state: { - camera: event.camera, - startTime: event.start_time, severity: event.severity, - } as RecordingStartingPoint, + recording: { + camera: event.camera, + startTime: event.start_time, + severity: event.severity, + } as RecordingStartingPoint, + }, }); + axios.post(`reviews/viewed`, { ids: [event.id] }); }, [navigate, event]); // image behavior diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx new file mode 100644 index 000000000..5c579e4d4 --- /dev/null +++ b/web/src/components/card/ReviewCard.tsx @@ -0,0 +1,73 @@ +import { baseUrl } from "@/api/baseUrl"; +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { ReviewSegment } from "@/types/review"; +import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil"; +import { isSafari } from "react-device-detect"; +import useSWR from "swr"; +import TimeAgo from "../dynamic/TimeAgo"; +import { useMemo } from "react"; +import useImageLoaded from "@/hooks/use-image-loaded"; +import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; + +type ReviewCardProps = { + event: ReviewSegment; + currentTime: number; + onClick?: () => void; +}; +export default function ReviewCard({ + event, + currentTime, + onClick, +}: ReviewCardProps) { + const { data: config } = useSWR("config"); + const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); + const formattedDate = useFormattedTimestamp( + event.start_time, + config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p", + ); + const isSelected = useMemo( + () => event.start_time <= currentTime && event.end_time >= currentTime, + [event, currentTime], + ); + + return ( +
+ + { + onImgLoad(); + }} + /> +
+
+ {event.data.objects.map((object) => { + return getIconForLabel(object, "size-3 text-white"); + })} + {event.data.audio.map((audio) => { + return getIconForLabel(audio, "size-3 text-white"); + })} + {event.data.sub_labels?.map((sub) => { + return getIconForSubLabel(sub, "size-3 text-white"); + })} +
{formattedDate}
+
+ +
+
+ ); +} diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index 0e021f634..f690b9101 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -22,7 +22,7 @@ export default function NewReviewData({ return false; } - return reviewItems.length != itemsToReview; + return reviewItems.length < itemsToReview; }, [reviewItems, itemsToReview]); return ( diff --git a/web/src/components/dynamic/TimeAgo.tsx b/web/src/components/dynamic/TimeAgo.tsx index a9993db8a..13892180e 100644 --- a/web/src/components/dynamic/TimeAgo.tsx +++ b/web/src/components/dynamic/TimeAgo.tsx @@ -1,6 +1,8 @@ import { FunctionComponent, useEffect, useMemo, useState } from "react"; interface IProp { + /** OPTIONAL: classname */ + className?: string; /** The time to calculate time-ago from */ time: number; /** OPTIONAL: overwrite current time */ @@ -73,6 +75,7 @@ const timeAgo = ({ }; const TimeAgo: FunctionComponent = ({ + className, time, manualRefreshInterval, ...rest @@ -105,6 +108,6 @@ const TimeAgo: FunctionComponent = ({ [currentTime, rest, time], ); - return {timeAgoValue}; + return {timeAgoValue}; }; export default TimeAgo; diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index d4fa7a0f0..4ebad426f 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, ReviewSeverity, ReviewSummary } from "@/types/review"; +import { ReviewFilter, ReviewSummary } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { FaCalendarAlt, FaFilter, FaRunning, FaVideo } from "react-icons/fa"; @@ -22,21 +22,29 @@ import FilterCheckBox from "./FilterCheckBox"; import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar"; const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; +const REVIEW_FILTERS = ["cameras", "date", "general", "motionOnly"] as const; +type ReviewFilters = (typeof REVIEW_FILTERS)[number]; +const DEFAULT_REVIEW_FILTERS: ReviewFilters[] = [ + "cameras", + "date", + "general", + "motionOnly", +]; type ReviewFilterGroupProps = { + filters?: ReviewFilters[]; reviewSummary?: ReviewSummary; filter?: ReviewFilter; onUpdateFilter: (filter: ReviewFilter) => void; - severity: ReviewSeverity; motionOnly: boolean; setMotionOnly: React.Dispatch>; }; export default function ReviewFilterGroup({ + filters = DEFAULT_REVIEW_FILTERS, reviewSummary, filter, onUpdateFilter, - severity, motionOnly, setMotionOnly, }: ReviewFilterGroupProps) { @@ -101,27 +109,34 @@ export default function ReviewFilterGroup({ return (
- { - onUpdateFilter({ ...filter, cameras: newCameras }); - }} - /> - - {severity == "significant_motion" ? ( + {filters.includes("cameras") && ( + { + onUpdateFilter({ ...filter, cameras: newCameras }); + }} + /> + )} + {filters.includes("date") && ( + + )} + {filters.includes("motionOnly") && ( - ) : ( + )} + {filters.includes("general") && ( void; }; -export function CalendarFilterButton({ +function CalendarFilterButton({ reviewSummary, day, updateSelectedDay, diff --git a/web/src/components/indicators/ImageLoadingIndicator.tsx b/web/src/components/indicators/ImageLoadingIndicator.tsx new file mode 100644 index 000000000..32531ea2b --- /dev/null +++ b/web/src/components/indicators/ImageLoadingIndicator.tsx @@ -0,0 +1,20 @@ +import { isSafari } from "react-device-detect"; +import { Skeleton } from "../ui/skeleton"; + +export default function ImageLoadingIndicator({ + className, + imgLoaded, +}: { + className?: string; + imgLoaded: boolean; +}) { + if (imgLoaded) { + return; + } + + return isSafari ? ( +
+ ) : ( + + ); +} diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 76a16fe9d..ff7951434 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -14,11 +14,12 @@ import { isCurrentHour } from "@/utils/dateUtil"; import { baseUrl } from "@/api/baseUrl"; import { isAndroid, isChrome, isMobile, isSafari } from "react-device-detect"; import { Skeleton } from "../ui/skeleton"; +import { TimeRange } from "@/types/timeline"; type PreviewPlayerProps = { className?: string; camera: string; - timeRange: { start: number; end: number }; + timeRange: TimeRange; cameraPreviews: Preview[]; startTime?: number; isScrubbing: boolean; @@ -37,7 +38,7 @@ export default function PreviewPlayer({ }: PreviewPlayerProps) { const [currentHourFrame, setCurrentHourFrame] = useState(); - if (isCurrentHour(timeRange.end)) { + if (isCurrentHour(timeRange.before)) { return ( preview.camera == camera && - Math.round(preview.start) >= timeRange.start && - Math.floor(preview.end) <= timeRange.end, + Math.round(preview.start) >= timeRange.after && + Math.floor(preview.end) <= timeRange.before, ); // we only want to calculate this once @@ -179,8 +180,8 @@ function PreviewVideoPlayer({ const preview = cameraPreviews.find( (preview) => preview.camera == camera && - Math.round(preview.start) >= timeRange.start && - Math.floor(preview.end) <= timeRange.end, + Math.round(preview.start) >= timeRange.after && + Math.floor(preview.end) <= timeRange.before, ); if (preview != currentPreview) { @@ -292,7 +293,7 @@ function PreviewVideoPlayer({ class PreviewVideoController extends PreviewController { // main state private previewRef: MutableRefObject; - private timeRange: { start: number; end: number } | undefined = undefined; + private timeRange: TimeRange | undefined = undefined; // preview private preview: Preview | undefined = undefined; @@ -377,7 +378,7 @@ class PreviewVideoController extends PreviewController { type PreviewFramesPlayerProps = { className?: string; camera: string; - timeRange: { start: number; end: number }; + timeRange: TimeRange; startTime?: number; onControllerReady: (controller: PreviewController) => void; onClick?: () => void; @@ -395,8 +396,8 @@ function PreviewFramesPlayer({ // frames data const { data: previewFrames } = useSWR( - `preview/${camera}/start/${Math.floor(timeRange.start)}/end/${Math.ceil( - timeRange.end, + `preview/${camera}/start/${Math.floor(timeRange.after)}/end/${Math.ceil( + timeRange.before, )}/frames`, { revalidateOnFocus: false }, ); @@ -457,7 +458,7 @@ function PreviewFramesPlayer({ } if (!startTime) { - controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.start); + controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.after); } else { controller.scrubToTimestamp(startTime); } diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 12e4bd706..95b6bd6b2 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -17,9 +17,9 @@ import { isFirefox, isMobile, isSafari } from "react-device-detect"; import Chip from "@/components/indicators/Chip"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useImageLoaded from "@/hooks/use-image-loaded"; -import { Skeleton } from "../ui/skeleton"; import { useSwipeable } from "react-swipeable"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; type PreviewPlayerProps = { review: ReviewSegment; @@ -187,11 +187,14 @@ export default function PreviewThumbnailPlayer({ />
)} - +
); } - -function PreviewPlaceholder({ imgLoaded }: { imgLoaded: boolean }) { - if (imgLoaded) { - return; - } - - return isSafari ? ( -
- ) : ( - - ); -} diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 1421954cd..3db528165 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -8,7 +8,7 @@ import { Preview } from "@/types/preview"; import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; import { DynamicVideoController } from "./DynamicVideoController"; import HlsVideoPlayer from "../HlsVideoPlayer"; -import { Timeline } from "@/types/timeline"; +import { TimeRange, Timeline } from "@/types/timeline"; /** * Dynamically switches between video playback and scrubbing preview player. @@ -16,7 +16,7 @@ import { Timeline } from "@/types/timeline"; type DynamicVideoPlayerProps = { className?: string; camera: string; - timeRange: { start: number; end: number }; + timeRange: TimeRange; cameraPreviews: Preview[]; startTimestamp?: number; isScrubbing: boolean; @@ -100,7 +100,7 @@ export default function DynamicVideoPlayer({ const [isLoading, setIsLoading] = useState(false); const [source, setSource] = useState( - `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, + `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, ); // start at correct time @@ -134,8 +134,8 @@ export default function DynamicVideoPlayer({ const recordingParams = useMemo(() => { return { - before: timeRange.end, - after: timeRange.start, + before: timeRange.before, + after: timeRange.after, }; }, [timeRange]); const { data: recordings } = useSWR( @@ -153,7 +153,7 @@ export default function DynamicVideoPlayer({ } setSource( - `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, + `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, ); setIsLoading(true); diff --git a/web/src/pages/Export.tsx b/web/src/pages/Export.tsx index 82b982cb7..30148ba57 100644 --- a/web/src/pages/Export.tsx +++ b/web/src/pages/Export.tsx @@ -10,27 +10,9 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; -import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; -import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; -import { - DropdownMenuRadioGroup, - DropdownMenuTrigger, - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuRadioItem, -} from "@/components/ui/dropdown-menu"; import { Toaster } from "@/components/ui/sonner"; -import { FrigateConfig } from "@/types/frigateConfig"; import axios from "axios"; -import { format } from "date-fns"; -import { useCallback, useEffect, useState } from "react"; -import { DateRange } from "react-day-picker"; -import { isDesktop } from "react-device-detect"; -import { useLocation } from "react-router-dom"; -import { toast } from "sonner"; +import { useCallback, useState } from "react"; import useSWR from "swr"; type ExportItem = { @@ -38,96 +20,13 @@ type ExportItem = { }; function Export() { - const { data: config } = useSWR("config"); const { data: exports, mutate } = useSWR( "exports/", (url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data), ); - const location = useLocation(); - const [dialogOpen, setDialogOpen] = useState(false); - - // Export States - const [camera, setCamera] = useState(); - const [playback, setPlayback] = useState(); - - const currentDate = new Date(); - currentDate.setHours(0, 0, 0, 0); - - const [date, setDate] = useState({ - from: currentDate, - }); - const [startTime, setStartTime] = useState("00:00:00"); - const [endTime, setEndTime] = useState("23:59:59"); const [deleteClip, setDeleteClip] = useState(); - const onHandleExport = () => { - if (!camera) { - toast.error("A camera needs to be selected.", { position: "top-center" }); - return; - } - - if (!playback) { - toast.error("A playback factor needs to be selected.", { - position: "top-center", - }); - return; - } - - if (!date?.from || !startTime || !endTime) { - toast.error("A start and end time needs to be selected", { - position: "top-center", - }); - return; - } - - const startDate = new Date(date.from.getTime()); - const [startHour, startMin, startSec] = startTime.split(":"); - startDate.setHours( - parseInt(startHour), - parseInt(startMin), - parseInt(startSec), - 0, - ); - const start = startDate.getTime() / 1000; - const endDate = new Date((date.to || date.from).getTime()); - const [endHour, endMin, endSec] = endTime.split(":"); - endDate.setHours(parseInt(endHour), parseInt(endMin), parseInt(endSec), 0); - const end = endDate.getTime() / 1000; - - if (end <= start) { - toast.error("The end time must be after the start time.", { - position: "top-center", - }); - return; - } - - axios - .post(`export/${camera}/start/${start}/end/${end}`, { playback }) - .then((response) => { - if (response.status == 200) { - toast.success( - "Successfully started export. View the file in the /exports folder.", - { position: "top-center" }, - ); - } - - mutate(); - }) - .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", - }); - } - }); - }; - const onHandleDelete = useCallback(() => { if (!deleteClip) { return; @@ -141,27 +40,6 @@ function Export() { }); }, [deleteClip, mutate]); - const Create = isDesktop ? Dialog : Drawer; - const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; - const Content = isDesktop ? DialogContent : DrawerContent; - - useEffect(() => { - if (location.state && location.state.start && location.state.end) { - const startTimeString = format( - new Date(location.state.start * 1000), - "HH:mm:ss", - ); - const endTimeString = format( - new Date(location.state.end * 1000), - "HH:mm:ss", - ); - setStartTime(startTimeString); - setEndTime(endTimeString); - - setDialogOpen(true); - } - }, [location.state]); - return (
@@ -186,102 +64,6 @@ function Export() { -
- - - - - -
- - - - - - - Select Camera - - - - {Object.keys(config?.cameras || {}).map((item) => ( - - {item.replaceAll("_", " ")} - - ))} - - - - - - - - - - Select Playback - - - - - Realtime - - - Timelapse - - - - -
- -
- setStartTime(e.target.value)} - /> - setEndTime(e.target.value)} - /> -
-
- {`${ - date?.from ? format(date?.from, "LLL dd, y") : "" - } ${startTime} -> ${ - date?.to ? format(date?.to, "LLL dd, y") : "" - } ${endTime}`} - -
-
-
-
-
{exports && (
diff --git a/web/src/types/playback.ts b/web/src/types/playback.ts index c47158f39..6c4202304 100644 --- a/web/src/types/playback.ts +++ b/web/src/types/playback.ts @@ -1,5 +1,6 @@ import { Preview } from "./preview"; import { Recording } from "./record"; +import { TimeRange } from "./timeline"; export type DynamicPlayback = { recordings: Recording[]; @@ -7,5 +8,5 @@ export type DynamicPlayback = { export type PreviewPlayback = { preview: Preview | undefined; - timeRange: { end: number; start: number }; + timeRange: TimeRange; }; diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index 5bc19f03d..21ad6be9a 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -21,7 +21,7 @@ import { } from "react-icons/md"; import { FaBicycle } from "react-icons/fa"; import { endOfHourOrCurrentTime } from "./dateUtil"; -import { Timeline } from "@/types/timeline"; +import { TimeRange, Timeline } from "@/types/timeline"; export function getTimelineIcon(timelineItem: Timeline) { switch (timelineItem.class_type) { @@ -124,7 +124,7 @@ export function getTimelineItemDescription(timelineItem: Timeline) { export function getChunkedTimeDay(timestamp: number) { const endOfThisHour = new Date(); endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0); - const data: { start: number; end: number }[] = []; + const data: TimeRange[] = []; const startDay = new Date(timestamp * 1000); startDay.setHours(0, 0, 0, 0); const startTimestamp = startDay.getTime() / 1000; @@ -140,8 +140,8 @@ export function getChunkedTimeDay(timestamp: number) { end = endOfHourOrCurrentTime(startDay.getTime() / 1000); data.push({ - start, - end, + after: start, + before: end, }); start = startDay.getTime() / 1000; } @@ -155,7 +155,7 @@ export function getChunkedTimeRange( ) { const endOfThisHour = new Date(); endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0); - const data: { start: number; end: number }[] = []; + const data: TimeRange[] = []; const startDay = new Date(startTimestamp * 1000); startDay.setMinutes(0, 0, 0); let start = startDay.getTime() / 1000; @@ -170,8 +170,8 @@ export function getChunkedTimeRange( end = endOfHourOrCurrentTime(startDay.getTime() / 1000); data.push({ - start, - end, + after: start, + before: end, }); start = startDay.getTime() / 1000; } diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index c0f532a17..8c752b46a 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -254,10 +254,14 @@ export default function EventView({ {selectedReviews.length <= 0 ? ( @@ -667,7 +671,7 @@ function MotionReview({ } return timeRangeSegments.ranges.findIndex( - (seg) => seg.start <= startTime && seg.end >= startTime, + (seg) => seg.after <= startTime && seg.before >= startTime, ); // only render once // eslint-disable-next-line react-hooks/exhaustive-deps @@ -675,7 +679,7 @@ function MotionReview({ const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex); const [currentTime, setCurrentTime] = useState( - startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.end, + startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.before, ); const currentTimeRange = useMemo( () => timeRangeSegments.ranges[selectedRangeIdx], @@ -689,11 +693,11 @@ function MotionReview({ useEffect(() => { if ( - currentTime > currentTimeRange.end + 60 || - currentTime < currentTimeRange.start - 60 + currentTime > currentTimeRange.before + 60 || + currentTime < currentTimeRange.after - 60 ) { const index = timeRangeSegments.ranges.findIndex( - (seg) => seg.start <= currentTime && seg.end >= currentTime, + (seg) => seg.after <= currentTime && seg.before >= currentTime, ); if (index != -1) { diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index e9981c1be..8efe3317a 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -1,5 +1,6 @@ +import ReviewCard from "@/components/card/ReviewCard"; import FilterCheckBox from "@/components/filter/FilterCheckBox"; -import { CalendarFilterButton } from "@/components/filter/ReviewFilterGroup"; +import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import PreviewPlayer, { PreviewController, } from "@/components/player/PreviewPlayer"; @@ -8,6 +9,8 @@ import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { useOverlayState } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { @@ -16,9 +19,15 @@ import { ReviewSegment, ReviewSummary, } from "@/types/review"; -import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { getChunkedTimeDay } from "@/utils/timelineUtil"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + MutableRefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { isDesktop, isMobile } from "react-device-detect"; import { FaCircle, FaVideo } from "react-icons/fa"; import { IoMdArrowRoundBack } from "react-icons/io"; @@ -26,6 +35,7 @@ import { useNavigate } from "react-router-dom"; import useSWR from "swr"; const SEGMENT_DURATION = 30; +type TimelineType = "timeline" | "events"; type RecordingViewProps = { startCamera: string; @@ -64,12 +74,17 @@ export function RecordingView({ [reviewItems, mainCamera], ); - // timeline time + // timeline + + const [timelineType, setTimelineType] = useOverlayState( + "timelineType", + "timeline", + ); const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]); const [selectedRangeIdx, setSelectedRangeIdx] = useState( timeRange.ranges.findIndex((chunk) => { - return chunk.start <= startTime && chunk.end >= startTime; + return chunk.after <= startTime && chunk.before >= startTime; }), ); const currentTimeRange = useMemo( @@ -98,7 +113,7 @@ export function RecordingView({ const updateSelectedSegment = useCallback( (currentTime: number, updateStartTime: boolean) => { const index = timeRange.ranges.findIndex( - (seg) => seg.start <= currentTime && seg.end >= currentTime, + (seg) => seg.after <= currentTime && seg.before >= currentTime, ); if (index != -1) { @@ -115,8 +130,8 @@ export function RecordingView({ useEffect(() => { if (scrubbing) { if ( - currentTime > currentTimeRange.end + 60 || - currentTime < currentTimeRange.start - 60 + currentTime > currentTimeRange.before + 60 || + currentTime < currentTimeRange.after - 60 ) { updateSelectedSegment(currentTime, false); return; @@ -140,8 +155,8 @@ export function RecordingView({ if (!scrubbing) { if (Math.abs(currentTime - playerTime) > 10) { if ( - currentTimeRange.start <= currentTime && - currentTimeRange.end >= currentTime + currentTimeRange.after <= currentTime && + currentTimeRange.before >= currentTime ) { mainControllerRef.current?.seekToTimestamp(currentTime, true); } else { @@ -165,16 +180,6 @@ export function RecordingView({ // motion timeline data - const { data: motionData } = useSWR([ - "review/activity/motion", - { - before: timeRange.end, - after: timeRange.start, - scale: SEGMENT_DURATION / 2, - cameras: mainCamera, - }, - ]); - const mainCameraAspect = useMemo(() => { if (!config) { return "normal"; @@ -204,31 +209,13 @@ export function RecordingView({ }, [mainCameraAspect]); return ( -
-
+
+
-
- { - updateFilter({ - ...filter, - after: day == undefined ? undefined : day.getTime() / 1000, - before: - day == undefined ? undefined : getEndOfDayTimestamp(day), - }); - }} - /> +
{isMobile && ( @@ -258,11 +245,45 @@ export function RecordingView({ )} + {}} + /> + {isDesktop && ( + + value ? setTimelineType(value, true) : null + } // don't allow the severity to be unselected + > + +
Timeline
+
+ +
Events
+
+
+ )}
- -
- setScrubbing(scrubbing)} - /> -
+ {isMobile && ( + + value ? setTimelineType(value) : null + } // don't allow the severity to be unselected + > + +
Timeline
+
+ +
Events
+
+
+ )} +
); } + +type TimelineProps = { + contentRef: MutableRefObject; + mainCamera: string; + timelineType: TimelineType; + timeRange: { start: number; end: number }; + mainCameraReviewItems: ReviewSegment[]; + currentTime: number; + setCurrentTime: React.Dispatch>; + setScrubbing: React.Dispatch>; +}; +function Timeline({ + contentRef, + mainCamera, + timelineType, + timeRange, + mainCameraReviewItems, + currentTime, + setCurrentTime, + setScrubbing, +}: TimelineProps) { + const { data: motionData } = useSWR([ + "review/activity/motion", + { + before: timeRange.end, + after: timeRange.start, + scale: SEGMENT_DURATION / 2, + cameras: mainCamera, + }, + ]); + + if (timelineType == "timeline") { + return ( +
+ setScrubbing(scrubbing)} + /> +
+ ); + } + + return ( +
+ {mainCameraReviewItems.map((review) => { + if (review.severity == "significant_motion") { + return; + } + + return ( + setCurrentTime(review.start_time)} + /> + ); + })} +
+ ); +} diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 649e102b5..bcad9751b 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -2,7 +2,7 @@ import { useFrigateReviews } from "@/api/ws"; import Logo from "@/components/Logo"; import { CameraGroupSelector } from "@/components/filter/CameraGroupSelector"; import { LiveGridIcon, LiveListIcon } from "@/components/icons/LiveIcons"; -import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail"; +import { AnimatedEventCard } from "@/components/card/AnimatedEventCard"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import LivePlayer from "@/components/player/LivePlayer"; import { Button } from "@/components/ui/button"; @@ -166,7 +166,7 @@ export default function LiveDashboardView({
{events.map((event) => { - return ; + return ; })}
From c82ed43c137bc431bc2c22cc5bf1a4388dcb1186 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:36:28 -0500 Subject: [PATCH 302/751] Timeline tweaks (#10693) * make segment height static * fix timeline overscrolling * better alignment of motion timeline segments --- .../timeline/EventReviewTimeline.tsx | 2 +- web/src/components/timeline/EventSegment.tsx | 10 ++-- .../timeline/MotionReviewTimeline.tsx | 2 +- web/src/components/timeline/MotionSegment.tsx | 16 +++---- .../components/timeline/SummaryTimeline.tsx | 2 +- .../components/timeline/segment-metadata.tsx | 4 +- web/src/hooks/use-camera-activity.ts | 5 +- web/src/hooks/use-draggable-element.ts | 48 +++++++++---------- web/src/hooks/use-timeline-utils.ts | 8 +--- web/src/pages/UIPlayground.tsx | 6 +-- 10 files changed, 48 insertions(+), 55 deletions(-) diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 56f2f6472..bccf43cd2 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -166,7 +166,7 @@ export function EventReviewTimeline({ // Generate segments for the timeline const generateSegments = useCallback(() => { - const segmentCount = timelineDuration / segmentDuration; + const segmentCount = Math.ceil(timelineDuration / segmentDuration); return Array.from({ length: segmentCount }, (_, index) => { const segmentTime = timelineStartAligned - index * segmentDuration; diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 9fa2d025d..ab8f30157 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -139,7 +139,7 @@ export function EventSegment({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [showMinimap, isFirstSegmentInMinimap, events, segmentDuration]); - const segmentClasses = `h-2 relative w-full ${ + const segmentClasses = `h-[8px] relative w-full ${ showMinimap ? isInMinimapRange ? "bg-secondary-highlight" @@ -149,7 +149,7 @@ export function EventSegment({ : "" } ${ isFirstSegmentInMinimap || isLastSegmentInMinimap - ? "relative h-2 border-b-2 border-neutral-600" + ? "relative h-[8px] border-b-2 border-neutral-600" : "" }`; @@ -230,16 +230,16 @@ export function EventSegment({ {severityValue === displaySeverityType && ( -
+
diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 7d2d5371f..fa94355ca 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -169,7 +169,7 @@ export function MotionReviewTimeline({ // Generate segments for the timeline const generateSegments = useCallback(() => { - const segmentCount = timelineDuration / segmentDuration; + const segmentCount = Math.ceil(timelineDuration / segmentDuration); return Array.from({ length: segmentCount }, (_, index) => { const segmentTime = timelineStartAligned - index * segmentDuration; diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 279f69a9a..c7b30b741 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -144,7 +144,7 @@ export function MotionSegment({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [showMinimap, isFirstSegmentInMinimap, events, segmentDuration]); - const segmentClasses = `h-2 relative w-full ${ + const segmentClasses = `h-[8px] relative w-full ${ showMinimap ? isInMinimapRange ? "bg-secondary-highlight" @@ -154,7 +154,7 @@ export function MotionSegment({ : "" } ${ isFirstSegmentInMinimap || isLastSegmentInMinimap - ? "relative h-2 border-b-2 border-gray-500" + ? "relative h-[8px] border-b-2 border-gray-500" : "" }`; @@ -223,9 +223,9 @@ export function MotionSegment({ )} -
-
-
+
+
+
-
+
0) { return ( -
+
{ - const segmentCount = reviewTimelineDuration / segmentDuration; + const segmentCount = Math.ceil(reviewTimelineDuration / segmentDuration); if (segmentHeight) { return Array.from({ length: segmentCount }, (_, index) => { diff --git a/web/src/components/timeline/segment-metadata.tsx b/web/src/components/timeline/segment-metadata.tsx index a0f303175..349f56276 100644 --- a/web/src/components/timeline/segment-metadata.tsx +++ b/web/src/components/timeline/segment-metadata.tsx @@ -59,7 +59,7 @@ export function MinimapBounds({ export function Tick({ timestamp, timestampSpread }: TickSegmentProps) { return (
-
+
+
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
{ - if (!motionData || !reviewItems) { + if (!motionData || !reviewItems || !motionData) { return; } @@ -100,8 +100,7 @@ export function useCameraMotionNextTimestamp( alignStartDateToTimeline(timeRangeSegmentEnd)) % segmentDuration; - const startIndex = - offset > 0 ? Math.floor(offset / (segmentDuration / 15)) : 0; + const startIndex = Math.abs(Math.floor(offset / 15)); for ( let i = startIndex; diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index f68ea5ebe..eb32ce2bb 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -42,10 +42,12 @@ function useDraggableElement({ setIsDragging, setDraggableElementPosition, }: DraggableElementProps) { + const segmentHeight = 8; const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); const [elementScrollIntoView, setElementScrollIntoView] = useState(true); const [scrollEdgeSize, setScrollEdgeSize] = useState(); + const [fullTimelineHeight, setFullTimelineHeight] = useState(); const [segments, setSegments] = useState([]); const { alignStartDateToTimeline, getCumulativeScrollTop } = useTimelineUtils( { @@ -137,15 +139,9 @@ function useDraggableElement({ const timestampToPixels = useCallback( (time: number) => { - const { scrollHeight: timelineHeight } = - timelineRef.current as HTMLDivElement; - - const segmentHeight = - timelineHeight / (timelineDuration / segmentDuration); - return ((timelineStartAligned - time) / segmentDuration) * segmentHeight; }, - [segmentDuration, timelineRef, timelineStartAligned, timelineDuration], + [segmentDuration, timelineStartAligned], ); const updateDraggableElementPosition = useCallback( @@ -226,21 +222,17 @@ function useDraggableElement({ showDraggableElement && isDragging && clientYPosition && - segments + segments && + fullTimelineHeight ) { - const { scrollHeight: timelineHeight, scrollTop: scrolled } = - timelineRef.current; - - const segmentHeight = - timelineHeight / (timelineDuration / segmentDuration); + const { scrollTop: scrolled } = timelineRef.current; const parentScrollTop = getCumulativeScrollTop(timelineRef.current); // bottom of timeline const elementEarliest = draggableElementEarliestTime ? timestampToPixels(draggableElementEarliestTime) - : segmentHeight * (timelineDuration / segmentDuration) - - segmentHeight * 3.5; + : fullTimelineHeight - segmentHeight * 1.5; // top of timeline - default 2 segments added for draggableElement visibility const elementLatest = draggableElementLatestTime @@ -314,7 +306,11 @@ function useDraggableElement({ scrollEdgeSize)) / scrollEdgeSize, ); - timelineRef.current.scrollTop += segmentHeight * intensity; + const newScrollTop = Math.min( + fullTimelineHeight - segmentHeight, + timelineRef.current.scrollTop + segmentHeight * intensity, + ); + timelineRef.current.scrollTop = newScrollTop; } } @@ -374,11 +370,7 @@ function useDraggableElement({ !isDragging && segments.length > 0 ) { - const { scrollHeight: timelineHeight, scrollTop: scrolled } = - timelineRef.current; - - const segmentHeight = - timelineHeight / (timelineDuration / segmentDuration); + const { scrollTop: scrolled } = timelineRef.current; const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); @@ -426,6 +418,7 @@ function useDraggableElement({ useEffect(() => { if (timelineRef.current && draggableElementTime && timelineCollapsed) { + setFullTimelineHeight(timelineRef.current.scrollHeight); const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); let segmentElement = timelineRef.current.querySelector( @@ -435,8 +428,12 @@ function useDraggableElement({ if (!segmentElement) { // segment not found, maybe we collapsed over a collapsible segment let searchTime = alignedSegmentTime; - while (searchTime >= timelineStartAligned - timelineDuration) { - searchTime -= segmentDuration; + + while ( + searchTime < timelineStartAligned && + searchTime < timelineStartAligned + timelineDuration + ) { + searchTime += segmentDuration; segmentElement = timelineRef.current.querySelector( `[data-segment-id="${searchTime}"]`, ); @@ -456,10 +453,11 @@ function useDraggableElement({ }, [timelineCollapsed]); useEffect(() => { - if (timelineRef.current) { + if (timelineRef.current && segments) { setScrollEdgeSize(timelineRef.current.clientHeight * 0.03); + setFullTimelineHeight(timelineRef.current.scrollHeight); } - }, [timelineRef]); + }, [timelineRef, segments]); return { handleMouseDown, handleMouseUp, handleMouseMove }; } diff --git a/web/src/hooks/use-timeline-utils.ts b/web/src/hooks/use-timeline-utils.ts index c52213161..0bd35a39c 100644 --- a/web/src/hooks/use-timeline-utils.ts +++ b/web/src/hooks/use-timeline-utils.ts @@ -40,13 +40,9 @@ export function useTimelineUtils({ const getVisibleTimelineDuration = useCallback(() => { if (timelineRef?.current && timelineDuration) { - const { - scrollHeight: timelineHeight, - clientHeight: visibleTimelineHeight, - } = timelineRef.current; + const { clientHeight: visibleTimelineHeight } = timelineRef.current; - const segmentHeight = - timelineHeight / (timelineDuration / segmentDuration); + const segmentHeight = 8; const visibleTime = (visibleTimelineHeight / segmentHeight) * segmentDuration; diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 62f47fb57..7107abade 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -367,7 +367,7 @@ function UIPlayground() { segmentDuration={zoomSettings.segmentDuration} // seconds per segment timestampSpread={zoomSettings.timestampSpread} // minutes between each major timestamp timelineStart={Math.floor(Date.now() / 1000)} // timestamp start of the timeline - the earlier time - timelineEnd={Math.floor(Date.now() / 1000) - 24 * 60 * 60} // end of timeline - the later time + timelineEnd={Math.floor(Date.now() / 1000) - 4 * 60 * 60} // end of timeline - the later time showHandlebar // show / hide the handlebar handlebarTime={handlebarTime} // set the time of the handlebar setHandlebarTime={setHandlebarTime} // expose handler to set the handlebar time @@ -391,7 +391,7 @@ function UIPlayground() { segmentDuration={zoomSettings.segmentDuration} // seconds per segment timestampSpread={zoomSettings.timestampSpread} // minutes between each major timestamp timelineStart={Math.floor(Date.now() / 1000)} // timestamp start of the timeline - the earlier time - timelineEnd={Math.floor(Date.now() / 1000) - 24 * 60 * 60} // end of timeline - the later time + timelineEnd={Math.floor(Date.now() / 1000) - 4 * 60 * 60} // end of timeline - the later time showHandlebar // show / hide the handlebar handlebarTime={handlebarTime} // set the time of the handlebar setHandlebarTime={setHandlebarTime} // expose handler to set the handlebar time @@ -416,7 +416,7 @@ function UIPlayground() { Date: Tue, 26 Mar 2024 15:37:45 -0600 Subject: [PATCH 303/751] Add ability to export from recordings page (#10692) * Add dialog to export recordings * Add export dialog functionality * Add ability to name exports * Add ability to choose custom time range on timeline * Add ability to choose custom time range on timeline * Add custom time selection * Make hot keys optional for typing name of export * Tweaks to dialog * Tweaks to dialog * round corners more * Final tweaks --- frigate/api/media.py | 3 + frigate/record/export.py | 22 +- web/src/components/overlay/ExportDialog.tsx | 401 ++++++++++++++++++ web/src/components/player/HlsVideoPlayer.tsx | 3 + web/src/components/player/VideoControls.tsx | 4 +- .../player/dynamic/DynamicVideoPlayer.tsx | 3 + web/src/pages/Export.tsx | 3 - web/src/types/filter.ts | 2 + web/src/views/events/EventView.tsx | 2 +- web/src/views/events/RecordingView.tsx | 58 ++- 10 files changed, 483 insertions(+), 18 deletions(-) create mode 100644 web/src/components/overlay/ExportDialog.tsx diff --git a/frigate/api/media.py b/frigate/api/media.py index 78e8c711e..c72d9b933 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -8,6 +8,7 @@ import re import subprocess as sp import time from datetime import datetime, timedelta, timezone +from typing import Optional from urllib.parse import unquote import cv2 @@ -618,6 +619,7 @@ def export_recording(camera_name: str, start_time, end_time): json: dict[str, any] = request.get_json(silent=True) or {} playback_factor = json.get("playback", "realtime") + name: Optional[str] = json.get("name") recordings_count = ( Recordings.select() @@ -641,6 +643,7 @@ def export_recording(camera_name: str, start_time, end_time): exporter = RecordingExporter( current_app.frigate_config, camera_name, + secure_filename(name.replace(" ", "_")) if name else None, int(start_time), int(end_time), ( diff --git a/frigate/record/export.py b/frigate/record/export.py index 65ebf13c9..f5861d4f7 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -38,6 +38,7 @@ class RecordingExporter(threading.Thread): self, config: FrigateConfig, camera: str, + name: str, start_time: int, end_time: int, playback_factor: PlaybackFactorEnum, @@ -45,6 +46,7 @@ class RecordingExporter(threading.Thread): threading.Thread.__init__(self) self.config = config self.camera = camera + self.user_provided_name = name self.start_time = start_time self.end_time = end_time self.playback_factor = playback_factor @@ -57,8 +59,12 @@ class RecordingExporter(threading.Thread): logger.debug( f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}" ) - file_name = f"{EXPORT_DIR}/in_progress.{self.camera}@{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}.mp4" - final_file_name = f"{EXPORT_DIR}/{self.camera}_{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}.mp4" + file_name = ( + self.user_provided_name + or f"{self.camera}@{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}" + ) + file_path = f"{EXPORT_DIR}/in_progress.{file_name}.mp4" + final_file_path = f"{EXPORT_DIR}/{file_name}.mp4" if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8" @@ -97,14 +103,14 @@ class RecordingExporter(threading.Thread): if self.playback_factor == PlaybackFactorEnum.realtime: ffmpeg_cmd = ( - f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_name}" + f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_path}" ).split(" ") elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: ffmpeg_cmd = ( parse_preset_hardware_acceleration_encode( self.config.ffmpeg.hwaccel_args, f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} {file_name}", + f"{self.config.cameras[self.camera].record.export.timelapse_args} {file_path}", EncodeTypeEnum.timelapse, ) ).split(" ") @@ -122,9 +128,9 @@ class RecordingExporter(threading.Thread): f"Failed to export recording for command {' '.join(ffmpeg_cmd)}" ) logger.error(p.stderr) - Path(file_name).unlink(missing_ok=True) + Path(file_path).unlink(missing_ok=True) return - logger.debug(f"Updating finalized export {file_name}") - os.rename(file_name, final_file_name) - logger.debug(f"Finished exporting {file_name}") + logger.debug(f"Updating finalized export {file_path}") + os.rename(file_path, final_file_path) + logger.debug(f"Finished exporting {file_path}") diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx new file mode 100644 index 000000000..ea55720e5 --- /dev/null +++ b/web/src/components/overlay/ExportDialog.tsx @@ -0,0 +1,401 @@ +import { useCallback, useMemo, useState } from "react"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { Label } from "../ui/label"; +import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; +import { Button } from "../ui/button"; +import { ExportMode } from "@/types/filter"; +import { FaArrowDown, FaArrowRight, FaCalendarAlt } from "react-icons/fa"; +import axios from "axios"; +import { toast } from "sonner"; +import { Input } from "../ui/input"; +import { TimeRange } from "@/types/timeline"; +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import ReviewActivityCalendar from "./ReviewActivityCalendar"; +import { SelectSeparator } from "../ui/select"; + +const EXPORT_OPTIONS = [ + "1", + "4", + "8", + "12", + "24", + "timeline", + "custom", +] as const; +type ExportOption = (typeof EXPORT_OPTIONS)[number]; + +type ExportDialogProps = { + camera: string; + latestTime: number; + currentTime: number; + range?: TimeRange; + mode: ExportMode; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; +}; +export default function ExportDialog({ + camera, + latestTime, + currentTime, + range, + mode, + setRange, + setMode, +}: ExportDialogProps) { + const [selectedOption, setSelectedOption] = useState("1"); + const [name, setName] = useState(""); + + const onSelectTime = useCallback( + (option: ExportOption) => { + setSelectedOption(option); + + const now = new Date(latestTime * 1000); + let start = 0; + switch (option) { + case "1": + now.setHours(now.getHours() - 1); + start = now.getTime() / 1000; + break; + case "4": + now.setHours(now.getHours() - 4); + start = now.getTime() / 1000; + break; + case "8": + now.setHours(now.getHours() - 8); + start = now.getTime() / 1000; + break; + case "12": + now.setHours(now.getHours() - 12); + start = now.getTime() / 1000; + break; + case "24": + now.setHours(now.getHours() - 24); + start = now.getTime() / 1000; + break; + } + + setRange({ + before: latestTime, + after: start, + }); + }, + [latestTime, setRange], + ); + + const onStartExport = useCallback(() => { + if (!range) { + toast.error("No valid time range selected", { position: "top-center" }); + return; + } + + axios + .post(`export/${camera}/start/${range.after}/end/${range.before}`, { + playback: "realtime", + name, + }) + .then((response) => { + if (response.status == 200) { + toast.success( + "Successfully started export. View the file in the /exports folder.", + { position: "top-center" }, + ); + setName(""); + setRange(undefined); + setSelectedOption("1"); + } + }) + .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", + }); + } + }); + }, [camera, name, range, setRange]); + + return ( + { + if (!open) { + setMode("none"); + } + }} + > + + + + + + Export + + + onSelectTime(value as ExportOption)} + > + {EXPORT_OPTIONS.map((opt) => { + return ( +
+ + +
+ ); + })} +
+ {selectedOption == "custom" && ( + + )} + setName(e.target.value)} + /> + + + setMode("none")}>Cancel + + +
+
+ ); +} + +type CustomTimeSelectorProps = { + latestTime: number; + range?: TimeRange; + setRange: (range: TimeRange | undefined) => void; +}; +function CustomTimeSelector({ + latestTime, + range, + setRange, +}: CustomTimeSelectorProps) { + const { data: config } = useSWR("config"); + + // times + + const startTime = useMemo( + () => range?.after || latestTime - 3600, + [range, latestTime], + ); + const endTime = useMemo( + () => range?.before || latestTime, + [range, latestTime], + ); + const formattedStart = useFormattedTimestamp( + startTime, + config?.ui.time_format == "24hour" + ? "%b %-d, %H:%M:%S" + : "%b %-d, %I:%M:%S %p", + ); + const formattedEnd = useFormattedTimestamp( + endTime, + config?.ui.time_format == "24hour" + ? "%b %-d, %H:%M:%S" + : "%b %-d, %I:%M:%S %p", + ); + + const startClock = useMemo(() => { + const date = new Date(startTime * 1000); + return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; + }, [startTime]); + const endClock = useMemo(() => { + const date = new Date(endTime * 1000); + return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; + }, [endTime]); + + // calendars + + const [startOpen, setStartOpen] = useState(false); + const [endOpen, setEndOpen] = useState(false); + + return ( +
+ + { + if (!open) { + setStartOpen(false); + } + }} + > + + + + + { + if (!day) { + return; + } + + setRange({ + before: endTime, + after: day.getTime() / 1000 + 1, + }); + }} + /> + + { + const clock = e.target.value; + const [hour, minute, second] = clock.split(":"); + const start = new Date(startTime * 1000); + start.setHours( + parseInt(hour), + parseInt(minute), + parseInt(second), + 0, + ); + setRange({ + before: endTime, + after: start.getTime() / 1000, + }); + }} + /> + + + + { + if (!open) { + setEndOpen(false); + } + }} + > + + + + + { + if (!day) { + return; + } + + setRange({ + after: startTime, + before: day.getTime() / 1000, + }); + }} + /> + + { + const clock = e.target.value; + const [hour, minute, second] = clock.split(":"); + const end = new Date(endTime * 1000); + end.setHours( + parseInt(hour), + parseInt(minute), + parseInt(second), + 0, + ); + setRange({ + before: end.getTime() / 1000, + after: startTime, + }); + }} + /> + + +
+ ); +} diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 9a7814798..e0d42d6e6 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -22,6 +22,7 @@ type HlsVideoPlayerProps = { videoRef: MutableRefObject; visible: boolean; currentSource: string; + hotKeys: boolean; onClipEnded?: () => void; onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; @@ -33,6 +34,7 @@ export default function HlsVideoPlayer({ videoRef, visible, currentSource, + hotKeys, onClipEnded, onPlayerLoaded, onTimeUpdate, @@ -161,6 +163,7 @@ export default function HlsVideoPlayer({ controlsOpen={controlsOpen} setControlsOpen={setControlsOpen} playbackRate={videoRef.current?.playbackRate ?? 1} + hotKeys={hotKeys} onPlayPause={(play) => { if (!videoRef.current) { return; diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx index f6fa19d12..b4221c64b 100644 --- a/web/src/components/player/VideoControls.tsx +++ b/web/src/components/player/VideoControls.tsx @@ -41,6 +41,7 @@ type VideoControlsProps = { controlsOpen?: boolean; playbackRates?: number[]; playbackRate: number; + hotKeys?: boolean; setControlsOpen?: (open: boolean) => void; onPlayPause: (play: boolean) => void; onSeek: (diff: number) => void; @@ -55,6 +56,7 @@ export default function VideoControls({ controlsOpen, playbackRates = PLAYBACK_RATE_DEFAULT, playbackRate, + hotKeys = true, setControlsOpen, onPlayPause, onSeek, @@ -130,7 +132,7 @@ export default function VideoControls({ [video, isPlaying, onSeek], ); useKeyboardListener( - ["ArrowLeft", "ArrowRight", "m", " "], + hotKeys ? ["ArrowLeft", "ArrowRight", "m", " "] : [], onKeyboardShortcut, ); diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 3db528165..77e3b78a5 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -20,6 +20,7 @@ type DynamicVideoPlayerProps = { cameraPreviews: Preview[]; startTimestamp?: number; isScrubbing: boolean; + hotKeys: boolean; onControllerReady: (controller: DynamicVideoController) => void; onTimestampUpdate?: (timestamp: number) => void; onClipEnded?: () => void; @@ -31,6 +32,7 @@ export default function DynamicVideoPlayer({ cameraPreviews, startTimestamp, isScrubbing, + hotKeys, onControllerReady, onTimestampUpdate, onClipEnded, @@ -172,6 +174,7 @@ export default function DynamicVideoPlayer({ videoRef={playerRef} visible={!(isScrubbing || isLoading)} currentSource={source} + hotKeys={hotKeys} onTimeUpdate={onTimeUpdate} onPlayerLoaded={onPlayerLoaded} onClipEnded={onClipEnded} diff --git a/web/src/pages/Export.tsx b/web/src/pages/Export.tsx index 30148ba57..ff6f5765f 100644 --- a/web/src/pages/Export.tsx +++ b/web/src/pages/Export.tsx @@ -10,7 +10,6 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Toaster } from "@/components/ui/sonner"; import axios from "axios"; import { useCallback, useState } from "react"; import useSWR from "swr"; @@ -42,8 +41,6 @@ function Export() { return (
- - setDeleteClip(undefined)} diff --git a/web/src/types/filter.ts b/web/src/types/filter.ts index 722057fa1..228aea98f 100644 --- a/web/src/types/filter.ts +++ b/web/src/types/filter.ts @@ -1,3 +1,5 @@ // allow any // eslint-disable-next-line @typescript-eslint/no-explicit-any export type FilterType = { [searchKey: string]: any }; + +export type ExportMode = "select" | "timeline" | "none"; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 8c752b46a..3957cad82 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -496,7 +496,7 @@ function DetectionReview({ > {filter?.before == undefined && ( ("none"); + const [exportRange, setExportRange] = useState(); + // move to next clip const onClipEnded = useCallback(() => { @@ -210,6 +219,7 @@ export function RecordingView({ return (
+
@@ -318,7 +318,7 @@ export function ReviewTimeline({ }`} >
Date: Wed, 27 Mar 2024 07:22:28 -0600 Subject: [PATCH 306/751] Make deleting review items delete recordings as well (#10707) * Make deleting review items delete recordings as well * Fix wrong camera bug --- frigate/api/review.py | 46 +++++++++++++++++-- .../components/filter/ReviewActionGroup.tsx | 3 +- .../player/PreviewThumbnailPlayer.tsx | 4 +- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 5074d6df2..d3a49de9f 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -3,6 +3,7 @@ import logging from datetime import datetime, timedelta from functools import reduce +from pathlib import Path import pandas as pd from flask import Blueprint, jsonify, make_response, request @@ -62,6 +63,7 @@ def review(): .order_by(ReviewSegment.start_time.desc()) .limit(limit) .dicts() + .iterator() ) return jsonify([r for r in review]) @@ -334,15 +336,53 @@ def set_not_reviewed(id): ) -@ReviewBp.route("/reviews/", methods=("DELETE",)) -def delete_reviews(ids: str): - list_of_ids = ids.split(",") +@ReviewBp.route("/reviews/delete", methods=("POST",)) +def delete_reviews(): + json: dict[str, any] = request.get_json(silent=True) or {} + list_of_ids = json.get("ids", "") if not list_of_ids or len(list_of_ids) == 0: return make_response( jsonify({"success": False, "message": "Not a valid list of ids"}), 404 ) + reviews = ( + ReviewSegment.select( + ReviewSegment.camera, + ReviewSegment.start_time, + ReviewSegment.end_time, + ) + .where(ReviewSegment.id << list_of_ids) + .dicts() + .iterator() + ) + recording_ids = [] + + for review in reviews: + start_time = review["start_time"] + end_time = review["end_time"] + camera_name = review["camera"] + recordings = ( + Recordings.select(Recordings.id, Recordings.path) + .where( + Recordings.start_time.between(start_time, end_time) + | Recordings.end_time.between(start_time, end_time) + | ( + (start_time > Recordings.start_time) + & (end_time < Recordings.end_time) + ) + ) + .where(Recordings.camera == camera_name) + .dicts() + .iterator() + ) + + for recording in recordings: + Path(recording["path"]).unlink(missing_ok=True) + recording_ids.append(recording["id"]) + + # delete recordings and review segments + Recordings.delete().where(Recordings.id << recording_ids).execute() ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute() return make_response(jsonify({"success": True, "message": "Delete reviews"}), 200) diff --git a/web/src/components/filter/ReviewActionGroup.tsx b/web/src/components/filter/ReviewActionGroup.tsx index c3df52881..bc1951f75 100644 --- a/web/src/components/filter/ReviewActionGroup.tsx +++ b/web/src/components/filter/ReviewActionGroup.tsx @@ -29,8 +29,7 @@ export default function ReviewActionGroup({ }, [selectedReviews, setSelectedReviews, pullLatestData]); const onDelete = useCallback(async () => { - const idList = selectedReviews.join(","); - await axios.delete(`reviews/${idList}`); + await axios.post(`reviews/delete`, { ids: selectedReviews }); setSelectedReviews([]); pullLatestData(); }, [selectedReviews, setSelectedReviews, pullLatestData]); diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 95b6bd6b2..3f2ed212a 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -111,7 +111,9 @@ export default function PreviewThumbnailPlayer({ } else { // the second preview is longer, return the second if it exists if (firstIndex < allPreviews.length - 1) { - return allPreviews[firstIndex + 1]; + return allPreviews.find( + (preview, idx) => idx > firstIndex && preview.camera == review.camera, + ); } return undefined; From 559e6910c4c85fad13409ace04fedf92cc05d38a Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 27 Mar 2024 09:03:13 -0600 Subject: [PATCH 307/751] Improve restart timing (#10709) --- frigate/app.py | 10 +++++----- frigate/output/output.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frigate/app.py b/frigate/app.py index 2b1198278..78a2b0132 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -666,6 +666,11 @@ class FrigateApp: logger.info("Stopping...") self.stop_event.set() + # Stop Communicators + self.inter_process_communicator.stop() + self.inter_config_updater.stop() + self.inter_detection_proxy.stop() + for detector in self.detectors.values(): detector.stop() @@ -702,8 +707,3 @@ class FrigateApp: queue.get_nowait() queue.close() queue.join_thread() - - # Stop Communicators - self.inter_process_communicator.stop() - self.inter_config_updater.stop() - self.inter_detection_proxy.stop() diff --git a/frigate/output/output.py b/frigate/output/output.py index 348d4ba7a..88164d45a 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -79,7 +79,7 @@ def output_frames( websocket_thread.start() while not stop_event.is_set(): - (topic, data) = detection_subscriber.get_data(timeout=10) + (topic, data) = detection_subscriber.get_data(timeout=1) if not topic: continue From 4e800e19ffb796ace9450fda39103d76439f40b6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 27 Mar 2024 17:03:05 -0600 Subject: [PATCH 308/751] Mobile recordings redesign (#10711) * Only show back button text on desktop * Add mobile camera drawer to separate component * Use bottom sheet for export on mobile * Add intermediary mobile bottom sheet * fix filter * Fix mobile layout jumping * Fix desktop vertical camera view * Fix horizontal camera list * Add overlay instead of using same button for timeline exports * Don't use native hls for now * Fix export bottom sheet * Fix scrolling * Simplify checks * Adjust hls compat approach * Fix events shadow * Make corners consistent * Make corners consistent * fix max drawer height * Use separate buttons for export control * Add icons * Fix list views * Fix new items to review * bottom padding on bottom sheets * bottom padding on bottom sheets --- .../components/filter/ReviewFilterGroup.tsx | 140 +++++--- web/src/components/overlay/ExportDialog.tsx | 317 +++++++++++------- .../components/overlay/MobileCameraDrawer.tsx | 46 +++ .../overlay/MobileReviewSettingsDrawer.tsx | 289 ++++++++++++++++ .../overlay/MobileTimelineDrawer.tsx | 51 +++ .../components/overlay/SaveExportOverlay.tsx | 45 +++ web/src/components/player/HlsVideoPlayer.tsx | 116 ++++--- .../player/dynamic/DynamicVideoPlayer.tsx | 9 +- web/src/types/timeline.ts | 2 + web/src/views/events/EventView.tsx | 2 +- web/src/views/events/RecordingView.tsx | 221 ++++++------ 11 files changed, 890 insertions(+), 348 deletions(-) create mode 100644 web/src/components/overlay/MobileCameraDrawer.tsx create mode 100644 web/src/components/overlay/MobileReviewSettingsDrawer.tsx create mode 100644 web/src/components/overlay/MobileTimelineDrawer.tsx create mode 100644 web/src/components/overlay/SaveExportOverlay.tsx diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 4ebad426f..52809d7b4 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -108,7 +108,7 @@ export default function ReviewFilterGroup({ ); return ( -
+
{filters.includes("cameras") && ( - + ); const content = ( + setOpen(false)} + /> + ); + + if (isMobile) { + return ( + { + if (!open) { + setReviewed(showReviewed ?? 0); + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setReviewed(showReviewed ?? 0); + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type GeneralFilterContentProps = { + allLabels: string[]; + selectedLabels: string[] | undefined; + currentLabels: string[] | undefined; + showReviewed?: 0 | 1; + reviewed: 0 | 1; + updateLabelFilter: (labels: string[] | undefined) => void; + setCurrentLabels: (labels: string[] | undefined) => void; + setShowReviewed: (reviewed?: 0 | 1) => void; + setReviewed: (reviewed: 0 | 1) => void; + onClose: () => void; +}; +export function GeneralFilterContent({ + allLabels, + selectedLabels, + currentLabels, + showReviewed, + reviewed, + updateLabelFilter, + setCurrentLabels, + setShowReviewed, + setReviewed, + onClose, +}: GeneralFilterContentProps) { + return ( <>
Apply @@ -474,44 +556,6 @@ function GeneralFilterButton({
); - - if (isMobile) { - return ( - { - if (!open) { - setReviewed(showReviewed ?? 0); - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - - return ( - { - if (!open) { - setReviewed(showReviewed ?? 0); - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - {content} - - ); } type ShowMotionOnlyButtonProps = { diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index a2c074bd6..b4b0ae65d 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { Dialog, - DialogClose, DialogContent, DialogFooter, DialogHeader, @@ -23,6 +22,9 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import ReviewActivityCalendar from "./ReviewActivityCalendar"; import { SelectSeparator } from "../ui/select"; +import { isDesktop } from "react-device-detect"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import SaveExportOverlay from "./SaveExportOverlay"; const EXPORT_OPTIONS = [ "1", @@ -53,8 +55,121 @@ export default function ExportDialog({ setRange, setMode, }: ExportDialogProps) { - const [selectedOption, setSelectedOption] = useState("1"); const [name, setName] = useState(""); + const onStartExport = useCallback(() => { + if (!range) { + toast.error("No valid time range selected", { position: "top-center" }); + return; + } + + axios + .post(`export/${camera}/start/${range.after}/end/${range.before}`, { + playback: "realtime", + name, + }) + .then((response) => { + if (response.status == 200) { + toast.success( + "Successfully started export. View the file in the /exports folder.", + { position: "top-center" }, + ); + setName(""); + setRange(undefined); + setMode("none"); + } + }) + .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", + }); + } + }); + }, [camera, name, range, setRange, setName, setMode]); + + const Overlay = isDesktop ? Dialog : Drawer; + const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; + const Content = isDesktop ? DialogContent : DrawerContent; + + return ( + <> + onStartExport()} + onCancel={() => setMode("none")} + /> + { + if (!open) { + setMode("none"); + } + }} + > + + + + + setMode("none")} + /> + + + + ); +} + +type ExportContentProps = { + latestTime: number; + currentTime: number; + range?: TimeRange; + name: string; + onStartExport: () => void; + setName: (name: string) => void; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; + onCancel: () => void; +}; +export function ExportContent({ + latestTime, + currentTime, + range, + name, + onStartExport, + setName, + setRange, + setMode, + onCancel, +}: ExportContentProps) { + const [selectedOption, setSelectedOption] = useState("1"); const onSelectTime = useCallback( (option: ExportOption) => { @@ -93,136 +208,86 @@ export default function ExportDialog({ [latestTime, setRange], ); - const onStartExport = useCallback(() => { - if (!range) { - toast.error("No valid time range selected", { position: "top-center" }); - return; - } - - axios - .post(`export/${camera}/start/${range.after}/end/${range.before}`, { - playback: "realtime", - name, - }) - .then((response) => { - if (response.status == 200) { - toast.success( - "Successfully started export. View the file in the /exports folder.", - { position: "top-center" }, - ); - setName(""); - setRange(undefined); - setSelectedOption("1"); - } - }) - .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", - }); - } - }); - }, [camera, name, range, setRange]); - return ( - { - if (!open) { - setMode("none"); - } - }} - > - +
+ {isDesktop && ( + <> + + Export + + + + )} + onSelectTime(value as ExportOption)} + > + {EXPORT_OPTIONS.map((opt) => { + return ( +
+ + +
+ ); + })} +
+ {selectedOption == "custom" && ( + + )} + setName(e.target.value)} + /> + {isDesktop && } + +
+ Cancel +
- - - - Export - - - onSelectTime(value as ExportOption)} - > - {EXPORT_OPTIONS.map((opt) => { - return ( -
- - -
- ); - })} -
- {selectedOption == "custom" && ( - - )} - setName(e.target.value)} - /> - - - setMode("none")}>Cancel - - -
-
+ +
); } @@ -276,7 +341,9 @@ function CustomTimeSelector({ const [endOpen, setEndOpen] = useState(false); return ( -
+
+ + + {allCameras.map((cam) => ( +
{ + onSelectCamera(cam); + setCameraDrawer(false); + }} + > + {cam.replaceAll("_", " ")} +
+ ))} +
+ + ); +} diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx new file mode 100644 index 000000000..563237f7c --- /dev/null +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -0,0 +1,289 @@ +import { useCallback, useMemo, useState } from "react"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { Button } from "../ui/button"; +import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; +import { TimeRange } from "@/types/timeline"; +import { ExportContent } from "./ExportDialog"; +import { ExportMode } from "@/types/filter"; +import ReviewActivityCalendar from "./ReviewActivityCalendar"; +import { SelectSeparator } from "../ui/select"; +import { ReviewFilter } from "@/types/review"; +import { getEndOfDayTimestamp } from "@/utils/dateUtil"; +import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { toast } from "sonner"; +import axios from "axios"; +import SaveExportOverlay from "./SaveExportOverlay"; +import { isMobile } from "react-device-detect"; + +const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; +type DrawerMode = "none" | "select" | "export" | "calendar" | "filter"; + +type MobileReviewSettingsDrawerProps = { + camera: string; + filter?: ReviewFilter; + latestTime: number; + currentTime: number; + range?: TimeRange; + mode: ExportMode; + onUpdateFilter: (filter: ReviewFilter) => void; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; +}; +export default function MobileReviewSettingsDrawer({ + camera, + filter, + latestTime, + currentTime, + range, + mode, + onUpdateFilter, + setRange, + setMode, +}: MobileReviewSettingsDrawerProps) { + const { data: config } = useSWR("config"); + const [drawerMode, setDrawerMode] = useState("none"); + + // exports + + const [name, setName] = useState(""); + const onStartExport = useCallback(() => { + if (!range) { + toast.error("No valid time range selected", { position: "top-center" }); + return; + } + + axios + .post(`export/${camera}/start/${range.after}/end/${range.before}`, { + playback: "realtime", + name, + }) + .then((response) => { + if (response.status == 200) { + toast.success( + "Successfully started export. View the file in the /exports folder.", + { position: "top-center" }, + ); + setName(""); + setRange(undefined); + setMode("none"); + } + }) + .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", + }); + } + }); + }, [camera, name, range, setRange, setName, setMode]); + + // filters + + const allLabels = useMemo(() => { + if (!config) { + return []; + } + + const labels = new Set(); + const cameras = filter?.cameras || Object.keys(config.cameras); + + cameras.forEach((camera) => { + const cameraConfig = config.cameras[camera]; + cameraConfig.objects.track.forEach((label) => { + if (!ATTRIBUTES.includes(label)) { + labels.add(label); + } + }); + + if (cameraConfig.audio.enabled_in_config) { + cameraConfig.audio.listen.forEach((label) => { + labels.add(label); + }); + } + }); + + return [...labels].sort(); + }, [config, filter]); + const [currentLabels, setCurrentLabels] = useState( + filter?.labels, + ); + + if (!isMobile) { + return; + } + + let content; + if (drawerMode == "select") { + content = ( +
+ + + +
+ ); + } else if (drawerMode == "export") { + content = ( + { + setMode(mode); + + if (mode == "timeline") { + setDrawerMode("none"); + } + }} + onCancel={() => { + setMode("none"); + setRange(undefined); + setDrawerMode("select"); + }} + /> + ); + } else if (drawerMode == "calendar") { + content = ( +
+
+
setDrawerMode("select")} + > + Back +
+
+ Calendar +
+
+ { + onUpdateFilter({ + ...filter, + after: day == undefined ? undefined : day.getTime() / 1000, + before: day == undefined ? undefined : getEndOfDayTimestamp(day), + }); + }} + /> + +
+ +
+
+ ); + } else if (drawerMode == "filter") { + content = ( +
+
+
setDrawerMode("select")} + > + Back +
+
+ Filter +
+
+ + onUpdateFilter({ ...filter, labels: newLabels }) + } + setShowReviewed={() => {}} + setReviewed={() => {}} + onClose={() => setDrawerMode("select")} + /> +
+ ); + } + + return ( + <> + onStartExport()} + onCancel={() => setMode("none")} + /> + { + if (!open) { + setDrawerMode("none"); + } + }} + > + + + + + {content} + + + + ); +} + +/** + * + */ diff --git a/web/src/components/overlay/MobileTimelineDrawer.tsx b/web/src/components/overlay/MobileTimelineDrawer.tsx new file mode 100644 index 000000000..b29fde559 --- /dev/null +++ b/web/src/components/overlay/MobileTimelineDrawer.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { Button } from "../ui/button"; +import { FaFlag } from "react-icons/fa"; +import { TimelineType } from "@/types/timeline"; +import { isMobile } from "react-device-detect"; + +type MobileTimelineDrawerProps = { + selected: TimelineType; + onSelect: (timeline: TimelineType) => void; +}; +export default function MobileTimelineDrawer({ + selected, + onSelect, +}: MobileTimelineDrawerProps) { + const [drawer, setDrawer] = useState(false); + + if (!isMobile) { + return; + } + + return ( + + + + + +
{ + onSelect("timeline"); + setDrawer(false); + }} + > + Timeline +
+
{ + onSelect("events"); + setDrawer(false); + }} + > + Events +
+
+
+ ); +} diff --git a/web/src/components/overlay/SaveExportOverlay.tsx b/web/src/components/overlay/SaveExportOverlay.tsx new file mode 100644 index 000000000..25d625d0d --- /dev/null +++ b/web/src/components/overlay/SaveExportOverlay.tsx @@ -0,0 +1,45 @@ +import { LuX } from "react-icons/lu"; +import { Button } from "../ui/button"; +import { FaCompactDisc } from "react-icons/fa"; + +type SaveExportOverlayProps = { + className: string; + show: boolean; + onSave: () => void; + onCancel: () => void; +}; +export default function SaveExportOverlay({ + className, + show, + onSave, + onCancel, +}: SaveExportOverlayProps) { + return ( +
+
+ + +
+
+ ); +} diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index e0d42d6e6..d667f4be0 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -88,29 +88,36 @@ export default function HlsVideoPlayer({ const [controlsOpen, setControlsOpen] = useState(false); return ( -
{ - setControls(true); - } - : undefined - } - onMouseOut={ - isDesktop - ? () => { - setControls(controlsOpen); - } - : undefined - } - onClick={isDesktop ? undefined : () => setControls(!controls)} - > - - + +
{ + setControls(true); + } + : undefined + } + onMouseOut={ + isDesktop + ? () => { + setControls(controlsOpen); + } + : undefined + } + onClick={isDesktop ? undefined : () => setControls(!controls)} + > + - - { - if (!videoRef.current) { - return; - } + { + if (!videoRef.current) { + return; + } - if (play) { - videoRef.current.play(); - } else { - videoRef.current.pause(); - } - }} - onSeek={(diff) => { - const currentTime = videoRef.current?.currentTime; + if (play) { + videoRef.current.play(); + } else { + videoRef.current.pause(); + } + }} + onSeek={(diff) => { + const currentTime = videoRef.current?.currentTime; - if (!videoRef.current || !currentTime) { - return; - } + if (!videoRef.current || !currentTime) { + return; + } - videoRef.current.currentTime = Math.max(0, currentTime + diff); - }} - onSetPlaybackRate={(rate) => - videoRef.current ? (videoRef.current.playbackRate = rate) : null - } - /> - {children} -
+ videoRef.current.currentTime = Math.max(0, currentTime + diff); + }} + onSetPlaybackRate={(rate) => + videoRef.current ? (videoRef.current.playbackRate = rate) : null + } + /> + {children} +
+ ); } diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 77e3b78a5..ae15fff8e 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -9,6 +9,7 @@ import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; import { DynamicVideoController } from "./DynamicVideoController"; import HlsVideoPlayer from "../HlsVideoPlayer"; import { TimeRange, Timeline } from "@/types/timeline"; +import { isDesktop } from "react-device-detect"; /** * Dynamically switches between video playback and scrubbing preview player. @@ -54,7 +55,7 @@ export default function DynamicVideoPlayer({ if (aspectRatio > 2) { return ""; } else if (aspectRatio < 16 / 9) { - return "aspect-tall"; + return isDesktop ? "" : "aspect-tall"; } else { return "aspect-video"; } @@ -168,9 +169,9 @@ export default function DynamicVideoPlayer({ }, [controller, recordings]); return ( -
+
{filter?.before == undefined && ( { + if (isMobile) { + return ""; + } + if (mainCameraAspect == "wide") { return "w-full aspect-wide"; } else if (isDesktop && mainCameraAspect == "tall") { - return "h-full aspect-tall"; + return "h-full aspect-tall flex flex-col justify-center"; } else { return "w-full aspect-video"; } @@ -220,59 +224,50 @@ export function RecordingView({ return (
-
-
- {isMobile && ( - - - - - - {allCameras.map((cam) => ( - { - setPlaybackStart(currentTime); - setMainCamera(cam); - }} - /> - ))} - - - )} - - {}} + { + setPlaybackStart(currentTime); + setMainCamera(cam); + }} /> {isDesktop && ( + + )} + {isDesktop && ( + {}} + /> + )} + {isDesktop ? ( Events
+ ) : ( + )} +
@@ -339,7 +350,7 @@ export function RecordingView({
{isDesktop && (
{allCameras.map((cam) => { if (cam !== mainCamera) { @@ -347,7 +358,9 @@ export function RecordingView({
- {isMobile && ( - - value ? setTimelineType(value) : null - } // don't allow the severity to be unselected - > - -
Timeline
-
- -
Events
-
-
- )} + return ( +
+
+
+ {timelineType == "timeline" ? ( setScrubbing(scrubbing)} /> -
- ); - } + ) : ( +
+ {mainCameraReviewItems.map((review) => { + if (review.severity == "significant_motion") { + return; + } - return ( -
-
-
- {mainCameraReviewItems.map((review) => { - if (review.severity == "significant_motion") { - return; - } - - return ( - setCurrentTime(review.start_time)} - /> - ); - })} + return ( + setCurrentTime(review.start_time)} + /> + ); + })} +
+ )}
); } From f3abc590e70e587f48aaa82aa2937501dbe14e54 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 27 Mar 2024 17:24:41 -0600 Subject: [PATCH 309/751] Fix camera list not scrolling (#10713) --- web/src/components/overlay/MobileCameraDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/overlay/MobileCameraDrawer.tsx b/web/src/components/overlay/MobileCameraDrawer.tsx index 9bb0fd8ea..807ceba49 100644 --- a/web/src/components/overlay/MobileCameraDrawer.tsx +++ b/web/src/components/overlay/MobileCameraDrawer.tsx @@ -27,7 +27,7 @@ export default function MobileCameraDrawer({ - + {allCameras.map((cam) => (
Date: Thu, 28 Mar 2024 06:49:38 -0600 Subject: [PATCH 310/751] Fix inconsistent spacing (#10714) * Fix inconsistent spacing * Fix motion button alignment --- web/src/components/filter/ReviewFilterGroup.tsx | 1 - web/src/views/events/EventView.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 52809d7b4..c0f97cbee 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -588,7 +588,6 @@ function ShowMotionOnlyButton({
+ + ); +} + type CalendarFilterButtonProps = { reviewSummary?: ReviewSummary; day?: Date; @@ -371,19 +454,14 @@ function CalendarFilterButton({ type GeneralFilterButtonProps = { allLabels: string[]; selectedLabels: string[] | undefined; - showReviewed?: 0 | 1; updateLabelFilter: (labels: string[] | undefined) => void; - setShowReviewed: (reviewed?: 0 | 1) => void; }; function GeneralFilterButton({ allLabels, selectedLabels, - showReviewed, updateLabelFilter, - setShowReviewed, }: GeneralFilterButtonProps) { const [open, setOpen] = useState(false); - const [reviewed, setReviewed] = useState(showReviewed ?? 0); const [currentLabels, setCurrentLabels] = useState( selectedLabels, ); @@ -399,12 +477,8 @@ function GeneralFilterButton({ allLabels={allLabels} selectedLabels={selectedLabels} currentLabels={currentLabels} - showReviewed={showReviewed} - reviewed={reviewed} updateLabelFilter={updateLabelFilter} - setShowReviewed={setShowReviewed} setCurrentLabels={setCurrentLabels} - setReviewed={setReviewed} onClose={() => setOpen(false)} /> ); @@ -415,7 +489,6 @@ function GeneralFilterButton({ open={open} onOpenChange={(open) => { if (!open) { - setReviewed(showReviewed ?? 0); setCurrentLabels(selectedLabels); } @@ -435,7 +508,6 @@ function GeneralFilterButton({ open={open} onOpenChange={(open) => { if (!open) { - setReviewed(showReviewed ?? 0); setCurrentLabels(selectedLabels); } @@ -443,7 +515,7 @@ function GeneralFilterButton({ }} > {trigger} - {content} + {content} ); } @@ -452,87 +524,84 @@ type GeneralFilterContentProps = { allLabels: string[]; selectedLabels: string[] | undefined; currentLabels: string[] | undefined; - showReviewed?: 0 | 1; - reviewed: 0 | 1; updateLabelFilter: (labels: string[] | undefined) => void; setCurrentLabels: (labels: string[] | undefined) => void; - setShowReviewed: (reviewed?: 0 | 1) => void; - setReviewed: (reviewed: 0 | 1) => void; onClose: () => void; }; export function GeneralFilterContent({ allLabels, selectedLabels, currentLabels, - showReviewed, - reviewed, updateLabelFilter, setCurrentLabels, - setShowReviewed, - setReviewed, onClose, }: GeneralFilterContentProps) { return ( <> -
- setReviewed(reviewed == 0 ? 1 : 0)} - /> - -
- - - Filter Labels - -
- { - if (isChecked) { - setCurrentLabels(undefined); - } - }} - /> - - {allLabels.map((item) => ( - + + { if (isChecked) { - const updatedLabels = currentLabels ? [...currentLabels] : []; - - updatedLabels.push(item); - setCurrentLabels(updatedLabels); - } else { - const updatedLabels = currentLabels ? [...currentLabels] : []; - - // can not deselect the last item - if (updatedLabels.length > 1) { - updatedLabels.splice(updatedLabels.indexOf(item), 1); - setCurrentLabels(updatedLabels); - } + setCurrentLabels(undefined); } }} /> - ))} +
+ +
+ {allLabels.map((item) => ( +
+ + { + if (isChecked) { + const updatedLabels = currentLabels + ? [...currentLabels] + : []; + + updatedLabels.push(item); + setCurrentLabels(updatedLabels); + } else { + const updatedLabels = currentLabels + ? [...currentLabels] + : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setCurrentLabels(updatedLabels); + } + } + }} + /> +
+ ))} +
- - + {features.includes("export") && ( + + )} + {features.includes("calendar") && ( + + )} + {features.includes("filter") && ( + + )}
); } else if (drawerMode == "export") { @@ -230,17 +246,13 @@ export default function MobileReviewSettingsDrawer({
onUpdateFilter({ ...filter, labels: newLabels }) } - setShowReviewed={() => {}} - setReviewed={() => {}} onClose={() => setDrawerMode("select")} />
@@ -280,10 +292,3 @@ export default function MobileReviewSettingsDrawer({ ); } - -/** - * - */ diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index a1b7a8eac..3dccce110 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -257,7 +257,7 @@ export default function EventView({ filters={ severity == "significant_motion" ? ["cameras", "date", "motionOnly"] - : ["cameras", "date", "general"] + : ["cameras", "reviewed", "date", "general"] } reviewSummary={reviewSummary} filter={filter} From 36d5e5b45fbdbb18ec5bdbbfcd51e2e4f64d782e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 28 Mar 2024 10:03:06 -0500 Subject: [PATCH 312/751] Timeline tweaks for mobile (#10726) * add dense prop, combine duplicate code, fix mobile bug * put segment height in hook * playground --- .../timeline/EventReviewTimeline.tsx | 142 ++-------- web/src/components/timeline/EventSegment.tsx | 3 + .../timeline/MotionReviewTimeline.tsx | 147 ++-------- web/src/components/timeline/MotionSegment.tsx | 3 + .../components/timeline/ReviewTimeline.tsx | 250 ++++++++++++------ .../components/timeline/segment-metadata.tsx | 8 +- web/src/hooks/use-draggable-element.ts | 43 +-- web/src/hooks/use-timeline-utils.ts | 5 +- web/src/pages/UIPlayground.tsx | 3 + web/src/views/events/EventView.tsx | 2 + 10 files changed, 258 insertions(+), 348 deletions(-) diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index bccf43cd2..fe85f167a 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -1,12 +1,4 @@ -import useDraggableElement from "@/hooks/use-draggable-element"; -import { - useEffect, - useCallback, - useMemo, - useRef, - useState, - RefObject, -} from "react"; +import { useEffect, useCallback, useMemo, useRef, RefObject } from "react"; import EventSegment from "./EventSegment"; import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { ReviewSegment, ReviewSeverity } from "@/types/review"; @@ -35,6 +27,7 @@ export type EventReviewTimelineProps = { timelineRef?: RefObject; contentRef: RefObject; onHandlebarDraggingChange?: (isDragging: boolean) => void; + dense?: boolean; }; export function EventReviewTimeline({ @@ -59,18 +52,9 @@ export function EventReviewTimeline({ timelineRef, contentRef, onHandlebarDraggingChange, + dense = false, }: EventReviewTimelineProps) { - const [isDragging, setIsDragging] = useState(false); - const [exportStartPosition, setExportStartPosition] = useState(0); - const [exportEndPosition, setExportEndPosition] = useState(0); - const internalTimelineRef = useRef(null); - const handlebarRef = useRef(null); - const handlebarTimeRef = useRef(null); - const exportStartRef = useRef(null); - const exportStartTimeRef = useRef(null); - const exportEndRef = useRef(null); - const exportEndTimeRef = useRef(null); const selectedTimelineRef = timelineRef || internalTimelineRef; const timelineDuration = useMemo( @@ -78,92 +62,17 @@ export function EventReviewTimeline({ [timelineEnd, timelineStart], ); - const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils( - { - segmentDuration, - timelineDuration, - timelineRef: selectedTimelineRef, - }, - ); + const { alignStartDateToTimeline } = useTimelineUtils({ + segmentDuration, + timelineDuration, + timelineRef: selectedTimelineRef, + }); const timelineStartAligned = useMemo( () => alignStartDateToTimeline(timelineStart), [timelineStart, alignStartDateToTimeline], ); - const paddedExportStartTime = useMemo(() => { - if (exportStartTime) { - return alignStartDateToTimeline(exportStartTime) + segmentDuration; - } - }, [exportStartTime, segmentDuration, alignStartDateToTimeline]); - - const paddedExportEndTime = useMemo(() => { - if (exportEndTime) { - return alignEndDateToTimeline(exportEndTime) - segmentDuration * 2; - } - }, [exportEndTime, segmentDuration, alignEndDateToTimeline]); - - const { - handleMouseDown: handlebarMouseDown, - handleMouseUp: handlebarMouseUp, - handleMouseMove: handlebarMouseMove, - } = useDraggableElement({ - contentRef, - timelineRef: selectedTimelineRef, - draggableElementRef: handlebarRef, - segmentDuration, - showDraggableElement: showHandlebar, - draggableElementTime: handlebarTime, - setDraggableElementTime: setHandlebarTime, - timelineDuration, - timelineStartAligned, - isDragging, - setIsDragging, - draggableElementTimeRef: handlebarTimeRef, - }); - - const { - handleMouseDown: exportStartMouseDown, - handleMouseUp: exportStartMouseUp, - handleMouseMove: exportStartMouseMove, - } = useDraggableElement({ - contentRef, - timelineRef: selectedTimelineRef, - draggableElementRef: exportStartRef, - segmentDuration, - showDraggableElement: showExportHandles, - draggableElementTime: exportStartTime, - draggableElementLatestTime: paddedExportEndTime, - setDraggableElementTime: setExportStartTime, - timelineDuration, - timelineStartAligned, - isDragging, - setIsDragging, - draggableElementTimeRef: exportStartTimeRef, - setDraggableElementPosition: setExportStartPosition, - }); - - const { - handleMouseDown: exportEndMouseDown, - handleMouseUp: exportEndMouseUp, - handleMouseMove: exportEndMouseMove, - } = useDraggableElement({ - contentRef, - timelineRef: selectedTimelineRef, - draggableElementRef: exportEndRef, - segmentDuration, - showDraggableElement: showExportHandles, - draggableElementTime: exportEndTime, - draggableElementEarliestTime: paddedExportStartTime, - setDraggableElementTime: setExportEndTime, - timelineDuration, - timelineStartAligned, - isDragging, - setIsDragging, - draggableElementTimeRef: exportEndTimeRef, - setDraggableElementPosition: setExportEndPosition, - }); - // Generate segments for the timeline const generateSegments = useCallback(() => { const segmentCount = Math.ceil(timelineDuration / segmentDuration); @@ -184,6 +93,7 @@ export function EventReviewTimeline({ severityType={severityType} contentRef={contentRef} setHandlebarTime={setHandlebarTime} + dense={dense} /> ); }); @@ -216,12 +126,6 @@ export function EventReviewTimeline({ ], ); - useEffect(() => { - if (onHandlebarDraggingChange) { - onHandlebarDraggingChange(isDragging); - } - }, [isDragging, onHandlebarDraggingChange]); - useEffect(() => { if ( selectedTimelineRef.current && @@ -254,28 +158,20 @@ export function EventReviewTimeline({ return ( {segments} diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index ab8f30157..08a78a30b 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -30,6 +30,7 @@ type EventSegmentProps = { severityType: ReviewSeverity; contentRef: RefObject; setHandlebarTime?: React.Dispatch>; + dense: boolean; }; export function EventSegment({ @@ -43,6 +44,7 @@ export function EventSegment({ severityType, contentRef, setHandlebarTime, + dense, }: EventSegmentProps) { const { getSeverity, @@ -212,6 +214,7 @@ export function EventSegment({ alignedMinimapStartTime={alignedMinimapStartTime} alignedMinimapEndTime={alignedMinimapEndTime} firstMinimapSegmentRef={firstMinimapSegmentRef} + dense={dense} /> )} diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index fa94355ca..8f4e4b51d 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -1,12 +1,4 @@ -import useDraggableElement from "@/hooks/use-draggable-element"; -import { - useEffect, - useCallback, - useMemo, - useRef, - useState, - RefObject, -} from "react"; +import { useEffect, useCallback, useMemo, useRef, RefObject } from "react"; import MotionSegment from "./MotionSegment"; import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review"; @@ -37,6 +29,7 @@ export type MotionReviewTimelineProps = { contentRef: RefObject; timelineRef?: RefObject; onHandlebarDraggingChange?: (isDragging: boolean) => void; + dense?: boolean; }; export function MotionReviewTimeline({ @@ -62,111 +55,26 @@ export function MotionReviewTimeline({ contentRef, timelineRef, onHandlebarDraggingChange, + dense = false, }: MotionReviewTimelineProps) { - const [isDragging, setIsDragging] = useState(false); - const [exportStartPosition, setExportStartPosition] = useState(0); - const [exportEndPosition, setExportEndPosition] = useState(0); - const internalTimelineRef = useRef(null); - const handlebarRef = useRef(null); - const handlebarTimeRef = useRef(null); - const exportStartRef = useRef(null); - const exportStartTimeRef = useRef(null); - const exportEndRef = useRef(null); - const exportEndTimeRef = useRef(null); + const selectedTimelineRef = timelineRef || internalTimelineRef; const timelineDuration = useMemo( () => timelineStart - timelineEnd + 4 * segmentDuration, [timelineEnd, timelineStart, segmentDuration], ); - const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils( - { - segmentDuration, - timelineDuration, - }, - ); + const { alignStartDateToTimeline } = useTimelineUtils({ + segmentDuration, + timelineDuration, + }); const timelineStartAligned = useMemo( () => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration, [timelineStart, alignStartDateToTimeline, segmentDuration], ); - const paddedExportStartTime = useMemo(() => { - if (exportStartTime) { - return alignStartDateToTimeline(exportStartTime) + segmentDuration; - } - }, [exportStartTime, segmentDuration, alignStartDateToTimeline]); - - const paddedExportEndTime = useMemo(() => { - if (exportEndTime) { - return alignEndDateToTimeline(exportEndTime) - segmentDuration * 2; - } - }, [exportEndTime, segmentDuration, alignEndDateToTimeline]); - - const { - handleMouseDown: handlebarMouseDown, - handleMouseUp: handlebarMouseUp, - handleMouseMove: handlebarMouseMove, - } = useDraggableElement({ - contentRef, - timelineRef: timelineRef || internalTimelineRef, - draggableElementRef: handlebarRef, - segmentDuration, - showDraggableElement: showHandlebar, - draggableElementTime: handlebarTime, - setDraggableElementTime: setHandlebarTime, - initialScrollIntoViewOnly: onlyInitialHandlebarScroll, - timelineDuration, - timelineCollapsed: motionOnly, - timelineStartAligned, - isDragging, - setIsDragging, - draggableElementTimeRef: handlebarTimeRef, - }); - - const { - handleMouseDown: exportStartMouseDown, - handleMouseUp: exportStartMouseUp, - handleMouseMove: exportStartMouseMove, - } = useDraggableElement({ - contentRef, - timelineRef: timelineRef || internalTimelineRef, - draggableElementRef: exportStartRef, - segmentDuration, - showDraggableElement: showExportHandles, - draggableElementTime: exportStartTime, - draggableElementLatestTime: paddedExportEndTime, - setDraggableElementTime: setExportStartTime, - timelineDuration, - timelineStartAligned, - isDragging, - setIsDragging, - draggableElementTimeRef: exportStartTimeRef, - setDraggableElementPosition: setExportStartPosition, - }); - - const { - handleMouseDown: exportEndMouseDown, - handleMouseUp: exportEndMouseUp, - handleMouseMove: exportEndMouseMove, - } = useDraggableElement({ - contentRef, - timelineRef: timelineRef || internalTimelineRef, - draggableElementRef: exportEndRef, - segmentDuration, - showDraggableElement: showExportHandles, - draggableElementTime: exportEndTime, - draggableElementEarliestTime: paddedExportStartTime, - setDraggableElementTime: setExportEndTime, - timelineDuration, - timelineStartAligned, - isDragging, - setIsDragging, - draggableElementTimeRef: exportEndTimeRef, - setDraggableElementPosition: setExportEndPosition, - }); - // Generate segments for the timeline const generateSegments = useCallback(() => { const segmentCount = Math.ceil(timelineDuration / segmentDuration); @@ -187,6 +95,7 @@ export function MotionReviewTimeline({ minimapStartTime={minimapStartTime} minimapEndTime={minimapEndTime} setHandlebarTime={setHandlebarTime} + dense={dense} /> ); }); @@ -223,14 +132,7 @@ export function MotionReviewTimeline({ ], ); - useEffect(() => { - if (onHandlebarDraggingChange) { - onHandlebarDraggingChange(isDragging); - } - }, [isDragging, onHandlebarDraggingChange]); - const segmentsObserver = useRef(null); - const selectedTimelineRef = timelineRef || internalTimelineRef; useEffect(() => { if (selectedTimelineRef.current && segments && isDesktop) { segmentsObserver.current = new IntersectionObserver( @@ -268,29 +170,22 @@ export function MotionReviewTimeline({ return ( {segments} diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index c7b30b741..718d443b5 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -19,6 +19,7 @@ type MotionSegmentProps = { minimapStartTime?: number; minimapEndTime?: number; setHandlebarTime?: React.Dispatch>; + dense: boolean; }; export function MotionSegment({ @@ -32,6 +33,7 @@ export function MotionSegment({ minimapStartTime, minimapEndTime, setHandlebarTime, + dense, }: MotionSegmentProps) { const severityType = "all"; const { @@ -203,6 +205,7 @@ export function MotionSegment({ alignedMinimapStartTime={alignedMinimapStartTime} alignedMinimapEndTime={alignedMinimapEndTime} firstMinimapSegmentRef={firstMinimapSegmentRef} + dense={dense} /> )} diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx index 35dcd6c8c..49568c82e 100644 --- a/web/src/components/timeline/ReviewTimeline.tsx +++ b/web/src/components/timeline/ReviewTimeline.tsx @@ -1,3 +1,5 @@ +import useDraggableElement from "@/hooks/use-draggable-element"; +import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { DraggableElement } from "@/types/draggable-element"; import { ReactNode, @@ -12,85 +14,150 @@ import { isIOS, isMobile } from "react-device-detect"; export type ReviewTimelineProps = { timelineRef: RefObject; - handlebarRef: RefObject; - handlebarTimeRef: RefObject; - handlebarMouseMove: (e: MouseEvent | TouchEvent) => void; - handlebarMouseUp: (e: MouseEvent | TouchEvent) => void; - handlebarMouseDown: ( - e: - | React.MouseEvent - | React.TouchEvent, - ) => void; + contentRef: RefObject; segmentDuration: number; timelineDuration: number; + timelineStartAligned: number; showHandlebar: boolean; showExportHandles: boolean; - exportStartRef: RefObject; - exportStartTimeRef: RefObject; - exportEndRef: RefObject; - exportEndTimeRef: RefObject; - exportStartMouseMove: (e: MouseEvent | TouchEvent) => void; - exportStartMouseUp: (e: MouseEvent | TouchEvent) => void; - exportStartMouseDown: ( - e: - | React.MouseEvent - | React.TouchEvent, - ) => void; - exportEndMouseMove: (e: MouseEvent | TouchEvent) => void; - exportEndMouseUp: (e: MouseEvent | TouchEvent) => void; - exportEndMouseDown: ( - e: - | React.MouseEvent - | React.TouchEvent, - ) => void; - isDragging: boolean; - exportStartPosition?: number; - exportEndPosition?: number; + handlebarTime?: number; + setHandlebarTime?: React.Dispatch>; + onHandlebarDraggingChange?: (isDragging: boolean) => void; + onlyInitialHandlebarScroll?: boolean; + exportStartTime?: number; + exportEndTime?: number; + setExportStartTime?: React.Dispatch>; + setExportEndTime?: React.Dispatch>; + dense: boolean; children: ReactNode; }; export function ReviewTimeline({ timelineRef, - handlebarRef, - handlebarTimeRef, - handlebarMouseMove, - handlebarMouseUp, - handlebarMouseDown, + contentRef, segmentDuration, timelineDuration, + timelineStartAligned, showHandlebar = false, showExportHandles = false, - exportStartRef, - exportStartTimeRef, - exportEndRef, - exportEndTimeRef, - exportStartMouseMove, - exportStartMouseUp, - exportStartMouseDown, - exportEndMouseMove, - exportEndMouseUp, - exportEndMouseDown, - isDragging, - exportStartPosition, - exportEndPosition, + handlebarTime, + setHandlebarTime, + onHandlebarDraggingChange, + onlyInitialHandlebarScroll = false, + exportStartTime, + setExportStartTime, + exportEndTime, + setExportEndTime, + dense, children, }: ReviewTimelineProps) { + const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false); + const [isDraggingExportStart, setIsDraggingExportStart] = useState(false); + const [isDraggingExportEnd, setIsDraggingExportEnd] = useState(false); + const [exportStartPosition, setExportStartPosition] = useState(0); + const [exportEndPosition, setExportEndPosition] = useState(0); + const handlebarRef = useRef(null); + const handlebarTimeRef = useRef(null); + const exportStartRef = useRef(null); + const exportStartTimeRef = useRef(null); + const exportEndRef = useRef(null); + const exportEndTimeRef = useRef(null); + + const isDragging = useMemo( + () => isDraggingHandlebar || isDraggingExportStart || isDraggingExportEnd, + [isDraggingHandlebar, isDraggingExportStart, isDraggingExportEnd], + ); const exportSectionRef = useRef(null); - const segmentHeight = useMemo(() => { - if (timelineRef.current) { - const { scrollHeight: timelineHeight } = - timelineRef.current as HTMLDivElement; - - return timelineHeight / (timelineDuration / segmentDuration); - } - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [segmentDuration, timelineDuration, timelineRef, showExportHandles]); - const [draggableElementType, setDraggableElementType] = useState(); + const { alignStartDateToTimeline, alignEndDateToTimeline, segmentHeight } = + useTimelineUtils({ + segmentDuration, + timelineDuration, + timelineRef, + }); + + const paddedExportStartTime = useMemo(() => { + if (exportStartTime) { + return alignStartDateToTimeline(exportStartTime) + segmentDuration; + } + }, [exportStartTime, segmentDuration, alignStartDateToTimeline]); + + const paddedExportEndTime = useMemo(() => { + if (exportEndTime) { + return alignEndDateToTimeline(exportEndTime); + } + }, [exportEndTime, alignEndDateToTimeline]); + + const { + handleMouseDown: handlebarMouseDown, + handleMouseUp: handlebarMouseUp, + handleMouseMove: handlebarMouseMove, + } = useDraggableElement({ + contentRef, + timelineRef, + draggableElementRef: handlebarRef, + segmentDuration, + showDraggableElement: showHandlebar, + draggableElementTime: handlebarTime, + setDraggableElementTime: setHandlebarTime, + initialScrollIntoViewOnly: onlyInitialHandlebarScroll, + timelineDuration, + timelineStartAligned, + isDragging: isDraggingHandlebar, + setIsDragging: setIsDraggingHandlebar, + draggableElementTimeRef: handlebarTimeRef, + dense, + }); + + const { + handleMouseDown: exportStartMouseDown, + handleMouseUp: exportStartMouseUp, + handleMouseMove: exportStartMouseMove, + } = useDraggableElement({ + contentRef, + timelineRef, + draggableElementRef: exportStartRef, + segmentDuration, + showDraggableElement: showExportHandles, + draggableElementTime: exportStartTime, + draggableElementLatestTime: paddedExportEndTime, + setDraggableElementTime: setExportStartTime, + alignSetTimeToSegment: true, + timelineDuration, + timelineStartAligned, + isDragging: isDraggingExportStart, + setIsDragging: setIsDraggingExportStart, + draggableElementTimeRef: exportStartTimeRef, + setDraggableElementPosition: setExportStartPosition, + dense, + }); + + const { + handleMouseDown: exportEndMouseDown, + handleMouseUp: exportEndMouseUp, + handleMouseMove: exportEndMouseMove, + } = useDraggableElement({ + contentRef, + timelineRef, + draggableElementRef: exportEndRef, + segmentDuration, + showDraggableElement: showExportHandles, + draggableElementTime: exportEndTime, + draggableElementEarliestTime: paddedExportStartTime, + setDraggableElementTime: setExportEndTime, + alignSetTimeToSegment: true, + timelineDuration, + timelineStartAligned, + isDragging: isDraggingExportEnd, + setIsDragging: setIsDraggingExportEnd, + draggableElementTimeRef: exportEndTimeRef, + setDraggableElementPosition: setExportEndPosition, + dense, + }); + const handleHandlebar = useCallback( ( e: @@ -177,6 +244,19 @@ export function ReviewTimeline({ ], ); + const textSizeClasses = useCallback( + (draggableElement: DraggableElement) => { + if (isDragging && isMobile && draggableElementType === draggableElement) { + return "text-lg"; + } else if (dense) { + return "text-[8px] md:text-xs"; + } else { + return "text-xs"; + } + }, + [dense, isDragging, draggableElementType], + ); + useEffect(() => { if ( exportSectionRef.current && @@ -218,6 +298,12 @@ export function ReviewTimeline({ }; }, [handleMouseMove, handleMouseUp, isDragging]); + useEffect(() => { + if (onHandlebarDraggingChange) { + onHandlebarDraggingChange(isDraggingHandlebar); + } + }, [isDraggingHandlebar, onHandlebarDraggingChange]); + return (
{showHandlebar && (
@@ -245,21 +331,25 @@ export function ReviewTimeline({ >
@@ -268,7 +358,7 @@ export function ReviewTimeline({ {showExportHandles && ( <>
@@ -279,21 +369,25 @@ export function ReviewTimeline({ >
@@ -303,7 +397,7 @@ export function ReviewTimeline({ className="bg-selected/50 absolute w-full" >
@@ -318,16 +412,20 @@ export function ReviewTimeline({ }`} >
diff --git a/web/src/components/timeline/segment-metadata.tsx b/web/src/components/timeline/segment-metadata.tsx index 349f56276..33564b507 100644 --- a/web/src/components/timeline/segment-metadata.tsx +++ b/web/src/components/timeline/segment-metadata.tsx @@ -1,11 +1,10 @@ -import { isDesktop } from "react-device-detect"; - type MinimapSegmentProps = { isFirstSegmentInMinimap: boolean; isLastSegmentInMinimap: boolean; alignedMinimapStartTime: number; alignedMinimapEndTime: number; firstMinimapSegmentRef: React.MutableRefObject; + dense: boolean; }; type TickSegmentProps = { @@ -27,6 +26,7 @@ export function MinimapBounds({ alignedMinimapStartTime, alignedMinimapEndTime, firstMinimapSegmentRef, + dense, }: MinimapSegmentProps) { return ( <> @@ -38,7 +38,7 @@ export function MinimapBounds({ {new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", - ...(isDesktop && { month: "short", day: "2-digit" }), + ...(!dense && { month: "short", day: "2-digit" }), })}
)} @@ -48,7 +48,7 @@ export function MinimapBounds({ {new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", - ...(isDesktop && { month: "short", day: "2-digit" }), + ...(!dense && { month: "short", day: "2-digit" }), })}
)} diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 24b143e93..15b8773b2 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { isDesktop, isMobile } from "react-device-detect"; +import { isMobile } from "react-device-detect"; import scrollIntoView from "scroll-into-view-if-needed"; import { useTimelineUtils } from "./use-timeline-utils"; @@ -13,6 +13,7 @@ type DraggableElementProps = { draggableElementEarliestTime?: number; draggableElementLatestTime?: number; setDraggableElementTime?: React.Dispatch>; + alignSetTimeToSegment?: boolean; initialScrollIntoViewOnly?: boolean; draggableElementTimeRef: React.MutableRefObject; timelineDuration: number; @@ -21,6 +22,7 @@ type DraggableElementProps = { isDragging: boolean; setIsDragging: React.Dispatch>; setDraggableElementPosition?: React.Dispatch>; + dense: boolean; }; function useDraggableElement({ @@ -33,6 +35,7 @@ function useDraggableElement({ draggableElementEarliestTime, draggableElementLatestTime, setDraggableElementTime, + alignSetTimeToSegment = false, initialScrollIntoViewOnly, draggableElementTimeRef, timelineDuration, @@ -41,38 +44,39 @@ function useDraggableElement({ isDragging, setIsDragging, setDraggableElementPosition, + dense, }: DraggableElementProps) { - const segmentHeight = 8; const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); const [elementScrollIntoView, setElementScrollIntoView] = useState(true); const [scrollEdgeSize, setScrollEdgeSize] = useState(); const [fullTimelineHeight, setFullTimelineHeight] = useState(); const [segments, setSegments] = useState([]); - const { alignStartDateToTimeline, getCumulativeScrollTop } = useTimelineUtils( - { + const { alignStartDateToTimeline, getCumulativeScrollTop, segmentHeight } = + useTimelineUtils({ segmentDuration: segmentDuration, timelineDuration: timelineDuration, timelineRef, - }, - ); + }); const draggingAtTopEdge = useMemo(() => { if (clientYPosition && timelineRef.current && scrollEdgeSize) { + const timelineRect = timelineRef.current.getBoundingClientRect(); + const timelineTopAbsolute = timelineRect.top; return ( - clientYPosition - timelineRef.current.offsetTop < scrollEdgeSize && - isDragging + clientYPosition - timelineTopAbsolute < scrollEdgeSize && isDragging ); } }, [clientYPosition, timelineRef, isDragging, scrollEdgeSize]); const draggingAtBottomEdge = useMemo(() => { if (clientYPosition && timelineRef.current && scrollEdgeSize) { + const timelineRect = timelineRef.current.getBoundingClientRect(); + const timelineTopAbsolute = timelineRect.top; + const timelineHeightAbsolute = timelineRect.height; return ( - clientYPosition > - timelineRef.current.clientHeight + - timelineRef.current.offsetTop - - scrollEdgeSize && isDragging + timelineTopAbsolute + timelineHeightAbsolute - clientYPosition < + scrollEdgeSize && isDragging ); } }, [clientYPosition, timelineRef, isDragging, scrollEdgeSize]); @@ -141,7 +145,7 @@ function useDraggableElement({ (time: number) => { return ((timelineStartAligned - time) / segmentDuration) * segmentHeight; }, - [segmentDuration, timelineStartAligned], + [segmentDuration, timelineStartAligned, segmentHeight], ); const updateDraggableElementPosition = useCallback( @@ -165,7 +169,7 @@ function useDraggableElement({ ).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", - ...(segmentDuration < 60 && isDesktop && { second: "2-digit" }), + ...(segmentDuration < 60 && !dense && { second: "2-digit" }), }); if (scrollTimeline) { scrollIntoView(thumb, { @@ -188,6 +192,7 @@ function useDraggableElement({ draggableElementRef, setDraggableElementTime, setDraggableElementPosition, + dense, ], ); @@ -322,9 +327,13 @@ function useDraggableElement({ ); if (setDraggableElementTime) { - setDraggableElementTime( - targetSegmentId + segmentDuration * (offset / segmentHeight), - ); + if (alignSetTimeToSegment) { + setDraggableElementTime(targetSegmentId); + } else { + setDraggableElementTime( + targetSegmentId + segmentDuration * (offset / segmentHeight), + ); + } } if (draggingAtTopEdge || draggingAtBottomEdge) { diff --git a/web/src/hooks/use-timeline-utils.ts b/web/src/hooks/use-timeline-utils.ts index 0bd35a39c..9445a5b49 100644 --- a/web/src/hooks/use-timeline-utils.ts +++ b/web/src/hooks/use-timeline-utils.ts @@ -11,6 +11,8 @@ export function useTimelineUtils({ timelineDuration, timelineRef, }: TimelineUtilsProps) { + const segmentHeight = 8; + const alignEndDateToTimeline = useCallback( (time: number): number => { const remainder = time % segmentDuration; @@ -42,8 +44,6 @@ export function useTimelineUtils({ if (timelineRef?.current && timelineDuration) { const { clientHeight: visibleTimelineHeight } = timelineRef.current; - const segmentHeight = 8; - const visibleTime = (visibleTimelineHeight / segmentHeight) * segmentDuration; @@ -56,5 +56,6 @@ export function useTimelineUtils({ alignStartDateToTimeline, getCumulativeScrollTop, getVisibleTimelineDuration, + segmentHeight, }; } diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 7107abade..50647a128 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -27,6 +27,7 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useNavigate } from "react-router-dom"; import SummaryTimeline from "@/components/timeline/SummaryTimeline"; +import { isMobile } from "react-device-detect"; // Color data const colors = [ @@ -384,6 +385,7 @@ function UIPlayground() { motion_events={mockMotionData} severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later + dense={isMobile} // dense will produce a smaller handlebar and only minute resolution on timestamps /> )} {isEventsReviewTimeline && ( @@ -408,6 +410,7 @@ function UIPlayground() { severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later timelineRef={reviewTimelineRef} // save a ref to this timeline to connect with the summary timeline + dense // dense will produce a smaller handlebar and only minute resolution on timestamps /> )}
diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 3dccce110..3d9ccedf5 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -580,6 +580,7 @@ function DetectionReview({ severityType={severity} contentRef={contentRef} timelineRef={reviewTimelineRef} + dense={isMobile} />
@@ -864,6 +865,7 @@ function MotionReview({ setScrubbing(scrubbing); }} + dense={isMobile} />
From 0223d6df60d62b965199c63abfaa72dbee1e77b7 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 28 Mar 2024 11:53:36 -0600 Subject: [PATCH 313/751] UI Tweaks (#10727) * Fix drawer not being scrollable * Fix margin * Use 2 columns for large mobile devices * Move padding * Add review summary to mobile calendar * Make header spacing consistent between pages * remove red --- .../components/filter/ReviewFilterGroup.tsx | 3 +- .../components/overlay/MobileCameraDrawer.tsx | 28 ++++++++++--------- .../overlay/MobileReviewSettingsDrawer.tsx | 5 +++- web/src/views/events/EventView.tsx | 6 ++-- web/src/views/events/RecordingView.tsx | 9 ++++-- web/src/views/live/LiveDashboardView.tsx | 8 +++--- 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index fa26722e6..28e2475f2 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -187,6 +187,7 @@ export default function ReviewFilterGroup({ - - {allCameras.map((cam) => ( -
{ - onSelectCamera(cam); - setCameraDrawer(false); - }} - > - {cam.replaceAll("_", " ")} -
- ))} + +
+ {allCameras.map((cam) => ( +
{ + onSelectCamera(cam); + setCameraDrawer(false); + }} + > + {cam.replaceAll("_", " ")} +
+ ))} +
); diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 00bb71a3c..bf39632e9 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -7,7 +7,7 @@ import { ExportContent } from "./ExportDialog"; import { ExportMode } from "@/types/filter"; import ReviewActivityCalendar from "./ReviewActivityCalendar"; import { SelectSeparator } from "../ui/select"; -import { ReviewFilter } from "@/types/review"; +import { ReviewFilter, ReviewSummary } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; import useSWR from "swr"; @@ -36,6 +36,7 @@ type MobileReviewSettingsDrawerProps = { currentTime: number; range?: TimeRange; mode: ExportMode; + reviewSummary?: ReviewSummary; onUpdateFilter: (filter: ReviewFilter) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; @@ -48,6 +49,7 @@ export default function MobileReviewSettingsDrawer({ currentTime, range, mode, + reviewSummary, onUpdateFilter, setRange, setMode, @@ -201,6 +203,7 @@ export default function MobileReviewSettingsDrawer({
-
+
{isMobile && ( - + )}
{filter?.before == undefined && (
{isMobile && ( - + )} ) : ( diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 5f01e2698..b406f603b 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -204,7 +204,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { className={ fullscreen ? `fixed inset-0 bg-black z-30` - : `size-full flex flex-col ${isMobile ? "landscape:flex-row" : ""}` + : `size-full p-2 flex flex-col ${isMobile ? "landscape:flex-row" : ""}` } >
navigate(-1)} > @@ -228,7 +228,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { )}
{!isIOS && ( config?.birdseye, [config]); return ( -
+
{isMobile && ( -
+
@@ -164,7 +164,7 @@ export default function LiveDashboardView({ {events && events.length > 0 && ( -
+
{events.map((event) => { return ; })} From 89f843cf953d0a839fbb3100ed80822232cafda0 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 30 Mar 2024 12:45:13 -0600 Subject: [PATCH 318/751] Implement alerts when a potential problem is detected (#10734) * Implement alerts on statusbar when a potential problem is detected * Add alert to mobile --- web/src/components/Statusbar.tsx | 104 +++++++++++--------- web/src/components/navigation/Bottombar.tsx | 45 ++++++++- web/src/hooks/use-stats.ts | 63 ++++++++++++ web/src/types/stats.ts | 5 + 4 files changed, 171 insertions(+), 46 deletions(-) create mode 100644 web/src/hooks/use-stats.ts diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index e5564eecf..52bbe3114 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -1,6 +1,8 @@ import { useFrigateStats } from "@/api/ws"; +import useStats from "@/hooks/use-stats"; import { FrigateStats } from "@/types/stats"; import { useMemo } from "react"; +import { IoIosWarning } from "react-icons/io"; import { MdCircle } from "react-icons/md"; import useSWR from "swr"; @@ -27,58 +29,70 @@ export default function Statusbar() { return parseInt(systemCpu); }, [stats]); + const { potentialProblems } = useStats(stats); + return ( -
- {cpuPercent && ( -
- - CPU {cpuPercent}% -
- )} - {Object.entries(stats?.gpu_usages || {}).map(([name, stats]) => { - if (name == "error-gpu") { - return; - } - - let gpuTitle; - switch (name) { - case "amd-vaapi": - gpuTitle = "AMD GPU"; - break; - case "intel-vaapi": - case "intel-qsv": - gpuTitle = "Intel GPU"; - break; - default: - gpuTitle = name; - break; - } - - const gpu = parseInt(stats.gpu); - - return ( -
+
+
+ {cpuPercent && ( +
- {gpuTitle} {gpu}% + CPU {cpuPercent}%
- ); - })} + )} + {Object.entries(stats?.gpu_usages || {}).map(([name, stats]) => { + if (name == "error-gpu") { + return; + } + + let gpuTitle; + switch (name) { + case "amd-vaapi": + gpuTitle = "AMD GPU"; + break; + case "intel-vaapi": + case "intel-qsv": + gpuTitle = "Intel GPU"; + break; + default: + gpuTitle = name; + break; + } + + const gpu = parseInt(stats.gpu); + + return ( +
+ + {gpuTitle} {gpu}% +
+ ); + })} +
+
+ {potentialProblems.map((prob) => ( +
+ + {prob.text} +
+ ))} +
); } diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx index d4e1e0760..68b28d22a 100644 --- a/web/src/components/navigation/Bottombar.tsx +++ b/web/src/components/navigation/Bottombar.tsx @@ -1,6 +1,13 @@ import { navbarLinks } from "@/pages/site-navigation"; import NavItem from "./NavItem"; import SettingsNavItems from "../settings/SettingsNavItems"; +import { IoIosWarning } from "react-icons/io"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import useSWR from "swr"; +import { FrigateStats } from "@/types/stats"; +import { useFrigateStats } from "@/api/ws"; +import { useMemo } from "react"; +import useStats from "@/hooks/use-stats"; function Bottombar() { return ( @@ -17,10 +24,46 @@ function Bottombar() { /> ))} +
); } -// +function StatusAlertNav() { + const { data: initialStats } = useSWR("stats", { + revalidateOnFocus: false, + }); + const { payload: latestStats } = useFrigateStats(); + const stats = useMemo(() => { + if (latestStats) { + return latestStats; + } + + return initialStats; + }, [initialStats, latestStats]); + const { potentialProblems } = useStats(stats); + + if (!potentialProblems || potentialProblems.length == 0) { + return; + } + + return ( + + + + + +
+ {potentialProblems.map((prob) => ( +
+ + {prob.text} +
+ ))} +
+
+
+ ); +} export default Bottombar; diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts new file mode 100644 index 000000000..b5e0407a6 --- /dev/null +++ b/web/src/hooks/use-stats.ts @@ -0,0 +1,63 @@ +import { FrigateStats, PotentialProblem } from "@/types/stats"; +import { useMemo } from "react"; + +export default function useStats(stats: FrigateStats | undefined) { + const potentialProblems = useMemo(() => { + const problems: PotentialProblem[] = []; + + if (!stats) { + return problems; + } + + // check detectors for high inference speeds + Object.entries(stats["detectors"]).forEach(([key, det]) => { + if (det["inference_speed"] > 100) { + problems.push({ + text: `${key} is very slow (${det["inference_speed"]} ms)`, + color: "text-danger", + }); + } else if (det["inference_speed"] > 50) { + problems.push({ + text: `${key} is slow (${det["inference_speed"]} ms)`, + color: "text-orange-400", + }); + } + }); + + // check for offline cameras + Object.entries(stats["cameras"]).forEach(([name, cam]) => { + if (cam["camera_fps"] == 0) { + problems.push({ + text: `${name.replaceAll("_", " ")} is offline`, + color: "text-danger", + }); + } + }); + + // check camera cpu usages + Object.entries(stats["cameras"]).forEach(([name, cam]) => { + const ffmpegAvg = parseFloat( + stats["cpu_usages"][cam["ffmpeg_pid"]].cpu_average, + ); + const detectAvg = parseFloat(stats["cpu_usages"][cam["pid"]].cpu_average); + + if (!isNaN(ffmpegAvg) && ffmpegAvg >= 20.0) { + problems.push({ + text: `${name.replaceAll("_", " ")} has high FFMPEG CPU usage (${ffmpegAvg}%)`, + color: "text-danger", + }); + } + + if (!isNaN(detectAvg) && detectAvg >= 40.0) { + problems.push({ + text: `${name.replaceAll("_", " ")} has high detect CPU usage (${detectAvg}%)`, + color: "text-danger", + }); + } + }); + + return problems; + }, [stats]); + + return { potentialProblems }; +} diff --git a/web/src/types/stats.ts b/web/src/types/stats.ts index 1ae1199c0..831e2e639 100644 --- a/web/src/types/stats.ts +++ b/web/src/types/stats.ts @@ -58,3 +58,8 @@ export type StorageStats = { used: number; mount_type: string; }; + +export type PotentialProblem = { + text: string; + color: string; +}; From 4d522be7fb1e9889aa211e327676a93aae7a44bf Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 30 Mar 2024 12:45:42 -0600 Subject: [PATCH 319/751] Improve review book keeping (#10735) * Improve review book keeping * Cleanup * Cleanup for new labels * Final cleanup * Fix sub label checking --- frigate/review/maintainer.py | 50 ++++++++----------- web/src/components/card/AnimatedEventCard.tsx | 2 +- web/src/components/card/ReviewCard.tsx | 5 +- .../components/filter/ReviewFilterGroup.tsx | 5 +- .../player/PreviewThumbnailPlayer.tsx | 14 ++---- web/src/types/review.ts | 1 - web/src/utils/iconUtil.tsx | 27 +++++++--- 7 files changed, 47 insertions(+), 57 deletions(-) diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 9728d19dd..6633c2015 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -17,7 +17,7 @@ from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.inter_process import InterProcessRequestor from frigate.config import CameraConfig, FrigateConfig -from frigate.const import CLIPS_DIR, UPSERT_REVIEW_SEGMENT +from frigate.const import ALL_ATTRIBUTE_LABELS, CLIPS_DIR, UPSERT_REVIEW_SEGMENT from frigate.models import ReviewSegment from frigate.object_processing import TrackedObject from frigate.util.image import SharedMemoryFrameManager, calculate_16_9_crop @@ -45,9 +45,7 @@ class PendingReviewSegment: camera: str, frame_time: float, severity: SeverityEnum, - detections: set[str] = set(), - objects: set[str] = set(), - sub_labels: set[str] = set(), + detections: dict[str, str], zones: set[str] = set(), audio: set[str] = set(), motion: list[int] = [], @@ -58,8 +56,6 @@ class PendingReviewSegment: self.start_time = frame_time self.severity = severity self.detections = detections - self.objects = objects - self.sub_labels = sub_labels self.zones = zones self.audio = audio self.sig_motion_areas = motion @@ -114,9 +110,8 @@ class PendingReviewSegment: ReviewSegment.severity: self.severity.value, ReviewSegment.thumb_path: path, ReviewSegment.data: { - "detections": list(self.detections), - "objects": list(self.objects), - "sub_labels": list(self.sub_labels), + "detections": list(set(self.detections.keys())), + "objects": list(set(self.detections.values())), "zones": list(self.zones), "audio": list(self.audio), "significant_motion_areas": self.sig_motion_areas, @@ -180,11 +175,12 @@ class ReviewSegmentMaintainer(threading.Thread): self.frame_manager.close(frame_id) for object in active_objects: - segment.detections.add(object["id"]) - segment.objects.add(object["label"]) - - if object["sub_label"]: - segment.sub_labels.add(object["sub_label"][0]) + if not object["sub_label"]: + segment.detections[object["id"]] = object["label"] + elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS: + segment.detections[object["id"]] = object["sub_label"][0] + else: + segment.detections[object["id"]] = f'{object["label"]}-verified' # if object is alert label and has qualified for recording # mark this review as alert @@ -224,9 +220,7 @@ class ReviewSegmentMaintainer(threading.Thread): if len(active_objects) > 0: has_sig_object = False - detections: set = set() - objects: set = set() - sub_labels: set = set() + detections: dict[str, str] = {} zones: set = set() for object in active_objects: @@ -237,11 +231,12 @@ class ReviewSegmentMaintainer(threading.Thread): ): has_sig_object = True - detections.add(object["id"]) - objects.add(object["label"]) - - if object["sub_label"]: - sub_labels.add(object["sub_label"][0]) + if not object["sub_label"]: + detections[object["id"]] = object["label"] + elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS: + detections[object["id"]] = object["sub_label"][0] + else: + detections[object["id"]] = f'{object["label"]}-verified' zones.update(object["current_zones"]) @@ -250,8 +245,6 @@ class ReviewSegmentMaintainer(threading.Thread): frame_time, SeverityEnum.alert if has_sig_object else SeverityEnum.detection, detections, - objects=objects, - sub_labels=sub_labels, audio=set(), zones=zones, motion=[], @@ -268,9 +261,8 @@ class ReviewSegmentMaintainer(threading.Thread): camera, frame_time, SeverityEnum.signification_motion, - detections=set(), - objects=set(), - sub_labels=set(), + detections={}, + audio=set(), motion=motion, zones=set(), ) @@ -340,9 +332,7 @@ class ReviewSegmentMaintainer(threading.Thread): camera, frame_time, SeverityEnum.detection, - set(), - set(), - set(), + {}, set(), set(audio_detections), [], diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index 2944e6c33..03199e01a 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -83,7 +83,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
- {`${[...event.data.objects, ...event.data.audio, ...(event.data.sub_labels || [])].join(", ")} detected`} + {`${[...event.data.objects, ...event.data.audio].join(", ").replaceAll("-verified", "")} detected`} ); diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index 4c547e4ea..353bb6583 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -2,7 +2,7 @@ import { baseUrl } from "@/api/baseUrl"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment } from "@/types/review"; -import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil"; +import { getIconForLabel } from "@/utils/iconUtil"; import { isSafari } from "react-device-detect"; import useSWR from "swr"; import TimeAgo from "../dynamic/TimeAgo"; @@ -57,9 +57,6 @@ export default function ReviewCard({ {event.data.audio.map((audio) => { return getIconForLabel(audio, "size-3 text-white"); })} - {event.data.sub_labels?.map((sub) => { - return getIconForSubLabel(sub, "size-3 text-white"); - })}
{formattedDate}
{ const cameraConfig = config.cameras[camera]; cameraConfig.objects.track.forEach((label) => { - if (!ATTRIBUTES.includes(label)) { - labels.add(label); - } + labels.add(label); }); if (cameraConfig.audio.enabled_in_config) { diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 3f2ed212a..5afea161b 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -9,7 +9,7 @@ import { useApiHost } from "@/api"; import { isCurrentHour } from "@/utils/dateUtil"; import { ReviewSegment } from "@/types/review"; import { Slider } from "../ui/slider-no-thumb"; -import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil"; +import { getIconForLabel } from "@/utils/iconUtil"; import TimeAgo from "../dynamic/TimeAgo"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; @@ -227,9 +227,6 @@ export default function PreviewThumbnailPlayer({ {review.data.audio.map((audio) => { return getIconForLabel(audio, "size-3 text-white"); })} - {review.data.sub_labels?.map((sub) => { - return getIconForSubLabel(sub, "size-3 text-white"); - })} )} @@ -237,13 +234,10 @@ export default function PreviewThumbnailPlayer({
- {[ - ...(review.data.objects || []), - ...(review.data.audio || []), - ...(review.data.sub_labels || []), - ] + {[...(review.data.objects || []), ...(review.data.audio || [])] .filter((item) => item !== undefined) - .join(", ")} + .join(", ") + .replaceAll("-verified", "")}
diff --git a/web/src/types/review.ts b/web/src/types/review.ts index ffd33b620..e18d3b785 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -15,7 +15,6 @@ export type ReviewData = { audio: string[]; detections: string[]; objects: string[]; - sub_labels?: string[]; significant_motion_areas: number[]; zones: string[]; }; diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index d688048b4..0c326f4d5 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -3,6 +3,7 @@ import { FaAmazon, FaCarSide, FaCat, + FaCheckCircle, FaCircle, FaDog, FaFedex, @@ -34,6 +35,10 @@ export function getIconForGroup(icon: string, className: string = "size-4") { } export function getIconForLabel(label: string, className?: string) { + if (label.endsWith("-verified")) { + return getVerifiedIcon(label, className); + } + switch (label) { case "car": return ; @@ -48,24 +53,32 @@ export function getIconForLabel(label: string, className?: string) { return ; case "person": return ; + // audio case "crying": case "laughter": case "scream": case "speech": case "yell": return ; - default: - return ; - } -} - -export function getIconForSubLabel(label: string, className?: string) { - switch (label) { + // sub labels case "amazon": return ; case "fedex": return ; case "ups": return ; + default: + return ; } } + +function getVerifiedIcon(label: string, className?: string) { + const simpleLabel = label.substring(0, label.lastIndexOf("-")); + + return ( +
+ {getIconForLabel(simpleLabel, className)} + +
+ ); +} From 5b5606cb8a5013c8c329e27367071a48adf3ebc6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 30 Mar 2024 13:07:30 -0600 Subject: [PATCH 320/751] Make export date/time respect configured timezone in config (#10750) * Make export page timezone aware * Fix changeover --- web/src/components/overlay/ExportDialog.tsx | 49 +++++++++++--- .../overlay/ReviewActivityCalendar.tsx | 66 +++++++++++++++++++ web/src/utils/dateUtil.ts | 2 +- 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index b4b0ae65d..a9d597f83 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -20,11 +20,12 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import ReviewActivityCalendar from "./ReviewActivityCalendar"; +import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; import { SelectSeparator } from "../ui/select"; import { isDesktop } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import SaveExportOverlay from "./SaveExportOverlay"; +import { getUTCOffset } from "@/utils/dateUtil"; const EXPORT_OPTIONS = [ "1", @@ -305,14 +306,42 @@ function CustomTimeSelector({ // times - const startTime = useMemo( - () => range?.after || latestTime - 3600, - [range, latestTime], + const timezoneOffset = useMemo( + () => + config?.ui.timezone + ? Math.round(getUTCOffset(new Date(), config.ui.timezone)) + : undefined, + [config?.ui.timezone], ); - const endTime = useMemo( - () => range?.before || latestTime, - [range, latestTime], + const localTimeOffset = useMemo( + () => + Math.round( + getUTCOffset( + new Date(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + ), + ), + [], ); + + const startTime = useMemo(() => { + let time = range?.after || latestTime - 3600; + + if (timezoneOffset) { + time = time + (timezoneOffset - localTimeOffset) * 60; + } + + return time; + }, [range, latestTime, timezoneOffset, localTimeOffset]); + const endTime = useMemo(() => { + let time = range?.before || latestTime; + + if (timezoneOffset) { + time = time + (timezoneOffset - localTimeOffset) * 60; + } + + return time; + }, [range, latestTime, timezoneOffset, localTimeOffset]); const formattedStart = useFormattedTimestamp( startTime, config?.ui.time_format == "24hour" @@ -367,7 +396,8 @@ function CustomTimeSelector({ - { if (!day) { @@ -428,7 +458,8 @@ function CustomTimeSelector({ - { if (!day) { diff --git a/web/src/components/overlay/ReviewActivityCalendar.tsx b/web/src/components/overlay/ReviewActivityCalendar.tsx index 009339fc8..7ddc528e4 100644 --- a/web/src/components/overlay/ReviewActivityCalendar.tsx +++ b/web/src/components/overlay/ReviewActivityCalendar.tsx @@ -2,6 +2,7 @@ import { ReviewSummary } from "@/types/review"; import { Calendar } from "../ui/calendar"; import { useMemo } from "react"; import { FaCircle } from "react-icons/fa"; +import { getUTCOffset } from "@/utils/dateUtil"; type ReviewActivityCalendarProps = { reviewSummary?: ReviewSummary; @@ -76,3 +77,68 @@ function ReviewActivityDay({ reviewSummary, day }: ReviewActivityDayProps) {
); } + +type TimezoneAwareCalendarProps = { + timezone?: string; + selectedDay?: Date; + onSelect: (day?: Date) => void; +}; +export function TimezoneAwareCalendar({ + timezone, + selectedDay, + onSelect, +}: TimezoneAwareCalendarProps) { + const timezoneOffset = useMemo( + () => + timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined, + [timezone], + ); + const disabledDates = useMemo(() => { + const tomorrow = new Date(); + + if (timezoneOffset) { + tomorrow.setHours( + tomorrow.getHours() + 24, + tomorrow.getMinutes() + timezoneOffset, + 0, + 0, + ); + } else { + tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); + } + + const future = new Date(); + future.setFullYear(tomorrow.getFullYear() + 10); + return { from: tomorrow, to: future }; + }, [timezoneOffset]); + + const today = useMemo(() => { + if (!timezoneOffset) { + return undefined; + } + + const date = new Date(); + const utc = Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + ); + const todayUtc = new Date(utc); + todayUtc.setMinutes(todayUtc.getMinutes() + timezoneOffset, 0, 0); + return todayUtc; + }, [timezoneOffset]); + + return ( + + ); +} diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index 8d8b7dfbf..5eb7ea760 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -235,7 +235,7 @@ export const getDurationFromTimestamps = ( * @param timezone string representation of the timezone the user is requesting * @returns number of minutes offset from UTC */ -const getUTCOffset = (date: Date, timezone: string): number => { +export const getUTCOffset = (date: Date, timezone: string): number => { // If timezone is in UTC±HH:MM format, parse it to get offset const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/); if (utcOffsetMatch) { From 7e5eb82882c3d05a87b501444564a42c6ea53729 Mon Sep 17 00:00:00 2001 From: Daniel <47092714+Daniel-dev22@users.noreply.github.com> Date: Sat, 30 Mar 2024 15:23:32 -0400 Subject: [PATCH 321/751] Delete download-models (#10755) --- .../etc/s6-overlay/s6-rc.d/frigate/dependencies.d/download-models | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/dependencies.d/download-models diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/dependencies.d/download-models b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/dependencies.d/download-models deleted file mode 100644 index e69de29bb..000000000 From 5853393396c99130404aad8d284f80e839b5c7d0 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 1 Apr 2024 08:20:27 -0600 Subject: [PATCH 322/751] Fix mobile playback (#10774) --- web/src/components/player/HlsVideoPlayer.tsx | 6 ++++-- web/src/utils/iconUtil.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index d667f4be0..b6480d6bd 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -6,10 +6,12 @@ import { useState, } from "react"; import Hls from "hls.js"; -import { isDesktop, isMobile } from "react-device-detect"; +import { isAndroid, isDesktop, isMobile } from "react-device-detect"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import VideoControls from "./VideoControls"; +// Android native hls does not seek correctly +const USE_NATIVE_HLS = !isAndroid; const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const; const unsupportedErrorCodes = [ MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, @@ -51,7 +53,7 @@ export default function HlsVideoPlayer({ return; } - if (videoRef.current.canPlayType(HLS_MIME_TYPE)) { + if (USE_NATIVE_HLS && videoRef.current.canPlayType(HLS_MIME_TYPE)) { return; } else if (Hls.isSupported()) { setUseHlsCompat(true); diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index 0c326f4d5..bc9f74a8b 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -76,7 +76,7 @@ function getVerifiedIcon(label: string, className?: string) { const simpleLabel = label.substring(0, label.lastIndexOf("-")); return ( -
+
{getIconForLabel(simpleLabel, className)}
From 7fac91dce480b8a9efac0bf7bb96022418f8d6f8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 1 Apr 2024 09:23:57 -0500 Subject: [PATCH 323/751] UI tweaks and bugfixes (#10775) * fix wrong segments when changing filters in motion only mode * pixel alignment, better outlines, and more figma matching * fix stats from crashing the ui * separate layout from aspect classes * check for invalid value * avoid undefined classnames --- web/src/App.tsx | 2 +- web/src/components/timeline/EventSegment.tsx | 14 +- .../timeline/MotionReviewTimeline.tsx | 38 +++- .../components/timeline/ReviewTimeline.tsx | 209 +++++++++--------- web/src/hooks/use-draggable-element.ts | 36 ++- web/src/hooks/use-stats.ts | 6 +- web/src/views/events/EventView.tsx | 68 +++--- web/src/views/events/RecordingView.tsx | 2 +- 8 files changed, 221 insertions(+), 154 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index c50c53307..743d98711 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -31,7 +31,7 @@ function App() { {isMobile && }
diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 08a78a30b..7f8d0f72c 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -170,24 +170,20 @@ export function EventSegment({ const segmentClick = useCallback(() => { if (contentRef.current && startTimestamp) { const element = contentRef.current.querySelector( - `[data-segment-start="${startTimestamp - segmentDuration}"]`, + `[data-segment-start="${startTimestamp - segmentDuration}"] .review-item-ring`, ); if (element instanceof HTMLElement) { scrollIntoView(element, { scrollMode: "if-needed", behavior: "smooth", }); - element.classList.add( - `outline-severity_${severityType}`, - `shadow-severity_${severityType}`, - ); - element.classList.add("outline-3"); - element.classList.remove("outline-0"); + element.classList.add(`outline-severity_${severityType}`); + element.classList.remove("outline-transparent"); // Remove the classes after a short timeout setTimeout(() => { - element.classList.remove("outline-3"); - element.classList.add("outline-0"); + element.classList.remove(`outline-severity_${severityType}`); + element.classList.add("outline-transparent"); }, 3000); } diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 43d5f324d..deac2ea44 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -4,6 +4,7 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review"; import ReviewTimeline from "./ReviewTimeline"; import { isDesktop } from "react-device-detect"; +import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; export type MotionReviewTimelineProps = { segmentDuration: number; @@ -75,14 +76,37 @@ export function MotionReviewTimeline({ [timelineStart, alignStartDateToTimeline, segmentDuration], ); + const { getMotionSegmentValue } = useMotionSegmentUtils( + segmentDuration, + motion_events, + ); + // Generate segments for the timeline const generateSegments = useCallback(() => { - const segmentCount = Math.ceil(timelineDuration / segmentDuration); + const segments = []; + let segmentTime = timelineStartAligned; - return Array.from({ length: segmentCount }, (_, index) => { - const segmentTime = timelineStartAligned - index * segmentDuration; + while (segmentTime >= timelineStartAligned - timelineDuration) { + const motionStart = segmentTime; + const motionEnd = motionStart + segmentDuration; - return ( + const segmentMotion = + getMotionSegmentValue(motionStart) > 0 || + getMotionSegmentValue(motionStart + segmentDuration / 2) > 0; + const overlappingReviewItems = events.some( + (item) => + (item.start_time >= motionStart && item.start_time < motionEnd) || + (item.end_time > motionStart && item.end_time <= motionEnd) || + (item.start_time <= motionStart && item.end_time >= motionEnd), + ); + + if ((!segmentMotion || overlappingReviewItems) && motionOnly) { + // exclude segment if necessary when in motion only mode + segmentTime -= segmentDuration; + continue; + } + + segments.push( + />, ); - }); + segmentTime -= segmentDuration; + } + return segments; // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [ diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx index 6a686c8e5..6d5ba883f 100644 --- a/web/src/components/timeline/ReviewTimeline.tsx +++ b/web/src/components/timeline/ReviewTimeline.tsx @@ -30,7 +30,7 @@ export type ReviewTimelineProps = { setExportEndTime?: React.Dispatch>; timelineCollapsed?: boolean; dense: boolean; - children: ReactNode; + children: ReactNode[]; }; export function ReviewTimeline({ @@ -113,6 +113,7 @@ export function ReviewTimeline({ setIsDragging: setIsDraggingHandlebar, draggableElementTimeRef: handlebarTimeRef, dense, + timelineSegments: children, }); const { @@ -136,6 +137,7 @@ export function ReviewTimeline({ draggableElementTimeRef: exportStartTimeRef, setDraggableElementPosition: setExportStartPosition, dense, + timelineSegments: children, }); const { @@ -159,6 +161,7 @@ export function ReviewTimeline({ draggableElementTimeRef: exportEndTimeRef, setDraggableElementPosition: setExportEndPosition, dense, + timelineSegments: children, }); const handleHandlebar = useCallback( @@ -321,119 +324,123 @@ export function ReviewTimeline({
{children}
- {showHandlebar && ( -
-
-
-
-
-
-
-
-
-
- )} - {showExportHandles && ( + {children.length > 0 && ( <> -
+ {showHandlebar && (
-
-
-
-
-
-
-
-
-
-
-
+ className={`bg-destructive rounded-full mx-auto ${ + dense + ? "w-12 md:w-20" + : segmentDuration < 60 + ? "w-24" + : "w-20" + } h-5 ${isDraggingHandlebar && isMobile ? "fixed top-[18px] left-1/2 transform -translate-x-1/2 z-20 w-32 h-[30px] bg-destructive/80" : "static"} flex items-center justify-center`} + > +
+
-
+ )} + {showExportHandles && ( + <> +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + )} )}
diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 15b8773b2..1f957e39d 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +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"; @@ -23,6 +23,7 @@ type DraggableElementProps = { setIsDragging: React.Dispatch>; setDraggableElementPosition?: React.Dispatch>; dense: boolean; + timelineSegments: ReactNode[]; }; function useDraggableElement({ @@ -45,6 +46,7 @@ function useDraggableElement({ setIsDragging, setDraggableElementPosition, dense, + timelineSegments, }: DraggableElementProps) { const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); @@ -213,10 +215,10 @@ function useDraggableElement({ ); useEffect(() => { - if (timelineRef.current) { + if (timelineRef.current && timelineSegments.length) { setSegments(Array.from(timelineRef.current.querySelectorAll(".segment"))); } - }, [timelineRef, segmentDuration, timelineDuration, timelineCollapsed]); + }, [timelineRef, timelineCollapsed, timelineSegments]); useEffect(() => { let animationFrameId: number | null = null; @@ -426,7 +428,13 @@ function useDraggableElement({ ]); useEffect(() => { - if (timelineRef.current && draggableElementTime && timelineCollapsed) { + if ( + timelineRef.current && + draggableElementTime && + timelineCollapsed && + timelineSegments && + segments + ) { setFullTimelineHeight(timelineRef.current.scrollHeight); const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); @@ -452,14 +460,30 @@ function useDraggableElement({ if (setDraggableElementTime) { setDraggableElementTime(searchTime); } - break; + return; + } + } + } + if (!segmentElement) { + // segment still not found, just start at the beginning of the timeline or at now() + if (segments?.length) { + const searchTime = parseInt( + segments[0].getAttribute("data-segment-id") || "0", + 10, + ); + if (setDraggableElementTime) { + setDraggableElementTime(searchTime); + } + } else { + if (setDraggableElementTime) { + setDraggableElementTime(timelineStartAligned); } } } } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timelineCollapsed]); + }, [timelineCollapsed, segments]); useEffect(() => { if (timelineRef.current && segments) { diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index b5e0407a6..57461d063 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -37,9 +37,11 @@ export default function useStats(stats: FrigateStats | undefined) { // check camera cpu usages Object.entries(stats["cameras"]).forEach(([name, cam]) => { const ffmpegAvg = parseFloat( - stats["cpu_usages"][cam["ffmpeg_pid"]].cpu_average, + stats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average, + ); + const detectAvg = parseFloat( + stats["cpu_usages"][cam["pid"]]?.cpu_average, ); - const detectAvg = parseFloat(stats["cpu_usages"][cam["pid"]].cpu_average); if (!isNaN(ffmpegAvg) && ffmpegAvg >= 20.0) { problems.push({ diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 6d49762e3..1524deeea 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -206,12 +206,12 @@ export default function EventView({ return (
-
+
{isMobile && ( )} {currentItems && @@ -533,7 +533,7 @@ function DetectionReview({ data-segment-start={ alignStartDateToTimeline(value.start_time) - segmentDuration } - className={`review-item outline outline-offset-1 rounded-lg shadow-none transition-all my-1 md:my-0 ${selected ? `outline-3 outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-0 duration-500"}`} + className="review-item relative rounded-lg" >
+
); })} @@ -563,7 +566,7 @@ function DetectionReview({ )}
-
+
{reviewCameras.map((camera) => { let grow; + let spans; const aspectRatio = camera.detect.width / camera.detect.height; if (aspectRatio > 2) { - grow = "sm:col-span-2 aspect-wide"; + grow = "aspect-wide"; + spans = "sm:col-span-2"; } else if (aspectRatio < 1) { - grow = "md:row-span-2 md:h-full aspect-tall"; + grow = "md:h-full aspect-tall"; + spans = "md:row-span-2"; } else { grow = "aspect-video"; + spans = ""; } const detectionType = getDetectionType(camera.name); return ( - { - videoPlayersRef.current[camera.name] = controller; - }} - onClick={() => - onOpenRecording({ - camera: camera.name, - startTime: currentTime, - severity: "significant_motion", - }) - } - /> +
+ { + videoPlayersRef.current[camera.name] = controller; + }} + onClick={() => + onOpenRecording({ + camera: camera.name, + startTime: currentTime, + severity: "significant_motion", + }) + } + /> +
+
); })}
-
+
{isMobile && ( From 52f65a4dc46af061d9d83c5d5d65adcb079a32d3 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 1 Apr 2024 09:31:31 -0600 Subject: [PATCH 324/751] Use drawer instead of dropdown menu for mobile settings (#10761) * Separate settings items so layout is more consistent * Convert settings on mobile to drawer * Fix sizing on mobile and make scrollable * remove padding * Use dialog instead of popover * Don't focus on first item * Simpler tab fix --- web/src/App.tsx | 2 +- web/src/components/navigation/Bottombar.tsx | 6 +- web/src/components/navigation/Sidebar.tsx | 8 +- .../components/settings/AccountSettings.tsx | 22 + .../components/settings/GeneralSettings.tsx | 420 ++++++++++++++++++ .../components/settings/SettingsNavItems.tsx | 312 ------------- 6 files changed, 453 insertions(+), 317 deletions(-) create mode 100644 web/src/components/settings/AccountSettings.tsx create mode 100644 web/src/components/settings/GeneralSettings.tsx delete mode 100644 web/src/components/settings/SettingsNavItems.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 743d98711..98385fc20 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -25,7 +25,7 @@ function App() { -
+
{isDesktop && } {isDesktop && } {isMobile && } diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx index 68b28d22a..bee4a0616 100644 --- a/web/src/components/navigation/Bottombar.tsx +++ b/web/src/components/navigation/Bottombar.tsx @@ -1,6 +1,5 @@ import { navbarLinks } from "@/pages/site-navigation"; import NavItem from "./NavItem"; -import SettingsNavItems from "../settings/SettingsNavItems"; import { IoIosWarning } from "react-icons/io"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import useSWR from "swr"; @@ -8,6 +7,8 @@ import { FrigateStats } from "@/types/stats"; import { useFrigateStats } from "@/api/ws"; import { useMemo } from "react"; import useStats from "@/hooks/use-stats"; +import GeneralSettings from "../settings/GeneralSettings"; +import AccountSettings from "../settings/AccountSettings"; function Bottombar() { return ( @@ -23,7 +24,8 @@ function Bottombar() { dev={item.dev} /> ))} - + +
); diff --git a/web/src/components/navigation/Sidebar.tsx b/web/src/components/navigation/Sidebar.tsx index 024c9113c..61ad84a38 100644 --- a/web/src/components/navigation/Sidebar.tsx +++ b/web/src/components/navigation/Sidebar.tsx @@ -1,9 +1,10 @@ import Logo from "../Logo"; import { navbarLinks } from "@/pages/site-navigation"; -import SettingsNavItems from "../settings/SettingsNavItems"; import NavItem from "./NavItem"; import { CameraGroupSelector } from "../filter/CameraGroupSelector"; import { useLocation } from "react-router-dom"; +import GeneralSettings from "../settings/GeneralSettings"; +import AccountSettings from "../settings/AccountSettings"; function Sidebar() { const location = useLocation(); @@ -31,7 +32,10 @@ function Sidebar() { ); })}
- +
+ + +
); } diff --git a/web/src/components/settings/AccountSettings.tsx b/web/src/components/settings/AccountSettings.tsx new file mode 100644 index 000000000..e5881465e --- /dev/null +++ b/web/src/components/settings/AccountSettings.tsx @@ -0,0 +1,22 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { VscAccount } from "react-icons/vsc"; +import { Button } from "../ui/button"; + +export default function AccountSettings() { + return ( + + + + + +

Account

+
+
+ ); +} diff --git a/web/src/components/settings/GeneralSettings.tsx b/web/src/components/settings/GeneralSettings.tsx new file mode 100644 index 000000000..ba2168980 --- /dev/null +++ b/web/src/components/settings/GeneralSettings.tsx @@ -0,0 +1,420 @@ +import { + LuActivity, + LuGithub, + LuHardDrive, + LuLifeBuoy, + LuList, + LuMoon, + LuPenSquare, + LuRotateCw, + LuSettings, + LuSun, + LuSunMoon, +} from "react-icons/lu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Button } from "../ui/button"; +import { Link } from "react-router-dom"; +import { CgDarkMode } from "react-icons/cg"; +import { + colorSchemes, + friendlyColorSchemeName, + useTheme, +} from "@/context/theme-provider"; +import { IoColorPalette } from "react-icons/io5"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { useEffect, useState } from "react"; +import { useRestart } from "@/api/ws"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "../ui/sheet"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { isDesktop } from "react-device-detect"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { + Dialog, + DialogClose, + DialogContent, + DialogPortal, + DialogTrigger, +} from "../ui/dialog"; + +type GeneralSettings = { + className?: string; +}; +export default function GeneralSettings({ className }: GeneralSettings) { + const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); + const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const [restartingSheetOpen, setRestartingSheetOpen] = useState(false); + const [countdown, setCountdown] = useState(60); + + const { send: sendRestart } = useRestart(); + + useEffect(() => { + let countdownInterval: NodeJS.Timeout; + + if (restartingSheetOpen) { + countdownInterval = setInterval(() => { + setCountdown((prevCountdown) => prevCountdown - 1); + }, 1000); + } + + return () => { + clearInterval(countdownInterval); + }; + }, [restartingSheetOpen]); + + useEffect(() => { + if (countdown === 0) { + window.location.href = "/"; + } + }, [countdown]); + + const handleForceReload = () => { + window.location.href = "/"; + }; + + const Container = isDesktop ? DropdownMenu : Drawer; + const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; + const Content = isDesktop ? DropdownMenuContent : DrawerContent; + const MenuItem = isDesktop ? DropdownMenuItem : DialogClose; + const SubItem = isDesktop ? DropdownMenuSub : Dialog; + const SubItemTrigger = isDesktop ? DropdownMenuSubTrigger : DialogTrigger; + const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent; + const Portal = isDesktop ? DropdownMenuPortal : DialogPortal; + + return ( + <> +
+ + + + + + + + +

Settings

+
+
+
+
+ +
+ System + + + + + + Storage + + + + + + System metrics + + + + + + System logs + + + + + Configuration + + + + + + + Settings + + + + + + Configuration editor + + + + Appearance + + + + + + Dark Mode + + + + + setTheme("light")} + > + {theme === "light" ? ( + <> + + Light + + ) : ( + Light + )} + + setTheme("dark")} + > + {theme === "dark" ? ( + <> + + Dark + + ) : ( + Dark + )} + + setTheme("system")} + > + {theme === "system" ? ( + <> + + System + + ) : ( + System + )} + + + + + + + + Theme + + + + + {colorSchemes.map((scheme) => ( + setColorScheme(scheme)} + > + {scheme === colorScheme ? ( + <> + + {friendlyColorSchemeName(scheme)} + + ) : ( + + {friendlyColorSchemeName(scheme)} + + )} + + ))} + + + + + + Help + + + + + + Documentation + + + + + + GitHub + + + + setRestartDialogOpen(true)} + > + + Restart Frigate + +
+
+
+
+ {restartDialogOpen && ( + setRestartDialogOpen(false)} + > + + + + Are you sure you want to restart Frigate? + + + + Cancel + { + setRestartingSheetOpen(true); + sendRestart("restart"); + }} + > + Restart + + + + + )} + {restartingSheetOpen && ( + <> + setRestartingSheetOpen(false)} + > + e.preventDefault()} + > +
+ + + + Frigate is Restarting + + +

This page will reload in {countdown} seconds.

+
+
+ +
+
+
+ + )} + + ); +} diff --git a/web/src/components/settings/SettingsNavItems.tsx b/web/src/components/settings/SettingsNavItems.tsx deleted file mode 100644 index a43c7bb91..000000000 --- a/web/src/components/settings/SettingsNavItems.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import { - LuActivity, - LuGithub, - LuHardDrive, - LuLifeBuoy, - LuList, - LuMoon, - LuPenSquare, - LuRotateCw, - LuSettings, - LuSun, - LuSunMoon, -} from "react-icons/lu"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuPortal, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import { Button } from "../ui/button"; -import { Link } from "react-router-dom"; -import { CgDarkMode } from "react-icons/cg"; -import { VscAccount } from "react-icons/vsc"; -import { - colorSchemes, - friendlyColorSchemeName, - useTheme, -} from "@/context/theme-provider"; -import { IoColorPalette } from "react-icons/io5"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../ui/alert-dialog"; -import { useEffect, useState } from "react"; -import { useRestart } from "@/api/ws"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "../ui/sheet"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import ActivityIndicator from "../indicators/activity-indicator"; - -type SettingsNavItemsProps = { - className?: string; -}; -export default function SettingsNavItems({ className }: SettingsNavItemsProps) { - const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); - const [restartDialogOpen, setRestartDialogOpen] = useState(false); - const [restartingSheetOpen, setRestartingSheetOpen] = useState(false); - const [countdown, setCountdown] = useState(60); - - const { send: sendRestart } = useRestart(); - - useEffect(() => { - let countdownInterval: NodeJS.Timeout; - - if (restartingSheetOpen) { - countdownInterval = setInterval(() => { - setCountdown((prevCountdown) => prevCountdown - 1); - }, 1000); - } - - return () => { - clearInterval(countdownInterval); - }; - }, [restartingSheetOpen]); - - useEffect(() => { - if (countdown === 0) { - window.location.href = "/"; - } - }, [countdown]); - - const handleForceReload = () => { - window.location.href = "/"; - }; - - return ( - <> -
- - - - - - - - -

Settings

-
-
-
-
- - System - - - - - - Storage - - - - - - System metrics - - - - - - System logs - - - - - Configuration - - - - - - - Settings - - - - - - Configuration editor - - - Appearance - - - - - Dark Mode - - - - setTheme("light")}> - {theme === "light" ? ( - <> - - Light - - ) : ( - Light - )} - - setTheme("dark")}> - {theme === "dark" ? ( - <> - - Dark - - ) : ( - Dark - )} - - setTheme("system")}> - {theme === "system" ? ( - <> - - System - - ) : ( - System - )} - - - - - - - - Theme - - - - {colorSchemes.map((scheme) => ( - setColorScheme(scheme)} - > - {scheme === colorScheme ? ( - <> - - {friendlyColorSchemeName(scheme)} - - ) : ( - - {friendlyColorSchemeName(scheme)} - - )} - - ))} - - - - - Help - - - - - Documentation - - - - - - GitHub - - - - setRestartDialogOpen(true)}> - - Restart Frigate - - -
- - - - - -

Account

-
-
-
- {restartDialogOpen && ( - setRestartDialogOpen(false)} - > - - - - Are you sure you want to restart Frigate? - - - - Cancel - { - setRestartingSheetOpen(true); - sendRestart("restart"); - }} - > - Restart - - - - - )} - {restartingSheetOpen && ( - <> - setRestartingSheetOpen(false)} - > - e.preventDefault()} - > -
- - - - Frigate is Restarting - - -

This page will reload in {countdown} seconds.

-
-
- -
-
-
- - )} - - ); -} From 99878d9eeeddc8b772e0443d84eba0d5541f7e66 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 1 Apr 2024 10:57:35 -0500 Subject: [PATCH 325/751] use single lookup for motion data (#10778) --- .../timeline/MotionReviewTimeline.tsx | 11 ++++-- web/src/components/timeline/MotionSegment.tsx | 39 +++++++------------ 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index deac2ea44..fef69a75f 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -90,9 +90,13 @@ export function MotionReviewTimeline({ const motionStart = segmentTime; const motionEnd = motionStart + segmentDuration; + const firstHalfMotionValue = getMotionSegmentValue(motionStart); + const secondHalfMotionValue = getMotionSegmentValue( + motionStart + segmentDuration / 2, + ); + const segmentMotion = - getMotionSegmentValue(motionStart) > 0 || - getMotionSegmentValue(motionStart + segmentDuration / 2) > 0; + firstHalfMotionValue > 0 || secondHalfMotionValue > 0; const overlappingReviewItems = events.some( (item) => (item.start_time >= motionStart && item.start_time < motionEnd) || @@ -110,7 +114,8 @@ export function MotionReviewTimeline({ { - return interpolateMotionAudioData( - getMotionSegmentValue(segmentTime), - maxSegmentWidth, - ); - }, [ - segmentTime, - maxSegmentWidth, - getMotionSegmentValue, - interpolateMotionAudioData, - ]); + return interpolateMotionAudioData(firstHalfMotionValue, maxSegmentWidth); + }, [maxSegmentWidth, firstHalfMotionValue, interpolateMotionAudioData]); const secondHalfSegmentWidth = useMemo(() => { - return interpolateMotionAudioData( - getMotionSegmentValue(segmentTime + segmentDuration / 2), - maxSegmentWidth, - ); - }, [ - segmentTime, - segmentDuration, - maxSegmentWidth, - getMotionSegmentValue, - interpolateMotionAudioData, - ]); + return interpolateMotionAudioData(secondHalfMotionValue, maxSegmentWidth); + }, [maxSegmentWidth, secondHalfMotionValue, interpolateMotionAudioData]); const alignedMinimapStartTime = useMemo( () => alignStartDateToTimeline(minimapStartTime ?? 0), From bd70bf1c3169bacd5946dc467bbdfd6c2564dd92 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 1 Apr 2024 11:03:10 -0500 Subject: [PATCH 326/751] fix outlines (#10779) --- web/src/components/card/ReviewCard.tsx | 2 +- web/src/views/events/EventView.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index 353bb6583..66710d6ff 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -42,7 +42,7 @@ export default function ReviewCard({ /> { diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 1524deeea..e0754a275 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -546,7 +546,7 @@ function DetectionReview({ />
); @@ -851,7 +851,7 @@ function MotionReview({ } />
); From a886b6a3e595c8ba4d3e1156ab28b8e0c1f74839 Mon Sep 17 00:00:00 2001 From: faurel Date: Mon, 1 Apr 2024 21:19:24 +0200 Subject: [PATCH 327/751] Support ONVIF exceptions when sending pan-tilt commands (#10777) Co-authored-by: Aurel --- frigate/ptz/onvif.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index d8af877e9..335e2ee29 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -349,7 +349,10 @@ class OnvifController: } } - onvif.get_service("ptz").ContinuousMove(move_request) + try: + onvif.get_service("ptz").ContinuousMove(move_request) + except ONVIFError as e: + logger.warning(f"Onvif sending move request to {camera_name} failed: {e}") def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None: if "pt-r-fov" not in self.cams[camera_name]["features"]: From 4d8d3cd22e131022d63910b00a162093819cc9c5 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 2 Apr 2024 06:45:16 -0600 Subject: [PATCH 328/751] Live view improvements (#10781) * Show frigate features in bottom sheet on mobile * Use flex wrap on mobile so the ptz icons are not cutoff * Support opening pip from live view * Remove unused --- web/src/components/player/LivePlayer.tsx | 4 + web/src/components/player/MsePlayer.tsx | 12 ++ web/src/components/player/WebRTCPlayer.tsx | 13 ++ web/src/views/live/LiveCameraView.tsx | 240 ++++++++++++++++----- 4 files changed, 215 insertions(+), 54 deletions(-) diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index a8fe5d3b1..22559105a 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -22,6 +22,7 @@ type LivePlayerProps = { playAudio?: boolean; micEnabled?: boolean; // only webrtc supports mic iOSCompatFullScreen?: boolean; + pip?: boolean; onClick?: () => void; }; @@ -35,6 +36,7 @@ export default function LivePlayer({ playAudio = false, micEnabled = false, iOSCompatFullScreen = false, + pip, onClick, }: LivePlayerProps) { // camera activity @@ -105,6 +107,7 @@ export default function LivePlayer({ microphoneEnabled={micEnabled} iOSCompatFullScreen={iOSCompatFullScreen} onPlaying={() => setLiveReady(true)} + pip={pip} /> ); } else if (liveMode == "mse") { @@ -116,6 +119,7 @@ export default function LivePlayer({ playbackEnabled={cameraActive} audioEnabled={playAudio} onPlaying={() => setLiveReady(true)} + pip={pip} /> ); } else { diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index 42ae9aab6..7c88780a8 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -6,6 +6,7 @@ type MSEPlayerProps = { className?: string; playbackEnabled?: boolean; audioEnabled?: boolean; + pip?: boolean; onPlaying?: () => void; }; @@ -14,6 +15,7 @@ function MSEPlayer({ className, playbackEnabled = true, audioEnabled = false, + pip = false, onPlaying, }: MSEPlayerProps) { let connectTS: number = 0; @@ -268,6 +270,16 @@ function MSEPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [playbackEnabled, onDisconnect, onConnect]); + // control pip + + useEffect(() => { + if (!videoRef.current || !pip) { + return; + } + + videoRef.current.requestPictureInPicture(); + }, [pip, videoRef]); + return (
@@ -333,6 +322,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { micEnabled={mic} iOSCompatFullScreen={isIOS} preferredLiveMode={preferredLiveMode} + pip={pip} />
{camera.onvif.host != "" && ( @@ -405,7 +395,7 @@ function PtzControlPanel({ ); return ( -
+
{ptz?.features?.includes("pt") && ( <>
); diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index bbd59e49b..91b8f1839 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -329,20 +329,22 @@ export default function Events() { return ; } - if (selectedReviewData) { - return ( - - ); + if (recording) { + if (selectedReviewData) { + return ( + + ); + } } else { return ( navigate(-1)} > diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 42a30f55f..9480da644 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -22,6 +22,7 @@ import { useResizeObserver } from "@/hooks/resize-observer"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { CameraConfig } from "@/types/frigateConfig"; import { CameraPtzInfo } from "@/types/ptz"; +import { RecordingStartingPoint } from "@/types/record"; import React, { useCallback, useEffect, @@ -50,10 +51,11 @@ import { } from "react-icons/fa"; import { GiSpeaker, GiSpeakerOff } from "react-icons/gi"; import { HiViewfinderCircle } from "react-icons/hi2"; -import { IoMdArrowBack } from "react-icons/io"; +import { IoMdArrowRoundBack } from "react-icons/io"; import { LuEar, LuEarOff, + LuHistory, LuPictureInPicture, LuVideo, LuVideoOff, @@ -218,14 +220,37 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { } > {!fullscreen ? ( - +
+ + +
) : (
)} diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index b2a0126ba..08e75a184 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -27,8 +27,8 @@ --secondary: hsl(0, 0%, 96%); --secondary: 0 0% 96%; - --secondary-foreground: hsl(0, 0%, 45%); - --secondary-foreground: 0 0% 45%; + --secondary-foreground: hsl(0, 0%, 32%); + --secondary-foreground: 0 0% 32%; --secondary-highlight: hsl(0, 0%, 94%); --secondary-highlight: 0 0% 94%; From 15dcf1fcc890ea16e548e2bab0b36d41b2e86c22 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 2 Apr 2024 13:39:11 -0600 Subject: [PATCH 332/751] Don't stop mse in background when using pip (#10794) * Don't stop mse when pip is enabled and not visible * Cleanup visibility listener --- web/src/components/player/MsePlayer.tsx | 55 ++++++++++++------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index 7c88780a8..f25557d16 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -33,8 +33,7 @@ function MSEPlayer({ "opus", // OPUS Chrome, Firefox ]; - const visibilityThreshold: number = 0; - const visibilityCheck: boolean = true; + const visibilityCheck: boolean = !pip; const [wsState, setWsState] = useState(WebSocket.CLOSED); @@ -235,32 +234,6 @@ function MSEPlayer({ // @ts-expect-error for typing msRef.current = new MediaSourceConstructor(); - if ("hidden" in document && visibilityCheck) { - document.addEventListener("visibilitychange", () => { - if (document.hidden) { - onDisconnect(); - } else if (videoRef.current?.isConnected) { - onConnect(); - } - }); - } - - if ("IntersectionObserver" in window && visibilityThreshold) { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) { - onDisconnect(); - } else if (videoRef.current?.isConnected) { - onConnect(); - } - }); - }, - { threshold: visibilityThreshold }, - ); - observer.observe(videoRef.current!); - } - onConnect(); return () => { @@ -270,6 +243,32 @@ function MSEPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [playbackEnabled, onDisconnect, onConnect]); + // check visibility + + useEffect(() => { + if (!playbackEnabled || !visibilityCheck) { + return; + } + + if (!("hidden" in document)) { + return; + } + + const listener = () => { + if (document.hidden) { + onDisconnect(); + } else if (videoRef.current?.isConnected) { + onConnect(); + } + }; + + document.addEventListener("visibilitychange", listener); + + return () => { + document.removeEventListener("visibilitychange", listener); + }; + }, [playbackEnabled, visibilityCheck, onConnect, onDisconnect]); + // control pip useEffect(() => { From 476a900708592870d0ec0f9974b0535a4b6bb4de Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 3 Apr 2024 08:02:07 -0600 Subject: [PATCH 333/751] Add ability to rename exports (#10791) * Add ability to rename exports * Address feedback --- frigate/record/export.py | 2 +- web/src/components/card/ExportCard.tsx | 198 ++++++++++++++++++------- web/src/pages/Export.tsx | 15 +- 3 files changed, 156 insertions(+), 59 deletions(-) diff --git a/frigate/record/export.py b/frigate/record/export.py index f5861d4f7..2a5b46fa3 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -61,7 +61,7 @@ class RecordingExporter(threading.Thread): ) file_name = ( self.user_provided_name - or f"{self.camera}@{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}" + or f"{self.camera}_{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}" ) file_path = f"{EXPORT_DIR}/in_progress.{file_name}.mp4" final_file_path = f"{EXPORT_DIR}/{file_name}.mp4" diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 8d5d2eef7..7431781ea 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -1,21 +1,25 @@ import { baseUrl } from "@/api/baseUrl"; import ActivityIndicator from "../indicators/activity-indicator"; -import { LuTrash } from "react-icons/lu"; +import { LuPencil, LuTrash } from "react-icons/lu"; import { Button } from "../ui/button"; import { useMemo, useRef, useState } from "react"; import { isDesktop } from "react-device-detect"; import { FaPlay } from "react-icons/fa"; import Chip from "../indicators/Chip"; import { Skeleton } from "../ui/skeleton"; +import { Dialog, DialogContent, DialogFooter, DialogTitle } from "../ui/dialog"; +import { Input } from "../ui/input"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; type ExportProps = { file: { name: string; }; + onRename: (original: string, update: string) => void; onDelete: (file: string) => void; }; -export default function ExportCard({ file, onDelete }: ExportProps) { +export default function ExportCard({ file, onRename, onDelete }: ExportProps) { const videoRef = useRef(null); const [hovered, setHovered] = useState(false); const [playing, setPlaying] = useState(false); @@ -25,63 +29,143 @@ export default function ExportCard({ file, onDelete }: ExportProps) { [file.name], ); + // editing name + + const [editName, setEditName] = useState<{ + original: string; + update: string; + }>(); + + useKeyboardListener( + editName != undefined ? ["Enter"] : [], + (_, down, repeat) => { + if (down && !repeat && editName && editName.update.length > 0) { + onRename(editName.original, editName.update.replaceAll(" ", "_")); + setEditName(undefined); + } + }, + ); + return ( -
setHovered(true) : undefined - } - onMouseLeave={ - isDesktop && !inProgress ? () => setHovered(false) : undefined - } - onClick={isDesktop || inProgress ? undefined : () => setHovered(!hovered)} - > - {!playing && hovered && ( - <> -
- onDelete(file.name)} + <> + { + if (!open) { + setEditName(undefined); + } + }} + > + + Rename Export + {editName && ( + <> + + setEditName({ + original: editName.original ?? "", + update: e.target.value, + }) + } + /> + + + + + )} + + + +
setHovered(true) : undefined + } + onMouseLeave={ + isDesktop && !inProgress ? () => setHovered(false) : undefined + } + onClick={ + isDesktop || inProgress ? undefined : () => setHovered(!hovered) + } + > + {hovered && ( + <> + {!playing && ( +
+ )} +
+ setEditName({ original: file.name, update: "" })} + > + + + onDelete(file.name)} + > + + +
+ {!playing && ( + + )} + + )} + {inProgress ? ( + + ) : ( +
+ ); } diff --git a/web/src/pages/Export.tsx b/web/src/pages/Export.tsx index ff6f5765f..a5f775419 100644 --- a/web/src/pages/Export.tsx +++ b/web/src/pages/Export.tsx @@ -26,6 +26,18 @@ function Export() { const [deleteClip, setDeleteClip] = useState(); + const onHandleRename = useCallback( + (original: string, update: string) => { + axios.patch(`export/${original}/${update}`).then((response) => { + if (response.status == 200) { + setDeleteClip(undefined); + mutate(); + } + }); + }, + [mutate], + ); + const onHandleDelete = useCallback(() => { if (!deleteClip) { return; @@ -61,13 +73,14 @@ function Export() { -
+
{exports && (
{Object.values(exports).map((item) => ( setDeleteClip(file)} /> ))} From 59335c06283d91d1556da7c6427484e86f4e8378 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 3 Apr 2024 10:55:13 -0600 Subject: [PATCH 334/751] Redesign log page and pull logs in chunks (#10809) * Redesign log page to have formatting * Support other log types as well * fix border * Support log data format * Only load necessary logs * Load incrementally * Cleanup * Cleanup * Render all items * avoid flashing scroll to bottom * Fix not listening at first * Always ensure logLine is defined * Group logs based on timestamp * Formatting * remove scrollbar * Don't repull when there are no items to pull * Add newline to end * Fix log lines missing * typo --- frigate/api/app.py | 49 ++++- web/src/pages/Logs.tsx | 399 +++++++++++++++++++++++++++++++++++++---- web/src/types/log.ts | 13 ++ web/vite.config.ts | 12 +- 4 files changed, 428 insertions(+), 45 deletions(-) create mode 100644 web/src/types/log.ts diff --git a/frigate/api/app.py b/frigate/api/app.py index 6fdedab90..3900aac05 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -9,14 +9,7 @@ from datetime import datetime, timedelta from functools import reduce import requests -from flask import ( - Blueprint, - Flask, - current_app, - jsonify, - make_response, - request, -) +from flask import Blueprint, Flask, current_app, jsonify, make_response, request from markupsafe import escape from peewee import operator from playhouse.sqliteq import SqliteQueueDatabase @@ -425,11 +418,49 @@ def logs(service: str): 404, ) + start = request.args.get("start", type=int, default=0) + end = request.args.get("end", type=int) + try: file = open(service_location, "r") contents = file.read() file.close() - return contents, 200 + + # use the start timestamp to group logs together`` + logLines = [] + keyLength = 0 + dateEnd = 0 + currentKey = "" + currentLine = "" + + for rawLine in contents.splitlines(): + cleanLine = rawLine.strip() + + if len(cleanLine) < 10: + continue + + if dateEnd == 0: + dateEnd = cleanLine.index(" ") + keyLength = dateEnd - (6 if service_location == "frigate" else 0) + + newKey = cleanLine[0:keyLength] + + if newKey == currentKey: + currentLine += f"\n{cleanLine[dateEnd:].strip()}" + continue + else: + if len(currentLine) > 0: + logLines.append(currentLine) + + currentKey = newKey + currentLine = cleanLine + + logLines.append(currentLine) + + return make_response( + jsonify({"totalLines": len(logLines), "lines": logLines[start:end]}), + 200, + ) except FileNotFoundError as e: logger.error(e) return make_response( diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index e8ca9002f..3983a38d2 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -1,56 +1,283 @@ import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { LogData, LogLine, LogSeverity } from "@/types/log"; import copy from "copy-to-clipboard"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { IoIosAlert } from "react-icons/io"; +import { GoAlertFill } from "react-icons/go"; import { LuCopy } from "react-icons/lu"; -import useSWR from "swr"; +import axios from "axios"; const logTypes = ["frigate", "go2rtc", "nginx"] as const; type LogType = (typeof logTypes)[number]; +type LogRange = { start: number; end: number }; + +const frigateDateStamp = /\[[\d\s-:]*]/; +const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/; +const frigateSection = /[\w.]*/; + +const goSeverity = /(DEB )|(INF )|(WARN )|(ERR )/; +const goSection = /\[[\w]*]/; + +const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/; + function Logs() { const [logService, setLogService] = useState("frigate"); - const { data: frigateLogs } = useSWR("logs/frigate", { - refreshInterval: 1000, - }); - const { data: go2rtcLogs } = useSWR("logs/go2rtc", { refreshInterval: 1000 }); - const { data: nginxLogs } = useSWR("logs/nginx", { refreshInterval: 1000 }); - const logs = useMemo(() => { - if (logService == "frigate") { - return frigateLogs; - } else if (logService == "go2rtc") { - return go2rtcLogs; - } else if (logService == "nginx") { - return nginxLogs; - } else { - return "unknown logs"; + // log data handling + + const [logRange, setLogRange] = useState({ start: 0, end: 0 }); + const [logs, setLogs] = useState([]); + + useEffect(() => { + axios + .get(`logs/${logService}?start=-100`) + .then((resp) => { + if (resp.status == 200) { + const data = resp.data as LogData; + setLogRange({ + start: Math.max(0, data.totalLines - 100), + end: data.totalLines, + }); + setLogs(data.lines); + } + }) + .catch(() => {}); + }, [logService]); + + useEffect(() => { + if (!logs || logs.length == 0) { + return; } - }, [logService, frigateLogs, go2rtcLogs, nginxLogs]); + + const id = setTimeout(() => { + axios + .get(`logs/${logService}?start=${logRange.end}`) + .then((resp) => { + if (resp.status == 200) { + const data = resp.data as LogData; + + if (data.lines.length > 0) { + setLogRange({ + start: logRange.start, + end: data.totalLines, + }); + setLogs([...logs, ...data.lines]); + } + } + }) + .catch(() => {}); + }, 5000); + + return () => { + if (id) { + clearTimeout(id); + } + }; + }, [logs, logService, logRange]); + + // convert to log data + + const logLines = useMemo(() => { + if (!logs) { + return []; + } + + if (logService == "frigate") { + return logs + .map((line) => { + const match = frigateDateStamp.exec(line); + + if (!match) { + const infoIndex = line.indexOf("[INFO]"); + + if (infoIndex != -1) { + return { + dateStamp: line.substring(0, 19), + severity: "info", + section: "startup", + content: line.substring(infoIndex + 6).trim(), + }; + } + + return null; + } + + const sectionMatch = frigateSection.exec( + line.substring(match.index + match[0].length).trim(), + ); + + if (!sectionMatch) { + return null; + } + + return { + dateStamp: match.toString().slice(1, -1), + severity: frigateSeverity + .exec(line) + ?.at(0) + ?.toString() + ?.toLowerCase() as LogSeverity, + section: sectionMatch.toString(), + content: line + .substring(line.indexOf(":", match.index + match[0].length) + 2) + .trim(), + }; + }) + .filter((value) => value != null) as LogLine[]; + } else if (logService == "go2rtc") { + return logs + .map((line) => { + if (line.length == 0) { + return null; + } + + const severity = goSeverity.exec(line); + + let section = + goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup"; + + if (frigateSeverity.exec(section)) { + section = "startup"; + } + + let contentStart; + + if (section == "startup") { + if (severity) { + contentStart = severity.index + severity[0].length; + } else { + contentStart = line.lastIndexOf("]") + 1; + } + } else { + contentStart = line.indexOf(section) + section.length + 2; + } + + return { + dateStamp: line.substring(0, 19), + severity: "INFO", + section: section, + content: line.substring(contentStart).trim(), + }; + }) + .filter((value) => value != null) as LogLine[]; + } else if (logService == "nginx") { + return logs + .map((line) => { + if (line.length == 0) { + return null; + } + + return { + dateStamp: line.substring(0, 19), + severity: "INFO", + section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META", + content: line.substring(line.indexOf(" ", 20)).trim(), + }; + }) + .filter((value) => value != null) as LogLine[]; + } else { + return []; + } + }, [logs, logService]); const handleCopyLogs = useCallback(() => { - copy(logs); + if (logs) { + copy(logs.join("\n")); + } }, [logs]); - // scroll to bottom button + // scroll to bottom + + const [initialScroll, setInitialScroll] = useState(false); const contentRef = useRef(null); const [endVisible, setEndVisible] = useState(true); - const observer = useRef(null); + const endObserver = useRef(null); const endLogRef = useCallback( (node: HTMLElement | null) => { - if (observer.current) observer.current.disconnect(); + if (endObserver.current) endObserver.current.disconnect(); try { - observer.current = new IntersectionObserver((entries) => { + endObserver.current = new IntersectionObserver((entries) => { setEndVisible(entries[0].isIntersecting); }); - if (node) observer.current.observe(node); + if (node) endObserver.current.observe(node); } catch (e) { // no op } }, [setEndVisible], ); + const startObserver = useRef(null); + const startLogRef = useCallback( + (node: HTMLElement | null) => { + if (startObserver.current) startObserver.current.disconnect(); + + if (logs.length == 0 || !initialScroll) { + return; + } + + try { + startObserver.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && logRange.start > 0) { + const start = Math.max(0, logRange.start - 100); + + axios + .get(`logs/${logService}?start=${start}&end=${logRange.start}`) + .then((resp) => { + if (resp.status == 200) { + const data = resp.data as LogData; + + if (data.lines.length > 0) { + setLogRange({ + start: start, + end: logRange.end, + }); + setLogs([...data.lines, ...logs]); + } + } + }) + .catch(() => {}); + } + }); + if (node) startObserver.current.observe(node); + } catch (e) { + // no op + } + }, + // we need to listen on the current range of visible items + // eslint-disable-next-line react-hooks/exhaustive-deps + [logRange, initialScroll], + ); + + useEffect(() => { + if (logLines.length == 0) { + setInitialScroll(false); + return; + } + + if (initialScroll) { + return; + } + + if (!contentRef.current) { + return; + } + + if (contentRef.current.scrollHeight <= contentRef.current.clientHeight) { + setInitialScroll(true); + return; + } + + contentRef.current?.scrollTo({ + top: contentRef.current?.scrollHeight, + behavior: "instant", + }); + setTimeout(() => setInitialScroll(true), 300); + // we need to listen on the current range of visible items + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logLines, logService]); return (
@@ -60,9 +287,12 @@ function Logs() { type="single" size="sm" value={logService} - onValueChange={(value: LogType) => - value ? setLogService(value) : null - } // don't allow the severity to be unselected + onValueChange={(value: LogType) => { + if (value) { + setLogs([]); + setLogService(value); + } + }} // don't allow the severity to be unselected > {Object.values(logTypes).map((item) => (
- {!endVisible && ( + {initialScroll && !endVisible && ( + )}
); diff --git a/web/src/types/log.ts b/web/src/types/log.ts new file mode 100644 index 000000000..a9a4342ff --- /dev/null +++ b/web/src/types/log.ts @@ -0,0 +1,13 @@ +export type LogData = { + totalLines: number; + lines: string[]; +}; + +export type LogSeverity = "info" | "warning" | "error" | "debug"; + +export type LogLine = { + dateStamp: string; + severity: LogSeverity; + section: string; + content: string; +}; diff --git a/web/vite.config.ts b/web/vite.config.ts index 5afefa331..cc3ead707 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -12,24 +12,24 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "http://localhost:5000", + target: "http://192.168.50.106:5000", ws: true, }, "/vod": { - target: "http://localhost:5000", + target: "http://192.168.50.106:5000", }, "/clips": { - target: "http://localhost:5000", + target: "http://192.168.50.106:5000", }, "/exports": { - target: "http://localhost:5000", + target: "http://192.168.50.106:5000", }, "/ws": { - target: "ws://localhost:5000", + target: "ws://192.168.50.106:5000", ws: true, }, "/live": { - target: "ws://localhost:5000", + target: "ws://192.168.50.106:5000", changeOrigin: true, ws: true, }, From 483d64e41924c4e29b36f4c1b146403f3cb1d6d1 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 3 Apr 2024 10:56:04 -0600 Subject: [PATCH 335/751] Don't show warning for disabled cameras (#10811) --- web/src/components/Statusbar.tsx | 5 ++++- web/src/hooks/use-stats.ts | 12 ++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index 52bbe3114..a210db508 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -87,7 +87,10 @@ export default function Statusbar() {
{potentialProblems.map((prob) => ( -
+
{prob.text}
diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 57461d063..18346538d 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -1,7 +1,11 @@ +import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateStats, PotentialProblem } from "@/types/stats"; import { useMemo } from "react"; +import useSWR from "swr"; export default function useStats(stats: FrigateStats | undefined) { + const { data: config } = useSWR("config"); + const potentialProblems = useMemo(() => { const problems: PotentialProblem[] = []; @@ -26,7 +30,11 @@ export default function useStats(stats: FrigateStats | undefined) { // check for offline cameras Object.entries(stats["cameras"]).forEach(([name, cam]) => { - if (cam["camera_fps"] == 0) { + if (!config) { + return; + } + + if (config.cameras[name].enabled && cam["camera_fps"] == 0) { problems.push({ text: `${name.replaceAll("_", " ")} is offline`, color: "text-danger", @@ -59,7 +67,7 @@ export default function useStats(stats: FrigateStats | undefined) { }); return problems; - }, [stats]); + }, [config, stats]); return { potentialProblems }; } From 427c6a6afb4fc60c880d8f38c20818503e971c48 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:20:47 -0500 Subject: [PATCH 336/751] Timeline tweaks (#10816) * limit handles from overdragging when segments don't fill up timeline * use separate state for switch * add key --- .../components/filter/ReviewFilterGroup.tsx | 15 ++++++++----- .../components/timeline/ReviewTimeline.tsx | 6 +++++- web/src/hooks/use-draggable-element.ts | 21 +++++++++++++++---- web/src/views/events/EventView.tsx | 2 +- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 1803cac81..4d2e1db8e 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -2,7 +2,7 @@ import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -631,16 +631,21 @@ function ShowMotionOnlyButton({ motionOnly, setMotionOnly, }: ShowMotionOnlyButtonProps) { + const [motionOnlyButton, setMotionOnlyButton] = useState(motionOnly); + + useEffect( + () => setMotionOnly(motionOnlyButton), + [motionOnlyButton, setMotionOnly], + ); + return ( <>
{ - setMotionOnly(!motionOnly); - }} + checked={motionOnlyButton} + onCheckedChange={setMotionOnlyButton} />