mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
8 Commits
708c3fb06c
...
79e239d2bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e239d2bd | ||
|
|
224cbdc2d6 | ||
|
|
3f9b153758 | ||
|
|
8e8346099e | ||
|
|
b0527df3c7 | ||
|
|
301e0a1a3a | ||
|
|
213a1fbd00 | ||
|
|
fbf4388b37 |
@ -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 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.
|
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/).
|
||||||
|
|
||||||
- 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
|
||||||
|
|||||||
@ -320,6 +320,12 @@ http {
|
|||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /fonts/ {
|
||||||
|
access_log off;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
location /locales/ {
|
location /locales/ {
|
||||||
access_log off;
|
access_log off;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
|
|||||||
@ -25,7 +25,7 @@ Examples of available modules are:
|
|||||||
|
|
||||||
- `frigate.app`
|
- `frigate.app`
|
||||||
- `frigate.mqtt`
|
- `frigate.mqtt`
|
||||||
- `frigate.object_detection`
|
- `frigate.object_detection.base`
|
||||||
- `detector.<detector_name>`
|
- `detector.<detector_name>`
|
||||||
- `watchdog.<camera_name>`
|
- `watchdog.<camera_name>`
|
||||||
- `ffmpeg.<camera_name>.<sorted_roles>` NOTE: All FFmpeg logs are sent as `error` level.
|
- `ffmpeg.<camera_name>.<sorted_roles>` NOTE: All FFmpeg logs are sent as `error` level.
|
||||||
|
|||||||
@ -35,6 +35,15 @@ For object classification:
|
|||||||
- Ideal when multiple attributes can coexist independently.
|
- Ideal when multiple attributes can coexist independently.
|
||||||
- Example: Detecting if a `person` in a construction yard is wearing a helmet or not.
|
- Example: Detecting if a `person` in a construction yard is wearing a helmet or not.
|
||||||
|
|
||||||
|
## Assignment Requirements
|
||||||
|
|
||||||
|
Sub labels and attributes are only assigned when both conditions are met:
|
||||||
|
|
||||||
|
1. **Threshold**: Each classification attempt must have a confidence score that meets or exceeds the configured `threshold` (default: `0.8`).
|
||||||
|
2. **Class Consensus**: After at least 3 classification attempts, 60% of attempts must agree on the same class label. If the consensus class is `none`, no assignment is made.
|
||||||
|
|
||||||
|
This two-step verification prevents false positives by requiring consistent predictions across multiple frames before assigning a sub label or attribute.
|
||||||
|
|
||||||
## Example use cases
|
## Example use cases
|
||||||
|
|
||||||
### Sub label
|
### Sub label
|
||||||
@ -66,14 +75,18 @@ classification:
|
|||||||
|
|
||||||
## Training the model
|
## Training the model
|
||||||
|
|
||||||
Creating and training the model is done within the Frigate UI using the `Classification` page.
|
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of two steps:
|
||||||
|
|
||||||
### Getting Started
|
### Step 1: Name and Define
|
||||||
|
|
||||||
|
Enter a name for your model, select the object label to classify (e.g., `person`, `dog`, `car`), choose the classification type (sub label or attribute), and define your classes. Include a `none` class for objects that don't fit any specific category.
|
||||||
|
|
||||||
|
### Step 2: Assign Training Examples
|
||||||
|
|
||||||
|
The system will automatically generate example images from detected objects matching your selected label. You'll be guided through each class one at a time to select which images represent that class. Any images not assigned to a specific class will automatically be assigned to `none` when you complete the last class. Once all images are processed, training will begin automatically.
|
||||||
|
|
||||||
When choosing which objects to classify, start with a small number of visually distinct classes and ensure your training samples match camera viewpoints and distances typical for those objects.
|
When choosing which objects to classify, start with a small number of visually distinct classes and ensure your training samples match camera viewpoints and distances typical for those objects.
|
||||||
|
|
||||||
// TODO add this section once UI is implemented. Explain process of selecting objects and curating training examples.
|
|
||||||
|
|
||||||
### Improving the Model
|
### Improving the Model
|
||||||
|
|
||||||
- **Problem framing**: Keep classes visually distinct and relevant to the chosen object types.
|
- **Problem framing**: Keep classes visually distinct and relevant to the chosen object types.
|
||||||
|
|||||||
@ -48,13 +48,23 @@ classification:
|
|||||||
|
|
||||||
## Training the model
|
## Training the model
|
||||||
|
|
||||||
Creating and training the model is done within the Frigate UI using the `Classification` page.
|
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps:
|
||||||
|
|
||||||
### Getting Started
|
### Step 1: Name and Define
|
||||||
|
|
||||||
When choosing a portion of the camera frame for state classification, it is important to make the crop tight around the area of interest to avoid extra signals unrelated to what is being classified.
|
Enter a name for your model and define at least 2 classes (states) that represent mutually exclusive states. For example, `open` and `closed` for a door, or `on` and `off` for lights.
|
||||||
|
|
||||||
// TODO add this section once UI is implemented. Explain process of selecting a crop.
|
### Step 2: Select the Crop Area
|
||||||
|
|
||||||
|
Choose one or more cameras and draw a rectangle over the area of interest for each camera. The crop should be tight around the region you want to classify to avoid extra signals unrelated to what is being classified. You can drag and resize the rectangle to adjust the crop area.
|
||||||
|
|
||||||
|
### Step 3: Assign Training Examples
|
||||||
|
|
||||||
|
The system will automatically generate example images from your camera feeds. You'll be guided through each class one at a time to select which images represent that state.
|
||||||
|
|
||||||
|
**Important**: All images must be assigned to a state before training can begin. This includes images that may not be optimal, such as when people temporarily block the view, sun glare is present, or other distractions occur. Assign these images to the state that is actually present (based on what you know the state to be), not based on the distraction. This training helps the model correctly identify the state even when such conditions occur during inference.
|
||||||
|
|
||||||
|
Once all images are assigned, training will begin automatically.
|
||||||
|
|
||||||
### Improving the Model
|
### Improving the Model
|
||||||
|
|
||||||
|
|||||||
@ -70,7 +70,7 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru
|
|||||||
genai:
|
genai:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://localhost:11434
|
base_url: http://localhost:11434
|
||||||
model: llava:7b
|
model: qwen3-vl:4b
|
||||||
```
|
```
|
||||||
|
|
||||||
## Google Gemini
|
## Google Gemini
|
||||||
|
|||||||
@ -35,19 +35,18 @@ Each model is available in multiple parameter sizes (3b, 4b, 8b, etc.). Larger s
|
|||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
|
|
||||||
If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. https://github.com/skye-harris/ollama-modelfiles contains optimized model configs for this task.
|
If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. qwen3-VL supports vision and tools simultaneously in Ollama.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
The following models are recommended:
|
The following models are recommended:
|
||||||
|
|
||||||
| Model | Notes |
|
| Model | Notes |
|
||||||
| ----------------- | ----------------------------------------------------------- |
|
| ----------------- | -------------------------------------------------------------------- |
|
||||||
| `qwen3-vl` | Strong visual and situational understanding |
|
| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement |
|
||||||
| `Intern3.5VL` | Relatively fast with good vision comprehension |
|
| `Intern3.5VL` | Relatively fast with good vision comprehension |
|
||||||
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
|
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
|
||||||
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
|
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
|
||||||
| `llava-phi3` | Lightweight and fast model with vision comprehension |
|
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
|
|||||||
@ -3,18 +3,18 @@ id: license_plate_recognition
|
|||||||
title: License Plate Recognition (LPR)
|
title: License Plate Recognition (LPR)
|
||||||
---
|
---
|
||||||
|
|
||||||
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a [known](#matching) name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
||||||
|
|
||||||
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
|
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
|
||||||
|
|
||||||
When a plate is recognized, the details are:
|
When a plate is recognized, the details are:
|
||||||
|
|
||||||
- Added as a `sub_label` (if known) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
- Added as a `sub_label` (if [known](#matching)) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
||||||
- Viewable in the Review Item Details pane in Review (sub labels).
|
- Viewable in the Details pane in Review/History.
|
||||||
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
|
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
|
||||||
- Filterable through the More Filters menu in Explore.
|
- Filterable through the More Filters menu in Explore.
|
||||||
- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
- Published via the `frigate/events` MQTT topic as a `sub_label` ([known](#matching)) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
||||||
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if known) and `plate`.
|
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if [known](#matching)) and `plate`.
|
||||||
|
|
||||||
## Model Requirements
|
## Model Requirements
|
||||||
|
|
||||||
@ -31,6 +31,7 @@ In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle`
|
|||||||
## Minimum System Requirements
|
## Minimum System Requirements
|
||||||
|
|
||||||
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required.
|
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
License plate recognition is disabled by default. Enable it in your config file:
|
License plate recognition is disabled by default. Enable it in your config file:
|
||||||
@ -73,8 +74,8 @@ Fine-tune the LPR feature using these optional parameters at the global level of
|
|||||||
- Default: `small`
|
- Default: `small`
|
||||||
- This can be `small` or `large`.
|
- This can be `small` or `large`.
|
||||||
- The `small` model is fast and identifies groups of Latin and Chinese characters.
|
- The `small` model is fast and identifies groups of Latin and Chinese characters.
|
||||||
- The `large` model identifies Latin characters only, but uses an enhanced text detector and is more capable at finding characters on multi-line plates. It is significantly slower than the `small` model. Note that using the `large` model does not improve _text recognition_, but it may improve _text detection_.
|
- The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. It is significantly slower than the `small` model.
|
||||||
- For most users, the `small` model is recommended.
|
- If your country or region does not use multi-line plates, you should use the `small` model as performance is much better for single-line plates.
|
||||||
|
|
||||||
### Recognition
|
### Recognition
|
||||||
|
|
||||||
@ -177,7 +178,7 @@ lpr:
|
|||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
If you want to detect cars on cameras but don't want to use resources to run LPR on those cars, you should disable LPR for those specific cameras.
|
If a camera is configured to detect `car` or `motorcycle` but you don't want Frigate to run LPR for that camera, disable LPR at the camera level:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cameras:
|
cameras:
|
||||||
@ -305,7 +306,7 @@ With this setup:
|
|||||||
- Review items will always be classified as a `detection`.
|
- Review items will always be classified as a `detection`.
|
||||||
- Snapshots will always be saved.
|
- Snapshots will always be saved.
|
||||||
- Zones and object masks are **not** used.
|
- Zones and object masks are **not** used.
|
||||||
- The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a known plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field.
|
- The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a [known](#matching) plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field.
|
||||||
- License plate snapshots are saved at the highest-scoring moment and appear in Explore.
|
- License plate snapshots are saved at the highest-scoring moment and appear in Explore.
|
||||||
- Debug view will not show `license_plate` bounding boxes.
|
- Debug view will not show `license_plate` bounding boxes.
|
||||||
|
|
||||||
|
|||||||
@ -962,7 +962,6 @@ model:
|
|||||||
# path: /config/yolov9.zip
|
# path: /config/yolov9.zip
|
||||||
# The .zip file must contain:
|
# The .zip file must contain:
|
||||||
# ├── yolov9.dfp (a file ending with .dfp)
|
# ├── yolov9.dfp (a file ending with .dfp)
|
||||||
# └── yolov9_post.onnx (optional; only if the model includes a cropped post-processing network)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### YOLOX
|
#### YOLOX
|
||||||
|
|||||||
@ -141,7 +141,7 @@ Triggers are best configured through the Frigate UI.
|
|||||||
Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT.
|
Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT.
|
||||||
5. Save the trigger to update the configuration and store the embedding in the database.
|
5. Save the trigger to update the configuration and store the embedding in the database.
|
||||||
|
|
||||||
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification.
|
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification. Additionally, the UI will show the last date/time and tracked object ID that activated your trigger. The last triggered timestamp is not saved to the database or persisted through restarts of Frigate.
|
||||||
|
|
||||||
### Usage and Best Practices
|
### Usage and Best Practices
|
||||||
|
|
||||||
|
|||||||
@ -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 # Optional: 1GB of memory, reduces SSD/SD Card wear
|
- type: tmpfs # Recommended: 1GB of memory
|
||||||
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 # Optional: 1GB of memory, reduces SSD/SD Card wear
|
- type: tmpfs # Recommended: 1GB of memory
|
||||||
target: /tmp/cache
|
target: /tmp/cache
|
||||||
tmpfs:
|
tmpfs:
|
||||||
size: 1000000000
|
size: 1000000000
|
||||||
|
|||||||
@ -179,6 +179,36 @@ 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,9 +1781,8 @@ def create_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception(
|
||||||
logger.error(
|
|
||||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1807,8 +1806,8 @@ def create_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception("Error creating trigger embedding")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -1917,9 +1916,8 @@ def update_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception(
|
||||||
logger.error(
|
|
||||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1958,9 +1956,8 @@ def update_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception(
|
||||||
logger.error(
|
|
||||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1972,8 +1969,8 @@ def update_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception("Error updating trigger embedding")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -2033,9 +2030,8 @@ def delete_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception(
|
||||||
logger.error(
|
|
||||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2047,8 +2043,8 @@ def delete_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(e.with_traceback())
|
logger.exception("Error deleting trigger embedding")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|||||||
@ -762,6 +762,15 @@ 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:
|
||||||
@ -840,6 +849,7 @@ async def vod_ts(camera_name: str, start_ts: float, end_ts: float):
|
|||||||
|
|
||||||
clips = []
|
clips = []
|
||||||
durations = []
|
durations = []
|
||||||
|
min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame
|
||||||
max_duration_ms = MAX_SEGMENT_DURATION * 1000
|
max_duration_ms = MAX_SEGMENT_DURATION * 1000
|
||||||
|
|
||||||
recording: Recordings
|
recording: Recordings
|
||||||
@ -857,11 +867,11 @@ async def vod_ts(camera_name: str, start_ts: float, end_ts: float):
|
|||||||
if recording.end_time > end_ts:
|
if recording.end_time > end_ts:
|
||||||
duration -= int((recording.end_time - end_ts) * 1000)
|
duration -= int((recording.end_time - end_ts) * 1000)
|
||||||
|
|
||||||
if duration <= 0:
|
if duration < min_duration_ms:
|
||||||
# skip if the clip has no valid duration
|
# skip if the clip has no valid duration (too short to contain frames)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if 0 < duration < max_duration_ms:
|
if min_duration_ms <= duration < max_duration_ms:
|
||||||
clip["keyFrameDurations"] = [duration]
|
clip["keyFrameDurations"] = [duration]
|
||||||
clips.append(clip)
|
clips.append(clip)
|
||||||
durations.append(duration)
|
durations.append(duration)
|
||||||
|
|||||||
@ -136,6 +136,7 @@ class CameraMaintainer(threading.Thread):
|
|||||||
self.ptz_metrics[name],
|
self.ptz_metrics[name],
|
||||||
self.region_grids[name],
|
self.region_grids[name],
|
||||||
self.stop_event,
|
self.stop_event,
|
||||||
|
self.config.logger,
|
||||||
)
|
)
|
||||||
self.camera_processes[config.name] = camera_process
|
self.camera_processes[config.name] = camera_process
|
||||||
camera_process.start()
|
camera_process.start()
|
||||||
@ -156,7 +157,11 @@ class CameraMaintainer(threading.Thread):
|
|||||||
self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
|
self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
|
||||||
|
|
||||||
capture_process = CameraCapture(
|
capture_process = CameraCapture(
|
||||||
config, count, self.camera_metrics[name], self.stop_event
|
config,
|
||||||
|
count,
|
||||||
|
self.camera_metrics[name],
|
||||||
|
self.stop_event,
|
||||||
|
self.config.logger,
|
||||||
)
|
)
|
||||||
capture_process.daemon = True
|
capture_process.daemon = True
|
||||||
self.capture_processes[name] = capture_process
|
self.capture_processes[name] = capture_process
|
||||||
|
|||||||
@ -792,6 +792,10 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
# copy over auth and proxy config in case auth needs to be enforced
|
# copy over auth and proxy config in case auth needs to be enforced
|
||||||
safe_config["auth"] = config.get("auth", {})
|
safe_config["auth"] = config.get("auth", {})
|
||||||
safe_config["proxy"] = config.get("proxy", {})
|
safe_config["proxy"] = config.get("proxy", {})
|
||||||
|
|
||||||
|
# copy over database config for auth and so a new db is not created
|
||||||
|
safe_config["database"] = config.get("database", {})
|
||||||
|
|
||||||
return cls.parse_object(safe_config, **context)
|
return cls.parse_object(safe_config, **context)
|
||||||
|
|
||||||
# Validate and return the config dict.
|
# Validate and return the config dict.
|
||||||
|
|||||||
@ -132,17 +132,15 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
|
|
||||||
if image_source == ImageSourceEnum.recordings:
|
if image_source == ImageSourceEnum.recordings:
|
||||||
duration = final_data["end_time"] - final_data["start_time"]
|
duration = final_data["end_time"] - final_data["start_time"]
|
||||||
buffer_extension = min(
|
buffer_extension = min(5, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
||||||
10, max(2, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure minimum total duration for short review items
|
# Ensure minimum total duration for short review items
|
||||||
# This provides better context for brief events
|
# This provides better context for brief events
|
||||||
total_duration = duration + (2 * buffer_extension)
|
total_duration = duration + (2 * buffer_extension)
|
||||||
if total_duration < MIN_RECORDING_DURATION:
|
if total_duration < MIN_RECORDING_DURATION:
|
||||||
# Expand buffer to reach minimum duration, still respecting max of 10s per side
|
# Expand buffer to reach minimum duration, still respecting max of 5s per side
|
||||||
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
||||||
buffer_extension = min(10, additional_buffer_per_side)
|
buffer_extension = min(5, additional_buffer_per_side)
|
||||||
|
|
||||||
thumbs = self.get_recording_frames(
|
thumbs = self.get_recording_frames(
|
||||||
camera,
|
camera,
|
||||||
|
|||||||
@ -424,7 +424,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
|
|
||||||
if not res:
|
if not res:
|
||||||
return {
|
return {
|
||||||
"message": "No face was recognized.",
|
"message": "Model is still training, please try again in a few moments.",
|
||||||
"success": False,
|
"success": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,6 @@ from frigate.detectors.detector_config import (
|
|||||||
ModelTypeEnum,
|
ModelTypeEnum,
|
||||||
)
|
)
|
||||||
from frigate.util.file import FileLock
|
from frigate.util.file import FileLock
|
||||||
from frigate.util.model import post_process_yolo
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -178,13 +177,6 @@ class MemryXDetector(DetectionApi):
|
|||||||
logger.error(f"Failed to initialize MemryX model: {e}")
|
logger.error(f"Failed to initialize MemryX model: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def load_yolo_constants(self):
|
|
||||||
base = f"{self.cache_dir}/{self.model_folder}"
|
|
||||||
# constants for yolov9 post-processing
|
|
||||||
self.const_A = np.load(f"{base}/_model_22_Constant_9_output_0.npy")
|
|
||||||
self.const_B = np.load(f"{base}/_model_22_Constant_10_output_0.npy")
|
|
||||||
self.const_C = np.load(f"{base}/_model_22_Constant_12_output_0.npy")
|
|
||||||
|
|
||||||
def check_and_prepare_model(self):
|
def check_and_prepare_model(self):
|
||||||
if not os.path.exists(self.cache_dir):
|
if not os.path.exists(self.cache_dir):
|
||||||
os.makedirs(self.cache_dir, exist_ok=True)
|
os.makedirs(self.cache_dir, exist_ok=True)
|
||||||
@ -236,7 +228,6 @@ class MemryXDetector(DetectionApi):
|
|||||||
|
|
||||||
# Handle post model requirements by model type
|
# Handle post model requirements by model type
|
||||||
if self.memx_model_type in [
|
if self.memx_model_type in [
|
||||||
ModelTypeEnum.yologeneric,
|
|
||||||
ModelTypeEnum.yolonas,
|
ModelTypeEnum.yolonas,
|
||||||
ModelTypeEnum.ssd,
|
ModelTypeEnum.ssd,
|
||||||
]:
|
]:
|
||||||
@ -245,7 +236,10 @@ class MemryXDetector(DetectionApi):
|
|||||||
f"No *_post.onnx file found in custom model zip for {self.memx_model_type.name}."
|
f"No *_post.onnx file found in custom model zip for {self.memx_model_type.name}."
|
||||||
)
|
)
|
||||||
self.memx_post_model = post_candidates[0]
|
self.memx_post_model = post_candidates[0]
|
||||||
elif self.memx_model_type == ModelTypeEnum.yolox:
|
elif self.memx_model_type in [
|
||||||
|
ModelTypeEnum.yolox,
|
||||||
|
ModelTypeEnum.yologeneric,
|
||||||
|
]:
|
||||||
# Explicitly ignore any post model even if present
|
# Explicitly ignore any post model even if present
|
||||||
self.memx_post_model = None
|
self.memx_post_model = None
|
||||||
else:
|
else:
|
||||||
@ -273,8 +267,6 @@ class MemryXDetector(DetectionApi):
|
|||||||
logger.info("Using cached models.")
|
logger.info("Using cached models.")
|
||||||
self.memx_model_path = dfp_path
|
self.memx_model_path = dfp_path
|
||||||
self.memx_post_model = post_path
|
self.memx_post_model = post_path
|
||||||
if self.memx_model_type == ModelTypeEnum.yologeneric:
|
|
||||||
self.load_yolo_constants()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# ---------- CASE 3: download MemryX model (no cache) ----------
|
# ---------- CASE 3: download MemryX model (no cache) ----------
|
||||||
@ -303,9 +295,6 @@ class MemryXDetector(DetectionApi):
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.memx_model_type == ModelTypeEnum.yologeneric:
|
|
||||||
self.load_yolo_constants()
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(zip_path):
|
if os.path.exists(zip_path):
|
||||||
try:
|
try:
|
||||||
@ -600,127 +589,232 @@ class MemryXDetector(DetectionApi):
|
|||||||
|
|
||||||
self.output_queue.put(final_detections)
|
self.output_queue.put(final_detections)
|
||||||
|
|
||||||
def onnx_reshape_with_allowzero(
|
def _generate_anchors(self, sizes=[80, 40, 20]):
|
||||||
self, data: np.ndarray, shape: np.ndarray, allowzero: int = 0
|
"""Generate anchor points for YOLOv9 style processing"""
|
||||||
|
yscales = []
|
||||||
|
xscales = []
|
||||||
|
for s in sizes:
|
||||||
|
r = np.arange(s) + 0.5
|
||||||
|
yscales.append(np.repeat(r, s))
|
||||||
|
xscales.append(np.repeat(r[None, ...], s, axis=0).flatten())
|
||||||
|
|
||||||
|
yscales = np.concatenate(yscales)
|
||||||
|
xscales = np.concatenate(xscales)
|
||||||
|
anchors = np.stack([xscales, yscales], axis=1)
|
||||||
|
return anchors
|
||||||
|
|
||||||
|
def _generate_scales(self, sizes=[80, 40, 20]):
|
||||||
|
"""Generate scaling factors for each detection level"""
|
||||||
|
factors = [8, 16, 32]
|
||||||
|
s = np.concatenate([np.ones([int(s * s)]) * f for s, f in zip(sizes, factors)])
|
||||||
|
return s[:, None]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _softmax(x: np.ndarray, axis: int) -> np.ndarray:
|
||||||
|
"""Efficient softmax implementation"""
|
||||||
|
x = x - np.max(x, axis=axis, keepdims=True)
|
||||||
|
np.exp(x, out=x)
|
||||||
|
x /= np.sum(x, axis=axis, keepdims=True)
|
||||||
|
return x
|
||||||
|
|
||||||
|
def dfl(self, x: np.ndarray) -> np.ndarray:
|
||||||
|
"""Distribution Focal Loss decoding - YOLOv9 style"""
|
||||||
|
x = x.reshape(-1, 4, 16)
|
||||||
|
weights = np.arange(16, dtype=np.float32)
|
||||||
|
p = self._softmax(x, axis=2)
|
||||||
|
p = p * weights[None, None, :]
|
||||||
|
out = np.sum(p, axis=2, keepdims=False)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def dist2bbox(
|
||||||
|
self, x: np.ndarray, anchors: np.ndarray, scales: np.ndarray
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
shape = shape.astype(int)
|
"""Convert distances to bounding boxes - YOLOv9 style"""
|
||||||
input_shape = data.shape
|
lt = x[:, :2]
|
||||||
output_shape = []
|
rb = x[:, 2:]
|
||||||
|
|
||||||
for i, dim in enumerate(shape):
|
x1y1 = anchors - lt
|
||||||
if dim == 0 and allowzero == 0:
|
x2y2 = anchors + rb
|
||||||
output_shape.append(input_shape[i]) # Copy dimension from input
|
|
||||||
else:
|
|
||||||
output_shape.append(dim)
|
|
||||||
|
|
||||||
# Now let NumPy infer any -1 if needed
|
wh = x2y2 - x1y1
|
||||||
reshaped = np.reshape(data, output_shape)
|
c_xy = (x1y1 + x2y2) / 2
|
||||||
|
|
||||||
return reshaped
|
out = np.concatenate([c_xy, wh], axis=1)
|
||||||
|
out = out * scales
|
||||||
|
return out
|
||||||
|
|
||||||
|
def post_process_yolo_optimized(self, outputs):
|
||||||
|
"""
|
||||||
|
Custom YOLOv9 post-processing optimized for MemryX ONNX outputs.
|
||||||
|
Implements DFL decoding, confidence filtering, and NMS in pure NumPy.
|
||||||
|
"""
|
||||||
|
# YOLOv9 outputs: 6 outputs (lbox, lcls, mbox, mcls, sbox, scls)
|
||||||
|
conv_out1, conv_out2, conv_out3, conv_out4, conv_out5, conv_out6 = outputs
|
||||||
|
|
||||||
|
# Determine grid sizes based on input resolution
|
||||||
|
# YOLOv9 uses 3 detection heads with strides [8, 16, 32]
|
||||||
|
# Grid sizes = input_size / stride
|
||||||
|
sizes = [
|
||||||
|
self.memx_model_height
|
||||||
|
// 8, # Large objects (e.g., 80 for 640x640, 40 for 320x320)
|
||||||
|
self.memx_model_height
|
||||||
|
// 16, # Medium objects (e.g., 40 for 640x640, 20 for 320x320)
|
||||||
|
self.memx_model_height
|
||||||
|
// 32, # Small objects (e.g., 20 for 640x640, 10 for 320x320)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Generate anchors and scales if not already done
|
||||||
|
if not hasattr(self, "anchors"):
|
||||||
|
self.anchors = self._generate_anchors(sizes)
|
||||||
|
self.scales = self._generate_scales(sizes)
|
||||||
|
|
||||||
|
# Process outputs in YOLOv9 format: reshape and moveaxis for ONNX format
|
||||||
|
lbox = np.moveaxis(conv_out1, 1, -1) # Large boxes
|
||||||
|
lcls = np.moveaxis(conv_out2, 1, -1) # Large classes
|
||||||
|
mbox = np.moveaxis(conv_out3, 1, -1) # Medium boxes
|
||||||
|
mcls = np.moveaxis(conv_out4, 1, -1) # Medium classes
|
||||||
|
sbox = np.moveaxis(conv_out5, 1, -1) # Small boxes
|
||||||
|
scls = np.moveaxis(conv_out6, 1, -1) # Small classes
|
||||||
|
|
||||||
|
# Determine number of classes dynamically from the class output shape
|
||||||
|
# lcls shape should be (batch, height, width, num_classes)
|
||||||
|
num_classes = lcls.shape[-1]
|
||||||
|
|
||||||
|
# Validate that all class outputs have the same number of classes
|
||||||
|
if not (mcls.shape[-1] == num_classes and scls.shape[-1] == num_classes):
|
||||||
|
raise ValueError(
|
||||||
|
f"Class output shapes mismatch: lcls={lcls.shape}, mcls={mcls.shape}, scls={scls.shape}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Concatenate boxes and classes
|
||||||
|
boxes = np.concatenate(
|
||||||
|
[
|
||||||
|
lbox.reshape(-1, 64), # 64 is for 4 bbox coords * 16 DFL bins
|
||||||
|
mbox.reshape(-1, 64),
|
||||||
|
sbox.reshape(-1, 64),
|
||||||
|
],
|
||||||
|
axis=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
classes = np.concatenate(
|
||||||
|
[
|
||||||
|
lcls.reshape(-1, num_classes),
|
||||||
|
mcls.reshape(-1, num_classes),
|
||||||
|
scls.reshape(-1, num_classes),
|
||||||
|
],
|
||||||
|
axis=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply sigmoid to classes
|
||||||
|
classes = self.sigmoid(classes)
|
||||||
|
|
||||||
|
# Apply DFL to box predictions
|
||||||
|
boxes = self.dfl(boxes)
|
||||||
|
|
||||||
|
# YOLOv9 postprocessing with confidence filtering and NMS
|
||||||
|
confidence_thres = 0.4
|
||||||
|
iou_thres = 0.6
|
||||||
|
|
||||||
|
# Find the class with the highest score for each detection
|
||||||
|
max_scores = np.max(classes, axis=1) # Maximum class score for each detection
|
||||||
|
class_ids = np.argmax(classes, axis=1) # Index of the best class
|
||||||
|
|
||||||
|
# Filter out detections with scores below the confidence threshold
|
||||||
|
valid_indices = np.where(max_scores >= confidence_thres)[0]
|
||||||
|
if len(valid_indices) == 0:
|
||||||
|
# Return empty detections array
|
||||||
|
final_detections = np.zeros((20, 6), np.float32)
|
||||||
|
return final_detections
|
||||||
|
|
||||||
|
# Select only valid detections
|
||||||
|
valid_boxes = boxes[valid_indices]
|
||||||
|
valid_class_ids = class_ids[valid_indices]
|
||||||
|
valid_scores = max_scores[valid_indices]
|
||||||
|
|
||||||
|
# Convert distances to actual bounding boxes using anchors and scales
|
||||||
|
valid_boxes = self.dist2bbox(
|
||||||
|
valid_boxes, self.anchors[valid_indices], self.scales[valid_indices]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert bounding box coordinates from (x_center, y_center, w, h) to (x_min, y_min, x_max, y_max)
|
||||||
|
x_center, y_center, width, height = (
|
||||||
|
valid_boxes[:, 0],
|
||||||
|
valid_boxes[:, 1],
|
||||||
|
valid_boxes[:, 2],
|
||||||
|
valid_boxes[:, 3],
|
||||||
|
)
|
||||||
|
x_min = x_center - width / 2
|
||||||
|
y_min = y_center - height / 2
|
||||||
|
x_max = x_center + width / 2
|
||||||
|
y_max = y_center + height / 2
|
||||||
|
|
||||||
|
# Convert to format expected by cv2.dnn.NMSBoxes: [x, y, width, height]
|
||||||
|
boxes_for_nms = []
|
||||||
|
scores_for_nms = []
|
||||||
|
|
||||||
|
for i in range(len(valid_indices)):
|
||||||
|
# Ensure coordinates are within bounds and positive
|
||||||
|
x_min_clipped = max(0, x_min[i])
|
||||||
|
y_min_clipped = max(0, y_min[i])
|
||||||
|
x_max_clipped = min(self.memx_model_width, x_max[i])
|
||||||
|
y_max_clipped = min(self.memx_model_height, y_max[i])
|
||||||
|
|
||||||
|
width_clipped = x_max_clipped - x_min_clipped
|
||||||
|
height_clipped = y_max_clipped - y_min_clipped
|
||||||
|
|
||||||
|
if width_clipped > 0 and height_clipped > 0:
|
||||||
|
boxes_for_nms.append(
|
||||||
|
[x_min_clipped, y_min_clipped, width_clipped, height_clipped]
|
||||||
|
)
|
||||||
|
scores_for_nms.append(float(valid_scores[i]))
|
||||||
|
|
||||||
|
final_detections = np.zeros((20, 6), np.float32)
|
||||||
|
|
||||||
|
if len(boxes_for_nms) == 0:
|
||||||
|
return final_detections
|
||||||
|
|
||||||
|
# Apply NMS using OpenCV
|
||||||
|
indices = cv2.dnn.NMSBoxes(
|
||||||
|
boxes_for_nms, scores_for_nms, confidence_thres, iou_thres
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(indices) > 0:
|
||||||
|
# Flatten indices if they are returned as a list of arrays
|
||||||
|
if isinstance(indices[0], list) or isinstance(indices[0], np.ndarray):
|
||||||
|
indices = [i[0] for i in indices]
|
||||||
|
|
||||||
|
# Limit to top 20 detections
|
||||||
|
indices = indices[:20]
|
||||||
|
|
||||||
|
# Convert to Frigate format: [class_id, confidence, y_min, x_min, y_max, x_max] (normalized)
|
||||||
|
for i, idx in enumerate(indices):
|
||||||
|
class_id = valid_class_ids[idx]
|
||||||
|
confidence = valid_scores[idx]
|
||||||
|
|
||||||
|
# Get the box coordinates
|
||||||
|
box = boxes_for_nms[idx]
|
||||||
|
x_min_norm = box[0] / self.memx_model_width
|
||||||
|
y_min_norm = box[1] / self.memx_model_height
|
||||||
|
x_max_norm = (box[0] + box[2]) / self.memx_model_width
|
||||||
|
y_max_norm = (box[1] + box[3]) / self.memx_model_height
|
||||||
|
|
||||||
|
final_detections[i] = [
|
||||||
|
class_id,
|
||||||
|
confidence,
|
||||||
|
y_min_norm, # Frigate expects y_min first
|
||||||
|
x_min_norm,
|
||||||
|
y_max_norm,
|
||||||
|
x_max_norm,
|
||||||
|
]
|
||||||
|
|
||||||
|
return final_detections
|
||||||
|
|
||||||
def process_output(self, *outputs):
|
def process_output(self, *outputs):
|
||||||
"""Output callback function -- receives frames from the MX3 and triggers post-processing"""
|
"""Output callback function -- receives frames from the MX3 and triggers post-processing"""
|
||||||
if self.memx_model_type == ModelTypeEnum.yologeneric:
|
if self.memx_model_type == ModelTypeEnum.yologeneric:
|
||||||
if not self.memx_post_model:
|
# Use complete YOLOv9-style postprocessing (includes NMS)
|
||||||
conv_out1 = outputs[0]
|
final_detections = self.post_process_yolo_optimized(outputs)
|
||||||
conv_out2 = outputs[1]
|
|
||||||
conv_out3 = outputs[2]
|
|
||||||
conv_out4 = outputs[3]
|
|
||||||
conv_out5 = outputs[4]
|
|
||||||
conv_out6 = outputs[5]
|
|
||||||
|
|
||||||
concat_1 = self.onnx_concat([conv_out1, conv_out2], axis=1)
|
|
||||||
concat_2 = self.onnx_concat([conv_out3, conv_out4], axis=1)
|
|
||||||
concat_3 = self.onnx_concat([conv_out5, conv_out6], axis=1)
|
|
||||||
|
|
||||||
shape = np.array([1, 144, -1], dtype=np.int64)
|
|
||||||
|
|
||||||
reshaped_1 = self.onnx_reshape_with_allowzero(
|
|
||||||
concat_1, shape, allowzero=0
|
|
||||||
)
|
|
||||||
reshaped_2 = self.onnx_reshape_with_allowzero(
|
|
||||||
concat_2, shape, allowzero=0
|
|
||||||
)
|
|
||||||
reshaped_3 = self.onnx_reshape_with_allowzero(
|
|
||||||
concat_3, shape, allowzero=0
|
|
||||||
)
|
|
||||||
|
|
||||||
concat_4 = self.onnx_concat([reshaped_1, reshaped_2, reshaped_3], 2)
|
|
||||||
|
|
||||||
axis = 1
|
|
||||||
split_sizes = [64, 80]
|
|
||||||
|
|
||||||
# Calculate indices at which to split
|
|
||||||
indices = np.cumsum(split_sizes)[
|
|
||||||
:-1
|
|
||||||
] # [64] — split before the second chunk
|
|
||||||
|
|
||||||
# Perform split along axis 1
|
|
||||||
split_0, split_1 = np.split(concat_4, indices, axis=axis)
|
|
||||||
|
|
||||||
num_boxes = 2100 if self.memx_model_height == 320 else 8400
|
|
||||||
shape1 = np.array([1, 4, 16, num_boxes])
|
|
||||||
reshape_4 = self.onnx_reshape_with_allowzero(
|
|
||||||
split_0, shape1, allowzero=0
|
|
||||||
)
|
|
||||||
|
|
||||||
transpose_1 = reshape_4.transpose(0, 2, 1, 3)
|
|
||||||
|
|
||||||
axis = 1 # As per ONNX softmax node
|
|
||||||
|
|
||||||
# Subtract max for numerical stability
|
|
||||||
x_max = np.max(transpose_1, axis=axis, keepdims=True)
|
|
||||||
x_exp = np.exp(transpose_1 - x_max)
|
|
||||||
x_sum = np.sum(x_exp, axis=axis, keepdims=True)
|
|
||||||
softmax_output = x_exp / x_sum
|
|
||||||
|
|
||||||
# Weight W from the ONNX initializer (1, 16, 1, 1) with values 0 to 15
|
|
||||||
W = np.arange(16, dtype=np.float32).reshape(
|
|
||||||
1, 16, 1, 1
|
|
||||||
) # (1, 16, 1, 1)
|
|
||||||
|
|
||||||
# Apply 1x1 convolution: this is a weighted sum over channels
|
|
||||||
conv_output = np.sum(
|
|
||||||
softmax_output * W, axis=1, keepdims=True
|
|
||||||
) # shape: (1, 1, 4, 8400)
|
|
||||||
|
|
||||||
shape2 = np.array([1, 4, num_boxes])
|
|
||||||
reshape_5 = self.onnx_reshape_with_allowzero(
|
|
||||||
conv_output, shape2, allowzero=0
|
|
||||||
)
|
|
||||||
|
|
||||||
# ONNX Slice — get first 2 channels: [0:2] along axis 1
|
|
||||||
slice_output1 = reshape_5[:, 0:2, :] # Result: (1, 2, 8400)
|
|
||||||
|
|
||||||
# Slice channels 2 to 4 → axis = 1
|
|
||||||
slice_output2 = reshape_5[:, 2:4, :]
|
|
||||||
|
|
||||||
# Perform Subtraction
|
|
||||||
sub_output = self.const_A - slice_output1 # Equivalent to ONNX Sub
|
|
||||||
|
|
||||||
# Perform the ONNX-style Add
|
|
||||||
add_output = self.const_B + slice_output2
|
|
||||||
|
|
||||||
sub1 = add_output - sub_output
|
|
||||||
|
|
||||||
add1 = sub_output + add_output
|
|
||||||
|
|
||||||
div_output = add1 / 2.0
|
|
||||||
|
|
||||||
concat_5 = self.onnx_concat([div_output, sub1], axis=1)
|
|
||||||
|
|
||||||
# Expand B to (1, 1, 8400) so it can broadcast across axis=1 (4 channels)
|
|
||||||
const_C_expanded = self.const_C[:, np.newaxis, :] # Shape: (1, 1, 8400)
|
|
||||||
|
|
||||||
# Perform ONNX-style element-wise multiplication
|
|
||||||
mul_output = concat_5 * const_C_expanded # Result: (1, 4, 8400)
|
|
||||||
|
|
||||||
sigmoid_output = self.sigmoid(split_1)
|
|
||||||
outputs = self.onnx_concat([mul_output, sigmoid_output], axis=1)
|
|
||||||
|
|
||||||
final_detections = post_process_yolo(
|
|
||||||
outputs, self.memx_model_width, self.memx_model_height
|
|
||||||
)
|
|
||||||
self.output_queue.put(final_detections)
|
self.output_queue.put(final_detections)
|
||||||
|
|
||||||
elif self.memx_model_type == ModelTypeEnum.yolonas:
|
elif self.memx_model_type == ModelTypeEnum.yolonas:
|
||||||
|
|||||||
@ -362,7 +362,7 @@ def stats_snapshot(
|
|||||||
stats["embeddings"]["review_description_speed"] = round(
|
stats["embeddings"]["review_description_speed"] = round(
|
||||||
embeddings_metrics.review_desc_speed.value * 1000, 2
|
embeddings_metrics.review_desc_speed.value * 1000, 2
|
||||||
)
|
)
|
||||||
stats["embeddings"]["review_descriptions"] = round(
|
stats["embeddings"]["review_description_events_per_second"] = round(
|
||||||
embeddings_metrics.review_desc_dps.value, 2
|
embeddings_metrics.review_desc_dps.value, 2
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -370,7 +370,7 @@ def stats_snapshot(
|
|||||||
stats["embeddings"]["object_description_speed"] = round(
|
stats["embeddings"]["object_description_speed"] = round(
|
||||||
embeddings_metrics.object_desc_speed.value * 1000, 2
|
embeddings_metrics.object_desc_speed.value * 1000, 2
|
||||||
)
|
)
|
||||||
stats["embeddings"]["object_descriptions"] = round(
|
stats["embeddings"]["object_description_events_per_second"] = round(
|
||||||
embeddings_metrics.object_desc_dps.value, 2
|
embeddings_metrics.object_desc_dps.value, 2
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -378,7 +378,7 @@ def stats_snapshot(
|
|||||||
stats["embeddings"][f"{key}_classification_speed"] = round(
|
stats["embeddings"][f"{key}_classification_speed"] = round(
|
||||||
embeddings_metrics.classification_speeds[key].value * 1000, 2
|
embeddings_metrics.classification_speeds[key].value * 1000, 2
|
||||||
)
|
)
|
||||||
stats["embeddings"][f"{key}_classification"] = round(
|
stats["embeddings"][f"{key}_classification_events_per_second"] = round(
|
||||||
embeddings_metrics.classification_cps[key].value, 2
|
embeddings_metrics.classification_cps[key].value, 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -113,6 +113,7 @@ 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,
|
||||||
@ -137,7 +138,7 @@ class StorageMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
event_start = 0
|
event_start = 0
|
||||||
deleted_recordings = set()
|
deleted_recordings = []
|
||||||
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:
|
||||||
@ -172,7 +173,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.add(recording.id)
|
deleted_recordings.append(recording)
|
||||||
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
|
||||||
@ -186,6 +187,9 @@ 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,
|
||||||
)
|
)
|
||||||
@ -201,7 +205,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.add(recording.id)
|
deleted_recordings.append(recording)
|
||||||
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
|
||||||
@ -211,7 +215,50 @@ 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, ModelConfig
|
from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig
|
||||||
from frigate.config.camera.camera import CameraTypeEnum
|
from frigate.config.camera.camera import CameraTypeEnum
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
@ -539,6 +539,7 @@ class CameraCapture(FrigateProcess):
|
|||||||
shm_frame_count: int,
|
shm_frame_count: int,
|
||||||
camera_metrics: CameraMetrics,
|
camera_metrics: CameraMetrics,
|
||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
|
log_config: LoggerConfig | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
stop_event,
|
stop_event,
|
||||||
@ -549,9 +550,10 @@ class CameraCapture(FrigateProcess):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.shm_frame_count = shm_frame_count
|
self.shm_frame_count = shm_frame_count
|
||||||
self.camera_metrics = camera_metrics
|
self.camera_metrics = camera_metrics
|
||||||
|
self.log_config = log_config
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.pre_run_setup()
|
self.pre_run_setup(self.log_config)
|
||||||
camera_watchdog = CameraWatchdog(
|
camera_watchdog = CameraWatchdog(
|
||||||
self.config,
|
self.config,
|
||||||
self.shm_frame_count,
|
self.shm_frame_count,
|
||||||
@ -577,6 +579,7 @@ class CameraTracker(FrigateProcess):
|
|||||||
ptz_metrics: PTZMetrics,
|
ptz_metrics: PTZMetrics,
|
||||||
region_grid: list[list[dict[str, Any]]],
|
region_grid: list[list[dict[str, Any]]],
|
||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
|
log_config: LoggerConfig | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
stop_event,
|
stop_event,
|
||||||
@ -592,9 +595,10 @@ class CameraTracker(FrigateProcess):
|
|||||||
self.camera_metrics = camera_metrics
|
self.camera_metrics = camera_metrics
|
||||||
self.ptz_metrics = ptz_metrics
|
self.ptz_metrics = ptz_metrics
|
||||||
self.region_grid = region_grid
|
self.region_grid = region_grid
|
||||||
|
self.log_config = log_config
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.pre_run_setup()
|
self.pre_run_setup(self.log_config)
|
||||||
frame_queue = self.camera_metrics.frame_queue
|
frame_queue = self.camera_metrics.frame_queue
|
||||||
frame_shape = self.config.frame_shape
|
frame_shape = self.config.frame_shape
|
||||||
|
|
||||||
|
|||||||
@ -177,6 +177,10 @@
|
|||||||
"noCameras": {
|
"noCameras": {
|
||||||
"title": "No Cameras Configured",
|
"title": "No Cameras Configured",
|
||||||
"description": "Get started by connecting a camera to Frigate.",
|
"description": "Get started by connecting a camera to Frigate.",
|
||||||
"buttonText": "Add Camera"
|
"buttonText": "Add Camera",
|
||||||
|
"restricted": {
|
||||||
|
"title": "No Cameras Available",
|
||||||
|
"description": "You don't have permission to view any cameras in this group."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,7 +76,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npuUsage": "NPU Usage",
|
"npuUsage": "NPU Usage",
|
||||||
"npuMemory": "NPU Memory"
|
"npuMemory": "NPU Memory",
|
||||||
|
"intelGpuWarning": {
|
||||||
|
"title": "Intel GPU Stats Warning",
|
||||||
|
"message": "GPU stats unavailable",
|
||||||
|
"description": "This is a known bug in Intel's GPU stats reporting tools (intel_gpu_top) where it will break and repeatedly return a GPU usage of 0% even in cases where hardware acceleration and object detection are correctly running on the (i)GPU. This is not a Frigate bug. You can restart the host to temporarily fix the issue and confirm that the GPU is working correctly. This does not affect performance."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"otherProcesses": {
|
"otherProcesses": {
|
||||||
"title": "Other Processes",
|
"title": "Other Processes",
|
||||||
@ -169,6 +174,7 @@
|
|||||||
"enrichments": {
|
"enrichments": {
|
||||||
"title": "Enrichments",
|
"title": "Enrichments",
|
||||||
"infPerSecond": "Inferences Per Second",
|
"infPerSecond": "Inferences Per Second",
|
||||||
|
"averageInf": "Average Inference Time",
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"image_embedding": "Image Embedding",
|
"image_embedding": "Image Embedding",
|
||||||
"text_embedding": "Text Embedding",
|
"text_embedding": "Text Embedding",
|
||||||
@ -180,7 +186,13 @@
|
|||||||
"plate_recognition_speed": "Plate Recognition Speed",
|
"plate_recognition_speed": "Plate Recognition Speed",
|
||||||
"text_embedding_speed": "Text Embedding Speed",
|
"text_embedding_speed": "Text Embedding Speed",
|
||||||
"yolov9_plate_detection_speed": "YOLOv9 Plate Detection Speed",
|
"yolov9_plate_detection_speed": "YOLOv9 Plate Detection Speed",
|
||||||
"yolov9_plate_detection": "YOLOv9 Plate Detection"
|
"yolov9_plate_detection": "YOLOv9 Plate Detection",
|
||||||
|
"review_description": "Review Description",
|
||||||
|
"review_description_speed": "Review Description Speed",
|
||||||
|
"review_description_events_per_second": "Review Description",
|
||||||
|
"object_description": "Object Description",
|
||||||
|
"object_description_speed": "Object Description Speed",
|
||||||
|
"object_description_events_per_second": "Object Description"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,11 +44,16 @@ self.addEventListener("notificationclick", (event) => {
|
|||||||
switch (event.action ?? "default") {
|
switch (event.action ?? "default") {
|
||||||
case "markReviewed":
|
case "markReviewed":
|
||||||
if (event.notification.data) {
|
if (event.notification.data) {
|
||||||
|
event.waitUntil(
|
||||||
fetch("/api/reviews/viewed", {
|
fetch("/api/reviews/viewed", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": 1 },
|
headers: {
|
||||||
|
"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:
|
||||||
@ -58,7 +63,7 @@ self.addEventListener("notificationclick", (event) => {
|
|||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
if (clients.openWindow) {
|
if (clients.openWindow) {
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
return clients.openWindow(url);
|
event.waitUntil(clients.openWindow(url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -398,11 +398,7 @@ export function GroupedClassificationCard({
|
|||||||
threshold={threshold}
|
threshold={threshold}
|
||||||
selected={false}
|
selected={false}
|
||||||
i18nLibrary={i18nLibrary}
|
i18nLibrary={i18nLibrary}
|
||||||
onClick={(data, meta) => {
|
onClick={() => {}}
|
||||||
if (meta || selectedItems.length > 0) {
|
|
||||||
onClick(data);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children?.(data)}
|
{children?.(data)}
|
||||||
</ClassificationCard>
|
</ClassificationCard>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import useSWR from "swr";
|
|||||||
import { MdHome } from "react-icons/md";
|
import { MdHome } from "react-icons/md";
|
||||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||||
import { Button, buttonVariants } from "../ui/button";
|
import { Button, buttonVariants } from "../ui/button";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||||
import {
|
import {
|
||||||
@ -87,6 +87,8 @@ type CameraGroupSelectorProps = {
|
|||||||
export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||||
const { t } = useTranslation(["components/camera"]);
|
const { t } = useTranslation(["components/camera"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const allowedCameras = useAllowedCameras();
|
||||||
|
const isCustomRole = useIsCustomRole();
|
||||||
|
|
||||||
// tooltip
|
// tooltip
|
||||||
|
|
||||||
@ -119,10 +121,22 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.entries(config.camera_groups).sort(
|
const allGroups = Object.entries(config.camera_groups);
|
||||||
(a, b) => a[1].order - b[1].order,
|
|
||||||
|
// If custom role, filter out groups where user has no accessible cameras
|
||||||
|
if (isCustomRole) {
|
||||||
|
return allGroups
|
||||||
|
.filter(([, groupConfig]) => {
|
||||||
|
// Check if user has access to at least one camera in this group
|
||||||
|
return groupConfig.cameras.some((cameraName) =>
|
||||||
|
allowedCameras.includes(cameraName),
|
||||||
);
|
);
|
||||||
}, [config]);
|
})
|
||||||
|
.sort((a, b) => a[1].order - b[1].order);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allGroups.sort((a, b) => a[1].order - b[1].order);
|
||||||
|
}, [config, allowedCameras, isCustomRole]);
|
||||||
|
|
||||||
// add group
|
// add group
|
||||||
|
|
||||||
@ -139,6 +153,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
activeGroup={group}
|
activeGroup={group}
|
||||||
setGroup={setGroup}
|
setGroup={setGroup}
|
||||||
deleteGroup={deleteGroup}
|
deleteGroup={deleteGroup}
|
||||||
|
isCustomRole={isCustomRole}
|
||||||
/>
|
/>
|
||||||
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
||||||
<div
|
<div
|
||||||
@ -206,6 +221,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{!isCustomRole && (
|
||||||
<Button
|
<Button
|
||||||
className="bg-secondary text-muted-foreground"
|
className="bg-secondary text-muted-foreground"
|
||||||
aria-label={t("group.add")}
|
aria-label={t("group.add")}
|
||||||
@ -214,6 +230,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
>
|
>
|
||||||
<LuPlus className="size-4 text-primary" />
|
<LuPlus className="size-4 text-primary" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
{isMobile && <ScrollBar orientation="horizontal" className="h-0" />}
|
{isMobile && <ScrollBar orientation="horizontal" className="h-0" />}
|
||||||
</div>
|
</div>
|
||||||
</Scroller>
|
</Scroller>
|
||||||
@ -228,6 +245,7 @@ type NewGroupDialogProps = {
|
|||||||
activeGroup?: string;
|
activeGroup?: string;
|
||||||
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
|
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
|
||||||
deleteGroup: () => void;
|
deleteGroup: () => void;
|
||||||
|
isCustomRole?: boolean;
|
||||||
};
|
};
|
||||||
function NewGroupDialog({
|
function NewGroupDialog({
|
||||||
open,
|
open,
|
||||||
@ -236,6 +254,7 @@ function NewGroupDialog({
|
|||||||
activeGroup,
|
activeGroup,
|
||||||
setGroup,
|
setGroup,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
|
isCustomRole,
|
||||||
}: NewGroupDialogProps) {
|
}: NewGroupDialogProps) {
|
||||||
const { t } = useTranslation(["components/camera"]);
|
const { t } = useTranslation(["components/camera"]);
|
||||||
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
|
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
|
||||||
@ -261,6 +280,12 @@ function NewGroupDialog({
|
|||||||
`${activeGroup}-draggable-layout`,
|
`${activeGroup}-draggable-layout`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setEditState("none");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
// callbacks
|
// callbacks
|
||||||
|
|
||||||
const onDeleteGroup = useCallback(
|
const onDeleteGroup = useCallback(
|
||||||
@ -349,13 +374,7 @@ function NewGroupDialog({
|
|||||||
position="top-center"
|
position="top-center"
|
||||||
closeButton={true}
|
closeButton={true}
|
||||||
/>
|
/>
|
||||||
<Overlay
|
<Overlay open={open} onOpenChange={setOpen}>
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setEditState("none");
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Content
|
<Content
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-container overflow-y-auto",
|
"scrollbar-container overflow-y-auto",
|
||||||
@ -371,6 +390,7 @@ function NewGroupDialog({
|
|||||||
>
|
>
|
||||||
<Title>{t("group.label")}</Title>
|
<Title>{t("group.label")}</Title>
|
||||||
<Description className="sr-only">{t("group.edit")}</Description>
|
<Description className="sr-only">{t("group.edit")}</Description>
|
||||||
|
{!isCustomRole && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute",
|
"absolute",
|
||||||
@ -393,6 +413,7 @@ function NewGroupDialog({
|
|||||||
<LuPlus />
|
<LuPlus />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Header>
|
</Header>
|
||||||
<div className="flex flex-col gap-4 md:gap-3">
|
<div className="flex flex-col gap-4 md:gap-3">
|
||||||
{currentGroups.map((group) => (
|
{currentGroups.map((group) => (
|
||||||
@ -401,6 +422,7 @@ function NewGroupDialog({
|
|||||||
group={group}
|
group={group}
|
||||||
onDeleteGroup={() => onDeleteGroup(group[0])}
|
onDeleteGroup={() => onDeleteGroup(group[0])}
|
||||||
onEditGroup={() => onEditGroup(group)}
|
onEditGroup={() => onEditGroup(group)}
|
||||||
|
isReadOnly={isCustomRole}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -512,12 +534,14 @@ type CameraGroupRowProps = {
|
|||||||
group: [string, CameraGroupConfig];
|
group: [string, CameraGroupConfig];
|
||||||
onDeleteGroup: () => void;
|
onDeleteGroup: () => void;
|
||||||
onEditGroup: () => void;
|
onEditGroup: () => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CameraGroupRow({
|
export function CameraGroupRow({
|
||||||
group,
|
group,
|
||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
|
isReadOnly,
|
||||||
}: CameraGroupRowProps) {
|
}: CameraGroupRowProps) {
|
||||||
const { t } = useTranslation(["components/camera"]);
|
const { t } = useTranslation(["components/camera"]);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@ -564,7 +588,7 @@ export function CameraGroupRow({
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && !isReadOnly && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu modal={!isDesktop}>
|
<DropdownMenu modal={!isDesktop}>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
@ -589,7 +613,7 @@ export function CameraGroupRow({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isMobile && (
|
{!isMobile && !isReadOnly && (
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@ -4,9 +4,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
|
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import { MdImageSearch } from "react-icons/md";
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@ -31,11 +29,8 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { BsFillLightningFill } from "react-icons/bs";
|
|
||||||
import BlurredIconButton from "../button/BlurredIconButton";
|
import BlurredIconButton from "../button/BlurredIconButton";
|
||||||
import { PiPath } from "react-icons/pi";
|
|
||||||
|
|
||||||
type SearchResultActionsProps = {
|
type SearchResultActionsProps = {
|
||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
@ -98,7 +93,6 @@ export default function SearchResultActions({
|
|||||||
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
||||||
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
||||||
>
|
>
|
||||||
<LuDownload className="mr-2 size-4" />
|
|
||||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||||
</a>
|
</a>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -110,7 +104,6 @@ export default function SearchResultActions({
|
|||||||
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
||||||
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
||||||
>
|
>
|
||||||
<LuCamera className="mr-2 size-4" />
|
|
||||||
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
||||||
</a>
|
</a>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -120,16 +113,15 @@ export default function SearchResultActions({
|
|||||||
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
||||||
onClick={showTrackingDetails}
|
onClick={showTrackingDetails}
|
||||||
>
|
>
|
||||||
<PiPath className="mr-2 size-4" />
|
|
||||||
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{config?.semantic_search?.enabled && isContextMenu && (
|
{config?.semantic_search?.enabled &&
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
@ -139,25 +131,13 @@ 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",
|
"flex flex-col items-center justify-center rounded-lg p-[6px]",
|
||||||
className,
|
className,
|
||||||
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
|
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon className="size-5 md:m-[6px]" />
|
<Icon className="size-5" />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,8 @@ 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 } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -35,6 +36,7 @@ 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,
|
||||||
@ -54,9 +56,15 @@ 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
|
||||||
@ -69,7 +77,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.enum(["admin", "viewer"]),
|
role: z.string().min(1),
|
||||||
})
|
})
|
||||||
.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"),
|
||||||
@ -246,24 +254,22 @@ export default function CreateUserDialog({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
{roles.map((r) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="admin"
|
value={r}
|
||||||
|
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>
|
) : (
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<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" />
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
<span>{t("role.viewer", { ns: "common" })}</span>
|
)}
|
||||||
|
<span>{t(`role.${r}`, { ns: "common" }) || r}</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription className="text-xs text-muted-foreground">
|
<FormDescription className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@ -20,7 +21,6 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { LuPlus, LuScanFace } from "react-icons/lu";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import React, { ReactNode, useMemo, useState } from "react";
|
import React, { ReactNode, useMemo, useState } from "react";
|
||||||
@ -89,27 +89,26 @@ export default function FaceSelectionDialog({
|
|||||||
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-h-[40dvh] flex-col overflow-y-auto",
|
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
|
||||||
isMobile && "gap-2 pb-4",
|
isMobile && "gap-2 pb-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SelectorItem
|
|
||||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
|
||||||
onClick={() => setNewFace(true)}
|
|
||||||
>
|
|
||||||
<LuPlus />
|
|
||||||
{t("createFaceLibrary.new")}
|
|
||||||
</SelectorItem>
|
|
||||||
{faceNames.sort().map((faceName) => (
|
{faceNames.sort().map((faceName) => (
|
||||||
<SelectorItem
|
<SelectorItem
|
||||||
key={faceName}
|
key={faceName}
|
||||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
onClick={() => onTrainAttempt(faceName)}
|
onClick={() => onTrainAttempt(faceName)}
|
||||||
>
|
>
|
||||||
<LuScanFace />
|
|
||||||
{faceName}
|
{faceName}
|
||||||
</SelectorItem>
|
</SelectorItem>
|
||||||
))}
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<SelectorItem
|
||||||
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
|
onClick={() => setNewFace(true)}
|
||||||
|
>
|
||||||
|
{t("createFaceLibrary.new")}
|
||||||
|
</SelectorItem>
|
||||||
</div>
|
</div>
|
||||||
</SelectorContent>
|
</SelectorContent>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|||||||
@ -171,6 +171,18 @@ export default function ImagePicker({
|
|||||||
alt={selectedImage?.label || "Selected image"}
|
alt={selectedImage?.label || "Selected image"}
|
||||||
className="size-16 rounded object-cover"
|
className="size-16 rounded object-cover"
|
||||||
onLoad={() => handleImageLoad(selectedImageId || "")}
|
onLoad={() => handleImageLoad(selectedImageId || "")}
|
||||||
|
onError={(e) => {
|
||||||
|
// If trigger thumbnail fails to load, fall back to event thumbnail
|
||||||
|
if (!selectedImage) {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
if (
|
||||||
|
target.src.includes("clips/triggers") &&
|
||||||
|
selectedImageId
|
||||||
|
) {
|
||||||
|
target.src = `${apiHost}api/events/${selectedImageId}/thumbnail.webp`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
||||||
|
|||||||
@ -683,6 +683,22 @@ function ObjectDetailsTab({
|
|||||||
|
|
||||||
const mutate = useGlobalMutation();
|
const mutate = useGlobalMutation();
|
||||||
|
|
||||||
|
// Helper to map over SWR cached search results while preserving
|
||||||
|
// either paginated format (SearchResult[][]) or flat format (SearchResult[])
|
||||||
|
const mapSearchResults = useCallback(
|
||||||
|
(
|
||||||
|
currentData: SearchResult[][] | SearchResult[] | undefined,
|
||||||
|
fn: (event: SearchResult) => SearchResult,
|
||||||
|
) => {
|
||||||
|
if (!currentData) return currentData;
|
||||||
|
if (Array.isArray(currentData[0])) {
|
||||||
|
return (currentData as SearchResult[][]).map((page) => page.map(fn));
|
||||||
|
}
|
||||||
|
return (currentData as SearchResult[]).map(fn);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// users
|
// users
|
||||||
|
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
@ -791,6 +807,15 @@ function ObjectDetailsTab({
|
|||||||
}
|
}
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
|
const isEventsKey = useCallback((key: unknown): boolean => {
|
||||||
|
const candidate = Array.isArray(key) ? key[0] : key;
|
||||||
|
const EVENTS_KEY_PATTERNS = ["events", "events/search", "events/explore"];
|
||||||
|
return (
|
||||||
|
typeof candidate === "string" &&
|
||||||
|
EVENTS_KEY_PATTERNS.some((p) => candidate.includes(p))
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateDescription = useCallback(() => {
|
const updateDescription = useCallback(() => {
|
||||||
if (!search) {
|
if (!search) {
|
||||||
return;
|
return;
|
||||||
@ -805,28 +830,20 @@ function ObjectDetailsTab({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
mutate(
|
mutate(
|
||||||
(key) =>
|
(key) => isEventsKey(key),
|
||||||
typeof key === "string" &&
|
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||||
(key.includes("events") ||
|
mapSearchResults(currentData, (event) =>
|
||||||
key.includes("events/search") ||
|
|
||||||
key.includes("events/explore")),
|
|
||||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
|
||||||
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,
|
||||||
revalidate: false,
|
revalidate: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
setSearch({ ...search, data: { ...search.data, description: desc } });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@ -843,7 +860,7 @@ function ObjectDetailsTab({
|
|||||||
);
|
);
|
||||||
setDesc(search.data.description);
|
setDesc(search.data.description);
|
||||||
});
|
});
|
||||||
}, [desc, search, mutate, t]);
|
}, [desc, search, mutate, t, mapSearchResults, isEventsKey, setSearch]);
|
||||||
|
|
||||||
const regenerateDescription = useCallback(
|
const regenerateDescription = useCallback(
|
||||||
(source: "snapshot" | "thumbnails") => {
|
(source: "snapshot" | "thumbnails") => {
|
||||||
@ -910,14 +927,9 @@ function ObjectDetailsTab({
|
|||||||
});
|
});
|
||||||
|
|
||||||
mutate(
|
mutate(
|
||||||
(key) =>
|
(key) => isEventsKey(key),
|
||||||
typeof key === "string" &&
|
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||||
(key.includes("events") ||
|
mapSearchResults(currentData, (event) =>
|
||||||
key.includes("events/search") ||
|
|
||||||
key.includes("events/explore")),
|
|
||||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
|
||||||
if (!currentData) return currentData;
|
|
||||||
return currentData.flat().map((event) =>
|
|
||||||
event.id === search.id
|
event.id === search.id
|
||||||
? {
|
? {
|
||||||
...event,
|
...event,
|
||||||
@ -928,8 +940,7 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: event,
|
: event,
|
||||||
);
|
),
|
||||||
},
|
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -963,7 +974,7 @@ function ObjectDetailsTab({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[search, apiHost, mutate, setSearch, t],
|
[search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
// recognized plate
|
// recognized plate
|
||||||
@ -987,14 +998,9 @@ function ObjectDetailsTab({
|
|||||||
});
|
});
|
||||||
|
|
||||||
mutate(
|
mutate(
|
||||||
(key) =>
|
(key) => isEventsKey(key),
|
||||||
typeof key === "string" &&
|
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||||
(key.includes("events") ||
|
mapSearchResults(currentData, (event) =>
|
||||||
key.includes("events/search") ||
|
|
||||||
key.includes("events/explore")),
|
|
||||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
|
||||||
if (!currentData) return currentData;
|
|
||||||
return currentData.flat().map((event) =>
|
|
||||||
event.id === search.id
|
event.id === search.id
|
||||||
? {
|
? {
|
||||||
...event,
|
...event,
|
||||||
@ -1005,8 +1011,7 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: event,
|
: event,
|
||||||
);
|
),
|
||||||
},
|
|
||||||
{
|
{
|
||||||
optimisticData: true,
|
optimisticData: true,
|
||||||
rollbackOnError: true,
|
rollbackOnError: true,
|
||||||
@ -1040,7 +1045,7 @@ function ObjectDetailsTab({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[search, apiHost, mutate, setSearch, t],
|
[search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
// speech transcription
|
// speech transcription
|
||||||
@ -1096,23 +1101,15 @@ function ObjectDetailsTab({
|
|||||||
});
|
});
|
||||||
|
|
||||||
setState("submitted");
|
setState("submitted");
|
||||||
|
setSearch({ ...search, plus_id: "new_upload" });
|
||||||
mutate(
|
mutate(
|
||||||
(key) =>
|
(key) => isEventsKey(key),
|
||||||
typeof key === "string" &&
|
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||||
(key.includes("events") ||
|
mapSearchResults(currentData, (event) =>
|
||||||
key.includes("events/search") ||
|
|
||||||
key.includes("events/explore")),
|
|
||||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
|
||||||
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,
|
||||||
@ -1120,7 +1117,7 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[search, mutate],
|
[search, mutate, mapSearchResults, setSearch, isEventsKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -1503,7 +1500,7 @@ function ObjectDetailsTab({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
className="text-md h-32"
|
className="text-md h-32 md:text-sm"
|
||||||
placeholder={t("details.description.placeholder")}
|
placeholder={t("details.description.placeholder")}
|
||||||
value={desc}
|
value={desc}
|
||||||
onChange={(e) => setDesc(e.target.value)}
|
onChange={(e) => setDesc(e.target.value)}
|
||||||
@ -1511,25 +1508,7 @@ function ObjectDetailsTab({
|
|||||||
onBlur={handleDescriptionBlur}
|
onBlur={handleDescriptionBlur}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-row justify-end gap-4">
|
<div className="mb-10 flex flex-row justify-end gap-5">
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-label={t("button.save", { ns: "common" })}
|
|
||||||
className="text-primary/40 hover:text-primary/80"
|
|
||||||
onClick={() => {
|
|
||||||
setIsEditingDesc(false);
|
|
||||||
updateDescription();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaCheck className="size-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("button.save", { ns: "common" })}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@ -1540,13 +1519,31 @@ function ObjectDetailsTab({
|
|||||||
setDesc(originalDescRef.current ?? "");
|
setDesc(originalDescRef.current ?? "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaTimes className="size-4" />
|
<FaTimes className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("button.cancel", { ns: "common" })}
|
{t("button.cancel", { ns: "common" })}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-label={t("button.save", { ns: "common" })}
|
||||||
|
className="text-primary/40 hover:text-primary/80"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditingDesc(false);
|
||||||
|
updateDescription();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaCheck className="size-5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("button.save", { ns: "common" })}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||||
@ -11,7 +12,11 @@ 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 { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
import {
|
||||||
|
ASPECT_VERTICAL_LAYOUT,
|
||||||
|
ASPECT_WIDE_LAYOUT,
|
||||||
|
Recording,
|
||||||
|
} from "@/types/record";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@ -51,6 +56,7 @@ export function TrackingDetails({
|
|||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
const [imgLoaded, setImgLoaded] = useState(false);
|
const [imgLoaded, setImgLoaded] = useState(false);
|
||||||
|
const [isVideoLoading, setIsVideoLoading] = useState(true);
|
||||||
const [displaySource, _setDisplaySource] = useState<"video" | "image">(
|
const [displaySource, _setDisplaySource] = useState<"video" | "image">(
|
||||||
"video",
|
"video",
|
||||||
);
|
);
|
||||||
@ -65,6 +71,10 @@ export function TrackingDetails({
|
|||||||
(event.start_time ?? 0) + annotationOffset / 1000 - REVIEW_PADDING,
|
(event.start_time ?? 0) + annotationOffset / 1000 - REVIEW_PADDING,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVideoLoading(true);
|
||||||
|
}, [event.id]);
|
||||||
|
|
||||||
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
|
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
|
||||||
"timeline",
|
"timeline",
|
||||||
{
|
{
|
||||||
@ -74,6 +84,139 @@ 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);
|
||||||
@ -89,9 +232,16 @@ export function TrackingDetails({
|
|||||||
}, [manualOverride, currentTime, annotationOffset]);
|
}, [manualOverride, currentTime, annotationOffset]);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const [_selectedZone, setSelectedZone] = useState("");
|
const [_selectedZone, setSelectedZone] = useState("");
|
||||||
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
||||||
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
|
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
|
||||||
|
const [lineBottomOffsetPx, setLineBottomOffsetPx] = useState<number>(32);
|
||||||
|
const [lineTopOffsetPx, setLineTopOffsetPx] = useState<number>(8);
|
||||||
|
const [blueLineHeightPx, setBlueLineHeightPx] = useState<number>(0);
|
||||||
|
|
||||||
|
const [timelineSize] = useResizeObserver(timelineContainerRef);
|
||||||
|
|
||||||
const aspectRatio = useMemo(() => {
|
const aspectRatio = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@ -140,17 +290,14 @@ export function TrackingDetails({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For video mode: convert to video-relative time and seek player
|
// For video mode: convert to video-relative time (accounting for motion-only gaps)
|
||||||
const eventStartRecord =
|
const relativeTime = timestampToVideoTime(targetTimeRecord);
|
||||||
(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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[event.start_time, annotationOffset, displaySource],
|
[annotationOffset, displaySource, timestampToVideoTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedStart = config
|
const formattedStart = config
|
||||||
@ -169,8 +316,9 @@ export function TrackingDetails({
|
|||||||
})
|
})
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const formattedEnd = config
|
const formattedEnd =
|
||||||
? formatUnixTimestampToDateTime(event.end_time ?? 0, {
|
config && event.end_time != null
|
||||||
|
? 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"
|
||||||
@ -202,79 +350,83 @@ export function TrackingDetails({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seekToTimestamp is a record stream timestamp
|
// seekToTimestamp is a record stream timestamp
|
||||||
// event.start_time is detect stream time, convert to record
|
// Convert to video position (accounting for motion-only recording gaps)
|
||||||
// The video clip starts at (eventStartRecord - REVIEW_PADDING)
|
|
||||||
if (!videoRef.current) return;
|
if (!videoRef.current) return;
|
||||||
const eventStartRecord = event.start_time + annotationOffset / 1000;
|
const relativeTime = timestampToVideoTime(seekToTimestamp);
|
||||||
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 =
|
const isWithinEventRange = useMemo(() => {
|
||||||
effectiveTime !== undefined &&
|
if (effectiveTime === undefined || event.start_time === undefined) {
|
||||||
event.start_time !== undefined &&
|
return false;
|
||||||
event.end_time !== undefined &&
|
}
|
||||||
effectiveTime >= event.start_time &&
|
// If an event has not ended yet, fall back to last timestamp in eventSequence
|
||||||
effectiveTime <= event.end_time;
|
let eventEnd = event.end_time;
|
||||||
|
if (eventEnd == null && eventSequence && eventSequence.length > 0) {
|
||||||
// Calculate how far down the blue line should extend based on effectiveTime
|
const last = eventSequence[eventSequence.length - 1];
|
||||||
const calculateLineHeight = useCallback(() => {
|
if (last && last.timestamp !== undefined) {
|
||||||
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) {
|
eventEnd = last.timestamp;
|
||||||
return 0;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = effectiveTime ?? 0;
|
if (eventEnd == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
|
||||||
|
}, [effectiveTime, event.start_time, event.end_time, eventSequence]);
|
||||||
|
|
||||||
// Find which events have been passed
|
// Dynamically compute pixel offsets so the timeline line starts at the
|
||||||
let lastPassedIndex = -1;
|
// first row midpoint and ends at the last row midpoint. For accuracy,
|
||||||
for (let i = 0; i < eventSequence.length; i++) {
|
// measure the center Y of each lifecycle row and interpolate the current
|
||||||
if (currentTime >= (eventSequence[i].timestamp ?? 0)) {
|
// effective time into a pixel position; then set the blue line height
|
||||||
lastPassedIndex = i;
|
// so it reaches the center dot at the same time the dot becomes active.
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// No events passed yet
|
const bluePx = Math.round(Math.max(0, pixelPos - topOffset));
|
||||||
if (lastPassedIndex < 0) return 0;
|
setBlueLineHeightPx(bluePx);
|
||||||
|
}, [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
|
||||||
@ -312,14 +464,13 @@ export function TrackingDetails({
|
|||||||
|
|
||||||
const handleTimeUpdate = useCallback(
|
const handleTimeUpdate = useCallback(
|
||||||
(time: number) => {
|
(time: number) => {
|
||||||
// event.start_time is detect stream time, convert to record
|
// Convert video player time back to timeline timestamp
|
||||||
const eventStartRecord = event.start_time + annotationOffset / 1000;
|
// accounting for motion-only recording gaps
|
||||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
const absoluteTime = videoTimeToTimestamp(time);
|
||||||
const absoluteTime = time + videoStartTime;
|
|
||||||
|
|
||||||
setCurrentTime(absoluteTime);
|
setCurrentTime(absoluteTime);
|
||||||
},
|
},
|
||||||
[event.start_time, annotationOffset],
|
[videoTimeToTimestamp],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [src, setSrc] = useState(
|
const [src, setSrc] = useState(
|
||||||
@ -381,6 +532,7 @@ export function TrackingDetails({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displaySource == "video" && (
|
{displaySource == "video" && (
|
||||||
|
<>
|
||||||
<HlsVideoPlayer
|
<HlsVideoPlayer
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
@ -393,10 +545,15 @@ export function TrackingDetails({
|
|||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onSeekToTime={handleSeekToTime}
|
onSeekToTime={handleSeekToTime}
|
||||||
onUploadFrame={onUploadFrameToPlus}
|
onUploadFrame={onUploadFrameToPlus}
|
||||||
|
onPlaying={() => setIsVideoLoading(false)}
|
||||||
isDetailMode={true}
|
isDetailMode={true}
|
||||||
camera={event.camera}
|
camera={event.camera}
|
||||||
currentTimeOverride={currentTime}
|
currentTimeOverride={currentTime}
|
||||||
/>
|
/>
|
||||||
|
{isVideoLoading && (
|
||||||
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{displaySource == "image" && (
|
{displaySource == "image" && (
|
||||||
<>
|
<>
|
||||||
@ -503,9 +660,16 @@ 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>
|
||||||
<span className="md:text-md text-xs text-secondary-foreground">
|
<div className="md:text-md flex items-center text-xs text-secondary-foreground">
|
||||||
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
{formattedStart ?? ""}
|
||||||
</span>
|
{event.end_time != null ? (
|
||||||
|
<> - {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>
|
||||||
@ -531,12 +695,21 @@ export function TrackingDetails({
|
|||||||
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="-pb-2 relative mx-0">
|
<div
|
||||||
<div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
className="-pb-2 relative mx-0"
|
||||||
|
ref={timelineContainerRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute -top-2 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground"
|
||||||
|
style={{ bottom: lineBottomOffsetPx }}
|
||||||
|
/>
|
||||||
{isWithinEventRange && (
|
{isWithinEventRange && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-6 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
className="absolute left-6 z-[5] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
||||||
style={{ height: `${blueLineHeight}%` }}
|
style={{
|
||||||
|
top: `${lineTopOffsetPx}px`,
|
||||||
|
height: `${blueLineHeightPx}px`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -589,8 +762,13 @@ export function TrackingDetails({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LifecycleIconRow
|
<div
|
||||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||||
|
ref={(el) => {
|
||||||
|
rowRefs.current[idx] = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LifecycleIconRow
|
||||||
item={item}
|
item={item}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
formattedEventTimestamp={formattedEventTimestamp}
|
formattedEventTimestamp={formattedEventTimestamp}
|
||||||
@ -603,6 +781,7 @@ export function TrackingDetails({
|
|||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
isTimelineActive={isWithinEventRange}
|
isTimelineActive={isWithinEventRange}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,31 +6,68 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile, isSafari } from "react-device-detect";
|
||||||
import { ObjectSnapshotTab } from "../detail/SearchDetailDialog";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { FaCheckCircle } from "react-icons/fa";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||||
|
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||||
|
|
||||||
type FrigatePlusDialogProps = {
|
export type FrigatePlusDialogProps = {
|
||||||
upload?: Event;
|
upload?: Event;
|
||||||
dialog?: boolean;
|
dialog?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEventUploaded: () => void;
|
onEventUploaded: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FrigatePlusDialog({
|
export function FrigatePlusDialog({
|
||||||
upload,
|
upload,
|
||||||
dialog = true,
|
dialog = true,
|
||||||
onClose,
|
onClose,
|
||||||
onEventUploaded,
|
onEventUploaded,
|
||||||
}: FrigatePlusDialogProps) {
|
}: FrigatePlusDialogProps) {
|
||||||
if (!upload) {
|
const { t, i18n } = useTranslation(["components/dialog"]);
|
||||||
return;
|
|
||||||
}
|
type SubmissionState = "reviewing" | "uploading" | "submitted";
|
||||||
if (dialog) {
|
const [state, setState] = useState<SubmissionState>(
|
||||||
|
upload?.plus_id ? "submitted" : "reviewing",
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
setState(upload?.plus_id ? "submitted" : "reviewing");
|
||||||
|
}, [upload?.plus_id]);
|
||||||
|
|
||||||
|
const onSubmitToPlus = useCallback(
|
||||||
|
async (falsePositive: boolean) => {
|
||||||
|
if (!upload) return;
|
||||||
|
falsePositive
|
||||||
|
? axios.put(`events/${upload.id}/false_positive`)
|
||||||
|
: axios.post(`events/${upload.id}/plus`, { include_annotation: 1 });
|
||||||
|
setState("submitted");
|
||||||
|
onEventUploaded();
|
||||||
|
},
|
||||||
|
[upload, onEventUploaded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||||
|
const showCard =
|
||||||
|
!!upload &&
|
||||||
|
upload.data.type === "object" &&
|
||||||
|
upload.plus_id !== "not_enabled" &&
|
||||||
|
upload.end_time &&
|
||||||
|
upload.label !== "on_demand";
|
||||||
|
|
||||||
|
if (!dialog || !upload) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={true} onOpenChange={(open) => (!open ? onClose() : null)}>
|
||||||
open={upload != undefined}
|
|
||||||
onOpenChange={(open) => (!open ? onClose() : null)}
|
|
||||||
>
|
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-container overflow-y-auto",
|
"scrollbar-container overflow-y-auto",
|
||||||
@ -45,12 +82,123 @@ export function FrigatePlusDialog({
|
|||||||
Submit this snapshot to Frigate+
|
Submit this snapshot to Frigate+
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<ObjectSnapshotTab
|
|
||||||
search={upload}
|
<div className="relative size-full">
|
||||||
onEventUploaded={onEventUploaded}
|
<ImageLoadingIndicator
|
||||||
|
className="absolute inset-0 aspect-video min-h-[60dvh] w-full"
|
||||||
|
imgLoaded={imgLoaded}
|
||||||
/>
|
/>
|
||||||
|
<div className={imgLoaded ? "visible" : "invisible"}>
|
||||||
|
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
<TransformComponent
|
||||||
|
wrapperStyle={{ width: "100%", height: "100%" }}
|
||||||
|
contentStyle={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{upload.id && (
|
||||||
|
<div className="relative mx-auto">
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
className="mx-auto max-h-[60dvh] rounded-lg bg-black object-contain"
|
||||||
|
src={`${baseUrl}api/events/${upload.id}/snapshot.jpg`}
|
||||||
|
alt={`${upload.label}`}
|
||||||
|
loading={isSafari ? "eager" : "lazy"}
|
||||||
|
onLoad={onImgLoad}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TransformComponent>
|
||||||
|
|
||||||
|
{showCard && (
|
||||||
|
<Card className="p-1 text-sm md:p-2">
|
||||||
|
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
<div className="text-lg leading-none">
|
||||||
|
{t("explore.plus.submitToPlus.label")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("explore.plus.submitToPlus.desc")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end">
|
||||||
|
{state === "reviewing" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{i18n.language === "en" ? (
|
||||||
|
/^[aeiou]/i.test(upload.label || "") ? (
|
||||||
|
<Trans
|
||||||
|
ns="components/dialog"
|
||||||
|
values={{ label: upload.label }}
|
||||||
|
>
|
||||||
|
explore.plus.review.question.ask_an
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans
|
||||||
|
ns="components/dialog"
|
||||||
|
values={{ label: upload.label }}
|
||||||
|
>
|
||||||
|
explore.plus.review.question.ask_a
|
||||||
|
</Trans>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Trans
|
||||||
|
ns="components/dialog"
|
||||||
|
values={{
|
||||||
|
untranslatedLabel: upload.label,
|
||||||
|
translatedLabel: getTranslatedLabel(
|
||||||
|
upload.label,
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
explore.plus.review.question.ask_full
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex-1 bg-success"
|
||||||
|
aria-label={t("button.yes", { ns: "common" })}
|
||||||
|
onClick={() => {
|
||||||
|
setState("uploading");
|
||||||
|
onSubmitToPlus(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("button.yes", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1 text-white"
|
||||||
|
aria-label={t("button.no", { ns: "common" })}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setState("uploading");
|
||||||
|
onSubmitToPlus(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("button.no", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "uploading" && <ActivityIndicator />}
|
||||||
|
{state === "submitted" && (
|
||||||
|
<div className="flex flex-row items-center justify-center gap-2">
|
||||||
|
<FaCheckCircle className="size-4 text-success" />
|
||||||
|
{t("explore.plus.review.state.submitted")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TransformWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import { isAndroid, isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||||
import VideoControls from "./VideoControls";
|
import VideoControls from "./VideoControls";
|
||||||
import { VideoResolutionType } from "@/types/live";
|
import { VideoResolutionType } from "@/types/live";
|
||||||
@ -22,7 +22,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay";
|
import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay";
|
||||||
|
|
||||||
// Android native hls does not seek correctly
|
// Android native hls does not seek correctly
|
||||||
const USE_NATIVE_HLS = !isAndroid;
|
const USE_NATIVE_HLS = false;
|
||||||
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
|
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
|
||||||
const unsupportedErrorCodes = [
|
const unsupportedErrorCodes = [
|
||||||
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
||||||
@ -130,6 +130,8 @@ export default function HlsVideoPlayer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoadedMetadata(false);
|
||||||
|
|
||||||
const currentPlaybackRate = videoRef.current.playbackRate;
|
const currentPlaybackRate = videoRef.current.playbackRate;
|
||||||
|
|
||||||
if (!useHlsCompat) {
|
if (!useHlsCompat) {
|
||||||
@ -318,6 +320,7 @@ export default function HlsVideoPlayer({
|
|||||||
{isDetailMode &&
|
{isDetailMode &&
|
||||||
camera &&
|
camera &&
|
||||||
currentTime &&
|
currentTime &&
|
||||||
|
loadedMetadata &&
|
||||||
videoDimensions.width > 0 &&
|
videoDimensions.width > 0 &&
|
||||||
videoDimensions.height > 0 && (
|
videoDimensions.height > 0 && (
|
||||||
<div className="absolute z-50 size-full">
|
<div className="absolute z-50 size-full">
|
||||||
|
|||||||
@ -309,6 +309,7 @@ function PreviewVideoPlayer({
|
|||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
disableRemotePlayback
|
disableRemotePlayback
|
||||||
|
disablePictureInPicture
|
||||||
onSeeked={onPreviewSeeked}
|
onSeeked={onPreviewSeeked}
|
||||||
onLoadedData={() => {
|
onLoadedData={() => {
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { Recording } from "@/types/record";
|
|||||||
import { DynamicPlayback } from "@/types/playback";
|
import { DynamicPlayback } from "@/types/playback";
|
||||||
import { PreviewController } from "../PreviewPlayer";
|
import { PreviewController } from "../PreviewPlayer";
|
||||||
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
|
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
|
||||||
import { calculateInpointOffset } from "@/utils/videoUtil";
|
import {
|
||||||
|
calculateInpointOffset,
|
||||||
|
calculateSeekPosition,
|
||||||
|
} from "@/utils/videoUtil";
|
||||||
|
|
||||||
type PlayerMode = "playback" | "scrubbing";
|
type PlayerMode = "playback" | "scrubbing";
|
||||||
|
|
||||||
@ -72,39 +75,21 @@ export class DynamicVideoController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
this.recordings.length == 0 ||
|
|
||||||
time < this.recordings[0].start_time ||
|
|
||||||
time > this.recordings[this.recordings.length - 1].end_time
|
|
||||||
) {
|
|
||||||
this.setNoRecording(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.playerMode != "playback") {
|
if (this.playerMode != "playback") {
|
||||||
this.playerMode = "playback";
|
this.playerMode = "playback";
|
||||||
}
|
}
|
||||||
|
|
||||||
let seekSeconds = 0;
|
const seekSeconds = calculateSeekPosition(
|
||||||
(this.recordings || []).every((segment) => {
|
time,
|
||||||
// if the next segment is past the desired time, stop calculating
|
this.recordings,
|
||||||
if (segment.start_time > time) {
|
this.inpointOffset,
|
||||||
return false;
|
);
|
||||||
|
|
||||||
|
if (seekSeconds === undefined) {
|
||||||
|
this.setNoRecording(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segment.end_time < time) {
|
|
||||||
seekSeconds += segment.end_time - segment.start_time;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
seekSeconds +=
|
|
||||||
segment.end_time - segment.start_time - (segment.end_time - time);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// adjust for HLS inpoint offset
|
|
||||||
seekSeconds -= this.inpointOffset;
|
|
||||||
|
|
||||||
if (seekSeconds != 0) {
|
if (seekSeconds != 0) {
|
||||||
this.playerController.currentTime = seekSeconds;
|
this.playerController.currentTime = seekSeconds;
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,10 @@ import { VideoResolutionType } from "@/types/live";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { calculateInpointOffset } from "@/utils/videoUtil";
|
import {
|
||||||
|
calculateInpointOffset,
|
||||||
|
calculateSeekPosition,
|
||||||
|
} from "@/utils/videoUtil";
|
||||||
import { isFirefox } from "react-device-detect";
|
import { isFirefox } from "react-device-detect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -109,10 +112,10 @@ export default function DynamicVideoPlayer({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(false);
|
const [isBuffering, setIsBuffering] = useState(false);
|
||||||
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
|
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
|
||||||
const [source, setSource] = useState<HlsSource>({
|
|
||||||
playlist: `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
|
// Don't set source until recordings load - we need accurate startPosition
|
||||||
startPosition: startTimestamp ? timeRange.after - startTimestamp : 0,
|
// to avoid hls.js clamping to video end when startPosition exceeds duration
|
||||||
});
|
const [source, setSource] = useState<HlsSource | undefined>(undefined);
|
||||||
|
|
||||||
// start at correct time
|
// start at correct time
|
||||||
|
|
||||||
@ -184,7 +187,7 @@ export default function DynamicVideoPlayer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!controller || !recordings?.length) {
|
if (!recordings?.length) {
|
||||||
if (recordings?.length == 0) {
|
if (recordings?.length == 0) {
|
||||||
setNoRecording(true);
|
setNoRecording(true);
|
||||||
}
|
}
|
||||||
@ -192,10 +195,6 @@ export default function DynamicVideoPlayer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current.autoplay = !isScrubbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
let startPosition = undefined;
|
let startPosition = undefined;
|
||||||
|
|
||||||
if (startTimestamp) {
|
if (startTimestamp) {
|
||||||
@ -203,14 +202,12 @@ export default function DynamicVideoPlayer({
|
|||||||
recordingParams.after,
|
recordingParams.after,
|
||||||
(recordings || [])[0],
|
(recordings || [])[0],
|
||||||
);
|
);
|
||||||
const idealStartPosition = Math.max(
|
|
||||||
0,
|
|
||||||
startTimestamp - timeRange.after - inpointOffset,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (idealStartPosition >= recordings[0].start_time - timeRange.after) {
|
startPosition = calculateSeekPosition(
|
||||||
startPosition = idealStartPosition;
|
startTimestamp,
|
||||||
}
|
recordings,
|
||||||
|
inpointOffset,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSource({
|
setSource({
|
||||||
@ -218,6 +215,18 @@ export default function DynamicVideoPlayer({
|
|||||||
startPosition,
|
startPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [recordings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!controller || !recordings?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerRef.current) {
|
||||||
|
playerRef.current.autoplay = !isScrubbing;
|
||||||
|
}
|
||||||
|
|
||||||
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
|
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
|
||||||
|
|
||||||
controller.newPlayback({
|
controller.newPlayback({
|
||||||
@ -225,7 +234,7 @@ export default function DynamicVideoPlayer({
|
|||||||
timeRange,
|
timeRange,
|
||||||
});
|
});
|
||||||
|
|
||||||
// we only want this to change when recordings update
|
// we only want this to change when controller or recordings update
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [controller, recordings]);
|
}, [controller, recordings]);
|
||||||
|
|
||||||
@ -263,6 +272,7 @@ export default function DynamicVideoPlayer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{source && (
|
||||||
<HlsVideoPlayer
|
<HlsVideoPlayer
|
||||||
videoRef={playerRef}
|
videoRef={playerRef}
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
@ -303,6 +313,7 @@ export default function DynamicVideoPlayer({
|
|||||||
camera={contextCamera || camera}
|
camera={contextCamera || camera}
|
||||||
currentTimeOverride={currentTime}
|
currentTimeOverride={currentTime}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<PreviewPlayer
|
<PreviewPlayer
|
||||||
className={cn(
|
className={cn(
|
||||||
className,
|
className,
|
||||||
|
|||||||
@ -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 } from "react";
|
import { useState, useMemo, useEffect } 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,7 +42,15 @@ export default function CameraEditForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
}: CameraEditFormProps) {
|
}: CameraEditFormProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config, mutate: mutateConfig } =
|
||||||
|
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(
|
||||||
@ -145,14 +153,23 @@ 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 = config.go2rtc?.streams || {};
|
const go2rtcStreams =
|
||||||
|
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,
|
||||||
@ -196,6 +213,60 @@ 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",
|
||||||
@ -268,6 +339,8 @@ export default function CameraEditForm({
|
|||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
|
mutateConfig();
|
||||||
|
mutateRawPaths();
|
||||||
if (onSave) onSave();
|
if (onSave) onSave();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -277,6 +350,8 @@ export default function CameraEditForm({
|
|||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
|
mutateConfig();
|
||||||
|
mutateRawPaths();
|
||||||
if (onSave) onSave();
|
if (onSave) onSave();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -377,7 +377,7 @@ export default function Step1NameCamera({
|
|||||||
);
|
);
|
||||||
return selectedBrand &&
|
return selectedBrand &&
|
||||||
selectedBrand.value != "other" ? (
|
selectedBrand.value != "other" ? (
|
||||||
<Popover>
|
<Popover modal={true}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -600,7 +600,7 @@ export default function Step3StreamConfig({
|
|||||||
<Label className="text-sm font-medium text-primary-variant">
|
<Label className="text-sm font-medium text-primary-variant">
|
||||||
{t("cameraWizard.step3.roles")}
|
{t("cameraWizard.step3.roles")}
|
||||||
</Label>
|
</Label>
|
||||||
<Popover>
|
<Popover modal={true}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||||
<LuInfo className="size-3" />
|
<LuInfo className="size-3" />
|
||||||
@ -670,7 +670,7 @@ export default function Step3StreamConfig({
|
|||||||
<Label className="text-sm font-medium text-primary-variant">
|
<Label className="text-sm font-medium text-primary-variant">
|
||||||
{t("cameraWizard.step3.featuresTitle")}
|
{t("cameraWizard.step3.featuresTitle")}
|
||||||
</Label>
|
</Label>
|
||||||
<Popover>
|
<Popover modal={true}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||||
<LuInfo className="size-3" />
|
<LuInfo className="size-3" />
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
SegmentedReviewData,
|
SegmentedReviewData,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
|
import { TimelineType } from "@/types/timeline";
|
||||||
import {
|
import {
|
||||||
getBeginningOfDayTimestamp,
|
getBeginningOfDayTimestamp,
|
||||||
getEndOfDayTimestamp,
|
getEndOfDayTimestamp,
|
||||||
@ -49,6 +50,16 @@ export default function Events() {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [notificationTab, setNotificationTab] =
|
||||||
|
useState<TimelineType>("timeline");
|
||||||
|
|
||||||
|
useSearchEffect("tab", (tab: string) => {
|
||||||
|
if (tab === "timeline" || tab === "events" || tab === "detail") {
|
||||||
|
setNotificationTab(tab as TimelineType);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
useSearchEffect("id", (reviewId: string) => {
|
useSearchEffect("id", (reviewId: string) => {
|
||||||
axios
|
axios
|
||||||
.get(`review/${reviewId}`)
|
.get(`review/${reviewId}`)
|
||||||
@ -66,7 +77,7 @@ export default function Events() {
|
|||||||
camera: resp.data.camera,
|
camera: resp.data.camera,
|
||||||
startTime,
|
startTime,
|
||||||
severity: resp.data.severity,
|
severity: resp.data.severity,
|
||||||
timelineType: "detail",
|
timelineType: notificationTab,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -93,19 +93,23 @@ function Live() {
|
|||||||
const allowedCameras = useAllowedCameras();
|
const allowedCameras = useAllowedCameras();
|
||||||
|
|
||||||
const includesBirdseye = useMemo(() => {
|
const includesBirdseye = useMemo(() => {
|
||||||
|
// Restricted users should never have access to birdseye
|
||||||
|
if (isCustomRole) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config &&
|
config &&
|
||||||
Object.keys(config.camera_groups).length &&
|
Object.keys(config.camera_groups).length &&
|
||||||
cameraGroup &&
|
cameraGroup &&
|
||||||
config.camera_groups[cameraGroup] &&
|
config.camera_groups[cameraGroup] &&
|
||||||
cameraGroup != "default" &&
|
cameraGroup != "default"
|
||||||
(!isCustomRole || "birdseye" in allowedCameras)
|
|
||||||
) {
|
) {
|
||||||
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
|
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [config, cameraGroup, allowedCameras, isCustomRole]);
|
}, [config, cameraGroup, isCustomRole]);
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
const cameras = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
|
|||||||
@ -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 CameraSettingsView from "@/views/settings/CameraSettingsView";
|
import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView";
|
||||||
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: CameraSettingsView },
|
{ key: "cameraReview", component: CameraReviewSettingsView },
|
||||||
{ key: "masksAndZones", component: MasksAndZonesView },
|
{ key: "masksAndZones", component: MasksAndZonesView },
|
||||||
{ key: "motionTuner", component: MotionTunerView },
|
{ key: "motionTuner", component: MotionTunerView },
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ReviewSeverity } from "./review";
|
import { ReviewSeverity } from "./review";
|
||||||
|
import { TimelineType } from "./timeline";
|
||||||
|
|
||||||
export type Recording = {
|
export type Recording = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -37,7 +38,7 @@ export type RecordingStartingPoint = {
|
|||||||
camera: string;
|
camera: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
severity: ReviewSeverity;
|
severity: ReviewSeverity;
|
||||||
timelineType?: "timeline" | "events" | "detail";
|
timelineType?: TimelineType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecordingPlayerError = "stalled" | "startup";
|
export type RecordingPlayerError = "stalled" | "startup";
|
||||||
|
|||||||
@ -24,3 +24,57 @@ export function calculateInpointOffset(
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the video player time (in seconds) for a given timestamp
|
||||||
|
* by iterating through recording segments and summing their durations.
|
||||||
|
* This accounts for the fact that the video is a concatenation of segments,
|
||||||
|
* not a single continuous stream.
|
||||||
|
*
|
||||||
|
* @param timestamp - The target timestamp to seek to
|
||||||
|
* @param recordings - Array of recording segments
|
||||||
|
* @param inpointOffset - HLS inpoint offset to subtract from the result
|
||||||
|
* @returns The calculated seek position in seconds, or undefined if timestamp is out of range
|
||||||
|
*/
|
||||||
|
export function calculateSeekPosition(
|
||||||
|
timestamp: number,
|
||||||
|
recordings: Recording[],
|
||||||
|
inpointOffset: number = 0,
|
||||||
|
): number | undefined {
|
||||||
|
if (!recordings || recordings.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if timestamp is within the recordings range
|
||||||
|
if (
|
||||||
|
timestamp < recordings[0].start_time ||
|
||||||
|
timestamp > recordings[recordings.length - 1].end_time
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seekSeconds = 0;
|
||||||
|
|
||||||
|
(recordings || []).every((segment) => {
|
||||||
|
// if the next segment is past the desired time, stop calculating
|
||||||
|
if (segment.start_time > timestamp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.end_time < timestamp) {
|
||||||
|
// Add the full duration of this segment
|
||||||
|
seekSeconds += segment.end_time - segment.start_time;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're in this segment - calculate position within it
|
||||||
|
seekSeconds +=
|
||||||
|
segment.end_time - segment.start_time - (segment.end_time - timestamp);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjust for HLS inpoint offset
|
||||||
|
seekSeconds -= inpointOffset;
|
||||||
|
|
||||||
|
return seekSeconds >= 0 ? seekSeconds : undefined;
|
||||||
|
}
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaFolderPlus } from "react-icons/fa";
|
import { FaFolderPlus } from "react-icons/fa";
|
||||||
import { MdModelTraining } from "react-icons/md";
|
import { MdModelTraining } from "react-icons/md";
|
||||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
@ -40,6 +39,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import BlurredIconButton from "@/components/button/BlurredIconButton";
|
import BlurredIconButton from "@/components/button/BlurredIconButton";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
const allModelTypes = ["objects", "states"] as const;
|
const allModelTypes = ["objects", "states"] as const;
|
||||||
type ModelType = (typeof allModelTypes)[number];
|
type ModelType = (typeof allModelTypes)[number];
|
||||||
@ -333,9 +333,7 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
<ImageShadowOverlay lowerClassName="h-[30%] z-0" />
|
<ImageShadowOverlay lowerClassName="h-[30%] z-0" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex size-full items-center justify-center bg-background_alt">
|
<Skeleton className="flex size-full items-center justify-center" />
|
||||||
<MdModelTraining className="size-16 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
|
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
|
||||||
{config.name}
|
{config.name}
|
||||||
@ -352,11 +350,9 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={handleEditClick}>
|
<DropdownMenuItem onClick={handleEditClick}>
|
||||||
<LuPencil className="mr-2 size-4" />
|
|
||||||
<span>{t("button.edit", { ns: "common" })}</span>
|
<span>{t("button.edit", { ns: "common" })}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleDeleteClick}>
|
<DropdownMenuItem onClick={handleDeleteClick}>
|
||||||
<LuTrash2 className="mr-2 size-4" />
|
|
||||||
<span>{t("button.delete", { ns: "common" })}</span>
|
<span>{t("button.delete", { ns: "common" })}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@ -799,7 +799,7 @@ function DetectionReview({
|
|||||||
(itemsToReview ?? 0) > 0 && (
|
(itemsToReview ?? 0) > 0 && (
|
||||||
<div className="col-span-full flex items-center justify-center">
|
<div className="col-span-full flex items-center justify-center">
|
||||||
<Button
|
<Button
|
||||||
className="text-white"
|
className="text-balance text-white"
|
||||||
aria-label={t("markTheseItemsAsReviewed")}
|
aria-label={t("markTheseItemsAsReviewed")}
|
||||||
variant="select"
|
variant="select"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -850,6 +850,29 @@ function FrigateCameraFeatures({
|
|||||||
}
|
}
|
||||||
}, [activeToastId, t]);
|
}, [activeToastId, t]);
|
||||||
|
|
||||||
|
const endEventViaBeacon = useCallback(() => {
|
||||||
|
if (!recordingEventIdRef.current) return;
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/api/events/${recordingEventIdRef.current}/end`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
end_time: Math.ceil(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// this needs to be a synchronous XMLHttpRequest to guarantee the PUT
|
||||||
|
// reaches the server before the browser kills the page
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
try {
|
||||||
|
xhr.open("PUT", url, false);
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/json");
|
||||||
|
xhr.setRequestHeader("X-CSRF-TOKEN", "1");
|
||||||
|
xhr.setRequestHeader("X-CACHE-BYPASS", "1");
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.send(payload);
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore errors during unload
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEventButtonClick = useCallback(() => {
|
const handleEventButtonClick = useCallback(() => {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
endEvent();
|
endEvent();
|
||||||
@ -887,8 +910,19 @@ function FrigateCameraFeatures({
|
|||||||
}, [camera.name, isRestreamed, preferredLiveMode, t]);
|
}, [camera.name, isRestreamed, preferredLiveMode, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Handle page unload/close (browser close, tab close, refresh, navigation to external site)
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
if (recordingEventIdRef.current) {
|
||||||
|
endEventViaBeacon();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
// ensure manual event is stopped when component unmounts
|
// ensure manual event is stopped when component unmounts
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
if (recordingEventIdRef.current) {
|
if (recordingEventIdRef.current) {
|
||||||
endEvent();
|
endEvent();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,14 @@ import {
|
|||||||
FrigateConfig,
|
FrigateConfig,
|
||||||
} from "@/types/frigateConfig";
|
} from "@/types/frigateConfig";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
isDesktop,
|
isDesktop,
|
||||||
isMobile,
|
isMobile,
|
||||||
@ -46,6 +53,8 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { EmptyCard } from "@/components/card/EmptyCard";
|
import { EmptyCard } from "@/components/card/EmptyCard";
|
||||||
import { BsFillCameraVideoOffFill } from "react-icons/bs";
|
import { BsFillCameraVideoOffFill } from "react-icons/bs";
|
||||||
|
import { AuthContext } from "@/context/auth-context";
|
||||||
|
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||||
|
|
||||||
type LiveDashboardViewProps = {
|
type LiveDashboardViewProps = {
|
||||||
cameras: CameraConfig[];
|
cameras: CameraConfig[];
|
||||||
@ -374,10 +383,6 @@ export default function LiveDashboardView({
|
|||||||
onSaveMuting(true);
|
onSaveMuting(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cameras.length == 0 && !includeBirdseye) {
|
|
||||||
return <NoCameraView />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
|
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
|
||||||
@ -439,6 +444,10 @@ export default function LiveDashboardView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{cameras.length == 0 && !includeBirdseye ? (
|
||||||
|
<NoCameraView />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{!fullscreen && events && events.length > 0 && (
|
{!fullscreen && events && events.length > 0 && (
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@ -494,7 +503,8 @@ export default function LiveDashboardView({
|
|||||||
)}
|
)}
|
||||||
{cameras.map((camera) => {
|
{cameras.map((camera) => {
|
||||||
let grow;
|
let grow;
|
||||||
const aspectRatio = camera.detect.width / camera.detect.height;
|
const aspectRatio =
|
||||||
|
camera.detect.width / camera.detect.height;
|
||||||
if (aspectRatio > 2) {
|
if (aspectRatio > 2) {
|
||||||
grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
|
grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
|
||||||
} else if (aspectRatio < 1) {
|
} else if (aspectRatio < 1) {
|
||||||
@ -503,10 +513,12 @@ export default function LiveDashboardView({
|
|||||||
grow = "aspect-video";
|
grow = "aspect-video";
|
||||||
}
|
}
|
||||||
const availableStreams = camera.live.streams || {};
|
const availableStreams = camera.live.streams || {};
|
||||||
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
const firstStreamEntry =
|
||||||
|
Object.values(availableStreams)[0] || "";
|
||||||
|
|
||||||
const streamNameFromSettings =
|
const streamNameFromSettings =
|
||||||
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
|
currentGroupStreamingSettings?.[camera.name]?.streamName ||
|
||||||
|
"";
|
||||||
const streamExists =
|
const streamExists =
|
||||||
streamNameFromSettings &&
|
streamNameFromSettings &&
|
||||||
Object.values(availableStreams).includes(
|
Object.values(availableStreams).includes(
|
||||||
@ -535,7 +547,9 @@ export default function LiveDashboardView({
|
|||||||
camera={camera.name}
|
camera={camera.name}
|
||||||
cameraGroup={cameraGroup}
|
cameraGroup={cameraGroup}
|
||||||
streamName={streamName}
|
streamName={streamName}
|
||||||
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
preferredLiveMode={
|
||||||
|
preferredLiveModes[camera.name] ?? "mse"
|
||||||
|
}
|
||||||
isRestreamed={isRestreamedStates[camera.name]}
|
isRestreamed={isRestreamedStates[camera.name]}
|
||||||
supportsAudio={
|
supportsAudio={
|
||||||
supportsAudioOutputStates[streamName]?.supportsAudio ??
|
supportsAudioOutputStates[streamName]?.supportsAudio ??
|
||||||
@ -566,9 +580,13 @@ export default function LiveDashboardView({
|
|||||||
windowVisible && visibleCameras.includes(camera.name)
|
windowVisible && visibleCameras.includes(camera.name)
|
||||||
}
|
}
|
||||||
cameraConfig={camera}
|
cameraConfig={camera}
|
||||||
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
preferredLiveMode={
|
||||||
|
preferredLiveModes[camera.name] ?? "mse"
|
||||||
|
}
|
||||||
autoLive={autoLive ?? globalAutoLive}
|
autoLive={autoLive ?? globalAutoLive}
|
||||||
showStillWithoutActivity={showStillWithoutActivity ?? true}
|
showStillWithoutActivity={
|
||||||
|
showStillWithoutActivity ?? true
|
||||||
|
}
|
||||||
alwaysShowCameraName={displayCameraNames}
|
alwaysShowCameraName={displayCameraNames}
|
||||||
useWebGL={useWebGL}
|
useWebGL={useWebGL}
|
||||||
playInBackground={false}
|
playInBackground={false}
|
||||||
@ -576,7 +594,9 @@ export default function LiveDashboardView({
|
|||||||
streamName={streamName}
|
streamName={streamName}
|
||||||
onClick={() => onSelectCamera(camera.name)}
|
onClick={() => onSelectCamera(camera.name)}
|
||||||
onError={(e) => handleError(camera.name, e)}
|
onError={(e) => handleError(camera.name, e)}
|
||||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
onResetLiveMode={() =>
|
||||||
|
resetPreferredLiveMode(camera.name)
|
||||||
|
}
|
||||||
playAudio={audioStates[camera.name] ?? false}
|
playAudio={audioStates[camera.name] ?? false}
|
||||||
volume={volumeStates[camera.name]}
|
volume={volumeStates[camera.name]}
|
||||||
/>
|
/>
|
||||||
@ -632,21 +652,34 @@ export default function LiveDashboardView({
|
|||||||
toggleFullscreen={toggleFullscreen}
|
toggleFullscreen={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoCameraView() {
|
function NoCameraView() {
|
||||||
const { t } = useTranslation(["views/live"]);
|
const { t } = useTranslation(["views/live"]);
|
||||||
|
const { auth } = useContext(AuthContext);
|
||||||
|
const isCustomRole = useIsCustomRole();
|
||||||
|
|
||||||
|
// Check if this is a restricted user with no cameras in this group
|
||||||
|
const isRestricted = isCustomRole && auth.isAuthenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full items-center justify-center">
|
<div className="flex size-full items-center justify-center">
|
||||||
<EmptyCard
|
<EmptyCard
|
||||||
icon={<BsFillCameraVideoOffFill className="size-8" />}
|
icon={<BsFillCameraVideoOffFill className="size-8" />}
|
||||||
title={t("noCameras.title")}
|
title={
|
||||||
description={t("noCameras.description")}
|
isRestricted ? t("noCameras.restricted.title") : t("noCameras.title")
|
||||||
buttonText={t("noCameras.buttonText")}
|
}
|
||||||
link="/settings?page=cameraManagement"
|
description={
|
||||||
|
isRestricted
|
||||||
|
? t("noCameras.restricted.description")
|
||||||
|
: t("noCameras.description")
|
||||||
|
}
|
||||||
|
buttonText={!isRestricted ? t("noCameras.buttonText") : undefined}
|
||||||
|
link={!isRestricted ? "/settings?page=cameraManagement" : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,17 +5,9 @@ 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";
|
||||||
@ -90,31 +82,6 @@ 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">
|
||||||
|
|||||||
738
web/src/views/settings/CameraReviewSettingsView.tsx
Normal file
738
web/src/views/settings/CameraReviewSettingsView.tsx
Normal file
@ -0,0 +1,738 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,794 +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 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)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -198,15 +198,20 @@ export default function TriggerView({
|
|||||||
|
|
||||||
return axios
|
return axios
|
||||||
.put("config/set", configBody)
|
.put("config/set", configBody)
|
||||||
.then((configResponse) => {
|
.then(async (configResponse) => {
|
||||||
if (configResponse.status === 200) {
|
if (configResponse.status === 200) {
|
||||||
updateConfig();
|
await updateConfig();
|
||||||
|
const displayName =
|
||||||
|
friendly_name && friendly_name !== ""
|
||||||
|
? `${friendly_name} (${name})`
|
||||||
|
: name;
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
isEdit
|
isEdit
|
||||||
? "triggers.toast.success.updateTrigger"
|
? "triggers.toast.success.updateTrigger"
|
||||||
: "triggers.toast.success.createTrigger",
|
: "triggers.toast.success.createTrigger",
|
||||||
{ name },
|
{ name: displayName },
|
||||||
),
|
),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
@ -348,11 +353,22 @@ export default function TriggerView({
|
|||||||
|
|
||||||
return axios
|
return axios
|
||||||
.put("config/set", configBody)
|
.put("config/set", configBody)
|
||||||
.then((configResponse) => {
|
.then(async (configResponse) => {
|
||||||
if (configResponse.status === 200) {
|
if (configResponse.status === 200) {
|
||||||
updateConfig();
|
await updateConfig();
|
||||||
|
const friendly =
|
||||||
|
config?.cameras?.[selectedCamera]?.semantic_search
|
||||||
|
?.triggers?.[name]?.friendly_name;
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
friendly && friendly !== ""
|
||||||
|
? `${friendly} (${name})`
|
||||||
|
: name;
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("triggers.toast.success.deleteTrigger", { name }),
|
t("triggers.toast.success.deleteTrigger", {
|
||||||
|
name: displayName,
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
},
|
},
|
||||||
@ -381,7 +397,7 @@ export default function TriggerView({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
[t, updateConfig, selectedCamera, setUnsavedChanges, config],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -843,7 +859,14 @@ export default function TriggerView({
|
|||||||
/>
|
/>
|
||||||
<DeleteTriggerDialog
|
<DeleteTriggerDialog
|
||||||
show={showDelete}
|
show={showDelete}
|
||||||
triggerName={selectedTrigger?.name ?? ""}
|
triggerName={
|
||||||
|
selectedTrigger
|
||||||
|
? selectedTrigger.friendly_name &&
|
||||||
|
selectedTrigger.friendly_name !== ""
|
||||||
|
? `${selectedTrigger.friendly_name} (${selectedTrigger.name})`
|
||||||
|
: selectedTrigger.name
|
||||||
|
: ""
|
||||||
|
}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowDelete(false);
|
setShowDelete(false);
|
||||||
|
|||||||
@ -67,13 +67,14 @@ export default function EnrichmentMetrics({
|
|||||||
|
|
||||||
// features stats
|
// features stats
|
||||||
|
|
||||||
const embeddingInferenceTimeSeries = useMemo(() => {
|
const groupedEnrichmentMetrics = useMemo(() => {
|
||||||
if (!statsHistory) {
|
if (!statsHistory) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const series: {
|
const series: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
|
rawKey: string;
|
||||||
name: string;
|
name: string;
|
||||||
metrics: Threshold;
|
metrics: Threshold;
|
||||||
data: { x: number; y: number }[];
|
data: { x: number; y: number }[];
|
||||||
@ -90,6 +91,7 @@ export default function EnrichmentMetrics({
|
|||||||
|
|
||||||
if (!(key in series)) {
|
if (!(key in series)) {
|
||||||
series[key] = {
|
series[key] = {
|
||||||
|
rawKey,
|
||||||
name: t("enrichments.embeddings." + rawKey),
|
name: t("enrichments.embeddings." + rawKey),
|
||||||
metrics: getThreshold(rawKey),
|
metrics: getThreshold(rawKey),
|
||||||
data: [],
|
data: [],
|
||||||
@ -99,7 +101,57 @@ export default function EnrichmentMetrics({
|
|||||||
series[key].data.push({ x: statsIdx + 1, y: stat });
|
series[key].data.push({ x: statsIdx + 1, y: stat });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Object.values(series);
|
|
||||||
|
// Group series by category (extract base name from raw key)
|
||||||
|
const grouped: {
|
||||||
|
[category: string]: {
|
||||||
|
categoryName: string;
|
||||||
|
speedSeries?: {
|
||||||
|
name: string;
|
||||||
|
metrics: Threshold;
|
||||||
|
data: { x: number; y: number }[];
|
||||||
|
};
|
||||||
|
eventsSeries?: {
|
||||||
|
name: string;
|
||||||
|
metrics: Threshold;
|
||||||
|
data: { x: number; y: number }[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
Object.values(series).forEach((s) => {
|
||||||
|
// Extract base category name from raw key
|
||||||
|
// All metrics follow the pattern: {base}_speed and {base}_events_per_second
|
||||||
|
let categoryKey = s.rawKey;
|
||||||
|
let isSpeed = false;
|
||||||
|
|
||||||
|
if (s.rawKey.endsWith("_speed")) {
|
||||||
|
categoryKey = s.rawKey.replace("_speed", "");
|
||||||
|
isSpeed = true;
|
||||||
|
} else if (s.rawKey.endsWith("_events_per_second")) {
|
||||||
|
categoryKey = s.rawKey.replace("_events_per_second", "");
|
||||||
|
isSpeed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get translated category name
|
||||||
|
const categoryName = t("enrichments.embeddings." + categoryKey);
|
||||||
|
|
||||||
|
if (!(categoryKey in grouped)) {
|
||||||
|
grouped[categoryKey] = {
|
||||||
|
categoryName,
|
||||||
|
speedSeries: undefined,
|
||||||
|
eventsSeries: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSpeed) {
|
||||||
|
grouped[categoryKey].speedSeries = s;
|
||||||
|
} else {
|
||||||
|
grouped[categoryKey].eventsSeries = s;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(grouped);
|
||||||
}, [statsHistory, t, getThreshold]);
|
}, [statsHistory, t, getThreshold]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -110,36 +162,43 @@ export default function EnrichmentMetrics({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-4 grid w-full grid-cols-1 gap-2 sm:grid-cols-3",
|
"mt-4 grid w-full grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-4",
|
||||||
embeddingInferenceTimeSeries && "sm:grid-cols-4",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<>
|
<>
|
||||||
{embeddingInferenceTimeSeries.map((series) => (
|
{groupedEnrichmentMetrics.map((group) => (
|
||||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
<div
|
||||||
<div className="mb-5 smart-capitalize">{series.name}</div>
|
key={group.categoryName}
|
||||||
{series.name.endsWith("Speed") ? (
|
className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="mb-5 smart-capitalize">
|
||||||
|
{group.categoryName}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{group.speedSeries && (
|
||||||
<ThresholdBarGraph
|
<ThresholdBarGraph
|
||||||
key={series.name}
|
key={`${group.categoryName}-speed`}
|
||||||
graphId={`${series.name}-inference`}
|
graphId={`${group.categoryName}-inference`}
|
||||||
name={series.name}
|
name={t("enrichments.averageInf")}
|
||||||
unit="ms"
|
unit="ms"
|
||||||
threshold={series.metrics}
|
threshold={group.speedSeries.metrics}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[group.speedSeries]}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
|
{group.eventsSeries && (
|
||||||
<EventsPerSecondsLineGraph
|
<EventsPerSecondsLineGraph
|
||||||
key={series.name}
|
key={`${group.categoryName}-events`}
|
||||||
graphId={`${series.name}-fps`}
|
graphId={`${group.categoryName}-fps`}
|
||||||
unit=""
|
unit=""
|
||||||
name={t("enrichments.infPerSecond")}
|
name={t("enrichments.infPerSecond")}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[group.eventsSeries]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -375,6 +375,50 @@ export default function GeneralMetrics({
|
|||||||
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
|
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
|
||||||
}, [statsHistory]);
|
}, [statsHistory]);
|
||||||
|
|
||||||
|
// Check if Intel GPU has all 0% usage values (known bug)
|
||||||
|
const showIntelGpuWarning = useMemo(() => {
|
||||||
|
if (!statsHistory || statsHistory.length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpuKeys = Object.keys(statsHistory[0]?.gpu_usages ?? {});
|
||||||
|
const hasIntelGpu = gpuKeys.some(
|
||||||
|
(key) => key === "intel-vaapi" || key === "intel-qsv",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasIntelGpu) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all GPU usage values are 0% across all stats
|
||||||
|
let allZero = true;
|
||||||
|
let hasDataPoints = false;
|
||||||
|
|
||||||
|
for (const stats of statsHistory) {
|
||||||
|
if (!stats) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(stats.gpu_usages || {}).forEach(([key, gpuStats]) => {
|
||||||
|
if (key === "intel-vaapi" || key === "intel-qsv") {
|
||||||
|
if (gpuStats.gpu) {
|
||||||
|
hasDataPoints = true;
|
||||||
|
const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1));
|
||||||
|
if (!isNaN(gpuValue) && gpuValue > 0) {
|
||||||
|
allZero = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allZero) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasDataPoints && allZero;
|
||||||
|
}, [statsHistory]);
|
||||||
|
|
||||||
// npu stats
|
// npu stats
|
||||||
|
|
||||||
const npuSeries = useMemo(() => {
|
const npuSeries = useMemo(() => {
|
||||||
@ -639,8 +683,46 @@ export default function GeneralMetrics({
|
|||||||
<>
|
<>
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||||
<div className="mb-5">
|
<div className="mb-5 flex flex-row items-center justify-between">
|
||||||
{t("general.hardwareInfo.gpuUsage")}
|
{t("general.hardwareInfo.gpuUsage")}
|
||||||
|
{showIntelGpuWarning && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="flex flex-row items-center gap-1.5 text-yellow-600 focus:outline-none dark:text-yellow-500"
|
||||||
|
aria-label={t(
|
||||||
|
"general.hardwareInfo.intelGpuWarning.title",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CiCircleAlert
|
||||||
|
className="size-5"
|
||||||
|
aria-label={t(
|
||||||
|
"general.hardwareInfo.intelGpuWarning.title",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
{t(
|
||||||
|
"general.hardwareInfo.intelGpuWarning.message",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-semibold">
|
||||||
|
{t(
|
||||||
|
"general.hardwareInfo.intelGpuWarning.title",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t(
|
||||||
|
"general.hardwareInfo.intelGpuWarning.description",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{gpuSeries.map((series) => (
|
{gpuSeries.map((series) => (
|
||||||
<ThresholdBarGraph
|
<ThresholdBarGraph
|
||||||
@ -729,12 +811,9 @@ export default function GeneralMetrics({
|
|||||||
) : (
|
) : (
|
||||||
<Skeleton className="aspect-video w-full" />
|
<Skeleton className="aspect-video w-full" />
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{statsHistory[0]?.npu_usages && (
|
{statsHistory[0]?.npu_usages && (
|
||||||
<div
|
<>
|
||||||
className={cn("mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2")}
|
|
||||||
>
|
|
||||||
{statsHistory.length != 0 ? (
|
{statsHistory.length != 0 ? (
|
||||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
@ -755,7 +834,9 @@ export default function GeneralMetrics({
|
|||||||
) : (
|
) : (
|
||||||
<Skeleton className="aspect-video w-full" />
|
<Skeleton className="aspect-video w-full" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -72,8 +72,7 @@ export default function StorageMetrics({
|
|||||||
const earliestDate = useMemo(() => {
|
const earliestDate = useMemo(() => {
|
||||||
const keys = Object.keys(recordingsSummary || {});
|
const keys = Object.keys(recordingsSummary || {});
|
||||||
return keys.length
|
return keys.length
|
||||||
? new TZDate(keys[keys.length - 1] + "T00:00:00", timezone).getTime() /
|
? new TZDate(keys[0] + "T00:00:00", timezone).getTime() / 1000
|
||||||
1000
|
|
||||||
: null;
|
: null;
|
||||||
}, [recordingsSummary, timezone]);
|
}, [recordingsSummary, timezone]);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user