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

This commit is contained in:
Rui Alves 2024-12-04 11:26:43 +00:00
commit 7f69e59400
29 changed files with 618 additions and 201 deletions

View File

@ -36,8 +36,5 @@ RUN pip3 install -U /deps/hailo-wheels/*.whl
# Copy base files from the rootfs stage # Copy base files from the rootfs stage
COPY --from=rootfs / / COPY --from=rootfs / /
# Set Library path for hailo driver
ENV LD_LIBRARY_PATH=/rootfs/usr/local/lib/
# Set workdir # Set workdir
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/

View File

@ -10,10 +10,8 @@ elif [[ "${TARGETARCH}" == "arm64" ]]; then
arch="aarch64" arch="aarch64"
fi fi
mkdir -p /rootfs
wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${TARGETARCH}.tar.gz" | 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 mkdir -p /hailo-wheels

View File

@ -193,6 +193,7 @@ services:
container_name: frigate container_name: frigate
privileged: true # this may not be necessary for all setups privileged: true # this may not be necessary for all setups
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s # allow enough time to shut down the various services
image: ghcr.io/blakeblackshear/frigate:stable image: ghcr.io/blakeblackshear/frigate:stable
shm_size: "512mb" # update for your cameras based on calculation above shm_size: "512mb" # update for your cameras based on calculation above
devices: devices:
@ -224,6 +225,7 @@ If you can't use docker compose, you can run the container with something simila
docker run -d \ docker run -d \
--name frigate \ --name frigate \
--restart=unless-stopped \ --restart=unless-stopped \
--stop-timeout 30 \
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \ --mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \
--device /dev/bus/usb:/dev/bus/usb \ --device /dev/bus/usb:/dev/bus/usb \
--device /dev/dri/renderD128 \ --device /dev/dri/renderD128 \

View File

@ -115,6 +115,7 @@ services:
frigate: frigate:
container_name: frigate container_name: frigate
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s
image: ghcr.io/blakeblackshear/frigate:stable image: ghcr.io/blakeblackshear/frigate:stable
volumes: volumes:
- ./config:/config - ./config:/config

View File

@ -3225,7 +3225,7 @@ components:
title: Sub Label title: Sub Label
score: score:
anyOf: anyOf:
- type: integer - type: number
- type: 'null' - type: 'null'
title: Score title: Score
default: 0 default: 0
@ -3264,7 +3264,7 @@ components:
properties: properties:
end_time: end_time:
anyOf: anyOf:
- type: integer - type: number
- type: 'null' - type: 'null'
title: End Time title: End Time
type: object type: object

View File

@ -1,4 +1,4 @@
from typing import Optional, Union from typing import List, Optional, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -17,14 +17,18 @@ class EventsDescriptionBody(BaseModel):
class EventsCreateBody(BaseModel): class EventsCreateBody(BaseModel):
source_type: Optional[str] = "api" source_type: Optional[str] = "api"
sub_label: Optional[str] = None sub_label: Optional[str] = None
score: Optional[int] = 0 score: Optional[float] = 0
duration: Optional[int] = 30 duration: Optional[int] = 30
include_recording: Optional[bool] = True include_recording: Optional[bool] = True
draw: Optional[dict] = {} draw: Optional[dict] = {}
class EventsEndBody(BaseModel): 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): class SubmitPlusBody(BaseModel):

View File

@ -16,6 +16,7 @@ from playhouse.shortcuts import model_to_dict
from frigate.api.defs.events_body import ( from frigate.api.defs.events_body import (
EventsCreateBody, EventsCreateBody,
EventsDeleteBody,
EventsDescriptionBody, EventsDescriptionBody,
EventsEndBody, EventsEndBody,
EventsSubLabelBody, EventsSubLabelBody,
@ -35,8 +36,9 @@ from frigate.const import (
CLIPS_DIR, CLIPS_DIR,
) )
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.events.external import ExternalEventProcessor
from frigate.models import Event, ReviewSegment, Timeline 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 from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1035,34 +1037,64 @@ def regenerate_description(
) )
@router.delete("/events/{event_id}") def delete_single_event(event_id: str, request: Request) -> dict:
def delete_event(request: Request, event_id: str):
try: try:
event = Event.get(Event.id == event_id) event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
return JSONResponse( return {"success": False, "message": f"Event {event_id} not found"}
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
media_name = f"{event.camera}-{event.id}" media_name = f"{event.camera}-{event.id}"
if event.has_snapshot: if event.has_snapshot:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") snapshot_paths = [
media.unlink(missing_ok=True) Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg"),
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png") Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"),
media.unlink(missing_ok=True) ]
for media in snapshot_paths:
media.unlink(missing_ok=True)
event.delete_instance() event.delete_instance()
Timeline.delete().where(Timeline.source_id == event_id).execute() Timeline.delete().where(Timeline.source_id == event_id).execute()
# If semantic search is enabled, update the index # If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled: if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings context: EmbeddingsContext = request.app.embeddings
context.db.delete_embeddings_thumbnail(event_ids=[event_id]) context.db.delete_embeddings_thumbnail(event_ids=[event_id])
context.db.delete_embeddings_description(event_ids=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id])
return JSONResponse(
content=({"success": True, "message": "Event " + event_id + " deleted"}), return {"success": True, "message": f"Event {event_id} deleted"}
status_code=200,
)
@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") @router.post("/events/{camera_name}/{label}/create")
@ -1087,9 +1119,11 @@ def create_event(
) )
try: 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, camera_name,
label, label,
body.source_type, body.source_type,

View File

@ -36,6 +36,7 @@ from frigate.const import (
RECORD_DIR, RECORD_DIR,
) )
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment 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.builtin import get_tz_modifiers
from frigate.util.image import get_image_from_recording from frigate.util.image import get_image_from_recording
@ -79,7 +80,11 @@ def mjpeg_feed(
def imagestream( 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: while True:
# max out at specified FPS # max out at specified FPS
@ -118,6 +123,7 @@ def latest_frame(
extension: Extension, extension: Extension,
params: MediaLatestFrameQueryParams = Depends(), params: MediaLatestFrameQueryParams = Depends(),
): ):
frame_processor: TrackedObjectProcessor = request.app.detected_frames_processor
draw_options = { draw_options = {
"bounding_boxes": params.bbox, "bounding_boxes": params.bbox,
"timestamp": params.timestamp, "timestamp": params.timestamp,
@ -129,17 +135,14 @@ def latest_frame(
quality = params.quality quality = params.quality
if camera_name in request.app.frigate_config.cameras: if camera_name in request.app.frigate_config.cameras:
frame = request.app.detected_frames_processor.get_current_frame( frame = frame_processor.get_current_frame(camera_name, draw_options)
camera_name, draw_options
)
retry_interval = float( retry_interval = float(
request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval
or 10 or 10
) )
if frame is None or datetime.now().timestamp() > ( if frame is None or datetime.now().timestamp() > (
request.app.detected_frames_processor.get_current_frame_time(camera_name) frame_processor.get_current_frame_time(camera_name) + retry_interval
+ retry_interval
): ):
if request.app.camera_error_image is None: if request.app.camera_error_image is None:
error_image = glob.glob("/opt/frigate/frigate/images/camera-error.jpg") 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: elif camera_name == "birdseye" and request.app.frigate_config.birdseye.restream:
frame = cv2.cvtColor( frame = cv2.cvtColor(
request.app.detected_frames_processor.get_current_frame(camera_name), frame_processor.get_current_frame(camera_name),
cv2.COLOR_YUV2BGR_I420, cv2.COLOR_YUV2BGR_I420,
) )
@ -813,15 +816,15 @@ def grid_snapshot(
): ):
if camera_name in request.app.frigate_config.cameras: if camera_name in request.app.frigate_config.cameras:
detect = request.app.frigate_config.cameras[camera_name].detect 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( retry_interval = float(
request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval
or 10 or 10
) )
if frame is None or datetime.now().timestamp() > ( if frame is None or datetime.now().timestamp() > (
request.app.detected_frames_processor.get_current_frame_time(camera_name) frame_processor.get_current_frame_time(camera_name) + retry_interval
+ retry_interval
): ):
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Unable to get valid frame"}, content={"success": False, "message": "Unable to get valid frame"},

View File

@ -36,6 +36,7 @@ from frigate.const import (
EXPORT_DIR, EXPORT_DIR,
MODEL_CACHE_DIR, MODEL_CACHE_DIR,
RECORD_DIR, RECORD_DIR,
SHM_FRAMES_VAR,
) )
from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.embeddings import EmbeddingsContext, manage_embeddings from frigate.embeddings import EmbeddingsContext, manage_embeddings
@ -436,7 +437,7 @@ class FrigateApp:
# pre-create shms # pre-create shms
for i in range(shm_frame_count): for i in range(shm_frame_count):
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1] frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
self.frame_manager.create(f"{config.name}{i}", frame_size) self.frame_manager.create(f"{config.name}_{i}", frame_size)
capture_process = util.Process( capture_process = util.Process(
target=capture_camera, target=capture_camera,
@ -523,7 +524,10 @@ class FrigateApp:
if cam_total_frame_size == 0.0: if cam_total_frame_size == 0.0:
return 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( logger.debug(
f"Calculated total camera size {available_shm} / {cam_total_frame_size} :: {shm_frame_count} frames for each camera in SHM" f"Calculated total camera size {available_shm} / {cam_total_frame_size} :: {shm_frame_count} frames for each camera in SHM"

View File

@ -230,12 +230,16 @@ def verify_recording_segments_setup_with_reasonable_time(
try: try:
seg_arg_index = record_args.index("-segment_time") seg_arg_index = record_args.index("-segment_time")
except ValueError: except ValueError:
raise ValueError(f"Camera {camera_config.name} has no segment_time in \ raise ValueError(
recording output args, segment args are required for record.") 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: if int(record_args[seg_arg_index + 1]) > 60:
raise ValueError(f"Camera {camera_config.name} has invalid segment_time output arg, \ raise ValueError(
segment_time must be 60 or less.") 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: def verify_zone_objects_are_tracked(camera_config: CameraConfig) -> None:

View File

@ -13,6 +13,8 @@ FRIGATE_LOCALHOST = "http://127.0.0.1:5000"
PLUS_ENV_VAR = "PLUS_API_KEY" PLUS_ENV_VAR = "PLUS_API_KEY"
PLUS_API_HOST = "https://api.frigate.video" PLUS_API_HOST = "https://api.frigate.video"
SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
# Attribute & Object constants # Attribute & Object constants
DEFAULT_ATTRIBUTE_LABEL_MAP = { DEFAULT_ATTRIBUTE_LABEL_MAP = {

View File

@ -216,6 +216,10 @@ class AudioEventMaintainer(threading.Thread):
"label": label, "label": label,
"last_detection": datetime.datetime.now().timestamp(), "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: def expire_detections(self) -> None:
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()

View File

@ -110,7 +110,7 @@ class EventCleanup(threading.Thread):
.namedtuples() .namedtuples()
.iterator() .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 # delete the media from disk
for expired in expired_events: for expired in expired_events:
media_name = f"{expired.camera}-{expired.id}" media_name = f"{expired.camera}-{expired.id}"

View File

@ -10,6 +10,7 @@ from enum import Enum
from typing import Optional from typing import Optional
import cv2 import cv2
from numpy import ndarray
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
from frigate.comms.events_updater import EventUpdatePublisher from frigate.comms.events_updater import EventUpdatePublisher
@ -45,7 +46,7 @@ class ExternalEventProcessor:
duration: Optional[int], duration: Optional[int],
include_recording: bool, include_recording: bool,
draw: dict[str, any], draw: dict[str, any],
snapshot_frame: any, snapshot_frame: Optional[ndarray],
) -> str: ) -> str:
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
camera_config = self.config.cameras.get(camera) camera_config = self.config.cameras.get(camera)
@ -107,6 +108,7 @@ class ExternalEventProcessor:
EventTypeEnum.api, EventTypeEnum.api,
EventStateEnum.end, EventStateEnum.end,
None, None,
"",
{"id": event_id, "end_time": end_time}, {"id": event_id, "end_time": end_time},
) )
) )
@ -131,8 +133,11 @@ class ExternalEventProcessor:
label: str, label: str,
event_id: str, event_id: str,
draw: dict[str, any], draw: dict[str, any],
img_frame: any, img_frame: Optional[ndarray],
) -> str: ) -> Optional[str]:
if img_frame is None:
return None
# write clean snapshot if enabled # write clean snapshot if enabled
if camera_config.snapshots.clean_copy: if camera_config.snapshots.clean_copy:
ret, png = cv2.imencode(".png", img_frame) ret, png = cv2.imencode(".png", img_frame)

View File

@ -6,7 +6,7 @@ import queue
import threading import threading
from collections import Counter, defaultdict from collections import Counter, defaultdict
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from typing import Callable from typing import Callable, Optional
import cv2 import cv2
import numpy as np import numpy as np
@ -784,13 +784,18 @@ class TrackedObjectProcessor(threading.Thread):
else: else:
return {} 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": if camera == "birdseye":
return self.frame_manager.get( return self.frame_manager.get(
"birdseye", "birdseye",
(self.config.birdseye.height * 3 // 2, self.config.birdseye.width), (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) return self.camera_states[camera].get_current_frame(draw_options)
def get_current_frame_time(self, camera) -> int: def get_current_frame_time(self, camera) -> int:

View File

@ -480,7 +480,9 @@ class ReviewSegmentMaintainer(threading.Thread):
if not self.config.cameras[camera].record.enabled: if not self.config.cameras[camera].record.enabled:
if current_segment: if current_segment:
self.update_existing_segment(current_segment, frame_time, []) self.update_existing_segment(
current_segment, frame_name, frame_time, []
)
continue continue

View File

@ -222,16 +222,25 @@ def draw_box_with_label(
# set the text start position # set the text start position
if position == "ul": if position == "ul":
text_offset_x = x_min 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": elif position == "ur":
text_offset_x = x_max - (text_width + 8) text_offset_x = max(0, x_max - (text_width + 8))
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 == "bl": elif position == "bl":
text_offset_x = x_min text_offset_x = x_min
text_offset_y = y_max text_offset_y = y_max
elif position == "br": 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 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 # make the coords of the box with a small padding of two pixels
textbox_coords = ( textbox_coords = (
(text_offset_x, text_offset_y), (text_offset_x, text_offset_y),

View File

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

10
web/package-lock.json generated
View File

@ -72,6 +72,7 @@
"tailwind-merge": "^2.4.0", "tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0", "tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.2.0",
"vaul": "^0.9.1", "vaul": "^0.9.1",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.23.8" "zod": "^3.23.8"
@ -8709,6 +8710,15 @@
"scheduler": ">=0.19.0" "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": { "node_modules/use-sidecar": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",

View File

@ -78,6 +78,7 @@
"tailwind-merge": "^2.4.0", "tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0", "tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.2.0",
"vaul": "^0.9.1", "vaul": "^0.9.1",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react"; import { useMemo } from "react";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import useSWR from "swr"; import useSWR from "swr";
@ -12,10 +12,11 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import useContextMenu from "@/hooks/use-contextmenu";
type SearchThumbnailProps = { type SearchThumbnailProps = {
searchResult: SearchResult; searchResult: SearchResult;
onClick: (searchResult: SearchResult) => void; onClick: (searchResult: SearchResult, ctrl: boolean, detail: boolean) => void;
}; };
export default function SearchThumbnail({ export default function SearchThumbnail({
@ -28,9 +29,9 @@ export default function SearchThumbnail({
// interactions // interactions
const handleOnClick = useCallback(() => { useContextMenu(imgRef, () => {
onClick(searchResult); onClick(searchResult, true, false);
}, [searchResult, onClick]); });
const objectLabel = useMemo(() => { const objectLabel = useMemo(() => {
if ( if (
@ -45,7 +46,10 @@ export default function SearchThumbnail({
}, [config, searchResult]); }, [config, searchResult]);
return ( return (
<div className="relative size-full cursor-pointer" onClick={handleOnClick}> <div
className="relative size-full cursor-pointer"
onClick={() => onClick(searchResult, false, true)}
>
<ImageLoadingIndicator <ImageLoadingIndicator
className="absolute inset-0" className="absolute inset-0"
imgLoaded={imgLoaded} imgLoaded={imgLoaded}
@ -79,7 +83,7 @@ export default function SearchThumbnail({
<div className="mx-3 pb-1 text-sm text-white"> <div className="mx-3 pb-1 text-sm text-white">
<Chip <Chip
className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs`} className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs`}
onClick={() => onClick(searchResult)} onClick={() => onClick(searchResult, false, true)}
> >
{getIconForLabel(objectLabel, "size-3 text-white")} {getIconForLabel(objectLabel, "size-3 text-white")}
{Math.round( {Math.round(

View File

@ -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 (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
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 <em>NOT</em> be deleted.
<br />
<br />
Are you sure you want to proceed?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
<div className="p-1">{`${selectedObjects.length} selected`}</div>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
Unselect
</div>
</div>
<div className="flex items-center gap-1 md:gap-2">
<Button
className="flex items-center gap-2 p-2"
aria-label="Delete"
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog ? "Delete Now" : "Delete"}
</div>
)}
</Button>
</div>
</div>
</>
);
}

View File

@ -108,13 +108,15 @@ export default function SearchResultActions({
</a> </a>
</MenuItem> </MenuItem>
)} )}
<MenuItem {searchResult.data.type == "object" && (
aria-label="Show the object lifecycle" <MenuItem
onClick={showObjectLifecycle} aria-label="Show the object lifecycle"
> onClick={showObjectLifecycle}
<FaArrowsRotate className="mr-2 size-4" /> >
<span>View object lifecycle</span> <FaArrowsRotate className="mr-2 size-4" />
</MenuItem> <span>View object lifecycle</span>
</MenuItem>
)}
{config?.semantic_search?.enabled && isContextMenu && ( {config?.semantic_search?.enabled && isContextMenu && (
<MenuItem <MenuItem
aria-label="Find similar tracked objects" aria-label="Find similar tracked objects"
@ -128,6 +130,7 @@ export default function SearchResultActions({
config?.plus?.enabled && config?.plus?.enabled &&
searchResult.has_snapshot && searchResult.has_snapshot &&
searchResult.end_time && searchResult.end_time &&
searchResult.data.type == "object" &&
!searchResult.plus_id && ( !searchResult.plus_id && (
<MenuItem aria-label="Submit to Frigate Plus" onClick={showSnapshot}> <MenuItem aria-label="Submit to Frigate Plus" onClick={showSnapshot}>
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" /> <FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
@ -181,22 +184,24 @@ export default function SearchResultActions({
</ContextMenu> </ContextMenu>
) : ( ) : (
<> <>
{config?.semantic_search?.enabled && ( {config?.semantic_search?.enabled &&
<Tooltip> searchResult.data.type == "object" && (
<TooltipTrigger> <Tooltip>
<MdImageSearch <TooltipTrigger>
className="size-5 cursor-pointer text-primary-variant hover:text-primary" <MdImageSearch
onClick={findSimilar} className="size-5 cursor-pointer text-primary-variant hover:text-primary"
/> onClick={findSimilar}
</TooltipTrigger> />
<TooltipContent>Find similar</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent>Find similar</TooltipContent>
)} </Tooltip>
)}
{!isMobileOnly && {!isMobileOnly &&
config?.plus?.enabled && config?.plus?.enabled &&
searchResult.has_snapshot && searchResult.has_snapshot &&
searchResult.end_time && searchResult.end_time &&
searchResult.data.type == "object" &&
!searchResult.plus_id && ( !searchResult.plus_id && (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>

View File

@ -379,6 +379,7 @@ function EventItem({
{event.has_snapshot && {event.has_snapshot &&
event.plus_id == undefined && event.plus_id == undefined &&
event.data.type == "object" &&
config?.plus.enabled && ( config?.plus.enabled && (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>

View File

@ -452,7 +452,7 @@ function ObjectDetailsTab({
draggable={false} draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.jpg`} src={`${apiHost}api/events/${search.id}/thumbnail.jpg`}
/> />
{config?.semantic_search.enabled && ( {config?.semantic_search.enabled && search.data.type == "object" && (
<Button <Button
aria-label="Find similar tracked objects" aria-label="Find similar tracked objects"
onClick={() => { onClick={() => {
@ -626,65 +626,67 @@ export function ObjectSnapshotTab({
</div> </div>
)} )}
</TransformComponent> </TransformComponent>
{search.plus_id !== "not_enabled" && search.end_time && ( {search.data.type == "object" &&
<Card className="p-1 text-sm md:p-2"> search.plus_id !== "not_enabled" &&
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row"> search.end_time && (
<div className={cn("flex flex-col space-y-3")}> <Card className="p-1 text-sm md:p-2">
<div <CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
className={ <div className={cn("flex flex-col space-y-3")}>
"text-lg font-semibold leading-none tracking-tight" <div
} className={
> "text-lg font-semibold leading-none tracking-tight"
Submit To Frigate+ }
</div> >
<div className="text-sm text-muted-foreground"> Submit To Frigate+
Objects in locations you want to avoid are not false
positives. Submitting them as false positives will confuse
the model.
</div>
</div>
<div className="flex flex-row justify-center gap-2 md:justify-end">
{state == "reviewing" && (
<>
<Button
className="bg-success"
aria-label="Confirm this label for Frigate Plus"
onClick={() => {
setState("uploading");
onSubmitToPlus(false);
}}
>
This is{" "}
{/^[aeiou]/i.test(search?.label || "") ? "an" : "a"}{" "}
{search?.label}
</Button>
<Button
className="text-white"
aria-label="Do not confirm this label for Frigate Plus"
variant="destructive"
onClick={() => {
setState("uploading");
onSubmitToPlus(true);
}}
>
This is not{" "}
{/^[aeiou]/i.test(search?.label || "") ? "an" : "a"}{" "}
{search?.label}
</Button>
</>
)}
{state == "uploading" && <ActivityIndicator />}
{state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="text-success" />
Submitted
</div> </div>
)} <div className="text-sm text-muted-foreground">
</div> Objects in locations you want to avoid are not false
</CardContent> positives. Submitting them as false positives will
</Card> confuse the model.
)} </div>
</div>
<div className="flex flex-row justify-center gap-2 md:justify-end">
{state == "reviewing" && (
<>
<Button
className="bg-success"
aria-label="Confirm this label for Frigate Plus"
onClick={() => {
setState("uploading");
onSubmitToPlus(false);
}}
>
This is{" "}
{/^[aeiou]/i.test(search?.label || "") ? "an" : "a"}{" "}
{search?.label}
</Button>
<Button
className="text-white"
aria-label="Do not confirm this label for Frigate Plus"
variant="destructive"
onClick={() => {
setState("uploading");
onSubmitToPlus(true);
}}
>
This is not{" "}
{/^[aeiou]/i.test(search?.label || "") ? "an" : "a"}{" "}
{search?.label}
</Button>
</>
)}
{state == "uploading" && <ActivityIndicator />}
{state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="text-success" />
Submitted
</div>
)}
</div>
</CardContent>
</Card>
)}
</div> </div>
</TransformWrapper> </TransformWrapper>
</div> </div>

View File

@ -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<Parameters<typeof useLongPress>[1], "onCancel" | "onStart"> & {
onLongPress: NonNullable<Parameters<typeof useLongPress>[0]>;
onPress: (event: LongPressReactEvents<Element>) => 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<Element>,
meta: LongPressCallbackMeta<unknown>,
) => {
setHasLongPress(true);
onLongPress(event, meta);
},
[onLongPress],
),
{
...actualOptions,
onCancel,
onStart: onCancel,
},
);
return useCallback(
() => ({
...bind(),
onClick: (event: LongPressReactEvents<HTMLDivElement>) => {
if (!hasLongPress) {
onPress(event);
}
},
}),
[bind, hasLongPress, onPress],
);
}

View File

@ -62,6 +62,7 @@ function Live() {
if (selectedCameraName) { if (selectedCameraName) {
const capitalized = selectedCameraName const capitalized = selectedCameraName
.split("_") .split("_")
.filter((text) => text)
.map((text) => text[0].toUpperCase() + text.substring(1)); .map((text) => text[0].toUpperCase() + text.substring(1));
document.title = `${capitalized.join(" ")} - Live - Frigate`; document.title = `${capitalized.join(" ")} - Live - Frigate`;
} else if (cameraGroup && cameraGroup != "default") { } else if (cameraGroup && cameraGroup != "default") {

View File

@ -26,7 +26,7 @@ type ExploreViewProps = {
searchDetail: SearchResult | undefined; searchDetail: SearchResult | undefined;
setSearchDetail: (search: SearchResult | undefined) => void; setSearchDetail: (search: SearchResult | undefined) => void;
setSimilaritySearch: (search: SearchResult) => 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({ export default function ExploreView({
@ -125,7 +125,7 @@ type ThumbnailRowType = {
setSearchDetail: (search: SearchResult | undefined) => void; setSearchDetail: (search: SearchResult | undefined) => void;
mutate: () => void; mutate: () => void;
setSimilaritySearch: (search: SearchResult) => void; setSimilaritySearch: (search: SearchResult) => void;
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void;
}; };
function ThumbnailRow({ function ThumbnailRow({
@ -205,7 +205,7 @@ type ExploreThumbnailImageProps = {
setSearchDetail: (search: SearchResult | undefined) => void; setSearchDetail: (search: SearchResult | undefined) => void;
mutate: () => void; mutate: () => void;
setSimilaritySearch: (search: SearchResult) => void; setSimilaritySearch: (search: SearchResult) => void;
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void;
}; };
function ExploreThumbnailImage({ function ExploreThumbnailImage({
event, event,
@ -225,11 +225,11 @@ function ExploreThumbnailImage({
}; };
const handleShowObjectLifecycle = () => { const handleShowObjectLifecycle = () => {
onSelectSearch(event, 0, "object lifecycle"); onSelectSearch(event, false, "object lifecycle");
}; };
const handleShowSnapshot = () => { const handleShowSnapshot = () => {
onSelectSearch(event, 0, "snapshot"); onSelectSearch(event, false, "snapshot");
}; };
return ( return (

View File

@ -30,6 +30,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import Chip from "@/components/indicators/Chip"; import Chip from "@/components/indicators/Chip";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import SearchActionGroup from "@/components/filter/SearchActionGroup";
type SearchViewProps = { type SearchViewProps = {
search: string; search: string;
@ -181,20 +182,53 @@ export default function SearchView({
// search interaction // search interaction
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedObjects, setSelectedObjects] = useState<string[]>([]);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback( const onSelectSearch = useCallback(
(item: SearchResult, index: number, page: SearchTab = "details") => { (item: SearchResult, ctrl: boolean, page: SearchTab = "details") => {
setPage(page); if (selectedObjects.length > 1 || ctrl) {
setSearchDetail(item); const index = selectedObjects.indexOf(item.id);
setSelectedIndex(index);
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(() => { useEffect(() => {
setSelectedIndex(0); setSelectedObjects([]);
// unselect items when search term or filter changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm, searchFilter]); }, [searchTerm, searchFilter]);
// confidence score // confidence score
@ -243,23 +277,44 @@ export default function SearchView({
} }
switch (key) { switch (key) {
case "ArrowLeft": case "a":
setSelectedIndex((prevIndex) => { if (modifiers.ctrl) {
const newIndex = onSelectAllObjects();
prevIndex === null }
? uniqueResults.length - 1
: (prevIndex - 1 + uniqueResults.length) % uniqueResults.length;
setSearchDetail(uniqueResults[newIndex]);
return newIndex;
});
break; break;
case "ArrowRight": case "ArrowLeft":
setSelectedIndex((prevIndex) => { if (uniqueResults.length > 0) {
const currentIndex = searchDetail
? uniqueResults.findIndex(
(result) => result.id === searchDetail.id,
)
: -1;
const newIndex = const newIndex =
prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length; currentIndex === -1
? uniqueResults.length - 1
: (currentIndex - 1 + uniqueResults.length) %
uniqueResults.length;
setSearchDetail(uniqueResults[newIndex]); 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; break;
case "PageDown": case "PageDown":
contentRef.current?.scrollBy({ contentRef.current?.scrollBy({
@ -275,32 +330,80 @@ export default function SearchView({
break; break;
} }
}, },
[uniqueResults, inputFocused], [uniqueResults, inputFocused, onSelectAllObjects, searchDetail],
); );
useKeyboardListener( useKeyboardListener(
["ArrowLeft", "ArrowRight", "PageDown", "PageUp"], ["a", "ArrowLeft", "ArrowRight", "PageDown", "PageUp"],
onKeyboardShortcut, onKeyboardShortcut,
!inputFocused, !inputFocused,
); );
// scroll into view // scroll into view
const [prevSearchDetail, setPrevSearchDetail] = useState<
SearchResult | undefined
>();
// keep track of previous ref to outline thumbnail when dialog closes
const prevSearchDetailRef = useRef<SearchResult | undefined>();
useEffect(() => { useEffect(() => {
if ( if (searchDetail === undefined && prevSearchDetailRef.current) {
selectedIndex !== null && setPrevSearchDetail(prevSearchDetailRef.current);
uniqueResults &&
itemRefs.current?.[selectedIndex]
) {
scrollIntoView(itemRefs.current[selectedIndex], {
block: "center",
behavior: "smooth",
scrollMode: "if-needed",
});
} }
// 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 // 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 // observer for loading more
@ -369,22 +472,39 @@ export default function SearchView({
{hasExistingSearch && ( {hasExistingSearch && (
<ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]"> <ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]">
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<SearchFilterGroup {selectedObjects.length == 0 ? (
className={cn( <>
"w-full justify-between md:justify-start lg:justify-end", <SearchFilterGroup
)} className={cn(
filter={searchFilter} "w-full justify-between md:justify-start lg:justify-end",
onUpdateFilter={onUpdateFilter} )}
/> filter={searchFilter}
<SearchSettings onUpdateFilter={onUpdateFilter}
columns={columns} />
setColumns={setColumns} <SearchSettings
defaultView={defaultView} columns={columns}
setDefaultView={setDefaultView} setColumns={setColumns}
filter={searchFilter} defaultView={defaultView}
onUpdateFilter={onUpdateFilter} setDefaultView={setDefaultView}
/> filter={searchFilter}
<ScrollBar orientation="horizontal" className="h-0" /> onUpdateFilter={onUpdateFilter}
/>
<ScrollBar orientation="horizontal" className="h-0" />
</>
) : (
<div
className={cn(
"scrollbar-container flex justify-center gap-2 overflow-x-auto",
"h-10 w-full justify-between md:justify-start lg:justify-end",
)}
>
<SearchActionGroup
selectedObjects={selectedObjects}
setSelectedObjects={setSelectedObjects}
pullLatestData={refresh}
/>
</div>
)}
</div> </div>
</ScrollArea> </ScrollArea>
)} )}
@ -412,14 +532,14 @@ export default function SearchView({
<div className={gridClassName}> <div className={gridClassName}>
{uniqueResults && {uniqueResults &&
uniqueResults.map((value, index) => { uniqueResults.map((value, index) => {
const selected = selectedIndex === index; const selected = selectedObjects.includes(value.id);
return ( return (
<div <div
key={value.id} key={value.id}
ref={(item) => (itemRefs.current[index] = item)} ref={(item) => (itemRefs.current[index] = item)}
data-start={value.start_time} data-start={value.start_time}
className="review-item relative flex flex-col rounded-lg" className="relative flex flex-col rounded-lg"
> >
<div <div
className={cn( className={cn(
@ -428,7 +548,20 @@ export default function SearchView({
> >
<SearchThumbnail <SearchThumbnail
searchResult={value} searchResult={value}
onClick={() => 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 || {(searchTerm ||
searchFilter?.search_type?.includes("similarity")) && ( searchFilter?.search_type?.includes("similarity")) && (
@ -469,10 +602,10 @@ export default function SearchView({
}} }}
refreshResults={refresh} refreshResults={refresh}
showObjectLifecycle={() => showObjectLifecycle={() =>
onSelectSearch(value, index, "object lifecycle") onSelectSearch(value, false, "object lifecycle")
} }
showSnapshot={() => showSnapshot={() =>
onSelectSearch(value, index, "snapshot") onSelectSearch(value, false, "snapshot")
} }
/> />
</div> </div>