mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
Miscellaneous Fixes (#20897)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
* don't flatten the search result cache when updating this would cause an infinite swr fetch if something was mutated and then fetch was called again * Properly sort keys for recording summary in StorageMetrics * tracked object description box tweaks * Remove ability to right click on elements inside of face popup * Update reprocess message * don't show object track until video metadata is loaded * fix blue line height calc for in progress events * Use timeline tab by default for notifications but add a query arg for customization * Try and improve notification opening behavior * Reduce review item buffering behavior * ensure logging config is passed to camera capture and tracker processes * ensure on demand recording stops when browser closes * improve active line progress height with resize observer * remove icons and duplicate find similar link in explore context menu * fix for initial broken image when creating trigger from explore * display friendly names for triggers in toasts * lpr and triggers docs updates * remove icons from dropdowns in face and classification * fix comma dangle linter issue * re-add incorrectly removed face library button icons * fix sidebar nav links on < 768px desktop layout * allow text to wrap on mark as reviewed button * match exact pixels * clarify LPR docs --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
parent
097673b845
commit
fbf4388b37
@ -3,18 +3,18 @@ id: license_plate_recognition
|
|||||||
title: License Plate Recognition (LPR)
|
title: License Plate Recognition (LPR)
|
||||||
---
|
---
|
||||||
|
|
||||||
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a [known](#matching) name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
||||||
|
|
||||||
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
|
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
|
||||||
|
|
||||||
When a plate is recognized, the details are:
|
When a plate is recognized, the details are:
|
||||||
|
|
||||||
- Added as a `sub_label` (if known) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
- Added as a `sub_label` (if [known](#matching)) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
||||||
- Viewable in the Review Item Details pane in Review (sub labels).
|
- Viewable in the Details pane in Review/History.
|
||||||
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
|
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
|
||||||
- Filterable through the More Filters menu in Explore.
|
- Filterable through the More Filters menu in Explore.
|
||||||
- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
- Published via the `frigate/events` MQTT topic as a `sub_label` ([known](#matching)) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
||||||
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if known) and `plate`.
|
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if [known](#matching)) and `plate`.
|
||||||
|
|
||||||
## Model Requirements
|
## Model Requirements
|
||||||
|
|
||||||
@ -31,6 +31,7 @@ In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle`
|
|||||||
## Minimum System Requirements
|
## Minimum System Requirements
|
||||||
|
|
||||||
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required.
|
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
License plate recognition is disabled by default. Enable it in your config file:
|
License plate recognition is disabled by default. Enable it in your config file:
|
||||||
@ -73,8 +74,8 @@ Fine-tune the LPR feature using these optional parameters at the global level of
|
|||||||
- Default: `small`
|
- Default: `small`
|
||||||
- This can be `small` or `large`.
|
- This can be `small` or `large`.
|
||||||
- The `small` model is fast and identifies groups of Latin and Chinese characters.
|
- The `small` model is fast and identifies groups of Latin and Chinese characters.
|
||||||
- The `large` model identifies Latin characters only, but uses an enhanced text detector and is more capable at finding characters on multi-line plates. It is significantly slower than the `small` model. Note that using the `large` model does not improve _text recognition_, but it may improve _text detection_.
|
- The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. It is significantly slower than the `small` model.
|
||||||
- For most users, the `small` model is recommended.
|
- If your country or region does not use multi-line plates, you should use the `small` model as performance is much better for single-line plates.
|
||||||
|
|
||||||
### Recognition
|
### Recognition
|
||||||
|
|
||||||
@ -177,7 +178,7 @@ lpr:
|
|||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
If you want to detect cars on cameras but don't want to use resources to run LPR on those cars, you should disable LPR for those specific cameras.
|
If a camera is configured to detect `car` or `motorcycle` but you don't want Frigate to run LPR for that camera, disable LPR at the camera level:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cameras:
|
cameras:
|
||||||
@ -305,7 +306,7 @@ With this setup:
|
|||||||
- Review items will always be classified as a `detection`.
|
- Review items will always be classified as a `detection`.
|
||||||
- Snapshots will always be saved.
|
- Snapshots will always be saved.
|
||||||
- Zones and object masks are **not** used.
|
- Zones and object masks are **not** used.
|
||||||
- The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a known plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field.
|
- The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a [known](#matching) plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field.
|
||||||
- License plate snapshots are saved at the highest-scoring moment and appear in Explore.
|
- License plate snapshots are saved at the highest-scoring moment and appear in Explore.
|
||||||
- Debug view will not show `license_plate` bounding boxes.
|
- Debug view will not show `license_plate` bounding boxes.
|
||||||
|
|
||||||
|
|||||||
@ -141,7 +141,7 @@ Triggers are best configured through the Frigate UI.
|
|||||||
Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT.
|
Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT.
|
||||||
5. Save the trigger to update the configuration and store the embedding in the database.
|
5. Save the trigger to update the configuration and store the embedding in the database.
|
||||||
|
|
||||||
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification.
|
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification. Additionally, the UI will show the last date/time and tracked object ID that activated your trigger. The last triggered timestamp is not saved to the database or persisted through restarts of Frigate.
|
||||||
|
|
||||||
### Usage and Best Practices
|
### Usage and Best Practices
|
||||||
|
|
||||||
|
|||||||
@ -1781,9 +1781,8 @@ def create_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception(
|
||||||
logger.error(
|
|
||||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1807,8 +1806,8 @@ def create_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception("Error creating trigger embedding")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -1917,9 +1916,8 @@ def update_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception(
|
||||||
logger.error(
|
|
||||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1958,9 +1956,8 @@ def update_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception(
|
||||||
logger.error(
|
|
||||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1972,8 +1969,8 @@ def update_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception("Error updating trigger embedding")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -2033,9 +2030,8 @@ def delete_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception(
|
||||||
logger.error(
|
|
||||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2047,8 +2043,8 @@ def delete_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception("Error deleting trigger embedding")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|||||||
@ -136,6 +136,7 @@ class CameraMaintainer(threading.Thread):
|
|||||||
self.ptz_metrics[name],
|
self.ptz_metrics[name],
|
||||||
self.region_grids[name],
|
self.region_grids[name],
|
||||||
self.stop_event,
|
self.stop_event,
|
||||||
|
self.config.logger,
|
||||||
)
|
)
|
||||||
self.camera_processes[config.name] = camera_process
|
self.camera_processes[config.name] = camera_process
|
||||||
camera_process.start()
|
camera_process.start()
|
||||||
@ -156,7 +157,11 @@ class CameraMaintainer(threading.Thread):
|
|||||||
self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
|
self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
|
||||||
|
|
||||||
capture_process = CameraCapture(
|
capture_process = CameraCapture(
|
||||||
config, count, self.camera_metrics[name], self.stop_event
|
config,
|
||||||
|
count,
|
||||||
|
self.camera_metrics[name],
|
||||||
|
self.stop_event,
|
||||||
|
self.config.logger,
|
||||||
)
|
)
|
||||||
capture_process.daemon = True
|
capture_process.daemon = True
|
||||||
self.capture_processes[name] = capture_process
|
self.capture_processes[name] = capture_process
|
||||||
|
|||||||
@ -132,17 +132,15 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
|
|
||||||
if image_source == ImageSourceEnum.recordings:
|
if image_source == ImageSourceEnum.recordings:
|
||||||
duration = final_data["end_time"] - final_data["start_time"]
|
duration = final_data["end_time"] - final_data["start_time"]
|
||||||
buffer_extension = min(
|
buffer_extension = min(5, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
||||||
10, max(2, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure minimum total duration for short review items
|
# Ensure minimum total duration for short review items
|
||||||
# This provides better context for brief events
|
# This provides better context for brief events
|
||||||
total_duration = duration + (2 * buffer_extension)
|
total_duration = duration + (2 * buffer_extension)
|
||||||
if total_duration < MIN_RECORDING_DURATION:
|
if total_duration < MIN_RECORDING_DURATION:
|
||||||
# Expand buffer to reach minimum duration, still respecting max of 10s per side
|
# Expand buffer to reach minimum duration, still respecting max of 5s per side
|
||||||
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
||||||
buffer_extension = min(10, additional_buffer_per_side)
|
buffer_extension = min(5, additional_buffer_per_side)
|
||||||
|
|
||||||
thumbs = self.get_recording_frames(
|
thumbs = self.get_recording_frames(
|
||||||
camera,
|
camera,
|
||||||
|
|||||||
@ -424,7 +424,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
|
|
||||||
if not res:
|
if not res:
|
||||||
return {
|
return {
|
||||||
"message": "No face was recognized.",
|
"message": "Model is still training, please try again in a few moments.",
|
||||||
"success": False,
|
"success": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from frigate.comms.recordings_updater import (
|
|||||||
RecordingsDataSubscriber,
|
RecordingsDataSubscriber,
|
||||||
RecordingsDataTypeEnum,
|
RecordingsDataTypeEnum,
|
||||||
)
|
)
|
||||||
from frigate.config import CameraConfig, DetectConfig, ModelConfig
|
from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig
|
||||||
from frigate.config.camera.camera import CameraTypeEnum
|
from frigate.config.camera.camera import CameraTypeEnum
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
@ -539,6 +539,7 @@ class CameraCapture(FrigateProcess):
|
|||||||
shm_frame_count: int,
|
shm_frame_count: int,
|
||||||
camera_metrics: CameraMetrics,
|
camera_metrics: CameraMetrics,
|
||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
|
log_config: LoggerConfig | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
stop_event,
|
stop_event,
|
||||||
@ -549,9 +550,10 @@ class CameraCapture(FrigateProcess):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.shm_frame_count = shm_frame_count
|
self.shm_frame_count = shm_frame_count
|
||||||
self.camera_metrics = camera_metrics
|
self.camera_metrics = camera_metrics
|
||||||
|
self.log_config = log_config
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.pre_run_setup()
|
self.pre_run_setup(self.log_config)
|
||||||
camera_watchdog = CameraWatchdog(
|
camera_watchdog = CameraWatchdog(
|
||||||
self.config,
|
self.config,
|
||||||
self.shm_frame_count,
|
self.shm_frame_count,
|
||||||
@ -577,6 +579,7 @@ class CameraTracker(FrigateProcess):
|
|||||||
ptz_metrics: PTZMetrics,
|
ptz_metrics: PTZMetrics,
|
||||||
region_grid: list[list[dict[str, Any]]],
|
region_grid: list[list[dict[str, Any]]],
|
||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
|
log_config: LoggerConfig | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
stop_event,
|
stop_event,
|
||||||
@ -592,9 +595,10 @@ class CameraTracker(FrigateProcess):
|
|||||||
self.camera_metrics = camera_metrics
|
self.camera_metrics = camera_metrics
|
||||||
self.ptz_metrics = ptz_metrics
|
self.ptz_metrics = ptz_metrics
|
||||||
self.region_grid = region_grid
|
self.region_grid = region_grid
|
||||||
|
self.log_config = log_config
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.pre_run_setup()
|
self.pre_run_setup(self.log_config)
|
||||||
frame_queue = self.camera_metrics.frame_queue
|
frame_queue = self.camera_metrics.frame_queue
|
||||||
frame_shape = self.config.frame_shape
|
frame_shape = self.config.frame_shape
|
||||||
|
|
||||||
|
|||||||
@ -44,11 +44,16 @@ self.addEventListener("notificationclick", (event) => {
|
|||||||
switch (event.action ?? "default") {
|
switch (event.action ?? "default") {
|
||||||
case "markReviewed":
|
case "markReviewed":
|
||||||
if (event.notification.data) {
|
if (event.notification.data) {
|
||||||
fetch("/api/reviews/viewed", {
|
event.waitUntil(
|
||||||
method: "POST",
|
fetch("/api/reviews/viewed", {
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": 1 },
|
method: "POST",
|
||||||
body: JSON.stringify({ ids: [event.notification.data.id] }),
|
headers: {
|
||||||
});
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-TOKEN": 1,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids: [event.notification.data.id] }),
|
||||||
|
}), // eslint-disable-line comma-dangle
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@ -58,7 +63,7 @@ self.addEventListener("notificationclick", (event) => {
|
|||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
if (clients.openWindow) {
|
if (clients.openWindow) {
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
return clients.openWindow(url);
|
event.waitUntil(clients.openWindow(url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -398,11 +398,7 @@ export function GroupedClassificationCard({
|
|||||||
threshold={threshold}
|
threshold={threshold}
|
||||||
selected={false}
|
selected={false}
|
||||||
i18nLibrary={i18nLibrary}
|
i18nLibrary={i18nLibrary}
|
||||||
onClick={(data, meta) => {
|
onClick={() => {}}
|
||||||
if (meta || selectedItems.length > 0) {
|
|
||||||
onClick(data);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children?.(data)}
|
{children?.(data)}
|
||||||
</ClassificationCard>
|
</ClassificationCard>
|
||||||
|
|||||||
@ -4,9 +4,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
|
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import { MdImageSearch } from "react-icons/md";
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@ -31,11 +29,8 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { BsFillLightningFill } from "react-icons/bs";
|
|
||||||
import BlurredIconButton from "../button/BlurredIconButton";
|
import BlurredIconButton from "../button/BlurredIconButton";
|
||||||
import { PiPath } from "react-icons/pi";
|
|
||||||
|
|
||||||
type SearchResultActionsProps = {
|
type SearchResultActionsProps = {
|
||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
@ -98,7 +93,6 @@ export default function SearchResultActions({
|
|||||||
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
||||||
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
||||||
>
|
>
|
||||||
<LuDownload className="mr-2 size-4" />
|
|
||||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||||
</a>
|
</a>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -110,7 +104,6 @@ export default function SearchResultActions({
|
|||||||
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
||||||
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
||||||
>
|
>
|
||||||
<LuCamera className="mr-2 size-4" />
|
|
||||||
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
||||||
</a>
|
</a>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -120,44 +113,31 @@ export default function SearchResultActions({
|
|||||||
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
||||||
onClick={showTrackingDetails}
|
onClick={showTrackingDetails}
|
||||||
>
|
>
|
||||||
<PiPath className="mr-2 size-4" />
|
|
||||||
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{config?.semantic_search?.enabled && isContextMenu && (
|
|
||||||
<MenuItem
|
|
||||||
aria-label={t("itemMenu.findSimilar.aria")}
|
|
||||||
onClick={findSimilar}
|
|
||||||
>
|
|
||||||
<MdImageSearch className="mr-2 size-4" />
|
|
||||||
<span>{t("itemMenu.findSimilar.label")}</span>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{config?.semantic_search?.enabled &&
|
|
||||||
searchResult.data.type == "object" && (
|
|
||||||
<MenuItem
|
|
||||||
aria-label={t("itemMenu.addTrigger.aria")}
|
|
||||||
onClick={addTrigger}
|
|
||||||
>
|
|
||||||
<BsFillLightningFill className="mr-2 size-4" />
|
|
||||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{config?.semantic_search?.enabled &&
|
{config?.semantic_search?.enabled &&
|
||||||
searchResult.data.type == "object" && (
|
searchResult.data.type == "object" && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={t("itemMenu.findSimilar.aria")}
|
aria-label={t("itemMenu.findSimilar.aria")}
|
||||||
onClick={findSimilar}
|
onClick={findSimilar}
|
||||||
>
|
>
|
||||||
<MdImageSearch className="mr-2 size-4" />
|
|
||||||
<span>{t("itemMenu.findSimilar.label")}</span>
|
<span>{t("itemMenu.findSimilar.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{config?.semantic_search?.enabled &&
|
||||||
|
searchResult.data.type == "object" && (
|
||||||
|
<MenuItem
|
||||||
|
aria-label={t("itemMenu.addTrigger.aria")}
|
||||||
|
onClick={addTrigger}
|
||||||
|
>
|
||||||
|
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={t("itemMenu.deleteTrackedObject.label")}
|
aria-label={t("itemMenu.deleteTrackedObject.label")}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<LuTrash2 className="mr-2 size-4" />
|
|
||||||
<span>{t("button.delete", { ns: "common" })}</span>
|
<span>{t("button.delete", { ns: "common" })}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -46,13 +46,13 @@ export default function NavItem({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
"flex flex-col items-center justify-center rounded-lg",
|
"flex flex-col items-center justify-center rounded-lg p-[6px]",
|
||||||
className,
|
className,
|
||||||
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
|
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon className="size-5 md:m-[6px]" />
|
<Icon className="size-5" />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@ -20,7 +21,6 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { LuPlus, LuScanFace } from "react-icons/lu";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import React, { ReactNode, useMemo, useState } from "react";
|
import React, { ReactNode, useMemo, useState } from "react";
|
||||||
@ -89,27 +89,26 @@ export default function FaceSelectionDialog({
|
|||||||
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-h-[40dvh] flex-col overflow-y-auto",
|
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
|
||||||
isMobile && "gap-2 pb-4",
|
isMobile && "gap-2 pb-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SelectorItem
|
|
||||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
|
||||||
onClick={() => setNewFace(true)}
|
|
||||||
>
|
|
||||||
<LuPlus />
|
|
||||||
{t("createFaceLibrary.new")}
|
|
||||||
</SelectorItem>
|
|
||||||
{faceNames.sort().map((faceName) => (
|
{faceNames.sort().map((faceName) => (
|
||||||
<SelectorItem
|
<SelectorItem
|
||||||
key={faceName}
|
key={faceName}
|
||||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
onClick={() => onTrainAttempt(faceName)}
|
onClick={() => onTrainAttempt(faceName)}
|
||||||
>
|
>
|
||||||
<LuScanFace />
|
|
||||||
{faceName}
|
{faceName}
|
||||||
</SelectorItem>
|
</SelectorItem>
|
||||||
))}
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<SelectorItem
|
||||||
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
|
onClick={() => setNewFace(true)}
|
||||||
|
>
|
||||||
|
{t("createFaceLibrary.new")}
|
||||||
|
</SelectorItem>
|
||||||
</div>
|
</div>
|
||||||
</SelectorContent>
|
</SelectorContent>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|||||||
@ -171,6 +171,18 @@ export default function ImagePicker({
|
|||||||
alt={selectedImage?.label || "Selected image"}
|
alt={selectedImage?.label || "Selected image"}
|
||||||
className="size-16 rounded object-cover"
|
className="size-16 rounded object-cover"
|
||||||
onLoad={() => handleImageLoad(selectedImageId || "")}
|
onLoad={() => handleImageLoad(selectedImageId || "")}
|
||||||
|
onError={(e) => {
|
||||||
|
// If trigger thumbnail fails to load, fall back to event thumbnail
|
||||||
|
if (!selectedImage) {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
if (
|
||||||
|
target.src.includes("clips/triggers") &&
|
||||||
|
selectedImageId
|
||||||
|
) {
|
||||||
|
target.src = `${apiHost}api/events/${selectedImageId}/thumbnail.webp`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
||||||
|
|||||||
@ -683,6 +683,22 @@ function ObjectDetailsTab({
|
|||||||
|
|
||||||
const mutate = useGlobalMutation();
|
const mutate = useGlobalMutation();
|
||||||
|
|
||||||
|
// Helper to map over SWR cached search results while preserving
|
||||||
|
// either paginated format (SearchResult[][]) or flat format (SearchResult[])
|
||||||
|
const mapSearchResults = useCallback(
|
||||||
|
(
|
||||||
|
currentData: SearchResult[][] | SearchResult[] | undefined,
|
||||||
|
fn: (event: SearchResult) => SearchResult,
|
||||||
|
) => {
|
||||||
|
if (!currentData) return currentData;
|
||||||
|
if (Array.isArray(currentData[0])) {
|
||||||
|
return (currentData as SearchResult[][]).map((page) => page.map(fn));
|
||||||
|
}
|
||||||
|
return (currentData as SearchResult[]).map(fn);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// users
|
// users
|
||||||
|
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
@ -810,17 +826,12 @@ function ObjectDetailsTab({
|
|||||||
(key.includes("events") ||
|
(key.includes("events") ||
|
||||||
key.includes("events/search") ||
|
key.includes("events/search") ||
|
||||||
key.includes("events/explore")),
|
key.includes("events/explore")),
|
||||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||||
if (!currentData) return currentData;
|
mapSearchResults(currentData, (event) =>
|
||||||
// optimistic update
|
event.id === search.id
|
||||||
return currentData
|
? { ...event, data: { ...event.data, description: desc } }
|
||||||
.flat()
|
: event,
|
||||||
.map((event) =>
|
),
|
||||||
event.id === search.id
|
|
||||||
? { ...event, data: { ...event.data, description: desc } }
|
|
||||||
: event,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -843,7 +854,7 @@ function ObjectDetailsTab({
|
|||||||
);
|
);
|
||||||
setDesc(search.data.description);
|
setDesc(search.data.description);
|
||||||
});
|
});
|
||||||
}, [desc, search, mutate, t]);
|
}, [desc, search, mutate, t, mapSearchResults]);
|
||||||
|
|
||||||
const regenerateDescription = useCallback(
|
const regenerateDescription = useCallback(
|
||||||
(source: "snapshot" | "thumbnails") => {
|
(source: "snapshot" | "thumbnails") => {
|
||||||
@ -915,9 +926,8 @@ function ObjectDetailsTab({
|
|||||||
(key.includes("events") ||
|
(key.includes("events") ||
|
||||||
key.includes("events/search") ||
|
key.includes("events/search") ||
|
||||||
key.includes("events/explore")),
|
key.includes("events/explore")),
|
||||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||||
if (!currentData) return currentData;
|
mapSearchResults(currentData, (event) =>
|
||||||
return currentData.flat().map((event) =>
|
|
||||||
event.id === search.id
|
event.id === search.id
|
||||||
? {
|
? {
|
||||||
...event,
|
...event,
|
||||||
@ -928,8 +938,7 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: event,
|
: event,
|
||||||
);
|
),
|
||||||
},
|
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -963,7 +972,7 @@ function ObjectDetailsTab({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[search, apiHost, mutate, setSearch, t],
|
[search, apiHost, mutate, setSearch, t, mapSearchResults],
|
||||||
);
|
);
|
||||||
|
|
||||||
// recognized plate
|
// recognized plate
|
||||||
@ -992,9 +1001,8 @@ function ObjectDetailsTab({
|
|||||||
(key.includes("events") ||
|
(key.includes("events") ||
|
||||||
key.includes("events/search") ||
|
key.includes("events/search") ||
|
||||||
key.includes("events/explore")),
|
key.includes("events/explore")),
|
||||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||||
if (!currentData) return currentData;
|
mapSearchResults(currentData, (event) =>
|
||||||
return currentData.flat().map((event) =>
|
|
||||||
event.id === search.id
|
event.id === search.id
|
||||||
? {
|
? {
|
||||||
...event,
|
...event,
|
||||||
@ -1005,8 +1013,7 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: event,
|
: event,
|
||||||
);
|
),
|
||||||
},
|
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -1040,7 +1047,7 @@ function ObjectDetailsTab({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[search, apiHost, mutate, setSearch, t],
|
[search, apiHost, mutate, setSearch, t, mapSearchResults],
|
||||||
);
|
);
|
||||||
|
|
||||||
// speech transcription
|
// speech transcription
|
||||||
@ -1102,17 +1109,12 @@ function ObjectDetailsTab({
|
|||||||
(key.includes("events") ||
|
(key.includes("events") ||
|
||||||
key.includes("events/search") ||
|
key.includes("events/search") ||
|
||||||
key.includes("events/explore")),
|
key.includes("events/explore")),
|
||||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||||
if (!currentData) return currentData;
|
mapSearchResults(currentData, (event) =>
|
||||||
// optimistic update
|
event.id === search.id
|
||||||
return currentData
|
? { ...event, plus_id: "new_upload" }
|
||||||
.flat()
|
: event,
|
||||||
.map((event) =>
|
),
|
||||||
event.id === search.id
|
|
||||||
? { ...event, plus_id: "new_upload" }
|
|
||||||
: event,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -1120,7 +1122,7 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[search, mutate],
|
[search, mutate, mapSearchResults],
|
||||||
);
|
);
|
||||||
|
|
||||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -1503,7 +1505,7 @@ function ObjectDetailsTab({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
className="text-md h-32"
|
className="text-md h-32 md:text-sm"
|
||||||
placeholder={t("details.description.placeholder")}
|
placeholder={t("details.description.placeholder")}
|
||||||
value={desc}
|
value={desc}
|
||||||
onChange={(e) => setDesc(e.target.value)}
|
onChange={(e) => setDesc(e.target.value)}
|
||||||
@ -1511,25 +1513,7 @@ function ObjectDetailsTab({
|
|||||||
onBlur={handleDescriptionBlur}
|
onBlur={handleDescriptionBlur}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-row justify-end gap-4">
|
<div className="mb-10 flex flex-row justify-end gap-5">
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-label={t("button.save", { ns: "common" })}
|
|
||||||
className="text-primary/40 hover:text-primary/80"
|
|
||||||
onClick={() => {
|
|
||||||
setIsEditingDesc(false);
|
|
||||||
updateDescription();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaCheck className="size-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("button.save", { ns: "common" })}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@ -1540,13 +1524,31 @@ function ObjectDetailsTab({
|
|||||||
setDesc(originalDescRef.current ?? "");
|
setDesc(originalDescRef.current ?? "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaTimes className="size-4" />
|
<FaTimes className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("button.cancel", { ns: "common" })}
|
{t("button.cancel", { ns: "common" })}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-label={t("button.save", { ns: "common" })}
|
||||||
|
className="text-primary/40 hover:text-primary/80"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditingDesc(false);
|
||||||
|
updateDescription();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaCheck className="size-5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("button.save", { ns: "common" })}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||||
@ -89,9 +90,16 @@ export function TrackingDetails({
|
|||||||
}, [manualOverride, currentTime, annotationOffset]);
|
}, [manualOverride, currentTime, annotationOffset]);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const [_selectedZone, setSelectedZone] = useState("");
|
const [_selectedZone, setSelectedZone] = useState("");
|
||||||
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
||||||
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
|
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
|
||||||
|
const [lineBottomOffsetPx, setLineBottomOffsetPx] = useState<number>(32);
|
||||||
|
const [lineTopOffsetPx, setLineTopOffsetPx] = useState<number>(8);
|
||||||
|
const [blueLineHeightPx, setBlueLineHeightPx] = useState<number>(0);
|
||||||
|
|
||||||
|
const [timelineSize] = useResizeObserver(timelineContainerRef);
|
||||||
|
|
||||||
const aspectRatio = useMemo(() => {
|
const aspectRatio = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@ -221,60 +229,74 @@ export function TrackingDetails({
|
|||||||
displaySource,
|
displaySource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isWithinEventRange =
|
const isWithinEventRange = useMemo(() => {
|
||||||
effectiveTime !== undefined &&
|
if (effectiveTime === undefined || event.start_time === undefined) {
|
||||||
event.start_time !== undefined &&
|
return false;
|
||||||
event.end_time !== undefined &&
|
|
||||||
effectiveTime >= event.start_time &&
|
|
||||||
effectiveTime <= event.end_time;
|
|
||||||
|
|
||||||
// Calculate how far down the blue line should extend based on effectiveTime
|
|
||||||
const calculateLineHeight = useCallback(() => {
|
|
||||||
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
// If an event has not ended yet, fall back to last timestamp in eventSequence
|
||||||
const currentTime = effectiveTime ?? 0;
|
let eventEnd = event.end_time;
|
||||||
|
if (eventEnd == null && eventSequence && eventSequence.length > 0) {
|
||||||
// Find which events have been passed
|
const last = eventSequence[eventSequence.length - 1];
|
||||||
let lastPassedIndex = -1;
|
if (last && last.timestamp !== undefined) {
|
||||||
for (let i = 0; i < eventSequence.length; i++) {
|
eventEnd = last.timestamp;
|
||||||
if (currentTime >= (eventSequence[i].timestamp ?? 0)) {
|
|
||||||
lastPassedIndex = i;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No events passed yet
|
if (eventEnd == null) {
|
||||||
if (lastPassedIndex < 0) return 0;
|
return false;
|
||||||
|
}
|
||||||
|
return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
|
||||||
|
}, [effectiveTime, event.start_time, event.end_time, eventSequence]);
|
||||||
|
|
||||||
// All events passed
|
// Dynamically compute pixel offsets so the timeline line starts at the
|
||||||
if (lastPassedIndex >= eventSequence.length - 1) return 100;
|
// first row midpoint and ends at the last row midpoint. For accuracy,
|
||||||
|
// measure the center Y of each lifecycle row and interpolate the current
|
||||||
|
// effective time into a pixel position; then set the blue line height
|
||||||
|
// so it reaches the center dot at the same time the dot becomes active.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!timelineContainerRef.current || !eventSequence) return;
|
||||||
|
|
||||||
// Calculate percentage based on item position, not time
|
const containerRect = timelineContainerRef.current.getBoundingClientRect();
|
||||||
// Each item occupies an equal visual space regardless of time gaps
|
const validRefs = rowRefs.current.filter((r) => r !== null);
|
||||||
const itemPercentage = 100 / (eventSequence.length - 1);
|
if (validRefs.length === 0) return;
|
||||||
|
|
||||||
// Find progress between current and next event for smooth transition
|
const centers = validRefs.map((n) => {
|
||||||
const currentEvent = eventSequence[lastPassedIndex];
|
const r = n.getBoundingClientRect();
|
||||||
const nextEvent = eventSequence[lastPassedIndex + 1];
|
return r.top + r.height / 2 - containerRect.top;
|
||||||
const currentTimestamp = currentEvent.timestamp ?? 0;
|
});
|
||||||
const nextTimestamp = nextEvent.timestamp ?? 0;
|
|
||||||
|
|
||||||
// Calculate interpolation between the two events
|
const topOffset = Math.max(0, centers[0]);
|
||||||
const timeBetween = nextTimestamp - currentTimestamp;
|
const bottomOffset = Math.max(
|
||||||
const timeElapsed = currentTime - currentTimestamp;
|
0,
|
||||||
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
|
containerRect.height - centers[centers.length - 1],
|
||||||
|
|
||||||
// Base position plus interpolated progress to next item
|
|
||||||
return Math.min(
|
|
||||||
100,
|
|
||||||
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
|
|
||||||
);
|
);
|
||||||
}, [eventSequence, effectiveTime, isWithinEventRange]);
|
|
||||||
|
|
||||||
const blueLineHeight = calculateLineHeight();
|
setLineTopOffsetPx(Math.round(topOffset));
|
||||||
|
setLineBottomOffsetPx(Math.round(bottomOffset));
|
||||||
|
|
||||||
|
const eff = effectiveTime ?? 0;
|
||||||
|
const timestamps = eventSequence.map((s) => s.timestamp ?? 0);
|
||||||
|
|
||||||
|
let pixelPos = centers[0];
|
||||||
|
if (eff <= timestamps[0]) {
|
||||||
|
pixelPos = centers[0];
|
||||||
|
} else if (eff >= timestamps[timestamps.length - 1]) {
|
||||||
|
pixelPos = centers[centers.length - 1];
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < timestamps.length - 1; i++) {
|
||||||
|
const t1 = timestamps[i];
|
||||||
|
const t2 = timestamps[i + 1];
|
||||||
|
if (eff >= t1 && eff <= t2) {
|
||||||
|
const ratio = t2 > t1 ? (eff - t1) / (t2 - t1) : 0;
|
||||||
|
pixelPos = centers[i] + ratio * (centers[i + 1] - centers[i]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bluePx = Math.round(Math.max(0, pixelPos - topOffset));
|
||||||
|
setBlueLineHeightPx(bluePx);
|
||||||
|
}, [eventSequence, timelineSize.width, timelineSize.height, effectiveTime]);
|
||||||
|
|
||||||
const videoSource = useMemo(() => {
|
const videoSource = useMemo(() => {
|
||||||
// event.start_time and event.end_time are in DETECT stream time
|
// event.start_time and event.end_time are in DETECT stream time
|
||||||
@ -531,12 +553,21 @@ export function TrackingDetails({
|
|||||||
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="-pb-2 relative mx-0">
|
<div
|
||||||
<div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
className="-pb-2 relative mx-0"
|
||||||
|
ref={timelineContainerRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute -top-2 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground"
|
||||||
|
style={{ bottom: lineBottomOffsetPx }}
|
||||||
|
/>
|
||||||
{isWithinEventRange && (
|
{isWithinEventRange && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-6 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
className="absolute left-6 z-[5] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
||||||
style={{ height: `${blueLineHeight}%` }}
|
style={{
|
||||||
|
top: `${lineTopOffsetPx}px`,
|
||||||
|
height: `${blueLineHeightPx}px`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -589,20 +620,26 @@ export function TrackingDetails({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LifecycleIconRow
|
<div
|
||||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||||
item={item}
|
ref={(el) => {
|
||||||
isActive={isActive}
|
rowRefs.current[idx] = el;
|
||||||
formattedEventTimestamp={formattedEventTimestamp}
|
}}
|
||||||
ratio={ratio}
|
>
|
||||||
areaPx={areaPx}
|
<LifecycleIconRow
|
||||||
areaPct={areaPct}
|
item={item}
|
||||||
onClick={() => handleLifecycleClick(item)}
|
isActive={isActive}
|
||||||
setSelectedZone={setSelectedZone}
|
formattedEventTimestamp={formattedEventTimestamp}
|
||||||
getZoneColor={getZoneColor}
|
ratio={ratio}
|
||||||
effectiveTime={effectiveTime}
|
areaPx={areaPx}
|
||||||
isTimelineActive={isWithinEventRange}
|
areaPct={areaPct}
|
||||||
/>
|
onClick={() => handleLifecycleClick(item)}
|
||||||
|
setSelectedZone={setSelectedZone}
|
||||||
|
getZoneColor={getZoneColor}
|
||||||
|
effectiveTime={effectiveTime}
|
||||||
|
isTimelineActive={isWithinEventRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -318,6 +318,7 @@ export default function HlsVideoPlayer({
|
|||||||
{isDetailMode &&
|
{isDetailMode &&
|
||||||
camera &&
|
camera &&
|
||||||
currentTime &&
|
currentTime &&
|
||||||
|
loadedMetadata &&
|
||||||
videoDimensions.width > 0 &&
|
videoDimensions.width > 0 &&
|
||||||
videoDimensions.height > 0 && (
|
videoDimensions.height > 0 && (
|
||||||
<div className="absolute z-50 size-full">
|
<div className="absolute z-50 size-full">
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
SegmentedReviewData,
|
SegmentedReviewData,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
|
import { TimelineType } from "@/types/timeline";
|
||||||
import {
|
import {
|
||||||
getBeginningOfDayTimestamp,
|
getBeginningOfDayTimestamp,
|
||||||
getEndOfDayTimestamp,
|
getEndOfDayTimestamp,
|
||||||
@ -49,6 +50,16 @@ export default function Events() {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [notificationTab, setNotificationTab] =
|
||||||
|
useState<TimelineType>("timeline");
|
||||||
|
|
||||||
|
useSearchEffect("tab", (tab: string) => {
|
||||||
|
if (tab === "timeline" || tab === "events" || tab === "detail") {
|
||||||
|
setNotificationTab(tab as TimelineType);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
useSearchEffect("id", (reviewId: string) => {
|
useSearchEffect("id", (reviewId: string) => {
|
||||||
axios
|
axios
|
||||||
.get(`review/${reviewId}`)
|
.get(`review/${reviewId}`)
|
||||||
@ -66,7 +77,7 @@ export default function Events() {
|
|||||||
camera: resp.data.camera,
|
camera: resp.data.camera,
|
||||||
startTime,
|
startTime,
|
||||||
severity: resp.data.severity,
|
severity: resp.data.severity,
|
||||||
timelineType: "detail",
|
timelineType: notificationTab,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ReviewSeverity } from "./review";
|
import { ReviewSeverity } from "./review";
|
||||||
|
import { TimelineType } from "./timeline";
|
||||||
|
|
||||||
export type Recording = {
|
export type Recording = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -37,7 +38,7 @@ export type RecordingStartingPoint = {
|
|||||||
camera: string;
|
camera: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
severity: ReviewSeverity;
|
severity: ReviewSeverity;
|
||||||
timelineType?: "timeline" | "events" | "detail";
|
timelineType?: TimelineType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecordingPlayerError = "stalled" | "startup";
|
export type RecordingPlayerError = "stalled" | "startup";
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaFolderPlus } from "react-icons/fa";
|
import { FaFolderPlus } from "react-icons/fa";
|
||||||
import { MdModelTraining } from "react-icons/md";
|
import { MdModelTraining } from "react-icons/md";
|
||||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
@ -352,11 +351,9 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={handleEditClick}>
|
<DropdownMenuItem onClick={handleEditClick}>
|
||||||
<LuPencil className="mr-2 size-4" />
|
|
||||||
<span>{t("button.edit", { ns: "common" })}</span>
|
<span>{t("button.edit", { ns: "common" })}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleDeleteClick}>
|
<DropdownMenuItem onClick={handleDeleteClick}>
|
||||||
<LuTrash2 className="mr-2 size-4" />
|
|
||||||
<span>{t("button.delete", { ns: "common" })}</span>
|
<span>{t("button.delete", { ns: "common" })}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@ -799,7 +799,7 @@ function DetectionReview({
|
|||||||
(itemsToReview ?? 0) > 0 && (
|
(itemsToReview ?? 0) > 0 && (
|
||||||
<div className="col-span-full flex items-center justify-center">
|
<div className="col-span-full flex items-center justify-center">
|
||||||
<Button
|
<Button
|
||||||
className="text-white"
|
className="text-balance text-white"
|
||||||
aria-label={t("markTheseItemsAsReviewed")}
|
aria-label={t("markTheseItemsAsReviewed")}
|
||||||
variant="select"
|
variant="select"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -850,6 +850,29 @@ function FrigateCameraFeatures({
|
|||||||
}
|
}
|
||||||
}, [activeToastId, t]);
|
}, [activeToastId, t]);
|
||||||
|
|
||||||
|
const endEventViaBeacon = useCallback(() => {
|
||||||
|
if (!recordingEventIdRef.current) return;
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/api/events/${recordingEventIdRef.current}/end`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
end_time: Math.ceil(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// this needs to be a synchronous XMLHttpRequest to guarantee the PUT
|
||||||
|
// reaches the server before the browser kills the page
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
try {
|
||||||
|
xhr.open("PUT", url, false);
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/json");
|
||||||
|
xhr.setRequestHeader("X-CSRF-TOKEN", "1");
|
||||||
|
xhr.setRequestHeader("X-CACHE-BYPASS", "1");
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.send(payload);
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore errors during unload
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEventButtonClick = useCallback(() => {
|
const handleEventButtonClick = useCallback(() => {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
endEvent();
|
endEvent();
|
||||||
@ -887,8 +910,19 @@ function FrigateCameraFeatures({
|
|||||||
}, [camera.name, isRestreamed, preferredLiveMode, t]);
|
}, [camera.name, isRestreamed, preferredLiveMode, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Handle page unload/close (browser close, tab close, refresh, navigation to external site)
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
if (recordingEventIdRef.current) {
|
||||||
|
endEventViaBeacon();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
// ensure manual event is stopped when component unmounts
|
// ensure manual event is stopped when component unmounts
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
if (recordingEventIdRef.current) {
|
if (recordingEventIdRef.current) {
|
||||||
endEvent();
|
endEvent();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -201,12 +201,17 @@ export default function TriggerView({
|
|||||||
.then((configResponse) => {
|
.then((configResponse) => {
|
||||||
if (configResponse.status === 200) {
|
if (configResponse.status === 200) {
|
||||||
updateConfig();
|
updateConfig();
|
||||||
|
const displayName =
|
||||||
|
friendly_name && friendly_name !== ""
|
||||||
|
? `${friendly_name} (${name})`
|
||||||
|
: name;
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
isEdit
|
isEdit
|
||||||
? "triggers.toast.success.updateTrigger"
|
? "triggers.toast.success.updateTrigger"
|
||||||
: "triggers.toast.success.createTrigger",
|
: "triggers.toast.success.createTrigger",
|
||||||
{ name },
|
{ name: displayName },
|
||||||
),
|
),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
@ -351,8 +356,19 @@ export default function TriggerView({
|
|||||||
.then((configResponse) => {
|
.then((configResponse) => {
|
||||||
if (configResponse.status === 200) {
|
if (configResponse.status === 200) {
|
||||||
updateConfig();
|
updateConfig();
|
||||||
|
const friendly =
|
||||||
|
config?.cameras?.[selectedCamera]?.semantic_search
|
||||||
|
?.triggers?.[name]?.friendly_name;
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
friendly && friendly !== ""
|
||||||
|
? `${friendly} (${name})`
|
||||||
|
: name;
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("triggers.toast.success.deleteTrigger", { name }),
|
t("triggers.toast.success.deleteTrigger", {
|
||||||
|
name: displayName,
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
},
|
},
|
||||||
@ -381,7 +397,7 @@ export default function TriggerView({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
[t, updateConfig, selectedCamera, setUnsavedChanges, config],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -843,7 +859,14 @@ export default function TriggerView({
|
|||||||
/>
|
/>
|
||||||
<DeleteTriggerDialog
|
<DeleteTriggerDialog
|
||||||
show={showDelete}
|
show={showDelete}
|
||||||
triggerName={selectedTrigger?.name ?? ""}
|
triggerName={
|
||||||
|
selectedTrigger
|
||||||
|
? selectedTrigger.friendly_name &&
|
||||||
|
selectedTrigger.friendly_name !== ""
|
||||||
|
? `${selectedTrigger.friendly_name} (${selectedTrigger.name})`
|
||||||
|
: selectedTrigger.name
|
||||||
|
: ""
|
||||||
|
}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowDelete(false);
|
setShowDelete(false);
|
||||||
|
|||||||
@ -72,8 +72,7 @@ export default function StorageMetrics({
|
|||||||
const earliestDate = useMemo(() => {
|
const earliestDate = useMemo(() => {
|
||||||
const keys = Object.keys(recordingsSummary || {});
|
const keys = Object.keys(recordingsSummary || {});
|
||||||
return keys.length
|
return keys.length
|
||||||
? new TZDate(keys[keys.length - 1] + "T00:00:00", timezone).getTime() /
|
? new TZDate(keys[0] + "T00:00:00", timezone).getTime() / 1000
|
||||||
1000
|
|
||||||
: null;
|
: null;
|
||||||
}, [recordingsSummary, timezone]);
|
}, [recordingsSummary, timezone]);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user