diff --git a/docker/hailo8l/Dockerfile b/docker/hailo8l/Dockerfile index 68ab09001..959e7692e 100644 --- a/docker/hailo8l/Dockerfile +++ b/docker/hailo8l/Dockerfile @@ -36,8 +36,5 @@ RUN pip3 install -U /deps/hailo-wheels/*.whl # Copy base files from the rootfs stage COPY --from=rootfs / / -# Set Library path for hailo driver -ENV LD_LIBRARY_PATH=/rootfs/usr/local/lib/ - # Set workdir WORKDIR /opt/frigate/ diff --git a/docker/hailo8l/install_hailort.sh b/docker/hailo8l/install_hailort.sh index 004db86c9..62eba9611 100755 --- a/docker/hailo8l/install_hailort.sh +++ b/docker/hailo8l/install_hailort.sh @@ -10,10 +10,8 @@ elif [[ "${TARGETARCH}" == "arm64" ]]; then arch="aarch64" fi -mkdir -p /rootfs - wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${TARGETARCH}.tar.gz" | - tar -C /rootfs/ -xzf - + tar -C / -xzf - mkdir -p /hailo-wheels diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index e3599e628..ede0fa897 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -193,6 +193,7 @@ services: container_name: frigate privileged: true # this may not be necessary for all setups restart: unless-stopped + stop_grace_period: 30s # allow enough time to shut down the various services image: ghcr.io/blakeblackshear/frigate:stable shm_size: "512mb" # update for your cameras based on calculation above devices: @@ -224,6 +225,7 @@ If you can't use docker compose, you can run the container with something simila docker run -d \ --name frigate \ --restart=unless-stopped \ + --stop-timeout 30 \ --mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \ --device /dev/bus/usb:/dev/bus/usb \ --device /dev/dri/renderD128 \ diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index 829612bb0..bb880b8f0 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -115,6 +115,7 @@ services: frigate: container_name: frigate restart: unless-stopped + stop_grace_period: 30s image: ghcr.io/blakeblackshear/frigate:stable volumes: - ./config:/config diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 325af2850..5e4ecdbdc 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -3225,7 +3225,7 @@ components: title: Sub Label score: anyOf: - - type: integer + - type: number - type: 'null' title: Score default: 0 @@ -3264,7 +3264,7 @@ components: properties: end_time: anyOf: - - type: integer + - type: number - type: 'null' title: End Time type: object diff --git a/frigate/api/defs/events_body.py b/frigate/api/defs/events_body.py index ca1256598..1c8576f02 100644 --- a/frigate/api/defs/events_body.py +++ b/frigate/api/defs/events_body.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import List, Optional, Union from pydantic import BaseModel, Field @@ -17,14 +17,18 @@ class EventsDescriptionBody(BaseModel): class EventsCreateBody(BaseModel): source_type: Optional[str] = "api" sub_label: Optional[str] = None - score: Optional[int] = 0 + score: Optional[float] = 0 duration: Optional[int] = 30 include_recording: Optional[bool] = True draw: Optional[dict] = {} class EventsEndBody(BaseModel): - end_time: Optional[int] = None + end_time: Optional[float] = None + + +class EventsDeleteBody(BaseModel): + event_ids: List[str] = Field(title="The event IDs to delete") class SubmitPlusBody(BaseModel): diff --git a/frigate/api/event.py b/frigate/api/event.py index bff1edc1a..fafa28272 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -16,6 +16,7 @@ from playhouse.shortcuts import model_to_dict from frigate.api.defs.events_body import ( EventsCreateBody, + EventsDeleteBody, EventsDescriptionBody, EventsEndBody, EventsSubLabelBody, @@ -35,8 +36,9 @@ from frigate.const import ( CLIPS_DIR, ) from frigate.embeddings import EmbeddingsContext +from frigate.events.external import ExternalEventProcessor from frigate.models import Event, ReviewSegment, Timeline -from frigate.object_processing import TrackedObject +from frigate.object_processing import TrackedObject, TrackedObjectProcessor from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) @@ -1035,34 +1037,64 @@ def regenerate_description( ) -@router.delete("/events/{event_id}") -def delete_event(request: Request, event_id: str): +def delete_single_event(event_id: str, request: Request) -> dict: try: event = Event.get(Event.id == event_id) except DoesNotExist: - return JSONResponse( - content=({"success": False, "message": "Event " + event_id + " not found"}), - status_code=404, - ) + return {"success": False, "message": f"Event {event_id} not found"} media_name = f"{event.camera}-{event.id}" if event.has_snapshot: - media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") - media.unlink(missing_ok=True) - media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png") - media.unlink(missing_ok=True) + snapshot_paths = [ + Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg"), + Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"), + ] + for media in snapshot_paths: + media.unlink(missing_ok=True) event.delete_instance() Timeline.delete().where(Timeline.source_id == event_id).execute() + # If semantic search is enabled, update the index if request.app.frigate_config.semantic_search.enabled: context: EmbeddingsContext = request.app.embeddings context.db.delete_embeddings_thumbnail(event_ids=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id]) - return JSONResponse( - content=({"success": True, "message": "Event " + event_id + " deleted"}), - status_code=200, - ) + + return {"success": True, "message": f"Event {event_id} deleted"} + + +@router.delete("/events/{event_id}") +def delete_event(request: Request, event_id: str): + result = delete_single_event(event_id, request) + status_code = 200 if result["success"] else 404 + return JSONResponse(content=result, status_code=status_code) + + +@router.delete("/events/") +def delete_events(request: Request, body: EventsDeleteBody): + if not body.event_ids: + return JSONResponse( + content=({"success": False, "message": "No event IDs provided."}), + status_code=404, + ) + + deleted_events = [] + not_found_events = [] + + for event_id in body.event_ids: + result = delete_single_event(event_id, request) + if result["success"]: + deleted_events.append(event_id) + else: + not_found_events.append(event_id) + + response = { + "success": True, + "deleted_events": deleted_events, + "not_found_events": not_found_events, + } + return JSONResponse(content=response, status_code=200) @router.post("/events/{camera_name}/{label}/create") @@ -1087,9 +1119,11 @@ def create_event( ) try: - frame = request.app.detected_frames_processor.get_current_frame(camera_name) + frame_processor: TrackedObjectProcessor = request.app.detected_frames_processor + external_processor: ExternalEventProcessor = request.app.external_processor - event_id = request.app.external_processor.create_manual_event( + frame = frame_processor.get_current_frame(camera_name) + event_id = external_processor.create_manual_event( camera_name, label, body.source_type, diff --git a/frigate/api/media.py b/frigate/api/media.py index dcfc44f89..a90766899 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -36,6 +36,7 @@ from frigate.const import ( RECORD_DIR, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment +from frigate.object_processing import TrackedObjectProcessor from frigate.util.builtin import get_tz_modifiers from frigate.util.image import get_image_from_recording @@ -79,7 +80,11 @@ def mjpeg_feed( def imagestream( - detected_frames_processor, camera_name: str, fps: int, height: int, draw_options + detected_frames_processor: TrackedObjectProcessor, + camera_name: str, + fps: int, + height: int, + draw_options: dict[str, any], ): while True: # max out at specified FPS @@ -118,6 +123,7 @@ def latest_frame( extension: Extension, params: MediaLatestFrameQueryParams = Depends(), ): + frame_processor: TrackedObjectProcessor = request.app.detected_frames_processor draw_options = { "bounding_boxes": params.bbox, "timestamp": params.timestamp, @@ -129,17 +135,14 @@ def latest_frame( quality = params.quality if camera_name in request.app.frigate_config.cameras: - frame = request.app.detected_frames_processor.get_current_frame( - camera_name, draw_options - ) + frame = frame_processor.get_current_frame(camera_name, draw_options) retry_interval = float( request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval or 10 ) if frame is None or datetime.now().timestamp() > ( - request.app.detected_frames_processor.get_current_frame_time(camera_name) - + retry_interval + frame_processor.get_current_frame_time(camera_name) + retry_interval ): if request.app.camera_error_image is None: error_image = glob.glob("/opt/frigate/frigate/images/camera-error.jpg") @@ -180,7 +183,7 @@ def latest_frame( ) elif camera_name == "birdseye" and request.app.frigate_config.birdseye.restream: frame = cv2.cvtColor( - request.app.detected_frames_processor.get_current_frame(camera_name), + frame_processor.get_current_frame(camera_name), cv2.COLOR_YUV2BGR_I420, ) @@ -813,15 +816,15 @@ def grid_snapshot( ): if camera_name in request.app.frigate_config.cameras: detect = request.app.frigate_config.cameras[camera_name].detect - frame = request.app.detected_frames_processor.get_current_frame(camera_name, {}) + frame_processor: TrackedObjectProcessor = request.app.detected_frames_processor + frame = frame_processor.get_current_frame(camera_name, {}) retry_interval = float( request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval or 10 ) if frame is None or datetime.now().timestamp() > ( - request.app.detected_frames_processor.get_current_frame_time(camera_name) - + retry_interval + frame_processor.get_current_frame_time(camera_name) + retry_interval ): return JSONResponse( content={"success": False, "message": "Unable to get valid frame"}, diff --git a/frigate/app.py b/frigate/app.py index 6518c1ddf..34dcf3cd7 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -36,6 +36,7 @@ from frigate.const import ( EXPORT_DIR, MODEL_CACHE_DIR, RECORD_DIR, + SHM_FRAMES_VAR, ) from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.embeddings import EmbeddingsContext, manage_embeddings @@ -436,7 +437,7 @@ class FrigateApp: # pre-create shms for i in range(shm_frame_count): frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1] - self.frame_manager.create(f"{config.name}{i}", frame_size) + self.frame_manager.create(f"{config.name}_{i}", frame_size) capture_process = util.Process( target=capture_camera, @@ -523,7 +524,10 @@ class FrigateApp: if cam_total_frame_size == 0.0: return 0 - shm_frame_count = min(200, int(available_shm / (cam_total_frame_size))) + shm_frame_count = min( + int(os.environ.get(SHM_FRAMES_VAR, "50")), + int(available_shm / (cam_total_frame_size)), + ) logger.debug( f"Calculated total camera size {available_shm} / {cam_total_frame_size} :: {shm_frame_count} frames for each camera in SHM" diff --git a/frigate/config/config.py b/frigate/config/config.py index 8fbb9ec6c..8c0b52e92 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -230,12 +230,16 @@ def verify_recording_segments_setup_with_reasonable_time( try: seg_arg_index = record_args.index("-segment_time") except ValueError: - raise ValueError(f"Camera {camera_config.name} has no segment_time in \ - recording output args, segment args are required for record.") + raise ValueError( + f"Camera {camera_config.name} has no segment_time in \ + recording output args, segment args are required for record." + ) if int(record_args[seg_arg_index + 1]) > 60: - raise ValueError(f"Camera {camera_config.name} has invalid segment_time output arg, \ - segment_time must be 60 or less.") + raise ValueError( + f"Camera {camera_config.name} has invalid segment_time output arg, \ + segment_time must be 60 or less." + ) def verify_zone_objects_are_tracked(camera_config: CameraConfig) -> None: diff --git a/frigate/const.py b/frigate/const.py index c83b10e73..5976f47b1 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -13,6 +13,8 @@ FRIGATE_LOCALHOST = "http://127.0.0.1:5000" PLUS_ENV_VAR = "PLUS_API_KEY" PLUS_API_HOST = "https://api.frigate.video" +SHM_FRAMES_VAR = "SHM_MAX_FRAMES" + # Attribute & Object constants DEFAULT_ATTRIBUTE_LABEL_MAP = { diff --git a/frigate/events/audio.py b/frigate/events/audio.py index 80d035894..7675f821b 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -216,6 +216,10 @@ class AudioEventMaintainer(threading.Thread): "label": label, "last_detection": datetime.datetime.now().timestamp(), } + else: + self.logger.warning( + f"Failed to create audio event with status code {resp.status_code}" + ) def expire_detections(self) -> None: now = datetime.datetime.now().timestamp() diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index 8ae38b534..5400cc660 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -110,7 +110,7 @@ class EventCleanup(threading.Thread): .namedtuples() .iterator() ) - logger.debug(f"{len(expired_events)} events can be expired") + logger.debug(f"{len(list(expired_events))} events can be expired") # delete the media from disk for expired in expired_events: media_name = f"{expired.camera}-{expired.id}" diff --git a/frigate/events/external.py b/frigate/events/external.py index 52ff5ffb7..0d3408975 100644 --- a/frigate/events/external.py +++ b/frigate/events/external.py @@ -10,6 +10,7 @@ from enum import Enum from typing import Optional import cv2 +from numpy import ndarray from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.events_updater import EventUpdatePublisher @@ -45,7 +46,7 @@ class ExternalEventProcessor: duration: Optional[int], include_recording: bool, draw: dict[str, any], - snapshot_frame: any, + snapshot_frame: Optional[ndarray], ) -> str: now = datetime.datetime.now().timestamp() camera_config = self.config.cameras.get(camera) @@ -107,6 +108,7 @@ class ExternalEventProcessor: EventTypeEnum.api, EventStateEnum.end, None, + "", {"id": event_id, "end_time": end_time}, ) ) @@ -131,8 +133,11 @@ class ExternalEventProcessor: label: str, event_id: str, draw: dict[str, any], - img_frame: any, - ) -> str: + img_frame: Optional[ndarray], + ) -> Optional[str]: + if img_frame is None: + return None + # write clean snapshot if enabled if camera_config.snapshots.clean_copy: ret, png = cv2.imencode(".png", img_frame) diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 937c935ba..ef23c3de3 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -6,7 +6,7 @@ import queue import threading from collections import Counter, defaultdict from multiprocessing.synchronize import Event as MpEvent -from typing import Callable +from typing import Callable, Optional import cv2 import numpy as np @@ -784,13 +784,18 @@ class TrackedObjectProcessor(threading.Thread): else: return {} - def get_current_frame(self, camera, draw_options={}): + def get_current_frame( + self, camera: str, draw_options: dict[str, any] = {} + ) -> Optional[np.ndarray]: if camera == "birdseye": return self.frame_manager.get( "birdseye", (self.config.birdseye.height * 3 // 2, self.config.birdseye.width), ) + if camera not in self.camera_states: + return None + return self.camera_states[camera].get_current_frame(draw_options) def get_current_frame_time(self, camera) -> int: diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 23a42e7a7..de137cb26 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -480,7 +480,9 @@ class ReviewSegmentMaintainer(threading.Thread): if not self.config.cameras[camera].record.enabled: if current_segment: - self.update_existing_segment(current_segment, frame_time, []) + self.update_existing_segment( + current_segment, frame_name, frame_time, [] + ) continue diff --git a/frigate/util/image.py b/frigate/util/image.py index 7b22c138e..301da9c6a 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -222,16 +222,25 @@ def draw_box_with_label( # set the text start position if position == "ul": text_offset_x = x_min - text_offset_y = 0 if y_min < line_height else y_min - (line_height + 8) + text_offset_y = max(0, y_min - (line_height + 8)) elif position == "ur": - text_offset_x = x_max - (text_width + 8) - text_offset_y = 0 if y_min < line_height else y_min - (line_height + 8) + text_offset_x = max(0, x_max - (text_width + 8)) + text_offset_y = max(0, y_min - (line_height + 8)) elif position == "bl": text_offset_x = x_min text_offset_y = y_max elif position == "br": - text_offset_x = x_max - (text_width + 8) + text_offset_x = max(0, x_max - (text_width + 8)) text_offset_y = y_max + + # Adjust position if it overlaps with the box + if position in {"ul", "ur"} and text_offset_y < y_min + thickness: + # Move the text below the box + text_offset_y = y_max + elif position in {"bl", "br"} and text_offset_y + line_height > y_max: + # Move the text above the box + text_offset_y = max(0, y_min - (line_height + 8)) + # make the coords of the box with a small padding of two pixels textbox_coords = ( (text_offset_x, text_offset_y), diff --git a/frigate/video.py b/frigate/video.py index 96b562e8c..d8ff1a869 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -113,7 +113,7 @@ def capture_frames( fps.value = frame_rate.eps() skipped_fps.value = skipped_eps.eps() current_frame.value = datetime.datetime.now().timestamp() - frame_name = f"{config.name}{frame_index}" + frame_name = f"{config.name}_{frame_index}" frame_buffer = frame_manager.write(frame_name) try: frame_buffer[:] = ffmpeg_process.stdout.read(frame_size) diff --git a/web/package-lock.json b/web/package-lock.json index a0971c361..7ce6345af 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -72,6 +72,7 @@ "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", + "use-long-press": "^3.2.0", "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.23.8" @@ -8709,6 +8710,15 @@ "scheduler": ">=0.19.0" } }, + "node_modules/use-long-press": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.2.0.tgz", + "integrity": "sha512-uq5o2qFR1VRjHn8Of7Fl344/AGvgk7C5Mcb4aSb1ZRVp6PkgdXJJLdRrlSTJQVkkQcDuqFbFc3mDX4COg7mRTA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/web/package.json b/web/package.json index 73b2ed309..d76e6ad10 100644 --- a/web/package.json +++ b/web/package.json @@ -78,6 +78,7 @@ "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", + "use-long-press": "^3.2.0", "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.23.8" diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index e96632400..7dfa7b583 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { useApiHost } from "@/api"; import { getIconForLabel } from "@/utils/iconUtil"; import useSWR from "swr"; @@ -12,10 +12,11 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { SearchResult } from "@/types/search"; import { cn } from "@/lib/utils"; import { TooltipPortal } from "@radix-ui/react-tooltip"; +import useContextMenu from "@/hooks/use-contextmenu"; type SearchThumbnailProps = { searchResult: SearchResult; - onClick: (searchResult: SearchResult) => void; + onClick: (searchResult: SearchResult, ctrl: boolean, detail: boolean) => void; }; export default function SearchThumbnail({ @@ -28,9 +29,9 @@ export default function SearchThumbnail({ // interactions - const handleOnClick = useCallback(() => { - onClick(searchResult); - }, [searchResult, onClick]); + useContextMenu(imgRef, () => { + onClick(searchResult, true, false); + }); const objectLabel = useMemo(() => { if ( @@ -45,7 +46,10 @@ export default function SearchThumbnail({ }, [config, searchResult]); return ( -
+
onClick(searchResult, false, true)} + > onClick(searchResult)} + onClick={() => onClick(searchResult, false, true)} > {getIconForLabel(objectLabel, "size-3 text-white")} {Math.round( diff --git a/web/src/components/filter/SearchActionGroup.tsx b/web/src/components/filter/SearchActionGroup.tsx new file mode 100644 index 000000000..aac03ad1c --- /dev/null +++ b/web/src/components/filter/SearchActionGroup.tsx @@ -0,0 +1,132 @@ +import { useCallback, useState } from "react"; +import axios from "axios"; +import { Button, buttonVariants } from "../ui/button"; +import { isDesktop } from "react-device-detect"; +import { HiTrash } from "react-icons/hi"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; +import { toast } from "sonner"; + +type SearchActionGroupProps = { + selectedObjects: string[]; + setSelectedObjects: (ids: string[]) => void; + pullLatestData: () => void; +}; +export default function SearchActionGroup({ + selectedObjects, + setSelectedObjects, + pullLatestData, +}: SearchActionGroupProps) { + const onClearSelected = useCallback(() => { + setSelectedObjects([]); + }, [setSelectedObjects]); + + const onDelete = useCallback(async () => { + await axios + .delete(`events/`, { + data: { event_ids: selectedObjects }, + }) + .then((resp) => { + if (resp.status == 200) { + toast.success("Tracked objects deleted successfully.", { + position: "top-center", + }); + setSelectedObjects([]); + pullLatestData(); + } + }) + .catch(() => { + toast.error("Failed to delete tracked objects.", { + position: "top-center", + }); + }); + }, [selectedObjects, setSelectedObjects, pullLatestData]); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [bypassDialog, setBypassDialog] = useState(false); + + useKeyboardListener(["Shift"], (_, modifiers) => { + setBypassDialog(modifiers.shift); + }); + + const handleDelete = useCallback(() => { + if (bypassDialog) { + onDelete(); + } else { + setDeleteDialogOpen(true); + } + }, [bypassDialog, onDelete]); + + return ( + <> + setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Deleting these {selectedObjects.length} tracked objects removes the + snapshot, any saved embeddings, and any associated object lifecycle + entries. Recorded footage of these tracked objects in History view + will NOT be deleted. +
+
+ Are you sure you want to proceed? +
+
+ Hold the Shift key to bypass this dialog in the future. +
+ + Cancel + + Delete + + +
+
+ +
+
+
{`${selectedObjects.length} selected`}
+
{"|"}
+
+ Unselect +
+
+
+ +
+
+ + ); +} diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 10f0ed623..fee12a50f 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -108,13 +108,15 @@ export default function SearchResultActions({ )} - - - View object lifecycle - + {searchResult.data.type == "object" && ( + + + View object lifecycle + + )} {config?.semantic_search?.enabled && isContextMenu && ( @@ -181,22 +184,24 @@ export default function SearchResultActions({ ) : ( <> - {config?.semantic_search?.enabled && ( - - - - - Find similar - - )} + {config?.semantic_search?.enabled && + searchResult.data.type == "object" && ( + + + + + Find similar + + )} {!isMobileOnly && config?.plus?.enabled && searchResult.has_snapshot && searchResult.end_time && + searchResult.data.type == "object" && !searchResult.plus_id && ( diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 74a9950b9..c3e7ac91d 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -379,6 +379,7 @@ function EventItem({ {event.has_snapshot && event.plus_id == undefined && + event.data.type == "object" && config?.plus.enabled && ( diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index f7af31606..b0eeac98d 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -452,7 +452,7 @@ function ObjectDetailsTab({ draggable={false} src={`${apiHost}api/events/${search.id}/thumbnail.jpg`} /> - {config?.semantic_search.enabled && ( + {config?.semantic_search.enabled && search.data.type == "object" && (
)} - {search.plus_id !== "not_enabled" && search.end_time && ( - - -
-
- Submit To Frigate+ -
-
- Objects in locations you want to avoid are not false - positives. Submitting them as false positives will confuse - the model. -
-
- -
- {state == "reviewing" && ( - <> - - - - )} - {state == "uploading" && } - {state == "submitted" && ( -
- - Submitted + {search.data.type == "object" && + search.plus_id !== "not_enabled" && + search.end_time && ( + + +
+
+ Submit To Frigate+
- )} -
-
-
- )} +
+ Objects in locations you want to avoid are not false + positives. Submitting them as false positives will + confuse the model. +
+
+ +
+ {state == "reviewing" && ( + <> + + + + )} + {state == "uploading" && } + {state == "submitted" && ( +
+ + Submitted +
+ )} +
+ + + )}
diff --git a/web/src/hooks/use-press.ts b/web/src/hooks/use-press.ts new file mode 100644 index 000000000..6e97ce11b --- /dev/null +++ b/web/src/hooks/use-press.ts @@ -0,0 +1,54 @@ +// https://gist.github.com/cpojer/641bf305e6185006ea453e7631b80f95 + +import { useCallback, useState } from "react"; +import { + LongPressCallbackMeta, + LongPressReactEvents, + useLongPress, +} from "use-long-press"; + +export default function usePress( + options: Omit[1], "onCancel" | "onStart"> & { + onLongPress: NonNullable[0]>; + onPress: (event: LongPressReactEvents) => void; + }, +) { + const { onLongPress, onPress, ...actualOptions } = options; + const [hasLongPress, setHasLongPress] = useState(false); + + const onCancel = useCallback(() => { + if (hasLongPress) { + setHasLongPress(false); + } + }, [hasLongPress]); + + const bind = useLongPress( + useCallback( + ( + event: LongPressReactEvents, + meta: LongPressCallbackMeta, + ) => { + setHasLongPress(true); + onLongPress(event, meta); + }, + [onLongPress], + ), + { + ...actualOptions, + onCancel, + onStart: onCancel, + }, + ); + + return useCallback( + () => ({ + ...bind(), + onClick: (event: LongPressReactEvents) => { + if (!hasLongPress) { + onPress(event); + } + }, + }), + [bind, hasLongPress, onPress], + ); +} diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index e19dccd07..97e565ef1 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -62,6 +62,7 @@ function Live() { if (selectedCameraName) { const capitalized = selectedCameraName .split("_") + .filter((text) => text) .map((text) => text[0].toUpperCase() + text.substring(1)); document.title = `${capitalized.join(" ")} - Live - Frigate`; } else if (cameraGroup && cameraGroup != "default") { diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx index ea9c3cbef..0ec825416 100644 --- a/web/src/views/explore/ExploreView.tsx +++ b/web/src/views/explore/ExploreView.tsx @@ -26,7 +26,7 @@ type ExploreViewProps = { searchDetail: SearchResult | undefined; setSearchDetail: (search: SearchResult | undefined) => void; setSimilaritySearch: (search: SearchResult) => void; - onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; + onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void; }; export default function ExploreView({ @@ -125,7 +125,7 @@ type ThumbnailRowType = { setSearchDetail: (search: SearchResult | undefined) => void; mutate: () => void; setSimilaritySearch: (search: SearchResult) => void; - onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; + onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void; }; function ThumbnailRow({ @@ -205,7 +205,7 @@ type ExploreThumbnailImageProps = { setSearchDetail: (search: SearchResult | undefined) => void; mutate: () => void; setSimilaritySearch: (search: SearchResult) => void; - onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; + onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void; }; function ExploreThumbnailImage({ event, @@ -225,11 +225,11 @@ function ExploreThumbnailImage({ }; const handleShowObjectLifecycle = () => { - onSelectSearch(event, 0, "object lifecycle"); + onSelectSearch(event, false, "object lifecycle"); }; const handleShowSnapshot = () => { - onSelectSearch(event, 0, "snapshot"); + onSelectSearch(event, false, "snapshot"); }; return ( diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 378b313e0..be430f134 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -30,6 +30,7 @@ import { } from "@/components/ui/tooltip"; import Chip from "@/components/indicators/Chip"; import { TooltipPortal } from "@radix-ui/react-tooltip"; +import SearchActionGroup from "@/components/filter/SearchActionGroup"; type SearchViewProps = { search: string; @@ -181,20 +182,53 @@ export default function SearchView({ // search interaction - const [selectedIndex, setSelectedIndex] = useState(null); + const [selectedObjects, setSelectedObjects] = useState([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const onSelectSearch = useCallback( - (item: SearchResult, index: number, page: SearchTab = "details") => { - setPage(page); - setSearchDetail(item); - setSelectedIndex(index); + (item: SearchResult, ctrl: boolean, page: SearchTab = "details") => { + if (selectedObjects.length > 1 || ctrl) { + const index = selectedObjects.indexOf(item.id); + + if (index != -1) { + if (selectedObjects.length == 1) { + setSelectedObjects([]); + } else { + const copy = [ + ...selectedObjects.slice(0, index), + ...selectedObjects.slice(index + 1), + ]; + setSelectedObjects(copy); + } + } else { + const copy = [...selectedObjects]; + copy.push(item.id); + setSelectedObjects(copy); + } + } else { + setPage(page); + setSearchDetail(item); + } }, - [], + [selectedObjects], ); + const onSelectAllObjects = useCallback(() => { + if (!uniqueResults || uniqueResults.length == 0) { + return; + } + + if (selectedObjects.length < uniqueResults.length) { + setSelectedObjects(uniqueResults.map((value) => value.id)); + } else { + setSelectedObjects([]); + } + }, [uniqueResults, selectedObjects]); + useEffect(() => { - setSelectedIndex(0); + setSelectedObjects([]); + // unselect items when search term or filter changes + // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchTerm, searchFilter]); // confidence score @@ -243,23 +277,44 @@ export default function SearchView({ } switch (key) { - case "ArrowLeft": - setSelectedIndex((prevIndex) => { - const newIndex = - prevIndex === null - ? uniqueResults.length - 1 - : (prevIndex - 1 + uniqueResults.length) % uniqueResults.length; - setSearchDetail(uniqueResults[newIndex]); - return newIndex; - }); + case "a": + if (modifiers.ctrl) { + onSelectAllObjects(); + } break; - case "ArrowRight": - setSelectedIndex((prevIndex) => { + case "ArrowLeft": + if (uniqueResults.length > 0) { + const currentIndex = searchDetail + ? uniqueResults.findIndex( + (result) => result.id === searchDetail.id, + ) + : -1; + const newIndex = - prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length; + currentIndex === -1 + ? uniqueResults.length - 1 + : (currentIndex - 1 + uniqueResults.length) % + uniqueResults.length; + setSearchDetail(uniqueResults[newIndex]); - return newIndex; - }); + } + break; + + case "ArrowRight": + if (uniqueResults.length > 0) { + const currentIndex = searchDetail + ? uniqueResults.findIndex( + (result) => result.id === searchDetail.id, + ) + : -1; + + const newIndex = + currentIndex === -1 + ? 0 + : (currentIndex + 1) % uniqueResults.length; + + setSearchDetail(uniqueResults[newIndex]); + } break; case "PageDown": contentRef.current?.scrollBy({ @@ -275,32 +330,80 @@ export default function SearchView({ break; } }, - [uniqueResults, inputFocused], + [uniqueResults, inputFocused, onSelectAllObjects, searchDetail], ); useKeyboardListener( - ["ArrowLeft", "ArrowRight", "PageDown", "PageUp"], + ["a", "ArrowLeft", "ArrowRight", "PageDown", "PageUp"], onKeyboardShortcut, !inputFocused, ); // scroll into view + const [prevSearchDetail, setPrevSearchDetail] = useState< + SearchResult | undefined + >(); + + // keep track of previous ref to outline thumbnail when dialog closes + const prevSearchDetailRef = useRef(); + useEffect(() => { - if ( - selectedIndex !== null && - uniqueResults && - itemRefs.current?.[selectedIndex] - ) { - scrollIntoView(itemRefs.current[selectedIndex], { - block: "center", - behavior: "smooth", - scrollMode: "if-needed", - }); + if (searchDetail === undefined && prevSearchDetailRef.current) { + setPrevSearchDetail(prevSearchDetailRef.current); } - // we only want to scroll when the index changes + prevSearchDetailRef.current = searchDetail; + }, [searchDetail]); + + useEffect(() => { + if (uniqueResults && itemRefs.current && prevSearchDetail) { + const selectedIndex = uniqueResults.findIndex( + (result) => result.id === prevSearchDetail.id, + ); + + const parent = itemRefs.current[selectedIndex]; + + if (selectedIndex !== -1 && parent) { + const target = parent.querySelector(".review-item-ring"); + if (target) { + scrollIntoView(target, { + block: "center", + behavior: "smooth", + scrollMode: "if-needed", + }); + target.classList.add(`outline-selected`); + target.classList.remove("outline-transparent"); + + setTimeout(() => { + target.classList.remove(`outline-selected`); + target.classList.add("outline-transparent"); + }, 3000); + } + } + } + // we only want to scroll when the dialog closes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedIndex]); + }, [prevSearchDetail]); + + useEffect(() => { + if (uniqueResults && itemRefs.current && searchDetail) { + const selectedIndex = uniqueResults.findIndex( + (result) => result.id === searchDetail.id, + ); + + const parent = itemRefs.current[selectedIndex]; + + if (selectedIndex !== -1 && parent) { + scrollIntoView(parent, { + block: "center", + behavior: "smooth", + scrollMode: "if-needed", + }); + } + } + // we only want to scroll when changing the detail pane + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchDetail]); // observer for loading more @@ -369,22 +472,39 @@ export default function SearchView({ {hasExistingSearch && (
- - - + {selectedObjects.length == 0 ? ( + <> + + + + + ) : ( +
+ +
+ )}
)} @@ -412,14 +532,14 @@ export default function SearchView({
{uniqueResults && uniqueResults.map((value, index) => { - const selected = selectedIndex === index; + const selected = selectedObjects.includes(value.id); return (
(itemRefs.current[index] = item)} data-start={value.start_time} - className="review-item relative flex flex-col rounded-lg" + className="relative flex flex-col rounded-lg" >
onSelectSearch(value, index)} + onClick={( + value: SearchResult, + ctrl: boolean, + detail: boolean, + ) => { + if (detail && selectedObjects.length == 0) { + setSearchDetail(value); + } else { + onSelectSearch( + value, + ctrl || selectedObjects.length > 0, + ); + } + }} /> {(searchTerm || searchFilter?.search_type?.includes("similarity")) && ( @@ -469,10 +602,10 @@ export default function SearchView({ }} refreshResults={refresh} showObjectLifecycle={() => - onSelectSearch(value, index, "object lifecycle") + onSelectSearch(value, false, "object lifecycle") } showSnapshot={() => - onSelectSearch(value, index, "snapshot") + onSelectSearch(value, false, "snapshot") } />