mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-21 20:46:43 +03:00
Compare commits
1 Commits
17f8bb828b
...
2117e8a7a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2117e8a7a9 |
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras.
|
A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras.
|
||||||
|
|
||||||
Use of a GPU or AI accelerator is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead. See Frigate's supported [object detectors](https://docs.frigate.video/configuration/object_detectors/).
|
Use of a GPU or AI accelerator such as a [Google Coral](https://coral.ai/products/) or [Hailo](https://hailo.ai/) is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead.
|
||||||
|
|
||||||
- Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration)
|
- Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration)
|
||||||
- Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary
|
- Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary
|
||||||
|
|||||||
@ -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](#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.
|
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.
|
||||||
|
|
||||||
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](#matching)) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
- Added as a `sub_label` (if known) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
||||||
- Viewable in the Details pane in Review/History.
|
- Viewable in the Review Item Details pane in Review (sub labels).
|
||||||
- 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](#matching)) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
- 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/tracked_object_update` MQTT topic with `name` (if [known](#matching)) and `plate`.
|
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if known) and `plate`.
|
||||||
|
|
||||||
## Model Requirements
|
## Model Requirements
|
||||||
|
|
||||||
@ -31,7 +31,6 @@ 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:
|
||||||
@ -74,8 +73,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, and uses an enhanced text detector to find characters on multi-line plates. It is significantly slower than the `small` model.
|
- 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_.
|
||||||
- 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.
|
- For most users, the `small` model is recommended.
|
||||||
|
|
||||||
### Recognition
|
### Recognition
|
||||||
|
|
||||||
@ -178,7 +177,7 @@ lpr:
|
|||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
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:
|
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.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cameras:
|
cameras:
|
||||||
@ -306,7 +305,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](#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.
|
- 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.
|
||||||
- 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. 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.
|
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification.
|
||||||
|
|
||||||
### Usage and Best Practices
|
### Usage and Best Practices
|
||||||
|
|
||||||
|
|||||||
@ -56,7 +56,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /path/to/your/config:/config
|
- /path/to/your/config:/config
|
||||||
- /path/to/your/storage:/media/frigate
|
- /path/to/your/storage:/media/frigate
|
||||||
- type: tmpfs # Recommended: 1GB of memory
|
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||||
target: /tmp/cache
|
target: /tmp/cache
|
||||||
tmpfs:
|
tmpfs:
|
||||||
size: 1000000000
|
size: 1000000000
|
||||||
@ -310,7 +310,7 @@ services:
|
|||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /path/to/your/config:/config
|
- /path/to/your/config:/config
|
||||||
- /path/to/your/storage:/media/frigate
|
- /path/to/your/storage:/media/frigate
|
||||||
- type: tmpfs # Recommended: 1GB of memory
|
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||||
target: /tmp/cache
|
target: /tmp/cache
|
||||||
tmpfs:
|
tmpfs:
|
||||||
size: 1000000000
|
size: 1000000000
|
||||||
|
|||||||
@ -179,36 +179,6 @@ def config(request: Request):
|
|||||||
return JSONResponse(content=config)
|
return JSONResponse(content=config)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))])
|
|
||||||
def config_raw_paths(request: Request):
|
|
||||||
"""Admin-only endpoint that returns camera paths and go2rtc streams without credential masking."""
|
|
||||||
config_obj: FrigateConfig = request.app.frigate_config
|
|
||||||
|
|
||||||
raw_paths = {"cameras": {}, "go2rtc": {"streams": {}}}
|
|
||||||
|
|
||||||
# Extract raw camera ffmpeg input paths
|
|
||||||
for camera_name, camera in config_obj.cameras.items():
|
|
||||||
raw_paths["cameras"][camera_name] = {
|
|
||||||
"ffmpeg": {
|
|
||||||
"inputs": [
|
|
||||||
{"path": input.path, "roles": input.roles}
|
|
||||||
for input in camera.ffmpeg.inputs
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract raw go2rtc stream URLs
|
|
||||||
go2rtc_config = config_obj.go2rtc.model_dump(
|
|
||||||
mode="json", warnings="none", exclude_none=True
|
|
||||||
)
|
|
||||||
for stream_name, stream in go2rtc_config.get("streams", {}).items():
|
|
||||||
if stream is None:
|
|
||||||
continue
|
|
||||||
raw_paths["go2rtc"]["streams"][stream_name] = stream
|
|
||||||
|
|
||||||
return JSONResponse(content=raw_paths)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config/raw")
|
@router.get("/config/raw")
|
||||||
def config_raw():
|
def config_raw():
|
||||||
config_file = find_config_file()
|
config_file = find_config_file()
|
||||||
|
|||||||
@ -1781,8 +1781,9 @@ 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:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.error(e.with_traceback())
|
||||||
|
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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1806,8 +1807,8 @@ def create_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception("Error creating trigger embedding")
|
logger.error(e.with_traceback())
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -1916,8 +1917,9 @@ 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:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.error(e.with_traceback())
|
||||||
|
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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1956,8 +1958,9 @@ 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:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.error(e.with_traceback())
|
||||||
|
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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1969,8 +1972,8 @@ def update_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception("Error updating trigger embedding")
|
logger.error(e.with_traceback())
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -2030,8 +2033,9 @@ 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:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.error(e.with_traceback())
|
||||||
|
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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2043,8 +2047,8 @@ def delete_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception("Error deleting trigger embedding")
|
logger.error(e.with_traceback())
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|||||||
@ -762,15 +762,6 @@ async def recording_clip(
|
|||||||
.order_by(Recordings.start_time.asc())
|
.order_by(Recordings.start_time.asc())
|
||||||
)
|
)
|
||||||
|
|
||||||
if recordings.count() == 0:
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": "No recordings found for the specified time range",
|
|
||||||
},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
|
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
|
||||||
file_path = os.path.join(CACHE_DIR, file_name)
|
file_path = os.path.join(CACHE_DIR, file_name)
|
||||||
with open(file_path, "w") as file:
|
with open(file_path, "w") as file:
|
||||||
|
|||||||
@ -136,7 +136,6 @@ 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()
|
||||||
@ -157,11 +156,7 @@ 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,
|
config, count, self.camera_metrics[name], self.stop_event
|
||||||
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,15 +132,17 @@ 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(5, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
buffer_extension = min(
|
||||||
|
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 5s per side
|
# Expand buffer to reach minimum duration, still respecting max of 10s per side
|
||||||
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
||||||
buffer_extension = min(5, additional_buffer_per_side)
|
buffer_extension = min(10, 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": "Model is still training, please try again in a few moments.",
|
"message": "No face was recognized.",
|
||||||
"success": False,
|
"success": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -113,7 +113,6 @@ class StorageMaintainer(threading.Thread):
|
|||||||
recordings: Recordings = (
|
recordings: Recordings = (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
Recordings.id,
|
Recordings.id,
|
||||||
Recordings.camera,
|
|
||||||
Recordings.start_time,
|
Recordings.start_time,
|
||||||
Recordings.end_time,
|
Recordings.end_time,
|
||||||
Recordings.segment_size,
|
Recordings.segment_size,
|
||||||
@ -138,7 +137,7 @@ class StorageMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
event_start = 0
|
event_start = 0
|
||||||
deleted_recordings = []
|
deleted_recordings = set()
|
||||||
for recording in recordings:
|
for recording in recordings:
|
||||||
# check if 1 hour of storage has been reclaimed
|
# check if 1 hour of storage has been reclaimed
|
||||||
if deleted_segments_size > hourly_bandwidth:
|
if deleted_segments_size > hourly_bandwidth:
|
||||||
@ -173,7 +172,7 @@ class StorageMaintainer(threading.Thread):
|
|||||||
if not keep:
|
if not keep:
|
||||||
try:
|
try:
|
||||||
clear_and_unlink(Path(recording.path), missing_ok=False)
|
clear_and_unlink(Path(recording.path), missing_ok=False)
|
||||||
deleted_recordings.append(recording)
|
deleted_recordings.add(recording.id)
|
||||||
deleted_segments_size += recording.segment_size
|
deleted_segments_size += recording.segment_size
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# this file was not found so we must assume no space was cleaned up
|
# this file was not found so we must assume no space was cleaned up
|
||||||
@ -187,9 +186,6 @@ class StorageMaintainer(threading.Thread):
|
|||||||
recordings = (
|
recordings = (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
Recordings.id,
|
Recordings.id,
|
||||||
Recordings.camera,
|
|
||||||
Recordings.start_time,
|
|
||||||
Recordings.end_time,
|
|
||||||
Recordings.path,
|
Recordings.path,
|
||||||
Recordings.segment_size,
|
Recordings.segment_size,
|
||||||
)
|
)
|
||||||
@ -205,7 +201,7 @@ class StorageMaintainer(threading.Thread):
|
|||||||
try:
|
try:
|
||||||
clear_and_unlink(Path(recording.path), missing_ok=False)
|
clear_and_unlink(Path(recording.path), missing_ok=False)
|
||||||
deleted_segments_size += recording.segment_size
|
deleted_segments_size += recording.segment_size
|
||||||
deleted_recordings.append(recording)
|
deleted_recordings.add(recording.id)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# this file was not found so we must assume no space was cleaned up
|
# this file was not found so we must assume no space was cleaned up
|
||||||
pass
|
pass
|
||||||
@ -215,50 +211,7 @@ class StorageMaintainer(threading.Thread):
|
|||||||
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
|
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
|
||||||
# delete up to 100,000 at a time
|
# delete up to 100,000 at a time
|
||||||
max_deletes = 100000
|
max_deletes = 100000
|
||||||
|
deleted_recordings_list = list(deleted_recordings)
|
||||||
# Update has_clip for events that overlap with deleted recordings
|
|
||||||
if deleted_recordings:
|
|
||||||
# Group deleted recordings by camera
|
|
||||||
camera_recordings = {}
|
|
||||||
for recording in deleted_recordings:
|
|
||||||
if recording.camera not in camera_recordings:
|
|
||||||
camera_recordings[recording.camera] = {
|
|
||||||
"min_start": recording.start_time,
|
|
||||||
"max_end": recording.end_time,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
camera_recordings[recording.camera]["min_start"] = min(
|
|
||||||
camera_recordings[recording.camera]["min_start"],
|
|
||||||
recording.start_time,
|
|
||||||
)
|
|
||||||
camera_recordings[recording.camera]["max_end"] = max(
|
|
||||||
camera_recordings[recording.camera]["max_end"],
|
|
||||||
recording.end_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find all events that overlap with deleted recordings time range per camera
|
|
||||||
events_to_update = []
|
|
||||||
for camera, time_range in camera_recordings.items():
|
|
||||||
overlapping_events = Event.select(Event.id).where(
|
|
||||||
Event.camera == camera,
|
|
||||||
Event.has_clip == True,
|
|
||||||
Event.start_time < time_range["max_end"],
|
|
||||||
Event.end_time > time_range["min_start"],
|
|
||||||
)
|
|
||||||
|
|
||||||
for event in overlapping_events:
|
|
||||||
events_to_update.append(event.id)
|
|
||||||
|
|
||||||
# Update has_clip to False for overlapping events
|
|
||||||
if events_to_update:
|
|
||||||
for i in range(0, len(events_to_update), max_deletes):
|
|
||||||
batch = events_to_update[i : i + max_deletes]
|
|
||||||
Event.update(has_clip=False).where(Event.id << batch).execute()
|
|
||||||
logger.debug(
|
|
||||||
f"Updated has_clip to False for {len(events_to_update)} events"
|
|
||||||
)
|
|
||||||
|
|
||||||
deleted_recordings_list = [r.id for r in deleted_recordings]
|
|
||||||
for i in range(0, len(deleted_recordings_list), max_deletes):
|
for i in range(0, len(deleted_recordings_list), max_deletes):
|
||||||
Recordings.delete().where(
|
Recordings.delete().where(
|
||||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from frigate.comms.recordings_updater import (
|
|||||||
RecordingsDataSubscriber,
|
RecordingsDataSubscriber,
|
||||||
RecordingsDataTypeEnum,
|
RecordingsDataTypeEnum,
|
||||||
)
|
)
|
||||||
from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig
|
from frigate.config import CameraConfig, DetectConfig, 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,7 +539,6 @@ 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,
|
||||||
@ -550,10 +549,9 @@ 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.log_config)
|
self.pre_run_setup()
|
||||||
camera_watchdog = CameraWatchdog(
|
camera_watchdog = CameraWatchdog(
|
||||||
self.config,
|
self.config,
|
||||||
self.shm_frame_count,
|
self.shm_frame_count,
|
||||||
@ -579,7 +577,6 @@ 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,
|
||||||
@ -595,10 +592,9 @@ 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.log_config)
|
self.pre_run_setup()
|
||||||
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,16 +44,11 @@ self.addEventListener("notificationclick", (event) => {
|
|||||||
switch (event.action ?? "default") {
|
switch (event.action ?? "default") {
|
||||||
case "markReviewed":
|
case "markReviewed":
|
||||||
if (event.notification.data) {
|
if (event.notification.data) {
|
||||||
event.waitUntil(
|
|
||||||
fetch("/api/reviews/viewed", {
|
fetch("/api/reviews/viewed", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": 1 },
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-TOKEN": 1,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ids: [event.notification.data.id] }),
|
body: JSON.stringify({ ids: [event.notification.data.id] }),
|
||||||
}), // eslint-disable-line comma-dangle
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@ -63,7 +58,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
|
||||||
event.waitUntil(clients.openWindow(url));
|
return clients.openWindow(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -398,7 +398,11 @@ export function GroupedClassificationCard({
|
|||||||
threshold={threshold}
|
threshold={threshold}
|
||||||
selected={false}
|
selected={false}
|
||||||
i18nLibrary={i18nLibrary}
|
i18nLibrary={i18nLibrary}
|
||||||
onClick={() => {}}
|
onClick={(data, meta) => {
|
||||||
|
if (meta || selectedItems.length > 0) {
|
||||||
|
onClick(data);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children?.(data)}
|
{children?.(data)}
|
||||||
</ClassificationCard>
|
</ClassificationCard>
|
||||||
|
|||||||
@ -4,7 +4,9 @@ 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,
|
||||||
@ -29,8 +31,11 @@ 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;
|
||||||
@ -93,6 +98,7 @@ 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>
|
||||||
@ -104,6 +110,7 @@ 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>
|
||||||
@ -113,15 +120,16 @@ 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 &&
|
{config?.semantic_search?.enabled && isContextMenu && (
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
@ -131,13 +139,25 @@ export default function SearchResultActions({
|
|||||||
aria-label={t("itemMenu.addTrigger.aria")}
|
aria-label={t("itemMenu.addTrigger.aria")}
|
||||||
onClick={addTrigger}
|
onClick={addTrigger}
|
||||||
>
|
>
|
||||||
|
<BsFillLightningFill className="mr-2 size-4" />
|
||||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{config?.semantic_search?.enabled &&
|
||||||
|
searchResult.data.type == "object" && (
|
||||||
|
<MenuItem
|
||||||
|
aria-label={t("itemMenu.findSimilar.aria")}
|
||||||
|
onClick={findSimilar}
|
||||||
|
>
|
||||||
|
<MdImageSearch className="mr-2 size-4" />
|
||||||
|
<span>{t("itemMenu.findSimilar.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 p-[6px]",
|
"flex flex-col items-center justify-center rounded-lg",
|
||||||
className,
|
className,
|
||||||
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
|
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon className="size-5" />
|
<Icon className="size-5 md:m-[6px]" />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import useSWR from "swr";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -36,7 +35,6 @@ import { LuCheck, LuX } from "react-icons/lu";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import {
|
import {
|
||||||
MobilePage,
|
MobilePage,
|
||||||
MobilePageContent,
|
MobilePageContent,
|
||||||
@ -56,15 +54,9 @@ export default function CreateUserDialog({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: CreateUserOverlayProps) {
|
}: CreateUserOverlayProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const roles = useMemo(() => {
|
|
||||||
const existingRoles = config ? Object.keys(config.auth?.roles || {}) : [];
|
|
||||||
return Array.from(new Set(["admin", "viewer", ...(existingRoles || [])]));
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
user: z
|
user: z
|
||||||
@ -77,7 +69,7 @@ export default function CreateUserDialog({
|
|||||||
confirmPassword: z
|
confirmPassword: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, t("users.dialog.createUser.confirmPassword")),
|
.min(1, t("users.dialog.createUser.confirmPassword")),
|
||||||
role: z.string().min(1),
|
role: z.enum(["admin", "viewer"]),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: t("users.dialog.form.password.notMatch"),
|
message: t("users.dialog.form.password.notMatch"),
|
||||||
@ -254,22 +246,24 @@ export default function CreateUserDialog({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{roles.map((r) => (
|
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value={r}
|
value="admin"
|
||||||
key={r}
|
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{r === "admin" ? (
|
|
||||||
<Shield className="h-4 w-4 text-primary" />
|
<Shield className="h-4 w-4 text-primary" />
|
||||||
) : (
|
<span>{t("role.admin", { ns: "common" })}</span>
|
||||||
<User className="h-4 w-4 text-muted-foreground" />
|
</div>
|
||||||
)}
|
</SelectItem>
|
||||||
<span>{t(`role.${r}`, { ns: "common" }) || r}</span>
|
<SelectItem
|
||||||
|
value="viewer"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{t("role.viewer", { ns: "common" })}</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription className="text-xs text-muted-foreground">
|
<FormDescription className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@ -21,6 +20,7 @@ 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,26 +89,27 @@ 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 overflow-x-hidden",
|
"flex max-h-[40dvh] flex-col overflow-y-auto",
|
||||||
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,18 +171,6 @@ 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,22 +683,6 @@ 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();
|
||||||
@ -826,12 +810,17 @@ 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) => {
|
||||||
mapSearchResults(currentData, (event) =>
|
if (!currentData) return currentData;
|
||||||
|
// optimistic update
|
||||||
|
return currentData
|
||||||
|
.flat()
|
||||||
|
.map((event) =>
|
||||||
event.id === search.id
|
event.id === search.id
|
||||||
? { ...event, data: { ...event.data, description: desc } }
|
? { ...event, data: { ...event.data, description: desc } }
|
||||||
: event,
|
: event,
|
||||||
),
|
);
|
||||||
|
},
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -854,7 +843,7 @@ function ObjectDetailsTab({
|
|||||||
);
|
);
|
||||||
setDesc(search.data.description);
|
setDesc(search.data.description);
|
||||||
});
|
});
|
||||||
}, [desc, search, mutate, t, mapSearchResults]);
|
}, [desc, search, mutate, t]);
|
||||||
|
|
||||||
const regenerateDescription = useCallback(
|
const regenerateDescription = useCallback(
|
||||||
(source: "snapshot" | "thumbnails") => {
|
(source: "snapshot" | "thumbnails") => {
|
||||||
@ -926,8 +915,9 @@ 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) => {
|
||||||
mapSearchResults(currentData, (event) =>
|
if (!currentData) return currentData;
|
||||||
|
return currentData.flat().map((event) =>
|
||||||
event.id === search.id
|
event.id === search.id
|
||||||
? {
|
? {
|
||||||
...event,
|
...event,
|
||||||
@ -938,7 +928,8 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: event,
|
: event,
|
||||||
),
|
);
|
||||||
|
},
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -972,7 +963,7 @@ function ObjectDetailsTab({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[search, apiHost, mutate, setSearch, t, mapSearchResults],
|
[search, apiHost, mutate, setSearch, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// recognized plate
|
// recognized plate
|
||||||
@ -1001,8 +992,9 @@ 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) => {
|
||||||
mapSearchResults(currentData, (event) =>
|
if (!currentData) return currentData;
|
||||||
|
return currentData.flat().map((event) =>
|
||||||
event.id === search.id
|
event.id === search.id
|
||||||
? {
|
? {
|
||||||
...event,
|
...event,
|
||||||
@ -1013,7 +1005,8 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: event,
|
: event,
|
||||||
),
|
);
|
||||||
|
},
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -1047,7 +1040,7 @@ function ObjectDetailsTab({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[search, apiHost, mutate, setSearch, t, mapSearchResults],
|
[search, apiHost, mutate, setSearch, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// speech transcription
|
// speech transcription
|
||||||
@ -1109,12 +1102,17 @@ 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) => {
|
||||||
mapSearchResults(currentData, (event) =>
|
if (!currentData) return currentData;
|
||||||
|
// optimistic update
|
||||||
|
return currentData
|
||||||
|
.flat()
|
||||||
|
.map((event) =>
|
||||||
event.id === search.id
|
event.id === search.id
|
||||||
? { ...event, plus_id: "new_upload" }
|
? { ...event, plus_id: "new_upload" }
|
||||||
: event,
|
: event,
|
||||||
),
|
);
|
||||||
|
},
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -1122,7 +1120,7 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[search, mutate, mapSearchResults],
|
[search, mutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -1505,7 +1503,7 @@ function ObjectDetailsTab({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
className="text-md h-32 md:text-sm"
|
className="text-md h-32"
|
||||||
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)}
|
||||||
@ -1513,25 +1511,7 @@ function ObjectDetailsTab({
|
|||||||
onBlur={handleDescriptionBlur}
|
onBlur={handleDescriptionBlur}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="mb-10 flex flex-row justify-end gap-5">
|
<div className="flex flex-row justify-end gap-4">
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-label={t("button.cancel", { ns: "common" })}
|
|
||||||
className="text-primary/40 hover:text-primary"
|
|
||||||
onClick={() => {
|
|
||||||
setIsEditingDesc(false);
|
|
||||||
setDesc(originalDescRef.current ?? "");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaTimes className="size-5" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("button.cancel", { ns: "common" })}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@ -1542,13 +1522,31 @@ function ObjectDetailsTab({
|
|||||||
updateDescription();
|
updateDescription();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaCheck className="size-5" />
|
<FaCheck className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("button.save", { ns: "common" })}
|
{t("button.save", { ns: "common" })}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-label={t("button.cancel", { ns: "common" })}
|
||||||
|
className="text-primary/40 hover:text-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditingDesc(false);
|
||||||
|
setDesc(originalDescRef.current ?? "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTimes className="size-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
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";
|
||||||
@ -12,11 +11,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
|
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { REVIEW_PADDING } from "@/types/review";
|
import { REVIEW_PADDING } from "@/types/review";
|
||||||
import {
|
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||||
ASPECT_VERTICAL_LAYOUT,
|
|
||||||
ASPECT_WIDE_LAYOUT,
|
|
||||||
Recording,
|
|
||||||
} from "@/types/record";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@ -79,139 +74,6 @@ export function TrackingDetails({
|
|||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
// Fetch recording segments for the event's time range to handle motion-only gaps
|
|
||||||
const eventStartRecord = useMemo(
|
|
||||||
() => (event.start_time ?? 0) + annotationOffset / 1000,
|
|
||||||
[event.start_time, annotationOffset],
|
|
||||||
);
|
|
||||||
const eventEndRecord = useMemo(
|
|
||||||
() => (event.end_time ?? Date.now() / 1000) + annotationOffset / 1000,
|
|
||||||
[event.end_time, annotationOffset],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: recordings } = useSWR<Recording[]>(
|
|
||||||
event.camera
|
|
||||||
? [
|
|
||||||
`${event.camera}/recordings`,
|
|
||||||
{
|
|
||||||
after: eventStartRecord - REVIEW_PADDING,
|
|
||||||
before: eventEndRecord + REVIEW_PADDING,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert a timeline timestamp to actual video player time, accounting for
|
|
||||||
// motion-only recording gaps. Uses the same algorithm as DynamicVideoController.
|
|
||||||
const timestampToVideoTime = useCallback(
|
|
||||||
(timestamp: number): number => {
|
|
||||||
if (!recordings || recordings.length === 0) {
|
|
||||||
// Fallback to simple calculation if no recordings data
|
|
||||||
return timestamp - (eventStartRecord - REVIEW_PADDING);
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
|
||||||
|
|
||||||
// If timestamp is before video start, return 0
|
|
||||||
if (timestamp < videoStartTime) return 0;
|
|
||||||
|
|
||||||
// Check if timestamp is before the first recording or after the last
|
|
||||||
if (
|
|
||||||
timestamp < recordings[0].start_time ||
|
|
||||||
timestamp > recordings[recordings.length - 1].end_time
|
|
||||||
) {
|
|
||||||
// No recording available at this timestamp
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the inpoint offset - the HLS video may start partway through the first segment
|
|
||||||
let inpointOffset = 0;
|
|
||||||
if (
|
|
||||||
videoStartTime > recordings[0].start_time &&
|
|
||||||
videoStartTime < recordings[0].end_time
|
|
||||||
) {
|
|
||||||
inpointOffset = videoStartTime - recordings[0].start_time;
|
|
||||||
}
|
|
||||||
|
|
||||||
let seekSeconds = 0;
|
|
||||||
for (const segment of recordings) {
|
|
||||||
// Skip segments that end before our timestamp
|
|
||||||
if (segment.end_time <= timestamp) {
|
|
||||||
// Add this segment's duration, but subtract inpoint offset from first segment
|
|
||||||
if (segment === recordings[0]) {
|
|
||||||
seekSeconds += segment.duration - inpointOffset;
|
|
||||||
} else {
|
|
||||||
seekSeconds += segment.duration;
|
|
||||||
}
|
|
||||||
} else if (segment.start_time <= timestamp) {
|
|
||||||
// The timestamp is within this segment
|
|
||||||
if (segment === recordings[0]) {
|
|
||||||
// For the first segment, account for the inpoint offset
|
|
||||||
seekSeconds +=
|
|
||||||
timestamp - Math.max(segment.start_time, videoStartTime);
|
|
||||||
} else {
|
|
||||||
seekSeconds += timestamp - segment.start_time;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return seekSeconds;
|
|
||||||
},
|
|
||||||
[recordings, eventStartRecord],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert video player time back to timeline timestamp, accounting for
|
|
||||||
// motion-only recording gaps. Reverse of timestampToVideoTime.
|
|
||||||
const videoTimeToTimestamp = useCallback(
|
|
||||||
(playerTime: number): number => {
|
|
||||||
if (!recordings || recordings.length === 0) {
|
|
||||||
// Fallback to simple calculation if no recordings data
|
|
||||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
|
||||||
return playerTime + videoStartTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
|
||||||
|
|
||||||
// Calculate the inpoint offset - the video may start partway through the first segment
|
|
||||||
let inpointOffset = 0;
|
|
||||||
if (
|
|
||||||
videoStartTime > recordings[0].start_time &&
|
|
||||||
videoStartTime < recordings[0].end_time
|
|
||||||
) {
|
|
||||||
inpointOffset = videoStartTime - recordings[0].start_time;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timestamp = 0;
|
|
||||||
let totalTime = 0;
|
|
||||||
|
|
||||||
for (const segment of recordings) {
|
|
||||||
const segmentDuration =
|
|
||||||
segment === recordings[0]
|
|
||||||
? segment.duration - inpointOffset
|
|
||||||
: segment.duration;
|
|
||||||
|
|
||||||
if (totalTime + segmentDuration > playerTime) {
|
|
||||||
// The player time is within this segment
|
|
||||||
if (segment === recordings[0]) {
|
|
||||||
// For the first segment, add the inpoint offset
|
|
||||||
timestamp =
|
|
||||||
Math.max(segment.start_time, videoStartTime) +
|
|
||||||
(playerTime - totalTime);
|
|
||||||
} else {
|
|
||||||
timestamp = segment.start_time + (playerTime - totalTime);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
totalTime += segmentDuration;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return timestamp;
|
|
||||||
},
|
|
||||||
[recordings, eventStartRecord],
|
|
||||||
);
|
|
||||||
|
|
||||||
eventSequence?.map((event) => {
|
eventSequence?.map((event) => {
|
||||||
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
|
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
|
||||||
return resolveZoneName(config, zone);
|
return resolveZoneName(config, zone);
|
||||||
@ -227,16 +89,9 @@ 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) {
|
||||||
@ -285,14 +140,17 @@ export function TrackingDetails({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For video mode: convert to video-relative time (accounting for motion-only gaps)
|
// For video mode: convert to video-relative time and seek player
|
||||||
const relativeTime = timestampToVideoTime(targetTimeRecord);
|
const eventStartRecord =
|
||||||
|
(event.start_time ?? 0) + annotationOffset / 1000;
|
||||||
|
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||||
|
const relativeTime = targetTimeRecord - videoStartTime;
|
||||||
|
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.currentTime = relativeTime;
|
videoRef.current.currentTime = relativeTime;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[annotationOffset, displaySource, timestampToVideoTime],
|
[event.start_time, annotationOffset, displaySource],
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedStart = config
|
const formattedStart = config
|
||||||
@ -311,9 +169,8 @@ export function TrackingDetails({
|
|||||||
})
|
})
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const formattedEnd =
|
const formattedEnd = config
|
||||||
config && event.end_time != null
|
? formatUnixTimestampToDateTime(event.end_time ?? 0, {
|
||||||
? formatUnixTimestampToDateTime(event.end_time, {
|
|
||||||
timezone: config.ui.timezone,
|
timezone: config.ui.timezone,
|
||||||
date_format:
|
date_format:
|
||||||
config.ui.time_format == "24hour"
|
config.ui.time_format == "24hour"
|
||||||
@ -345,83 +202,79 @@ export function TrackingDetails({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seekToTimestamp is a record stream timestamp
|
// seekToTimestamp is a record stream timestamp
|
||||||
// Convert to video position (accounting for motion-only recording gaps)
|
// event.start_time is detect stream time, convert to record
|
||||||
|
// The video clip starts at (eventStartRecord - REVIEW_PADDING)
|
||||||
if (!videoRef.current) return;
|
if (!videoRef.current) return;
|
||||||
const relativeTime = timestampToVideoTime(seekToTimestamp);
|
const eventStartRecord = event.start_time + annotationOffset / 1000;
|
||||||
|
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||||
|
const relativeTime = seekToTimestamp - videoStartTime;
|
||||||
if (relativeTime >= 0) {
|
if (relativeTime >= 0) {
|
||||||
videoRef.current.currentTime = relativeTime;
|
videoRef.current.currentTime = relativeTime;
|
||||||
}
|
}
|
||||||
setSeekToTimestamp(null);
|
setSeekToTimestamp(null);
|
||||||
}, [seekToTimestamp, displaySource, timestampToVideoTime]);
|
}, [
|
||||||
|
seekToTimestamp,
|
||||||
|
event.start_time,
|
||||||
|
annotationOffset,
|
||||||
|
apiHost,
|
||||||
|
event.camera,
|
||||||
|
displaySource,
|
||||||
|
]);
|
||||||
|
|
||||||
const isWithinEventRange = useMemo(() => {
|
const isWithinEventRange =
|
||||||
if (effectiveTime === undefined || event.start_time === undefined) {
|
effectiveTime !== undefined &&
|
||||||
return false;
|
event.start_time !== undefined &&
|
||||||
}
|
event.end_time !== undefined &&
|
||||||
// If an event has not ended yet, fall back to last timestamp in eventSequence
|
effectiveTime >= event.start_time &&
|
||||||
let eventEnd = event.end_time;
|
effectiveTime <= event.end_time;
|
||||||
if (eventEnd == null && eventSequence && eventSequence.length > 0) {
|
|
||||||
const last = eventSequence[eventSequence.length - 1];
|
// Calculate how far down the blue line should extend based on effectiveTime
|
||||||
if (last && last.timestamp !== undefined) {
|
const calculateLineHeight = useCallback(() => {
|
||||||
eventEnd = last.timestamp;
|
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) {
|
||||||
}
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventEnd == null) {
|
const currentTime = effectiveTime ?? 0;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
|
|
||||||
}, [effectiveTime, event.start_time, event.end_time, eventSequence]);
|
|
||||||
|
|
||||||
// Dynamically compute pixel offsets so the timeline line starts at the
|
// Find which events have been passed
|
||||||
// first row midpoint and ends at the last row midpoint. For accuracy,
|
let lastPassedIndex = -1;
|
||||||
// measure the center Y of each lifecycle row and interpolate the current
|
for (let i = 0; i < eventSequence.length; i++) {
|
||||||
// effective time into a pixel position; then set the blue line height
|
if (currentTime >= (eventSequence[i].timestamp ?? 0)) {
|
||||||
// so it reaches the center dot at the same time the dot becomes active.
|
lastPassedIndex = i;
|
||||||
useEffect(() => {
|
|
||||||
if (!timelineContainerRef.current || !eventSequence) return;
|
|
||||||
|
|
||||||
const containerRect = timelineContainerRef.current.getBoundingClientRect();
|
|
||||||
const validRefs = rowRefs.current.filter((r) => r !== null);
|
|
||||||
if (validRefs.length === 0) return;
|
|
||||||
|
|
||||||
const centers = validRefs.map((n) => {
|
|
||||||
const r = n.getBoundingClientRect();
|
|
||||||
return r.top + r.height / 2 - containerRect.top;
|
|
||||||
});
|
|
||||||
|
|
||||||
const topOffset = Math.max(0, centers[0]);
|
|
||||||
const bottomOffset = Math.max(
|
|
||||||
0,
|
|
||||||
containerRect.height - centers[centers.length - 1],
|
|
||||||
);
|
|
||||||
|
|
||||||
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 {
|
} 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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const bluePx = Math.round(Math.max(0, pixelPos - topOffset));
|
// No events passed yet
|
||||||
setBlueLineHeightPx(bluePx);
|
if (lastPassedIndex < 0) return 0;
|
||||||
}, [eventSequence, timelineSize.width, timelineSize.height, effectiveTime]);
|
|
||||||
|
// All events passed
|
||||||
|
if (lastPassedIndex >= eventSequence.length - 1) return 100;
|
||||||
|
|
||||||
|
// Calculate percentage based on item position, not time
|
||||||
|
// Each item occupies an equal visual space regardless of time gaps
|
||||||
|
const itemPercentage = 100 / (eventSequence.length - 1);
|
||||||
|
|
||||||
|
// Find progress between current and next event for smooth transition
|
||||||
|
const currentEvent = eventSequence[lastPassedIndex];
|
||||||
|
const nextEvent = eventSequence[lastPassedIndex + 1];
|
||||||
|
const currentTimestamp = currentEvent.timestamp ?? 0;
|
||||||
|
const nextTimestamp = nextEvent.timestamp ?? 0;
|
||||||
|
|
||||||
|
// Calculate interpolation between the two events
|
||||||
|
const timeBetween = nextTimestamp - currentTimestamp;
|
||||||
|
const timeElapsed = currentTime - currentTimestamp;
|
||||||
|
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
|
||||||
|
|
||||||
|
// Base position plus interpolated progress to next item
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
|
||||||
|
);
|
||||||
|
}, [eventSequence, effectiveTime, isWithinEventRange]);
|
||||||
|
|
||||||
|
const blueLineHeight = calculateLineHeight();
|
||||||
|
|
||||||
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
|
||||||
@ -459,13 +312,14 @@ export function TrackingDetails({
|
|||||||
|
|
||||||
const handleTimeUpdate = useCallback(
|
const handleTimeUpdate = useCallback(
|
||||||
(time: number) => {
|
(time: number) => {
|
||||||
// Convert video player time back to timeline timestamp
|
// event.start_time is detect stream time, convert to record
|
||||||
// accounting for motion-only recording gaps
|
const eventStartRecord = event.start_time + annotationOffset / 1000;
|
||||||
const absoluteTime = videoTimeToTimestamp(time);
|
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||||
|
const absoluteTime = time + videoStartTime;
|
||||||
|
|
||||||
setCurrentTime(absoluteTime);
|
setCurrentTime(absoluteTime);
|
||||||
},
|
},
|
||||||
[videoTimeToTimestamp],
|
[event.start_time, annotationOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [src, setSrc] = useState(
|
const [src, setSrc] = useState(
|
||||||
@ -649,16 +503,9 @@ export function TrackingDetails({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="capitalize">{label}</span>
|
<span className="capitalize">{label}</span>
|
||||||
<div className="md:text-md flex items-center text-xs text-secondary-foreground">
|
<span className="md:text-md text-xs text-secondary-foreground">
|
||||||
{formattedStart ?? ""}
|
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
||||||
{event.end_time != null ? (
|
</span>
|
||||||
<> - {formattedEnd}</>
|
|
||||||
) : (
|
|
||||||
<div className="inline-block">
|
|
||||||
<ActivityIndicator className="ml-3 size-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{event.data?.recognized_license_plate && (
|
{event.data?.recognized_license_plate && (
|
||||||
<>
|
<>
|
||||||
<span className="text-secondary-foreground">·</span>
|
<span className="text-secondary-foreground">·</span>
|
||||||
@ -684,21 +531,12 @@ export function TrackingDetails({
|
|||||||
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="-pb-2 relative mx-0">
|
||||||
className="-pb-2 relative mx-0"
|
<div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
||||||
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 z-[5] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
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"
|
||||||
style={{
|
style={{ height: `${blueLineHeight}%` }}
|
||||||
top: `${lineTopOffsetPx}px`,
|
|
||||||
height: `${blueLineHeightPx}px`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -751,13 +589,8 @@ export function TrackingDetails({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
|
||||||
ref={(el) => {
|
|
||||||
rowRefs.current[idx] = el;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LifecycleIconRow
|
<LifecycleIconRow
|
||||||
|
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||||
item={item}
|
item={item}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
formattedEventTimestamp={formattedEventTimestamp}
|
formattedEventTimestamp={formattedEventTimestamp}
|
||||||
@ -770,7 +603,6 @@ export function TrackingDetails({
|
|||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
isTimelineActive={isWithinEventRange}
|
isTimelineActive={isWithinEventRange}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -135,23 +135,6 @@ export default function HlsVideoPlayer({
|
|||||||
if (!useHlsCompat) {
|
if (!useHlsCompat) {
|
||||||
videoRef.current.src = currentSource.playlist;
|
videoRef.current.src = currentSource.playlist;
|
||||||
videoRef.current.load();
|
videoRef.current.load();
|
||||||
// For native HLS, we need to seek after metadata loads since startPosition isn't supported
|
|
||||||
if (currentSource.startPosition !== undefined) {
|
|
||||||
videoRef.current.addEventListener(
|
|
||||||
"loadedmetadata",
|
|
||||||
() => {
|
|
||||||
if (videoRef.current && currentSource.startPosition !== undefined) {
|
|
||||||
// Clamp startPosition to video duration to prevent seeking beyond the end
|
|
||||||
const clampedPosition = Math.min(
|
|
||||||
currentSource.startPosition,
|
|
||||||
videoRef.current.duration || Infinity,
|
|
||||||
);
|
|
||||||
videoRef.current.currentTime = clampedPosition;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,7 +318,6 @@ 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">
|
||||||
|
|||||||
@ -111,6 +111,7 @@ export default function DynamicVideoPlayer({
|
|||||||
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
|
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
|
||||||
const [source, setSource] = useState<HlsSource>({
|
const [source, setSource] = useState<HlsSource>({
|
||||||
playlist: `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
|
playlist: `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
|
||||||
|
startPosition: startTimestamp ? timeRange.after - startTimestamp : 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// start at correct time
|
// start at correct time
|
||||||
@ -195,8 +196,26 @@ export default function DynamicVideoPlayer({
|
|||||||
playerRef.current.autoplay = !isScrubbing;
|
playerRef.current.autoplay = !isScrubbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let startPosition = undefined;
|
||||||
|
|
||||||
|
if (startTimestamp) {
|
||||||
|
const inpointOffset = calculateInpointOffset(
|
||||||
|
recordingParams.after,
|
||||||
|
(recordings || [])[0],
|
||||||
|
);
|
||||||
|
const idealStartPosition = Math.max(
|
||||||
|
0,
|
||||||
|
startTimestamp - timeRange.after - inpointOffset,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (idealStartPosition >= recordings[0].start_time - timeRange.after) {
|
||||||
|
startPosition = idealStartPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSource({
|
setSource({
|
||||||
playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`,
|
playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`,
|
||||||
|
startPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
|
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import { z } from "zod";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast, Toaster } from "sonner";
|
import { toast, Toaster } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState, useMemo, useEffect } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { LuTrash2, LuPlus } from "react-icons/lu";
|
import { LuTrash2, LuPlus } from "react-icons/lu";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -42,15 +42,7 @@ export default function CameraEditForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
}: CameraEditFormProps) {
|
}: CameraEditFormProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const { data: config, mutate: mutateConfig } =
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
useSWR<FrigateConfig>("config");
|
|
||||||
const { data: rawPaths, mutate: mutateRawPaths } = useSWR<{
|
|
||||||
cameras: Record<
|
|
||||||
string,
|
|
||||||
{ ffmpeg: { inputs: { path: string; roles: string[] }[] } }
|
|
||||||
>;
|
|
||||||
go2rtc: { streams: Record<string, string | string[]> };
|
|
||||||
}>(cameraName ? "config/raw_paths" : null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const formSchema = useMemo(
|
const formSchema = useMemo(
|
||||||
@ -153,23 +145,14 @@ export default function CameraEditForm({
|
|||||||
if (cameraName && config?.cameras[cameraName]) {
|
if (cameraName && config?.cameras[cameraName]) {
|
||||||
const camera = config.cameras[cameraName];
|
const camera = config.cameras[cameraName];
|
||||||
defaultValues.enabled = camera.enabled ?? true;
|
defaultValues.enabled = camera.enabled ?? true;
|
||||||
|
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
|
||||||
// Use raw paths from the admin endpoint if available, otherwise fall back to masked paths
|
|
||||||
const rawCameraData = rawPaths?.cameras?.[cameraName];
|
|
||||||
defaultValues.ffmpeg.inputs = rawCameraData?.ffmpeg?.inputs?.length
|
|
||||||
? rawCameraData.ffmpeg.inputs.map((input) => ({
|
|
||||||
path: input.path,
|
|
||||||
roles: input.roles as Role[],
|
|
||||||
}))
|
|
||||||
: camera.ffmpeg?.inputs?.length
|
|
||||||
? camera.ffmpeg.inputs.map((input) => ({
|
? camera.ffmpeg.inputs.map((input) => ({
|
||||||
path: input.path,
|
path: input.path,
|
||||||
roles: input.roles as Role[],
|
roles: input.roles as Role[],
|
||||||
}))
|
}))
|
||||||
: defaultValues.ffmpeg.inputs;
|
: defaultValues.ffmpeg.inputs;
|
||||||
|
|
||||||
const go2rtcStreams =
|
const go2rtcStreams = config.go2rtc?.streams || {};
|
||||||
rawPaths?.go2rtc?.streams || config.go2rtc?.streams || {};
|
|
||||||
const cameraStreams: Record<string, string[]> = {};
|
const cameraStreams: Record<string, string[]> = {};
|
||||||
|
|
||||||
// get candidate stream names for this camera. could be the camera's own name,
|
// get candidate stream names for this camera. could be the camera's own name,
|
||||||
@ -213,60 +196,6 @@ export default function CameraEditForm({
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update form values when rawPaths loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
cameraName &&
|
|
||||||
config?.cameras[cameraName] &&
|
|
||||||
rawPaths?.cameras?.[cameraName]
|
|
||||||
) {
|
|
||||||
const camera = config.cameras[cameraName];
|
|
||||||
const rawCameraData = rawPaths.cameras[cameraName];
|
|
||||||
|
|
||||||
// Update ffmpeg inputs with raw paths
|
|
||||||
if (rawCameraData.ffmpeg?.inputs?.length) {
|
|
||||||
form.setValue(
|
|
||||||
"ffmpeg.inputs",
|
|
||||||
rawCameraData.ffmpeg.inputs.map((input) => ({
|
|
||||||
path: input.path,
|
|
||||||
roles: input.roles as Role[],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update go2rtc streams with raw URLs
|
|
||||||
if (rawPaths.go2rtc?.streams) {
|
|
||||||
const validNames = new Set<string>();
|
|
||||||
validNames.add(cameraName);
|
|
||||||
|
|
||||||
camera.ffmpeg?.inputs?.forEach((input) => {
|
|
||||||
const restreamMatch = input.path.match(
|
|
||||||
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
|
|
||||||
);
|
|
||||||
if (restreamMatch) {
|
|
||||||
validNames.add(restreamMatch[1]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const liveStreams = camera?.live?.streams;
|
|
||||||
if (liveStreams) {
|
|
||||||
Object.keys(liveStreams).forEach((key) => validNames.add(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
const cameraStreams: Record<string, string[]> = {};
|
|
||||||
Object.entries(rawPaths.go2rtc.streams).forEach(([name, urls]) => {
|
|
||||||
if (validNames.has(name)) {
|
|
||||||
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.keys(cameraStreams).length > 0) {
|
|
||||||
form.setValue("go2rtcStreams", cameraStreams);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [cameraName, config, rawPaths, form]);
|
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: "ffmpeg.inputs",
|
name: "ffmpeg.inputs",
|
||||||
@ -339,8 +268,6 @@ export default function CameraEditForm({
|
|||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
mutateConfig();
|
|
||||||
mutateRawPaths();
|
|
||||||
if (onSave) onSave();
|
if (onSave) onSave();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -350,8 +277,6 @@ export default function CameraEditForm({
|
|||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
mutateConfig();
|
|
||||||
mutateRawPaths();
|
|
||||||
if (onSave) onSave();
|
if (onSave) onSave();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import {
|
|||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
SegmentedReviewData,
|
SegmentedReviewData,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
import { TimelineType } from "@/types/timeline";
|
|
||||||
import {
|
import {
|
||||||
getBeginningOfDayTimestamp,
|
getBeginningOfDayTimestamp,
|
||||||
getEndOfDayTimestamp,
|
getEndOfDayTimestamp,
|
||||||
@ -50,16 +49,6 @@ 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}`)
|
||||||
@ -77,7 +66,7 @@ export default function Events() {
|
|||||||
camera: resp.data.camera,
|
camera: resp.data.camera,
|
||||||
startTime,
|
startTime,
|
||||||
severity: resp.data.severity,
|
severity: resp.data.severity,
|
||||||
timelineType: notificationTab,
|
timelineType: "detail",
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import useSWR from "swr";
|
|||||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||||
import { PolygonType } from "@/types/canvas";
|
import { PolygonType } from "@/types/canvas";
|
||||||
import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView";
|
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
||||||
import CameraManagementView from "@/views/settings/CameraManagementView";
|
import CameraManagementView from "@/views/settings/CameraManagementView";
|
||||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
import MotionTunerView from "@/views/settings/MotionTunerView";
|
||||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||||
@ -93,7 +93,7 @@ const settingsGroups = [
|
|||||||
label: "cameras",
|
label: "cameras",
|
||||||
items: [
|
items: [
|
||||||
{ key: "cameraManagement", component: CameraManagementView },
|
{ key: "cameraManagement", component: CameraManagementView },
|
||||||
{ key: "cameraReview", component: CameraReviewSettingsView },
|
{ key: "cameraReview", component: CameraSettingsView },
|
||||||
{ key: "masksAndZones", component: MasksAndZonesView },
|
{ key: "masksAndZones", component: MasksAndZonesView },
|
||||||
{ key: "motionTuner", component: MotionTunerView },
|
{ key: "motionTuner", component: MotionTunerView },
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { ReviewSeverity } from "./review";
|
import { ReviewSeverity } from "./review";
|
||||||
import { TimelineType } from "./timeline";
|
|
||||||
|
|
||||||
export type Recording = {
|
export type Recording = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -38,7 +37,7 @@ export type RecordingStartingPoint = {
|
|||||||
camera: string;
|
camera: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
severity: ReviewSeverity;
|
severity: ReviewSeverity;
|
||||||
timelineType?: TimelineType;
|
timelineType?: "timeline" | "events" | "detail";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecordingPlayerError = "stalled" | "startup";
|
export type RecordingPlayerError = "stalled" | "startup";
|
||||||
|
|||||||
@ -16,6 +16,7 @@ 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";
|
||||||
@ -351,9 +352,11 @@ 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-balance text-white"
|
className="text-white"
|
||||||
aria-label={t("markTheseItemsAsReviewed")}
|
aria-label={t("markTheseItemsAsReviewed")}
|
||||||
variant="select"
|
variant="select"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -850,29 +850,6 @@ 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();
|
||||||
@ -910,19 +887,8 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,17 @@ import { Button } from "@/components/ui/button";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import { LuPlus } from "react-icons/lu";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
@ -82,6 +90,31 @@ export default function CameraManagementView({
|
|||||||
</Button>
|
</Button>
|
||||||
{cameras.length > 0 && (
|
{cameras.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
<div className="my-4 flex flex-col gap-2">
|
||||||
|
<Label>{t("cameraManagement.editCamera")}</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setEditCameraName(value);
|
||||||
|
setViewMode("edit");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("cameraManagement.selectCamera")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{cameras.map((camera) => {
|
||||||
|
return (
|
||||||
|
<SelectItem key={camera} value={camera}>
|
||||||
|
<CameraNameLabel camera={camera} />
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
<div className="max-w-7xl space-y-4">
|
<div className="max-w-7xl space-y-4">
|
||||||
<Heading as="h4" className="my-2">
|
<Heading as="h4" className="my-2">
|
||||||
|
|||||||
@ -1,738 +0,0 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
|
||||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Toaster, toast } from "sonner";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
||||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
|
||||||
import axios from "axios";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
|
||||||
import { MdCircle } from "react-icons/md";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
|
||||||
import {
|
|
||||||
useAlertsState,
|
|
||||||
useDetectionsState,
|
|
||||||
useObjectDescriptionState,
|
|
||||||
useReviewDescriptionState,
|
|
||||||
} from "@/api/ws";
|
|
||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
|
||||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
|
||||||
import { formatList } from "@/utils/stringUtil";
|
|
||||||
|
|
||||||
type CameraReviewSettingsViewProps = {
|
|
||||||
selectedCamera: string;
|
|
||||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CameraReviewSettingsValueType = {
|
|
||||||
alerts_zones: string[];
|
|
||||||
detections_zones: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CameraReviewSettingsView({
|
|
||||||
selectedCamera,
|
|
||||||
setUnsavedChanges,
|
|
||||||
}: CameraReviewSettingsViewProps) {
|
|
||||||
const { t } = useTranslation(["views/settings"]);
|
|
||||||
const { getLocaleDocUrl } = useDocDomain();
|
|
||||||
|
|
||||||
const { data: config, mutate: updateConfig } =
|
|
||||||
useSWR<FrigateConfig>("config");
|
|
||||||
|
|
||||||
const cameraConfig = useMemo(() => {
|
|
||||||
if (config && selectedCamera) {
|
|
||||||
return config.cameras[selectedCamera];
|
|
||||||
}
|
|
||||||
}, [config, selectedCamera]);
|
|
||||||
|
|
||||||
const [changedValue, setChangedValue] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [selectDetections, setSelectDetections] = useState(false);
|
|
||||||
|
|
||||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
|
||||||
|
|
||||||
const selectCameraName = useCameraFriendlyName(selectedCamera);
|
|
||||||
|
|
||||||
// zones and labels
|
|
||||||
|
|
||||||
const getZoneName = useCallback(
|
|
||||||
(zoneId: string, cameraId?: string) =>
|
|
||||||
resolveZoneName(config, zoneId, cameraId),
|
|
||||||
[config],
|
|
||||||
);
|
|
||||||
|
|
||||||
const zones = useMemo(() => {
|
|
||||||
if (cameraConfig) {
|
|
||||||
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
|
|
||||||
camera: cameraConfig.name,
|
|
||||||
name,
|
|
||||||
friendly_name: cameraConfig.zones[name].friendly_name,
|
|
||||||
objects: zoneData.objects,
|
|
||||||
color: zoneData.color,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [cameraConfig]);
|
|
||||||
|
|
||||||
const alertsLabels = useMemo(() => {
|
|
||||||
return cameraConfig?.review.alerts.labels
|
|
||||||
? formatList(
|
|
||||||
cameraConfig.review.alerts.labels.map((label) =>
|
|
||||||
getTranslatedLabel(
|
|
||||||
label,
|
|
||||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
}, [cameraConfig]);
|
|
||||||
|
|
||||||
const detectionsLabels = useMemo(() => {
|
|
||||||
return cameraConfig?.review.detections.labels
|
|
||||||
? formatList(
|
|
||||||
cameraConfig.review.detections.labels.map((label) =>
|
|
||||||
getTranslatedLabel(
|
|
||||||
label,
|
|
||||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
}, [cameraConfig]);
|
|
||||||
|
|
||||||
// form
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
alerts_zones: z.array(z.string()),
|
|
||||||
detections_zones: z.array(z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
mode: "onChange",
|
|
||||||
defaultValues: {
|
|
||||||
alerts_zones: cameraConfig?.review.alerts.required_zones || [],
|
|
||||||
detections_zones: cameraConfig?.review.detections.required_zones || [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const watchedAlertsZones = form.watch("alerts_zones");
|
|
||||||
const watchedDetectionsZones = form.watch("detections_zones");
|
|
||||||
|
|
||||||
const { payload: alertsState, send: sendAlerts } =
|
|
||||||
useAlertsState(selectedCamera);
|
|
||||||
const { payload: detectionsState, send: sendDetections } =
|
|
||||||
useDetectionsState(selectedCamera);
|
|
||||||
|
|
||||||
const { payload: objDescState, send: sendObjDesc } =
|
|
||||||
useObjectDescriptionState(selectedCamera);
|
|
||||||
const { payload: revDescState, send: sendRevDesc } =
|
|
||||||
useReviewDescriptionState(selectedCamera);
|
|
||||||
|
|
||||||
const handleCheckedChange = useCallback(
|
|
||||||
(isChecked: boolean) => {
|
|
||||||
if (!isChecked) {
|
|
||||||
form.reset({
|
|
||||||
alerts_zones: watchedAlertsZones,
|
|
||||||
detections_zones: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setChangedValue(true);
|
|
||||||
setSelectDetections(isChecked as boolean);
|
|
||||||
},
|
|
||||||
// we know that these deps are correct
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[watchedAlertsZones],
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveToConfig = useCallback(
|
|
||||||
async (
|
|
||||||
{ alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form
|
|
||||||
) => {
|
|
||||||
const createQuery = (zones: string[], type: "alerts" | "detections") =>
|
|
||||||
zones.length
|
|
||||||
? zones
|
|
||||||
.map(
|
|
||||||
(zone) =>
|
|
||||||
`&cameras.${selectedCamera}.review.${type}.required_zones=${zone}`,
|
|
||||||
)
|
|
||||||
.join("")
|
|
||||||
: cameraConfig?.review[type]?.required_zones &&
|
|
||||||
cameraConfig?.review[type]?.required_zones.length > 0
|
|
||||||
? `&cameras.${selectedCamera}.review.${type}.required_zones`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const alertQueries = createQuery(alerts_zones, "alerts");
|
|
||||||
const detectionQueries = createQuery(detections_zones, "detections");
|
|
||||||
|
|
||||||
axios
|
|
||||||
.put(`config/set?${alertQueries}${detectionQueries}`, {
|
|
||||||
requires_restart: 0,
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res.status === 200) {
|
|
||||||
toast.success(
|
|
||||||
t("cameraReview.reviewClassification.toast.success"),
|
|
||||||
{
|
|
||||||
position: "top-center",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
updateConfig();
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
t("toast.save.error.title", {
|
|
||||||
errorMessage: res.statusText,
|
|
||||||
ns: "common",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
position: "top-center",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const errorMessage =
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.response?.data?.detail ||
|
|
||||||
"Unknown error";
|
|
||||||
toast.error(
|
|
||||||
t("toast.save.error.title", {
|
|
||||||
errorMessage,
|
|
||||||
ns: "common",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
position: "top-center",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onCancel = useCallback(() => {
|
|
||||||
if (!cameraConfig) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setChangedValue(false);
|
|
||||||
setUnsavedChanges(false);
|
|
||||||
removeMessage(
|
|
||||||
"camera_settings",
|
|
||||||
`review_classification_settings_${selectedCamera}`,
|
|
||||||
);
|
|
||||||
form.reset({
|
|
||||||
alerts_zones: cameraConfig?.review.alerts.required_zones ?? [],
|
|
||||||
detections_zones: cameraConfig?.review.detections.required_zones || [],
|
|
||||||
});
|
|
||||||
setSelectDetections(
|
|
||||||
!!cameraConfig?.review.detections.required_zones?.length,
|
|
||||||
);
|
|
||||||
// we know that these deps are correct
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [removeMessage, selectedCamera, setUnsavedChanges, cameraConfig]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onCancel();
|
|
||||||
// we know that these deps are correct
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedCamera]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (changedValue) {
|
|
||||||
addMessage(
|
|
||||||
"camera_settings",
|
|
||||||
t("cameraReview.reviewClassification.unsavedChanges", {
|
|
||||||
camera: selectedCamera,
|
|
||||||
}),
|
|
||||||
undefined,
|
|
||||||
`review_classification_settings_${selectedCamera}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
removeMessage(
|
|
||||||
"camera_settings",
|
|
||||||
`review_classification_settings_${selectedCamera}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// we know that these deps are correct
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [changedValue, selectedCamera]);
|
|
||||||
|
|
||||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
saveToConfig(values as CameraReviewSettingsValueType);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = t("documentTitle.cameraReview");
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
if (!cameraConfig && !selectedCamera) {
|
|
||||||
return <ActivityIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex size-full flex-col md:flex-row">
|
|
||||||
<Toaster position="top-center" closeButton={true} />
|
|
||||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
|
||||||
<Heading as="h4" className="mb-2">
|
|
||||||
{t("cameraReview.title")}
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<Heading as="h4" className="my-2">
|
|
||||||
<Trans ns="views/settings">cameraReview.review.title</Trans>
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<Switch
|
|
||||||
id="alerts-enabled"
|
|
||||||
className="mr-3"
|
|
||||||
checked={alertsState == "ON"}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
sendAlerts(isChecked ? "ON" : "OFF");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="alerts-enabled">
|
|
||||||
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<Switch
|
|
||||||
id="detections-enabled"
|
|
||||||
className="mr-3"
|
|
||||||
checked={detectionsState == "ON"}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
sendDetections(isChecked ? "ON" : "OFF");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="detections-enabled">
|
|
||||||
<Trans ns="views/settings">camera.review.detections</Trans>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
|
||||||
<Trans ns="views/settings">cameraReview.review.desc</Trans>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{cameraConfig?.objects?.genai?.enabled_in_config && (
|
|
||||||
<>
|
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
|
||||||
|
|
||||||
<Heading as="h4" className="my-2">
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.object_descriptions.title
|
|
||||||
</Trans>
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<Switch
|
|
||||||
id="alerts-enabled"
|
|
||||||
className="mr-3"
|
|
||||||
checked={objDescState == "ON"}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
sendObjDesc(isChecked ? "ON" : "OFF");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="genai-enabled">
|
|
||||||
<Trans>button.enabled</Trans>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.object_descriptions.desc
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cameraConfig?.review?.genai?.enabled_in_config && (
|
|
||||||
<>
|
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
|
||||||
|
|
||||||
<Heading as="h4" className="my-2">
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.review_descriptions.title
|
|
||||||
</Trans>
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<Switch
|
|
||||||
id="alerts-enabled"
|
|
||||||
className="mr-3"
|
|
||||||
checked={revDescState == "ON"}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
sendRevDesc(isChecked ? "ON" : "OFF");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="genai-enabled">
|
|
||||||
<Trans>button.enabled</Trans>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.review_descriptions.desc
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
|
||||||
|
|
||||||
<Heading as="h4" className="my-2">
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.reviewClassification.title
|
|
||||||
</Trans>
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<div className="max-w-6xl">
|
|
||||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
|
||||||
<p>
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.reviewClassification.desc
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center text-primary">
|
|
||||||
<Link
|
|
||||||
to={getLocaleDocUrl("configuration/review")}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline"
|
|
||||||
>
|
|
||||||
{t("readTheDocumentation", { ns: "common" })}
|
|
||||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="mt-2 space-y-6"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-full max-w-5xl space-y-0",
|
|
||||||
zones &&
|
|
||||||
zones?.length > 0 &&
|
|
||||||
"grid items-start gap-5 md:grid-cols-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="alerts_zones"
|
|
||||||
render={() => (
|
|
||||||
<FormItem>
|
|
||||||
{zones && zones?.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="mb-2">
|
|
||||||
<FormLabel className="flex flex-row items-center text-base">
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
camera.review.alerts
|
|
||||||
</Trans>
|
|
||||||
<MdCircle className="ml-3 size-2 text-severity_alert" />
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.reviewClassification.selectAlertsZones
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
|
||||||
{zones?.map((zone) => (
|
|
||||||
<FormField
|
|
||||||
key={zone.name}
|
|
||||||
control={form.control}
|
|
||||||
name="alerts_zones"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem
|
|
||||||
key={zone.name}
|
|
||||||
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
|
||||||
checked={field.value?.includes(
|
|
||||||
zone.name,
|
|
||||||
)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setChangedValue(true);
|
|
||||||
return checked
|
|
||||||
? field.onChange([
|
|
||||||
...field.value,
|
|
||||||
zone.name,
|
|
||||||
])
|
|
||||||
: field.onChange(
|
|
||||||
field.value?.filter(
|
|
||||||
(value) =>
|
|
||||||
value !== zone.name,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel
|
|
||||||
className={cn(
|
|
||||||
"font-normal",
|
|
||||||
!zone.friendly_name &&
|
|
||||||
"smart-capitalize",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{zone.friendly_name || zone.name}
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="font-normal text-destructive">
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.reviewClassification.noDefinedZones
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
|
||||||
<div className="text-sm">
|
|
||||||
{watchedAlertsZones && watchedAlertsZones.length > 0
|
|
||||||
? t(
|
|
||||||
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
|
||||||
{
|
|
||||||
alertsLabels,
|
|
||||||
zone: formatList(
|
|
||||||
watchedAlertsZones.map((zone) =>
|
|
||||||
getZoneName(zone),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cameraName: selectCameraName,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"cameraReview.reviewClassification.objectAlertsTips",
|
|
||||||
{
|
|
||||||
alertsLabels,
|
|
||||||
cameraName: selectCameraName,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="detections_zones"
|
|
||||||
render={() => (
|
|
||||||
<FormItem>
|
|
||||||
{zones && zones?.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="mb-2">
|
|
||||||
<FormLabel className="flex flex-row items-center text-base">
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
camera.review.detections
|
|
||||||
</Trans>
|
|
||||||
<MdCircle className="ml-3 size-2 text-severity_detection" />
|
|
||||||
</FormLabel>
|
|
||||||
{selectDetections && (
|
|
||||||
<FormDescription>
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.reviewClassification.selectDetectionsZones
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectDetections && (
|
|
||||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
|
||||||
{zones?.map((zone) => (
|
|
||||||
<FormField
|
|
||||||
key={zone.name}
|
|
||||||
control={form.control}
|
|
||||||
name="detections_zones"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem
|
|
||||||
key={zone.name}
|
|
||||||
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
|
||||||
checked={field.value?.includes(
|
|
||||||
zone.name,
|
|
||||||
)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
return checked
|
|
||||||
? field.onChange([
|
|
||||||
...field.value,
|
|
||||||
zone.name,
|
|
||||||
])
|
|
||||||
: field.onChange(
|
|
||||||
field.value?.filter(
|
|
||||||
(value) =>
|
|
||||||
value !== zone.name,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel
|
|
||||||
className={cn(
|
|
||||||
"font-normal",
|
|
||||||
!zone.friendly_name &&
|
|
||||||
"smart-capitalize",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{zone.friendly_name || zone.name}
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
|
||||||
|
|
||||||
<div className="mb-0 flex flex-row items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="select-detections"
|
|
||||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
|
||||||
checked={selectDetections}
|
|
||||||
onCheckedChange={handleCheckedChange}
|
|
||||||
/>
|
|
||||||
<div className="grid gap-1.5 leading-none">
|
|
||||||
<label
|
|
||||||
htmlFor="select-detections"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
<Trans ns="views/settings">
|
|
||||||
cameraReview.reviewClassification.limitDetections
|
|
||||||
</Trans>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-sm">
|
|
||||||
{watchedDetectionsZones &&
|
|
||||||
watchedDetectionsZones.length > 0 ? (
|
|
||||||
!selectDetections ? (
|
|
||||||
<Trans
|
|
||||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
|
||||||
values={{
|
|
||||||
detectionsLabels,
|
|
||||||
zone: formatList(
|
|
||||||
watchedDetectionsZones.map((zone) =>
|
|
||||||
getZoneName(zone),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cameraName: selectCameraName,
|
|
||||||
}}
|
|
||||||
ns="views/settings"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Trans
|
|
||||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
|
||||||
values={{
|
|
||||||
detectionsLabels,
|
|
||||||
zone: formatList(
|
|
||||||
watchedDetectionsZones.map((zone) =>
|
|
||||||
getZoneName(zone),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cameraName: selectCameraName,
|
|
||||||
}}
|
|
||||||
ns="views/settings"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Trans
|
|
||||||
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
|
|
||||||
values={{
|
|
||||||
detectionsLabels,
|
|
||||||
cameraName: selectCameraName,
|
|
||||||
}}
|
|
||||||
ns="views/settings"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
|
||||||
|
|
||||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
|
||||||
<Button
|
|
||||||
className="flex flex-1"
|
|
||||||
aria-label={t("button.reset", { ns: "common" })}
|
|
||||||
onClick={onCancel}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Trans>button.reset</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="select"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex flex-1"
|
|
||||||
aria-label={t("button.save", { ns: "common" })}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<ActivityIndicator />
|
|
||||||
<span>
|
|
||||||
<Trans>button.saving</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Trans>button.save</Trans>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
794
web/src/views/settings/CameraSettingsView.tsx
Normal file
794
web/src/views/settings/CameraSettingsView.tsx
Normal file
@ -0,0 +1,794 @@
|
|||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Toaster, toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||||
|
import axios from "axios";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
|
import { MdCircle } from "react-icons/md";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import {
|
||||||
|
useAlertsState,
|
||||||
|
useDetectionsState,
|
||||||
|
useObjectDescriptionState,
|
||||||
|
useReviewDescriptionState,
|
||||||
|
} from "@/api/ws";
|
||||||
|
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||||
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
|
import { formatList } from "@/utils/stringUtil";
|
||||||
|
|
||||||
|
type CameraSettingsViewProps = {
|
||||||
|
selectedCamera: string;
|
||||||
|
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CameraReviewSettingsValueType = {
|
||||||
|
alerts_zones: string[];
|
||||||
|
detections_zones: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CameraSettingsView({
|
||||||
|
selectedCamera,
|
||||||
|
setUnsavedChanges,
|
||||||
|
}: CameraSettingsViewProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
|
|
||||||
|
const { data: config, mutate: updateConfig } =
|
||||||
|
useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
const cameraConfig = useMemo(() => {
|
||||||
|
if (config && selectedCamera) {
|
||||||
|
return config.cameras[selectedCamera];
|
||||||
|
}
|
||||||
|
}, [config, selectedCamera]);
|
||||||
|
|
||||||
|
const [changedValue, setChangedValue] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [selectDetections, setSelectDetections] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
|
||||||
|
"settings",
|
||||||
|
); // Control view state
|
||||||
|
const [editCameraName, setEditCameraName] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
); // Track camera being edited
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
|
||||||
|
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||||
|
|
||||||
|
const selectCameraName = useCameraFriendlyName(selectedCamera);
|
||||||
|
|
||||||
|
// zones and labels
|
||||||
|
|
||||||
|
const getZoneName = useCallback(
|
||||||
|
(zoneId: string, cameraId?: string) =>
|
||||||
|
resolveZoneName(config, zoneId, cameraId),
|
||||||
|
[config],
|
||||||
|
);
|
||||||
|
|
||||||
|
const zones = useMemo(() => {
|
||||||
|
if (cameraConfig) {
|
||||||
|
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
|
||||||
|
camera: cameraConfig.name,
|
||||||
|
name,
|
||||||
|
friendly_name: cameraConfig.zones[name].friendly_name,
|
||||||
|
objects: zoneData.objects,
|
||||||
|
color: zoneData.color,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [cameraConfig]);
|
||||||
|
|
||||||
|
const alertsLabels = useMemo(() => {
|
||||||
|
return cameraConfig?.review.alerts.labels
|
||||||
|
? formatList(
|
||||||
|
cameraConfig.review.alerts.labels.map((label) =>
|
||||||
|
getTranslatedLabel(
|
||||||
|
label,
|
||||||
|
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
}, [cameraConfig]);
|
||||||
|
|
||||||
|
const detectionsLabels = useMemo(() => {
|
||||||
|
return cameraConfig?.review.detections.labels
|
||||||
|
? formatList(
|
||||||
|
cameraConfig.review.detections.labels.map((label) =>
|
||||||
|
getTranslatedLabel(
|
||||||
|
label,
|
||||||
|
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
}, [cameraConfig]);
|
||||||
|
|
||||||
|
// form
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
alerts_zones: z.array(z.string()),
|
||||||
|
detections_zones: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
alerts_zones: cameraConfig?.review.alerts.required_zones || [],
|
||||||
|
detections_zones: cameraConfig?.review.detections.required_zones || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchedAlertsZones = form.watch("alerts_zones");
|
||||||
|
const watchedDetectionsZones = form.watch("detections_zones");
|
||||||
|
|
||||||
|
const { payload: alertsState, send: sendAlerts } =
|
||||||
|
useAlertsState(selectedCamera);
|
||||||
|
const { payload: detectionsState, send: sendDetections } =
|
||||||
|
useDetectionsState(selectedCamera);
|
||||||
|
|
||||||
|
const { payload: objDescState, send: sendObjDesc } =
|
||||||
|
useObjectDescriptionState(selectedCamera);
|
||||||
|
const { payload: revDescState, send: sendRevDesc } =
|
||||||
|
useReviewDescriptionState(selectedCamera);
|
||||||
|
|
||||||
|
const handleCheckedChange = useCallback(
|
||||||
|
(isChecked: boolean) => {
|
||||||
|
if (!isChecked) {
|
||||||
|
form.reset({
|
||||||
|
alerts_zones: watchedAlertsZones,
|
||||||
|
detections_zones: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setChangedValue(true);
|
||||||
|
setSelectDetections(isChecked as boolean);
|
||||||
|
},
|
||||||
|
// we know that these deps are correct
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[watchedAlertsZones],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveToConfig = useCallback(
|
||||||
|
async (
|
||||||
|
{ alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form
|
||||||
|
) => {
|
||||||
|
const createQuery = (zones: string[], type: "alerts" | "detections") =>
|
||||||
|
zones.length
|
||||||
|
? zones
|
||||||
|
.map(
|
||||||
|
(zone) =>
|
||||||
|
`&cameras.${selectedCamera}.review.${type}.required_zones=${zone}`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: cameraConfig?.review[type]?.required_zones &&
|
||||||
|
cameraConfig?.review[type]?.required_zones.length > 0
|
||||||
|
? `&cameras.${selectedCamera}.review.${type}.required_zones`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const alertQueries = createQuery(alerts_zones, "alerts");
|
||||||
|
const detectionQueries = createQuery(detections_zones, "detections");
|
||||||
|
|
||||||
|
axios
|
||||||
|
.put(`config/set?${alertQueries}${detectionQueries}`, {
|
||||||
|
requires_restart: 0,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 200) {
|
||||||
|
toast.success(
|
||||||
|
t("cameraReview.reviewClassification.toast.success"),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
updateConfig();
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("toast.save.error.title", {
|
||||||
|
errorMessage: res.statusText,
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(
|
||||||
|
t("toast.save.error.title", {
|
||||||
|
errorMessage,
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCancel = useCallback(() => {
|
||||||
|
if (!cameraConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChangedValue(false);
|
||||||
|
setUnsavedChanges(false);
|
||||||
|
removeMessage(
|
||||||
|
"camera_settings",
|
||||||
|
`review_classification_settings_${selectedCamera}`,
|
||||||
|
);
|
||||||
|
form.reset({
|
||||||
|
alerts_zones: cameraConfig?.review.alerts.required_zones ?? [],
|
||||||
|
detections_zones: cameraConfig?.review.detections.required_zones || [],
|
||||||
|
});
|
||||||
|
setSelectDetections(
|
||||||
|
!!cameraConfig?.review.detections.required_zones?.length,
|
||||||
|
);
|
||||||
|
// we know that these deps are correct
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [removeMessage, selectedCamera, setUnsavedChanges, cameraConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCancel();
|
||||||
|
// we know that these deps are correct
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedCamera]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (changedValue) {
|
||||||
|
addMessage(
|
||||||
|
"camera_settings",
|
||||||
|
t("cameraReview.reviewClassification.unsavedChanges", {
|
||||||
|
camera: selectedCamera,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
`review_classification_settings_${selectedCamera}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
removeMessage(
|
||||||
|
"camera_settings",
|
||||||
|
`review_classification_settings_${selectedCamera}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// we know that these deps are correct
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [changedValue, selectedCamera]);
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
saveToConfig(values as CameraReviewSettingsValueType);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t("documentTitle.cameraReview");
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
// Handle back navigation from add/edit form
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setViewMode("settings");
|
||||||
|
setEditCameraName(undefined);
|
||||||
|
updateConfig();
|
||||||
|
}, [updateConfig]);
|
||||||
|
|
||||||
|
if (!cameraConfig && !selectedCamera && viewMode === "settings") {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
|
<Toaster position="top-center" closeButton={true} />
|
||||||
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||||
|
{viewMode === "settings" ? (
|
||||||
|
<>
|
||||||
|
<Heading as="h4" className="mb-2">
|
||||||
|
{t("cameraReview.title")}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
<Trans ns="views/settings">cameraReview.review.title</Trans>
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
id="alerts-enabled"
|
||||||
|
className="mr-3"
|
||||||
|
checked={alertsState == "ON"}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
sendAlerts(isChecked ? "ON" : "OFF");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="alerts-enabled">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.review.alerts
|
||||||
|
</Trans>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
id="detections-enabled"
|
||||||
|
className="mr-3"
|
||||||
|
checked={detectionsState == "ON"}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
sendDetections(isChecked ? "ON" : "OFF");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="detections-enabled">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
camera.review.detections
|
||||||
|
</Trans>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
|
<Trans ns="views/settings">cameraReview.review.desc</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cameraConfig?.objects?.genai?.enabled_in_config && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.object_descriptions.title
|
||||||
|
</Trans>
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
id="alerts-enabled"
|
||||||
|
className="mr-3"
|
||||||
|
checked={objDescState == "ON"}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
sendObjDesc(isChecked ? "ON" : "OFF");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="genai-enabled">
|
||||||
|
<Trans>button.enabled</Trans>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.object_descriptions.desc
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cameraConfig?.review?.genai?.enabled_in_config && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.review_descriptions.title
|
||||||
|
</Trans>
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
id="alerts-enabled"
|
||||||
|
className="mr-3"
|
||||||
|
checked={revDescState == "ON"}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
sendRevDesc(isChecked ? "ON" : "OFF");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="genai-enabled">
|
||||||
|
<Trans>button.enabled</Trans>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.review_descriptions.desc
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.reviewClassification.title
|
||||||
|
</Trans>
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<div className="max-w-6xl">
|
||||||
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||||
|
<p>
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.reviewClassification.desc
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center text-primary">
|
||||||
|
<Link
|
||||||
|
to={getLocaleDocUrl("configuration/review")}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
{t("readTheDocumentation", { ns: "common" })}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="mt-2 space-y-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full max-w-5xl space-y-0",
|
||||||
|
zones &&
|
||||||
|
zones?.length > 0 &&
|
||||||
|
"grid items-start gap-5 md:grid-cols-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="alerts_zones"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
{zones && zones?.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<FormLabel className="flex flex-row items-center text-base">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
camera.review.alerts
|
||||||
|
</Trans>
|
||||||
|
<MdCircle className="ml-3 size-2 text-severity_alert" />
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.reviewClassification.selectAlertsZones
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||||
|
{zones?.map((zone) => (
|
||||||
|
<FormField
|
||||||
|
key={zone.name}
|
||||||
|
control={form.control}
|
||||||
|
name="alerts_zones"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
key={zone.name}
|
||||||
|
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||||
|
checked={field.value?.includes(
|
||||||
|
zone.name,
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setChangedValue(true);
|
||||||
|
return checked
|
||||||
|
? field.onChange([
|
||||||
|
...field.value,
|
||||||
|
zone.name,
|
||||||
|
])
|
||||||
|
: field.onChange(
|
||||||
|
field.value?.filter(
|
||||||
|
(value) =>
|
||||||
|
value !== zone.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel
|
||||||
|
className={cn(
|
||||||
|
"font-normal",
|
||||||
|
!zone.friendly_name &&
|
||||||
|
"smart-capitalize",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{zone.friendly_name || zone.name}
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="font-normal text-destructive">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.reviewClassification.noDefinedZones
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
<div className="text-sm">
|
||||||
|
{watchedAlertsZones && watchedAlertsZones.length > 0
|
||||||
|
? t(
|
||||||
|
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
||||||
|
{
|
||||||
|
alertsLabels,
|
||||||
|
zone: formatList(
|
||||||
|
watchedAlertsZones.map((zone) =>
|
||||||
|
getZoneName(zone),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cameraName: selectCameraName,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"cameraReview.reviewClassification.objectAlertsTips",
|
||||||
|
{
|
||||||
|
alertsLabels,
|
||||||
|
cameraName: selectCameraName,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="detections_zones"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
{zones && zones?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<FormLabel className="flex flex-row items-center text-base">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
camera.review.detections
|
||||||
|
</Trans>
|
||||||
|
<MdCircle className="ml-3 size-2 text-severity_detection" />
|
||||||
|
</FormLabel>
|
||||||
|
{selectDetections && (
|
||||||
|
<FormDescription>
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.reviewClassification.selectDetectionsZones
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectDetections && (
|
||||||
|
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||||
|
{zones?.map((zone) => (
|
||||||
|
<FormField
|
||||||
|
key={zone.name}
|
||||||
|
control={form.control}
|
||||||
|
name="detections_zones"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
key={zone.name}
|
||||||
|
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||||
|
checked={field.value?.includes(
|
||||||
|
zone.name,
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
return checked
|
||||||
|
? field.onChange([
|
||||||
|
...field.value,
|
||||||
|
zone.name,
|
||||||
|
])
|
||||||
|
: field.onChange(
|
||||||
|
field.value?.filter(
|
||||||
|
(value) =>
|
||||||
|
value !== zone.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel
|
||||||
|
className={cn(
|
||||||
|
"font-normal",
|
||||||
|
!zone.friendly_name &&
|
||||||
|
"smart-capitalize",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{zone.friendly_name || zone.name}
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
|
||||||
|
<div className="mb-0 flex flex-row items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="select-detections"
|
||||||
|
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||||
|
checked={selectDetections}
|
||||||
|
onCheckedChange={handleCheckedChange}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1.5 leading-none">
|
||||||
|
<label
|
||||||
|
htmlFor="select-detections"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
cameraReview.reviewClassification.limitDetections
|
||||||
|
</Trans>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm">
|
||||||
|
{watchedDetectionsZones &&
|
||||||
|
watchedDetectionsZones.length > 0 ? (
|
||||||
|
!selectDetections ? (
|
||||||
|
<Trans
|
||||||
|
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
||||||
|
values={{
|
||||||
|
detectionsLabels,
|
||||||
|
zone: formatList(
|
||||||
|
watchedDetectionsZones.map((zone) =>
|
||||||
|
getZoneName(zone),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cameraName: selectCameraName,
|
||||||
|
}}
|
||||||
|
ns="views/settings"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Trans
|
||||||
|
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||||
|
values={{
|
||||||
|
detectionsLabels,
|
||||||
|
zone: formatList(
|
||||||
|
watchedDetectionsZones.map((zone) =>
|
||||||
|
getZoneName(zone),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cameraName: selectCameraName,
|
||||||
|
}}
|
||||||
|
ns="views/settings"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Trans
|
||||||
|
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
|
||||||
|
values={{
|
||||||
|
detectionsLabels,
|
||||||
|
cameraName: selectCameraName,
|
||||||
|
}}
|
||||||
|
ns="views/settings"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label={t("button.reset", { ns: "common" })}
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trans>button.reset</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label={t("button.save", { ns: "common" })}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ActivityIndicator />
|
||||||
|
<span>
|
||||||
|
<Trans>button.saving</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Trans>button.save</Trans>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
className={`flex items-center gap-2.5 rounded-lg`}
|
||||||
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="md:max-w-5xl">
|
||||||
|
<CameraEditForm
|
||||||
|
cameraName={viewMode === "edit" ? editCameraName : undefined}
|
||||||
|
onSave={handleBack}
|
||||||
|
onCancel={handleBack}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CameraWizardDialog
|
||||||
|
open={showWizard}
|
||||||
|
onClose={() => setShowWizard(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -201,17 +201,12 @@ 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: displayName },
|
{ name },
|
||||||
),
|
),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
@ -356,19 +351,8 @@ 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", {
|
t("triggers.toast.success.deleteTrigger", { name }),
|
||||||
name: displayName,
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
},
|
},
|
||||||
@ -397,7 +381,7 @@ export default function TriggerView({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[t, updateConfig, selectedCamera, setUnsavedChanges, config],
|
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -859,14 +843,7 @@ export default function TriggerView({
|
|||||||
/>
|
/>
|
||||||
<DeleteTriggerDialog
|
<DeleteTriggerDialog
|
||||||
show={showDelete}
|
show={showDelete}
|
||||||
triggerName={
|
triggerName={selectedTrigger?.name ?? ""}
|
||||||
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,7 +72,8 @@ 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[0] + "T00:00:00", timezone).getTime() / 1000
|
? new TZDate(keys[keys.length - 1] + "T00:00:00", timezone).getTime() /
|
||||||
|
1000
|
||||||
: null;
|
: null;
|
||||||
}, [recordingsSummary, timezone]);
|
}, [recordingsSummary, timezone]);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user