mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
Merge remote-tracking branch 'upstream/dev' into dev
This commit is contained in:
commit
7f69e59400
@ -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/
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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 \
|
||||||
|
@ -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
|
||||||
|
4
docs/static/frigate-api.yaml
vendored
4
docs/static/frigate-api.yaml
vendored
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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,
|
||||||
|
@ -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"},
|
||||||
|
@ -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"
|
||||||
|
@ -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:
|
||||||
|
@ -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 = {
|
||||||
|
@ -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()
|
||||||
|
@ -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}"
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
@ -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
10
web/package-lock.json
generated
@ -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",
|
||||||
|
@ -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"
|
||||||
|
@ -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(
|
||||||
|
132
web/src/components/filter/SearchActionGroup.tsx
Normal file
132
web/src/components/filter/SearchActionGroup.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
54
web/src/hooks/use-press.ts
Normal file
54
web/src/hooks/use-press.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
@ -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") {
|
||||||
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user