mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 00:18:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34009aa244 | ||
|
|
fc39518f71 | ||
|
|
0d6069ccb3 | ||
|
|
bf396e7c4d | ||
|
|
2f5c2af8fa | ||
|
|
f50a785e6a | ||
|
|
2455e8be79 | ||
|
|
1f5972426e | ||
|
|
5ddcf96756 | ||
|
|
e760868048 | ||
|
|
40f8ba1f7f | ||
|
|
397f5253a5 | ||
|
|
af0ba19196 |
@@ -5,3 +5,4 @@
|
||||
/docker/rockchip/ @MarcA711
|
||||
/docker/rocm/ @harakas
|
||||
/docker/hailo8l/ @spanner3003
|
||||
/docker/deepx/ @sixfab
|
||||
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Installs the DEEPX NPU kernel driver and the DX-RT runtime on the Docker host,
|
||||
# then enables the vendor's dxrt.service. A container cannot load kernel
|
||||
# modules, so this runs outside the image; the driver creates the /dev/dxrt*
|
||||
# nodes and the daemon multiplexes the NPU across host and container.
|
||||
#
|
||||
# Driver, runtime and firmware versions must agree or inference hangs instead
|
||||
# of failing at startup. The set this script installs is pinned in
|
||||
# driver_version, runtime_version and firmware_version below; move them
|
||||
# together, never one at a time.
|
||||
#
|
||||
# DEEPX NPU support in Frigate is maintained by Sixfab (https://sixfab.com).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
driver_version="v2.6.0"
|
||||
# the commit the tag resolves to, since DEEPX signs neither tags nor releases
|
||||
# and this is compiled and installed as root. Update both together
|
||||
driver_commit="7074748e7104f470b02f517583abba652b3f05fa"
|
||||
firmware_version="v2.7.4"
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git build-essential "linux-headers-$(uname -r)" pciutils wget
|
||||
|
||||
if ! lspci -d 1ff4: | grep -q .; then
|
||||
echo "No DEEPX device found on the PCIe bus (lspci -d 1ff4:)."
|
||||
echo "Check that the module is seated correctly before continuing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# fetch the pinned commit rather than cloning the tag, so a retag cannot swap
|
||||
# in different source. The build directory is reused so a second run after a
|
||||
# failure does not stop on the directory already being there
|
||||
mkdir -p dx_rt_npu_linux_driver
|
||||
cd dx_rt_npu_linux_driver
|
||||
git init -q
|
||||
git remote get-url origin > /dev/null 2>&1 ||
|
||||
git remote add origin https://github.com/DEEPX-AI/dx_rt_npu_linux_driver.git
|
||||
git fetch --depth 1 origin "${driver_commit}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
fetched_commit=$(git rev-parse HEAD)
|
||||
if [[ "${fetched_commit}" != "${driver_commit}" ]]; then
|
||||
echo "Fetched commit ${fetched_commit} does not match pinned driver_commit ${driver_commit}."
|
||||
echo "Refusing to build unverified driver source."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd modules
|
||||
|
||||
sudo ./build.sh -c install --reload
|
||||
|
||||
sudo depmod -A
|
||||
|
||||
# dx_dma is the PCIe transport, dxrt_driver the NPU driver on top of it
|
||||
for module in dx_dma dxrt_driver; do
|
||||
if ! sudo modprobe "${module}"; then
|
||||
echo "Unable to load the ${module} kernel module, common reasons are:"
|
||||
echo "- Secure Boot is enabled and is rejecting the unsigned module."
|
||||
echo "- The running kernel does not match the installed linux-headers."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! compgen -G "/dev/dxrt*" > /dev/null; then
|
||||
echo "Modules loaded but no /dev/dxrt* device node appeared."
|
||||
echo "Run ./sanity_check.sh from the driver repo to diagnose."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runtime_version="v3.4.0"
|
||||
declare -A runtime_sha256=(
|
||||
[amd64]="736cfef009ce9e974ab1ab610d867239d19d72a426a53e367ddcbd53297b6e20"
|
||||
[arm64]="eb6107f5f02f2ad76ae89f414e8b5f346f34fbc6f0888236136853a26be6f6a0"
|
||||
)
|
||||
|
||||
runtime_release="${runtime_version#v}"
|
||||
deb_arch=$(dpkg --print-architecture)
|
||||
deb_file="/tmp/libdxrt-bin_${runtime_release}_${deb_arch}.deb"
|
||||
|
||||
wget -qO "${deb_file}" \
|
||||
"https://raw.githubusercontent.com/DEEPX-AI/dx_rt/${runtime_version}/release/${runtime_release}/libdxrt-bin_${runtime_release}_${deb_arch}.deb"
|
||||
|
||||
expected_sha256="${runtime_sha256[${deb_arch}]:-}"
|
||||
if [[ -z "${expected_sha256}" ]]; then
|
||||
echo "No pinned SHA-256 for architecture ${deb_arch}; refusing to install."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(sha256sum "${deb_file}" | cut -d' ' -f1)" != "${expected_sha256}" ]]; then
|
||||
echo "SHA-256 mismatch for ${deb_file}; refusing to install."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo dpkg -i "${deb_file}"
|
||||
sudo ldconfig
|
||||
rm -f "${deb_file}"
|
||||
|
||||
sudo cp /usr/share/libdxrt-bin/service/dxrt.service /etc/systemd/system/
|
||||
|
||||
# With an endpoint set, dxrtd binds that path only, so the socket goes in a
|
||||
# directory Frigate can mount (kept across restarts so the mount stays valid)
|
||||
# and a symlink at the default /tmp path keeps host tools that do not set the
|
||||
# variable working through their own fallback.
|
||||
sudo mkdir -p /etc/systemd/system/dxrt.service.d
|
||||
sudo tee /etc/systemd/system/dxrt.service.d/frigate.conf > /dev/null <<'UNIT'
|
||||
[Service]
|
||||
RuntimeDirectory=dxrt
|
||||
RuntimeDirectoryMode=0755
|
||||
RuntimeDirectoryPreserve=yes
|
||||
Environment=DXRT_DYNAMIC_IPC_ENDPOINT=/run/dxrt/dxrt_dynamic_ipc.sock
|
||||
ExecStartPost=/bin/ln -sfn /run/dxrt/dxrt_dynamic_ipc.sock /tmp/dxrt_dynamic_ipc.sock
|
||||
UNIT
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable dxrt.service
|
||||
sudo systemctl restart dxrt.service
|
||||
|
||||
if ! sudo systemctl is-active --quiet dxrt.service; then
|
||||
echo "dxrt.service did not start. Check: sudo journalctl -u dxrt.service"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "DEEPX driver and runtime installation complete."
|
||||
echo "Driver version: $(modinfo -F version dxrt_driver) (expected ${driver_version#v})"
|
||||
echo "Runtime version: ${runtime_release}"
|
||||
echo "Device node(s): $(echo /dev/dxrt*)"
|
||||
echo
|
||||
echo "This driver expects NPU firmware ${firmware_version}. Check it with:"
|
||||
echo " dxrt-cli --status"
|
||||
echo "Update the module if it does not match before starting Frigate."
|
||||
@@ -37,6 +37,7 @@ device_globs=(
|
||||
"/dev/nvmap"
|
||||
"/dev/nvidia*"
|
||||
"/dev/memx*"
|
||||
"/dev/dxrt*"
|
||||
)
|
||||
|
||||
IFS=',' read -ra extra_globs <<< "${DEVICE_ACL_PATHS:-}"
|
||||
|
||||
@@ -967,6 +967,65 @@ memryx:
|
||||
# The .zip file must contain:
|
||||
# ├── ssdlite_mobilenet.dfp (a file ending with .dfp)
|
||||
# └── ssdlite_mobilenet_post.onnx (optional; only if the model includes a cropped post-processing network)
|
||||
deepx:
|
||||
title: DEEPX NPU
|
||||
models:
|
||||
- key: yolo
|
||||
label: YOLO
|
||||
recommended: true
|
||||
download: No model is bundled with Frigate. Download a pre-compiled YOLO `.dxnn` model from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo) or compile your own with DX-COM, then bind-mount it into the container and point the model's `path` at it. The recommended model is `yolox-s_640x640_ppu.dxnn`. Its Post-Processing Unit (PPU) compile moves candidate selection onto the NPU, which makes it the fastest ModelZoo model measured through Frigate (about 13 ms on a DX-M1). The output layout is read from the compiled model, so anchor-based, anchor-free, NMS-in-head and PPU models (anchor-based or anchor-free) all need no extra configuration; prefer a `PPU` variant whenever the ModelZoo offers one. PPU models must be compiled with DX-COM 2.4.0 or later, which writes the head layout Frigate reads into the file.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and select **DEEPX NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------------------------------- |
|
||||
| **Custom object detector model path** | `/config/model_cache/deepx/yolox-s_640x640_ppu.dxnn` |
|
||||
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
|
||||
| **Object detection model input width** | `640` |
|
||||
| **Object detection model input height** | `640` |
|
||||
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
|
||||
| **Model Input Tensor Shape** | `nhwc` (Frigate's default value) |
|
||||
| **Model Input D Type** | `int` (Frigate's default value) |
|
||||
| **Object Detection Model Type** | `yolo-generic` |
|
||||
|
||||
Quantization is baked into the compiled model, so no normalization is applied on the host and the input defaults do not need to be overridden.
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- deepx:PCIe:0
|
||||
path: /config/model_cache/deepx/yolox-s_640x640_ppu.dxnn
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
model_type: yolo-generic
|
||||
width: 640
|
||||
height: 640
|
||||
- key: yolox
|
||||
label: YOLOX
|
||||
recommended: false
|
||||
download: No model is bundled with Frigate. Download a pre-compiled YOLOX `.dxnn` model from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo), then bind-mount it into the container and point the model's `path` at it. The `_ppu` variant is faster and also works with the `yolo-generic` model type; the plain export needs `yolox` so its raw head is decoded.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and select **DEEPX NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------------------- |
|
||||
| **Custom object detector model path** | `/config/model_cache/deepx/yolox-s_640x640.dxnn`|
|
||||
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
|
||||
| **Object detection model input width** | `640` |
|
||||
| **Object detection model input height** | `640` |
|
||||
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
|
||||
| **Model Input Tensor Shape** | `nhwc` (Frigate's default value) |
|
||||
| **Model Input D Type** | `int` (Frigate's default value) |
|
||||
| **Object Detection Model Type** | `yolox` |
|
||||
|
||||
The width and height must match the resolution the `.dxnn` file was compiled for.
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- deepx:PCIe:0
|
||||
path: /config/model_cache/deepx/yolox-s_640x640.dxnn
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
model_type: yolox
|
||||
width: 640
|
||||
height: 640
|
||||
tensorrt:
|
||||
title: TensorRT
|
||||
models:
|
||||
|
||||
@@ -24,6 +24,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices.
|
||||
- [Hailo](#hailo): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
|
||||
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms.
|
||||
- <CommunityBadge /> [DEEPX](#deepx-npu): The DEEPX NPU is available in m.2 format and as a HAT+ for the Raspberry Pi 5, offering broad compatibility across various platforms.
|
||||
|
||||
**AMD**
|
||||
|
||||
@@ -612,6 +613,63 @@ For detailed instructions on compiling models, refer to the [MemryX Compiler](ht
|
||||
|
||||
---
|
||||
|
||||
## DEEPX NPU
|
||||
|
||||
This detector is available for use with the DEEPX NPU, both the DX-M1 M.2 module and the DX-M1M on the Sixfab AI HAT+ for the Raspberry Pi 5. The configuration below applies unchanged to either form factor. DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
|
||||
|
||||
See the [installation docs](../frigate/installation.md#deepx-npu) for information on installing the DEEPX kernel driver and runtime on the host and passing the NPU through to the container.
|
||||
|
||||
To run a model on a DEEPX NPU, list a `deepx` device on that model.
|
||||
|
||||
:::info
|
||||
|
||||
The DX-RT Python bindings are not part of the Frigate image. They are downloaded and installed into `/config/.local` the first time a DEEPX device is configured, verified against pinned checksums, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the files yourself.
|
||||
|
||||
:::
|
||||
|
||||
### Configuration {#configuration-deepx}
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DEEPX" models={objectDetectorsModels.deepx.models} />
|
||||
|
||||
Frigate does not bundle a model for this detector. Models must be compiled to DEEPX's `.dxnn` format. Two model types are supported:
|
||||
|
||||
- `yolo-generic` for YOLO object detection models, the recommended default. The detector reads the model's output layout from the compiled file, so anchor-based, anchor-free and NMS-in-head models all work with the same configuration, as do models compiled with DEEPX's Post-Processing Unit (PPU) support.
|
||||
- `yolox` for YOLOX models compiled without PPU support, whose raw head needs Frigate's YOLOX decoder. A YOLOX model compiled with PPU support works under either `yolox` or `yolo-generic`.
|
||||
|
||||
The quickest way to get one is the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo), which publishes pre-compiled `.dxnn` files for a range of YOLO object detection models. Download the `.dxnn`, bind-mount it into the container, and point the model's `path` at it. Alternatively, compile your own model with the DX-COM compiler. The recommended starting point is `yolox-s_640x640_ppu.dxnn`, the fastest ModelZoo model measured through Frigate:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- devices:
|
||||
- deepx:PCIe:0
|
||||
path: /config/model_cache/deepx/yolox-s_640x640_ppu.dxnn
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
model_type: yolo-generic
|
||||
width: 640
|
||||
height: 640
|
||||
```
|
||||
|
||||
For PPU models, use a `.dxnn` compiled with DX-COM 2.4.0 or later. Frigate reads the PPU head layout the compiler writes into the file and refuses to load a PPU model without it.
|
||||
|
||||
`model_type` must be set to `yolo-generic` or `yolox` to match the model; `yolo-generic` is the recommended default unless the model is a raw YOLOX export. Frigate defaults it to `ssd`, which this detector does not support, so the detector refuses to start on a model that leaves it unset.
|
||||
|
||||
`width` and `height` must match the resolution the model was compiled for. Quantization parameters are baked into the `.dxnn` file at compile time, so no normalization is applied on the host and Frigate's default `input_tensor`, `input_pixel_format`, and `input_dtype` values do not need to be overridden.
|
||||
|
||||
A DEEPX device is `PCIe:<index>`, as reported on the detector settings page. The NPU daemon multiplexes across processes, so the same device may be listed more than once to run additional inference processes against it:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- devices:
|
||||
- deepx:PCIe:0
|
||||
- deepx:PCIe:0
|
||||
```
|
||||
|
||||
#### Label maps
|
||||
|
||||
The object detection models in the DEEPX ModelZoo are trained on the standard 80-class COCO label set, so `labelmap_path` must be set to `/labelmap/coco-80.txt`. Frigate's default label map uses an extended 91-class COCO scheme, and leaving it in place will cause detections to be reported as the wrong object type. For `yolo-generic` models the label map is also what the detector uses to tell the output layout, so a label map with the wrong number of classes is reported as an error at startup.
|
||||
|
||||
---
|
||||
|
||||
## NVidia TensorRT Detector
|
||||
|
||||
Nvidia Jetson devices may be used for object detection using the TensorRT libraries. Due to the size of the additional libraries, this detector is only provided in images with the `-tensorrt-jp6` tag suffix, e.g. `ghcr.io/blakeblackshear/frigate:stable-tensorrt-jp6`. This detector is designed to work with Yolo models for object detection.
|
||||
|
||||
@@ -65,6 +65,11 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
- [Supports many model architectures](../../configuration/object_detectors#memryx-mx3)
|
||||
- Runs best with tiny, small, or medium-size models
|
||||
|
||||
- <CommunityBadge /> [DEEPX](#deepx-npu): The DEEPX NPU is available in m.2 format and as a HAT+ for the Raspberry Pi 5, allowing for a wide range of compatibility with devices.
|
||||
- [Supports YOLO model architectures](../../configuration/object_detectors#deepx-npu)
|
||||
- Runs best with tiny or small size models
|
||||
- Runs efficiently on low power hardware
|
||||
|
||||
**AMD**
|
||||
|
||||
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
|
||||
@@ -257,6 +262,32 @@ The MX3 is a pipelined architecture, where the maximum frames per second support
|
||||
|
||||
Inference speeds may vary depending on the host platform. The above data was measured on an **Intel 13700 CPU**. Platforms like Raspberry Pi, Orange Pi, and other ARM-based SBCs have different levels of processing capability, which may limit total FPS.
|
||||
|
||||
### DEEPX NPU
|
||||
|
||||
Frigate supports the DEEPX NPU in both of its form factors: the **DX-M1** M.2 module, which works on x86 (Intel/AMD) and ARM-based SBCs such as the Raspberry Pi 5, and the **DX-M1M** on the [Sixfab AI HAT+](https://docs.sixfab.com/docs/ai-hat-plus-raspberry-pi-5-quickstart) for the Raspberry Pi 5. Both use the same driver and runtime, so the configuration is identical for either one. DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
|
||||
|
||||
The DEEPX driver and runtime run on the Docker host rather than inside the Frigate container and must be installed before the NPU can be used. See the [installation docs](installation.md#deepx-npu) for the setup steps and [the detector docs](/configuration/object_detectors#deepx-npu) for the configuration.
|
||||
|
||||
Frigate does not bundle a model for this detector. Models use DEEPX's `.dxnn` format, and pre-compiled YOLO models can be downloaded from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo). Prefer a model with a `_ppu` suffix whenever one is available for the architecture you want: these run part of the post-processing on the NPU itself and are considerably faster, roughly 2.5x for the same architecture and input size. **YOLOX-S with PPU is the recommended starting point.**
|
||||
|
||||
Inference times for a few recommended models, measured through Frigate's own stats on a DX-M1:
|
||||
|
||||
| Model | Input Size | DX-M1 Inference Time |
|
||||
| ----------------- | ---------- | -------------------- |
|
||||
| YOLOX-S (PPU) | 640 | ~ 13 ms |
|
||||
| YOLOv9-t (PPU) | 640 | ~ 18 ms |
|
||||
| YOLOv4 (PPU) | 512 | ~ 20 ms |
|
||||
| YOLOX-S | 640 | ~ 34 ms |
|
||||
| YOLOv9-s | 640 | ~ 39 ms |
|
||||
|
||||
Other ModelZoo YOLO variants are also supported but have not been measured. Inference speeds vary with the host platform, so a slower host such as a Raspberry Pi 5 will report higher times than those above.
|
||||
|
||||
:::note
|
||||
|
||||
A few ModelZoo models can not be used with Frigate: SSD models (they are trained on Pascal VOC, so their labels do not match Frigate's), DAMO-YOLO models, face and pose models, and the PPU builds of YOLOv7.
|
||||
|
||||
:::
|
||||
|
||||
### Nvidia Jetson
|
||||
|
||||
Jetson devices are supported via the TensorRT or ONNX detectors when running Jetpack 6. It will [make use of the Jetson's hardware media engine](/configuration/hardware_acceleration_video#nvidia-jetson) when configured with the [appropriate presets](/configuration/ffmpeg_presets#hwaccel-presets), and will make use of the Jetson's GPU and DLA for object detection when configured with the [TensorRT detector](/configuration/object_detectors#nvidia-tensorrt-detector).
|
||||
|
||||
@@ -381,6 +381,99 @@ If you can't use Docker Compose, you can run the container with something simila
|
||||
|
||||
Finally, configure [hardware object detection](/configuration/object_detectors#memryx-mx3) to complete the setup.
|
||||
|
||||
### DEEPX NPU
|
||||
|
||||
The DEEPX NPU is available in two form factors, and Frigate supports both:
|
||||
|
||||
- **DX-M1** in the M.2 2280 form factor (like an NVMe SSD), for x86 (Intel/AMD) PCs, the Raspberry Pi 5, and other ARM SBCs with an exposed PCIe M.2 slot.
|
||||
- **DX-M1M** on the [Sixfab AI HAT+](https://docs.sixfab.com/docs/ai-hat-plus-raspberry-pi-5-quickstart), a HAT+ board that connects to the Raspberry Pi 5 over PCIe Gen 3 x1.
|
||||
|
||||
Both present the NPU through the same PCIe driver and DX-RT runtime, so the setup below and the detector configuration are identical for either one. Nothing needs to change when moving between them.
|
||||
|
||||
DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
|
||||
|
||||
#### Versions
|
||||
|
||||
A DEEPX install has several separately versioned pieces, and they all have to agree. The driver, the runtime, and the daemon live on the Docker host; Frigate itself carries only the Python bindings, which it downloads on first start:
|
||||
|
||||
| Component | Version | Installed on | Installed by |
|
||||
| -------------- | -------- | ------------ | ------------------------- |
|
||||
| Kernel driver | `v2.6.0` | Host | `user_installation.sh` |
|
||||
| DX-RT runtime | `v3.4.0` | Host | `user_installation.sh` |
|
||||
| NPU firmware | `v2.7.4` | The module | Flashed from the host |
|
||||
| DX-RT bindings | `v3.4.0` | Frigate | Downloaded at first start |
|
||||
|
||||
:::warning
|
||||
|
||||
A version mismatch does not produce a startup error. It typically shows up as inference requests that are accepted but never return a result, so detections simply stop appearing while Frigate looks healthy. If that happens after a Frigate upgrade, check every version in the table before anything else.
|
||||
|
||||
:::
|
||||
|
||||
The installation script installs the DX-RT runtime on the host and enables `dxrt.service`, so the daemon starts at boot and any other program on the host can share the NPU with Frigate. Check the firmware version with `dxrt-cli --status` and update the module if it does not match the table above.
|
||||
|
||||
#### Installation
|
||||
|
||||
The DEEPX kernel driver must be installed on the host rather than in the container, because containers share the host kernel and cannot load kernel modules. Installing it creates the `/dev/dxrt*` device nodes that are passed through to Frigate. The same script installs the DX-RT runtime and enables `dxrt.service`, the daemon that owns the NPU and hands work to it on behalf of Frigate and anything else on the host.
|
||||
|
||||
1. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/deepx/user_installation.sh).
|
||||
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
|
||||
3. Run the script with `./user_installation.sh`
|
||||
4. **Restart your computer** to complete driver installation.
|
||||
|
||||
Confirm the NPU is visible before continuing:
|
||||
|
||||
```bash
|
||||
ls /dev/dxrt*
|
||||
```
|
||||
|
||||
Then confirm the daemon is running and listening in `/run/dxrt`:
|
||||
|
||||
```bash
|
||||
systemctl is-active dxrt.service
|
||||
ls /run/dxrt/
|
||||
```
|
||||
|
||||
#### Setup
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
|
||||
#### Docker configuration
|
||||
|
||||
Frigate needs the NPU device node and the directory holding the daemon's socket:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
devices:
|
||||
- /dev/dxrt0:/dev/dxrt0
|
||||
volumes:
|
||||
- /run/dxrt:/run/dxrt
|
||||
```
|
||||
|
||||
If you can't use Docker Compose, add `--device /dev/dxrt0:/dev/dxrt0 -v /run/dxrt:/run/dxrt` to your `docker run` command.
|
||||
|
||||
Add one `--device` per NPU, contiguously from `/dev/dxrt0`, since the client stops enumerating at the first gap.
|
||||
|
||||
The installation script configures `dxrt.service` to place its socket in `/run/dxrt` through a systemd drop-in. Mounting the directory rather than the socket file means the container sees the new socket after `dxrt.service` is restarted, rather than holding on to a deleted one.
|
||||
|
||||
`dxrtd` listens on an abstract socket as well, but that one does not cross into a container, so Frigate names the filesystem socket through `DXRT_DYNAMIC_IPC_ENDPOINT` on your behalf. Set that variable on the container yourself only if the daemon listens somewhere else, which means you also set it for `dxrtd` through its own systemd drop-in. The script writes `/etc/systemd/system/dxrt.service.d/frigate.conf` for exactly that, and has `dxrt.service` link the socket to `/tmp/dxrt_dynamic_ipc.sock` when it starts, so the host's own `dxrt-cli` and `dxtop` keep finding it at the default path they fall back to.
|
||||
|
||||
:::note
|
||||
|
||||
The DX-RT client exits when `dxrt.service` stops, so restart the Frigate container after restarting `dxrt.service`.
|
||||
|
||||
:::
|
||||
|
||||
The device node is needed as well as the socket, because the client opens the NPU directly even though the daemon arbitrates access. Without it, inference fails with `Device not found`.
|
||||
|
||||
`/dev/shm` does not need sharing.
|
||||
|
||||
The DX-RT python bindings are not shipped in the Frigate image. Frigate downloads them on first start when a DEEPX detector is configured, and caches them under `/config`.
|
||||
|
||||
#### Configuration
|
||||
|
||||
Finally, configure [hardware object detection](/configuration/object_detectors#deepx-npu) to complete the setup.
|
||||
|
||||
### Rockchip platform
|
||||
|
||||
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and necessary drivers (especially rkvdec2 and rknpu). To check, enter the following commands:
|
||||
|
||||
@@ -273,6 +273,16 @@ def detect_memryx() -> DetectionHardware | None:
|
||||
return _hardware("memryx", "memryx", "MemryX MX3", units)
|
||||
|
||||
|
||||
def detect_deepx() -> DetectionHardware | None:
|
||||
"""Find DEEPX NPUs by their device nodes."""
|
||||
units = _dev_units("dxrt*", "deepx:PCIe:{index}", "PCIe")
|
||||
|
||||
if not units:
|
||||
return None
|
||||
|
||||
return _hardware("deepx", "deepx", "DEEPX NPU", units)
|
||||
|
||||
|
||||
def detect_rockchip() -> DetectionHardware | None:
|
||||
"""Find a Rockchip NPU by reading the SoC from the device tree."""
|
||||
compatible = _read(f"{PROC_ROOT}/device-tree/compatible")
|
||||
@@ -319,6 +329,7 @@ PROBES = (
|
||||
detect_coral_usb,
|
||||
detect_hailo,
|
||||
detect_memryx,
|
||||
detect_deepx,
|
||||
detect_intel_npu,
|
||||
detect_intel_gpu,
|
||||
detect_nvidia_gpu,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,31 @@ class TestEventsPerSecond(unittest.TestCase):
|
||||
clock[0] += 100.0
|
||||
self.assertEqual(eps.eps(), 0.0)
|
||||
|
||||
def test_burst_after_start_is_not_divided_by_a_tiny_window(self) -> None:
|
||||
eps = EventsPerSecond(last_n_seconds=10)
|
||||
clock = [1000.0]
|
||||
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
|
||||
eps.start()
|
||||
# eleven buffered frames arrive within 100 ms of starting
|
||||
for _ in range(11):
|
||||
clock[0] += 0.01
|
||||
eps.update()
|
||||
# 11 events over less than a second is at most 11 per second
|
||||
self.assertLessEqual(eps.eps(), 11.0)
|
||||
|
||||
def test_subsecond_window_keeps_its_rate(self) -> None:
|
||||
eps = EventsPerSecond(last_n_seconds=0.5)
|
||||
clock = [1000.0]
|
||||
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
|
||||
eps.start()
|
||||
# twenty events per second for two seconds
|
||||
for _ in range(40):
|
||||
clock[0] += 0.05
|
||||
eps.update()
|
||||
# read between events, so none sits exactly on the window edge
|
||||
clock[0] += 0.01
|
||||
self.assertAlmostEqual(eps.eps(), 20.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
"""Tests for the DEEPX detector."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
|
||||
from frigate.detectors.device import (
|
||||
DeviceParseError,
|
||||
build_detector_config,
|
||||
parse_device,
|
||||
)
|
||||
from frigate.detectors.plugins.deepx import (
|
||||
DEEPX_MANIFEST,
|
||||
DXRT_VERSION,
|
||||
PPU_RECORD_SIZE,
|
||||
DeepxDetector,
|
||||
DeepxDetectorConfig,
|
||||
PpuLayout,
|
||||
YoloLayout,
|
||||
class_count,
|
||||
decode_raw_anchor,
|
||||
decode_raw_nms_in_head,
|
||||
infer_yolo_layout,
|
||||
read_ppu_layout,
|
||||
resolve_device,
|
||||
validate_yolox_outputs,
|
||||
)
|
||||
|
||||
|
||||
def model_with_type(model_type) -> ModelConfig:
|
||||
return ModelConfig(
|
||||
model_type=model_type,
|
||||
labelmap_path=None,
|
||||
labelmap={79: "toothbrush"},
|
||||
width=640,
|
||||
height=640,
|
||||
)
|
||||
|
||||
|
||||
def build_ppu_record(box, score=0.9, label=0, grid=(7, 9, 2, 2)) -> np.ndarray:
|
||||
record = np.zeros(PPU_RECORD_SIZE, dtype=np.uint8)
|
||||
record[0:16] = np.array(box, dtype=np.float32).view(np.uint8)
|
||||
record[16:20] = grid
|
||||
record[20:24] = np.array([score], dtype=np.float32).view(np.uint8)
|
||||
record[24:28] = np.array([label], dtype=np.uint32).view(np.uint8)
|
||||
return record.reshape(1, 1, PPU_RECORD_SIZE)
|
||||
|
||||
|
||||
# compile_config.ppu as DX-COM writes it for the two head kinds
|
||||
ANCHOR_BASED_PPU = {"type": 0, "num_classes": 80, "activation": "Sigmoid"}
|
||||
ANCHOR_FREE_PPU = {"type": 1, "num_classes": 80}
|
||||
|
||||
PPU_BBOX_NODE = "/head/Mul_2"
|
||||
|
||||
# (grid_w, grid_h, entries) per scale, finest first; the grids give the
|
||||
# strides at a 640 input
|
||||
THREE_SCALE_ANCHORS = [(80, 80, 3), (40, 40, 3), (20, 20, 3)]
|
||||
TWO_SCALE_ANCHORS = [(40, 40, 3), (20, 20, 3)]
|
||||
FOUR_SCALE_ANCHORS = [(160, 160, 3), (80, 80, 3), (40, 40, 3), (20, 20, 3)]
|
||||
THREE_SCALE_FREE = [(80, 80, 1), (40, 40, 1), (20, 20, 1)]
|
||||
ONE_SCALE_FREE = [(100, 84, 1)]
|
||||
|
||||
|
||||
def proto_varint(value: int) -> bytes:
|
||||
out = bytearray()
|
||||
while True:
|
||||
byte = value & 0x7F
|
||||
value >>= 7
|
||||
out.append(byte | (0x80 if value else 0))
|
||||
if not value:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def proto_bytes(field: int, payload: bytes) -> bytes:
|
||||
return proto_varint(field << 3 | 2) + proto_varint(len(payload)) + payload
|
||||
|
||||
|
||||
def proto_number(field: int, value: int) -> bytes:
|
||||
return proto_varint(field << 3) + proto_varint(value)
|
||||
|
||||
|
||||
def onnx_node(op_type: str, name: str, inputs: list, outputs: list) -> bytes:
|
||||
body = b"".join(proto_bytes(1, tensor.encode()) for tensor in inputs)
|
||||
body += b"".join(proto_bytes(2, tensor.encode()) for tensor in outputs)
|
||||
return body + proto_bytes(3, name.encode()) + proto_bytes(4, op_type.encode())
|
||||
|
||||
|
||||
def onnx_box_graph(box_format: str) -> bytes:
|
||||
if box_format == "broken":
|
||||
return proto_varint(1 << 3 | 3)
|
||||
|
||||
unnamed = box_format == "unnamed"
|
||||
|
||||
def graph_node(op_type: str, name: str, inputs: list, outputs: list) -> bytes:
|
||||
return onnx_node(op_type, "" if unnamed else name, inputs, outputs)
|
||||
|
||||
nodes = [
|
||||
graph_node("Split", "dfl_split", ["dfl", "sizes"], ["lt", "rb"]),
|
||||
graph_node("Sub", "corner_min", ["anchors", "lt"], ["x1y1"]),
|
||||
graph_node("Add", "corner_max", ["rb", "anchors"], ["x2y2"]),
|
||||
]
|
||||
if box_format in ("centre", "unnamed"):
|
||||
nodes += [
|
||||
graph_node("Add", "corner_sum", ["x1y1", "x2y2"], ["sum"]),
|
||||
graph_node("Mul", "corner_mean", ["sum", "half"], ["cxy"]),
|
||||
graph_node("Sub", "corner_span", ["x2y2", "x1y1"], ["wh"]),
|
||||
graph_node("Concat", "box_concat", ["cxy", "wh"], ["box"]),
|
||||
]
|
||||
elif box_format == "corner":
|
||||
nodes.append(graph_node("Concat", "box_concat", ["x1y1", "x2y2"], ["box"]))
|
||||
else:
|
||||
nodes.append(graph_node("Concat", "box_concat", ["x1y1", "x1y1"], ["box"]))
|
||||
|
||||
box = "box"
|
||||
if unnamed:
|
||||
box = "box_flat"
|
||||
nodes.append(graph_node("Reshape", "box_reshape", ["shape", "box"], [box]))
|
||||
|
||||
nodes.append(onnx_node("Mul", PPU_BBOX_NODE, [box, "strides"], ["bbox_out"]))
|
||||
graph = b"".join(proto_bytes(1, node) for node in nodes)
|
||||
graph += proto_bytes(5, b"weights")
|
||||
return (
|
||||
proto_number(1, 10) # ir_version
|
||||
+ proto_bytes(2, b"onnx_frontend_compiler") # producer_name
|
||||
+ proto_bytes(7, graph)
|
||||
)
|
||||
|
||||
|
||||
def write_dxnn(
|
||||
directory, ppu, layers, name="model.dxnn", table=True, box_format=None
|
||||
) -> str:
|
||||
"""A minimal .dxnn as DX-RT's parsers read it: the container header, a
|
||||
compile_config carrying `ppu`, and either the PPU tensor table with one
|
||||
entry per (layer, anchor) as a v8 file has, or with `table` False only
|
||||
the rmap_info listing of the PPU output tensors, as a v7 file has.
|
||||
`layers` is (grid_w, grid_h, entries) per scale, finest first; an
|
||||
anchor-free scale has one entry, and grid_h 1 means a flattened
|
||||
(1, cells, channels) tensor. `box_format`, "centre" or "corner", adds
|
||||
the compiled graph that says how the head writes its boxes, along with
|
||||
the compile_config layer naming the node the PPU reads them from."""
|
||||
if box_format is not None and "layer" not in ppu:
|
||||
ppu = dict(ppu, layer=[{"bbox": PPU_BBOX_NODE, "cls_conf": "/head/Sigmoid"}])
|
||||
|
||||
compile_config = json.dumps({"compile_version": "2.4.0", "ppu": ppu}).encode()
|
||||
graph = onnx_box_graph(box_format) if box_format is not None else b""
|
||||
|
||||
if table:
|
||||
part = bytearray(struct.pack("<BBBB", 1, sum(n for _, _, n in layers), 0, 0))
|
||||
for conv, (grid_w, grid_h, entries) in enumerate(layers):
|
||||
for anchor in range(entries):
|
||||
part += struct.pack(
|
||||
"<HHfBBBBBBBB",
|
||||
128,
|
||||
80,
|
||||
0.001,
|
||||
conv,
|
||||
anchor,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
grid_w,
|
||||
grid_h,
|
||||
0,
|
||||
)
|
||||
else:
|
||||
outputs = []
|
||||
for conv, (grid_w, grid_h, entries) in enumerate(layers):
|
||||
for anchor in range(entries):
|
||||
name_ = f"PPU_Transpose_Output_{conv}"
|
||||
if entries > 1:
|
||||
name_ += f"_anchor_{anchor}"
|
||||
shape = [1, grid_w, 127] if grid_h == 1 else [1, grid_h, grid_w, 128]
|
||||
outputs.append({"name": name_, "shape": shape, "layout": "PPU_YOLO"})
|
||||
part = json.dumps({"inputs": [], "outputs": outputs}).encode()
|
||||
|
||||
data = {
|
||||
"compile_config": {
|
||||
"type": "str",
|
||||
"offset": 0,
|
||||
"size": len(compile_config),
|
||||
},
|
||||
"compiled_data": {
|
||||
"M1A_4K": {
|
||||
"npu_0": {
|
||||
"rmap": {"type": "bytes", "offset": 0, "size": 0},
|
||||
"ppu" if table else "rmap_info": {
|
||||
"type": "bytes" if table else "str",
|
||||
"offset": len(compile_config),
|
||||
"size": len(part),
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
if graph:
|
||||
data["vis_npu_models"] = {
|
||||
"npu_0": {
|
||||
"type": "bytes",
|
||||
"offset": len(compile_config) + len(part),
|
||||
"size": len(graph),
|
||||
}
|
||||
}
|
||||
|
||||
index = json.dumps(
|
||||
{
|
||||
"version": 8 if table else 7,
|
||||
"signature": "DXNN",
|
||||
"size": 8192,
|
||||
"data": data,
|
||||
}
|
||||
).encode()
|
||||
|
||||
path = os.path.join(directory, name)
|
||||
with open(path, "wb") as model:
|
||||
model.write(b"DXNN" + struct.pack("<I", 8))
|
||||
model.write(index.ljust(8192 - 8, b"\0"))
|
||||
model.write(compile_config + part + graph)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def layout_of(shapes, num_classes, ppu=False, dynamic_output=False) -> YoloLayout:
|
||||
return infer_yolo_layout(shapes, num_classes, ppu, dynamic_output).layout
|
||||
|
||||
|
||||
class TestDeepxModelFile(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
|
||||
def layout(self, ppu, layers, **kwargs) -> PpuLayout | None:
|
||||
return read_ppu_layout(write_dxnn(self.tmp.name, ppu, layers, **kwargs))
|
||||
|
||||
def test_the_head_kind_and_one_grid_per_scale_are_read(self):
|
||||
cases = {
|
||||
"anchor-based: three anchors per scale collapse to one grid each": (
|
||||
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, {}),
|
||||
PpuLayout(anchor_based=True, grids=((80, 80), (40, 40), (20, 20))),
|
||||
),
|
||||
"anchor-free, one scale flattened into a single tensor": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, {"box_format": "centre"}),
|
||||
PpuLayout(anchor_based=False, grids=((100, 84),), centre_boxes=True),
|
||||
),
|
||||
"a head kind the compiler does not name still yields the scales": (
|
||||
({"type": 7}, [(80, 80, 3), (40, 40, 3)], {}),
|
||||
PpuLayout(anchor_based=None, grids=((80, 80), (40, 40))),
|
||||
),
|
||||
"no table: the per-anchor split says anchor-based": (
|
||||
({"num_classes": 80}, THREE_SCALE_ANCHORS, {"table": False}),
|
||||
PpuLayout(anchor_based=True, grids=((80, 80), (40, 40), (20, 20))),
|
||||
),
|
||||
"no table: compile_config places the scales ahead of the names": (
|
||||
(
|
||||
{
|
||||
"type": 1,
|
||||
"outputs": {
|
||||
f"PPU_Transpose_Output_{i}": {"conv_idx": 2 - i}
|
||||
for i in range(3)
|
||||
},
|
||||
},
|
||||
[(20, 20, 1), (40, 40, 1), (80, 80, 1)],
|
||||
{"table": False},
|
||||
),
|
||||
PpuLayout(anchor_based=False, grids=((80, 80), (40, 40), (20, 20))),
|
||||
),
|
||||
"no table: every scale in one (1, cells, channels) tensor": (
|
||||
(ANCHOR_FREE_PPU, [(8400, 1, 1)], {"table": False}),
|
||||
PpuLayout(anchor_based=False, grids=((8400, 1),)),
|
||||
),
|
||||
}
|
||||
|
||||
for head, ((ppu, layers, kwargs), expected) in cases.items():
|
||||
with self.subTest(head=head):
|
||||
self.assertEqual(self.layout(ppu, layers, **kwargs), expected)
|
||||
|
||||
def test_the_box_format_comes_from_the_compiled_graph(self):
|
||||
for box_format, centre in (
|
||||
("centre", True),
|
||||
("corner", False),
|
||||
("unnamed", True),
|
||||
):
|
||||
with self.subTest(box_format=box_format):
|
||||
self.assertEqual(
|
||||
self.layout(ANCHOR_FREE_PPU, ONE_SCALE_FREE, box_format=box_format),
|
||||
PpuLayout(
|
||||
anchor_based=False, grids=((100, 84),), centre_boxes=centre
|
||||
),
|
||||
)
|
||||
|
||||
def test_a_box_format_the_graph_does_not_answer_is_left_open(self):
|
||||
cases = {
|
||||
"no compiled graph at all": (ANCHOR_FREE_PPU, ONE_SCALE_FREE, {}),
|
||||
"a graph without the node compile_config names": (
|
||||
dict(ANCHOR_FREE_PPU, layer=[{"bbox": "/head/Missing"}]),
|
||||
ONE_SCALE_FREE,
|
||||
{"box_format": "centre"},
|
||||
),
|
||||
"a graph that builds its boxes from neither shape": (
|
||||
ANCHOR_FREE_PPU,
|
||||
ONE_SCALE_FREE,
|
||||
{"box_format": "neither"},
|
||||
),
|
||||
"a graph section the reader cannot make sense of": (
|
||||
ANCHOR_FREE_PPU,
|
||||
ONE_SCALE_FREE,
|
||||
{"box_format": "broken"},
|
||||
),
|
||||
"a grid-decoded head, where the question does not arise": (
|
||||
ANCHOR_FREE_PPU,
|
||||
THREE_SCALE_FREE,
|
||||
{"box_format": "centre"},
|
||||
),
|
||||
}
|
||||
|
||||
for graph, (ppu, layers, kwargs) in cases.items():
|
||||
with self.subTest(graph=graph):
|
||||
self.assertIsNone(self.layout(ppu, layers, **kwargs).centre_boxes)
|
||||
|
||||
def test_nothing_is_read_from_a_model_without_ppu_metadata(self):
|
||||
self.assertIsNone(read_ppu_layout(os.path.join(self.tmp.name, "missing")))
|
||||
|
||||
path = write_dxnn(self.tmp.name, None, [(80, 80, 3)])
|
||||
self.assertIsNone(read_ppu_layout(path))
|
||||
|
||||
with open(path, "wb") as model:
|
||||
model.write(b"ONNX" + b"\0" * 100)
|
||||
self.assertIsNone(read_ppu_layout(path))
|
||||
|
||||
path = write_dxnn(self.tmp.name, ANCHOR_BASED_PPU, [(80, 80, 3), (40, 40, 3)])
|
||||
os.truncate(path, os.path.getsize(path) - 40)
|
||||
self.assertIsNone(read_ppu_layout(path))
|
||||
|
||||
|
||||
class TestDeepxLayoutInference(unittest.TestCase):
|
||||
def test_a_shape_and_class_count_pick_one_layout(self):
|
||||
cases = {
|
||||
"anchor-free, four columns ahead of the classes": (
|
||||
[(1, 84, 8400)],
|
||||
80,
|
||||
YoloLayout.anchor_free,
|
||||
84,
|
||||
),
|
||||
"anchor-free, row-major": ([(1, 8400, 84)], 80, YoloLayout.anchor_free, 84),
|
||||
"anchor-based, an objectness column as well": (
|
||||
[(1, 25200, 85)],
|
||||
80,
|
||||
YoloLayout.anchor,
|
||||
85,
|
||||
),
|
||||
"anchor-based, channel-major": (
|
||||
[(1, 85, 25200)],
|
||||
80,
|
||||
YoloLayout.anchor,
|
||||
85,
|
||||
),
|
||||
"NMS in the head, a fixed run of corner records": (
|
||||
[(1, 300, 6)],
|
||||
80,
|
||||
YoloLayout.nms_in_head,
|
||||
None,
|
||||
),
|
||||
# only the label map can tell these two apart
|
||||
"85 columns with 81 classes is anchor-free": (
|
||||
[(1, 8400, 85)],
|
||||
81,
|
||||
YoloLayout.anchor_free,
|
||||
85,
|
||||
),
|
||||
"85 columns with 80 classes is anchor-based": (
|
||||
[(1, 8400, 85)],
|
||||
80,
|
||||
YoloLayout.anchor,
|
||||
85,
|
||||
),
|
||||
# 6 columns is all three layouts, told apart by the row count
|
||||
"6 columns and thousands of rows, one class": (
|
||||
[(1, 25200, 6)],
|
||||
1,
|
||||
YoloLayout.anchor,
|
||||
6,
|
||||
),
|
||||
"6 columns and thousands of rows, two classes": (
|
||||
[(1, 8400, 6)],
|
||||
2,
|
||||
YoloLayout.anchor_free,
|
||||
6,
|
||||
),
|
||||
"6 columns and a few hundred rows, one class": (
|
||||
[(1, 300, 6)],
|
||||
1,
|
||||
YoloLayout.nms_in_head,
|
||||
None,
|
||||
),
|
||||
"7 columns with two classes is only anchor-based": (
|
||||
[(1, 8400, 7)],
|
||||
2,
|
||||
YoloLayout.anchor,
|
||||
7,
|
||||
),
|
||||
# a square output matches the same width on both axes, which must
|
||||
# not read as two candidate layouts
|
||||
"a square output is not ambiguous with itself": (
|
||||
[(1, 85, 85)],
|
||||
80,
|
||||
YoloLayout.anchor,
|
||||
85,
|
||||
),
|
||||
"three NCHW maps with 255 channels are feature maps": (
|
||||
[(1, 255, 80, 80), (1, 255, 40, 40), (1, 255, 20, 20)],
|
||||
80,
|
||||
YoloLayout.multipart,
|
||||
None,
|
||||
),
|
||||
}
|
||||
|
||||
for head, (shapes, num_classes, layout, columns) in cases.items():
|
||||
with self.subTest(head=head):
|
||||
output = infer_yolo_layout(shapes, num_classes, False, False)
|
||||
|
||||
self.assertIs(output.layout, layout)
|
||||
if columns is not None:
|
||||
self.assertEqual(output.columns, columns)
|
||||
|
||||
def test_the_runtime_flags_outrank_the_shapes(self):
|
||||
self.assertIs(layout_of([(8400,)], 80, ppu=True), YoloLayout.ppu)
|
||||
self.assertIs(
|
||||
layout_of([(1, -1, 6)], 80, dynamic_output=True), YoloLayout.nms_in_head
|
||||
)
|
||||
|
||||
def test_a_shape_no_layout_fits_is_refused_with_the_reason(self):
|
||||
cases = {
|
||||
"fits two layouts": ([(1, 84, 6)], 80),
|
||||
"labelmap_path": ([(1, 8400, 84)], 91),
|
||||
"no output tensor": ([], 80),
|
||||
"255 channels": (
|
||||
[(1, 80, 80, 255), (1, 40, 40, 255), (1, 20, 20, 255)],
|
||||
80,
|
||||
),
|
||||
"feature maps": ([(1, 8400, 80), (1, 8400, 4)], 80),
|
||||
"80-class": ([(1, 24, 80, 80), (1, 24, 40, 40), (1, 24, 20, 20)], 3),
|
||||
}
|
||||
|
||||
for reason, (shapes, num_classes) in cases.items():
|
||||
with (
|
||||
self.subTest(reason=reason),
|
||||
self.assertRaisesRegex(ValueError, reason),
|
||||
):
|
||||
layout_of(shapes, num_classes)
|
||||
|
||||
|
||||
class TestDeepxOutputValidation(unittest.TestCase):
|
||||
def test_the_raw_yolox_head_is_read_for_the_configured_input(self):
|
||||
for shapes, size in (
|
||||
([(1, 8400, 85)], 640),
|
||||
([(1, 85, 8400)], 640),
|
||||
# 52*52 + 26*26 + 13*13 cells at 416
|
||||
([(1, 3549, 85)], 416),
|
||||
):
|
||||
with self.subTest(shapes=shapes, size=size):
|
||||
self.assertEqual(validate_yolox_outputs(shapes, 80, size, size), 85)
|
||||
|
||||
def test_another_head_under_yolox_is_refused_with_the_reason(self):
|
||||
cases = {
|
||||
"width and height": ([(1, 8400, 85)], 80, 416),
|
||||
"labelmap_path": ([(1, 8400, 85)], 91, 640),
|
||||
"yolo-generic": ([(1, 8400, 80), (1, 8400, 4)], 80, 640),
|
||||
}
|
||||
|
||||
for reason, (shapes, num_classes, size) in cases.items():
|
||||
with (
|
||||
self.subTest(reason=reason),
|
||||
self.assertRaisesRegex(ValueError, reason),
|
||||
):
|
||||
validate_yolox_outputs(shapes, num_classes, size, size)
|
||||
|
||||
def test_the_label_map_bounds_the_class_count(self):
|
||||
self.assertEqual(class_count({0: "person", 79: "toothbrush"}), 80)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "labelmap_path"):
|
||||
class_count({})
|
||||
|
||||
|
||||
class TestDeepxRawDecode(unittest.TestCase):
|
||||
def anchor_rows(self, rows) -> list:
|
||||
out = np.zeros((1, len(rows), 85), dtype=np.float32)
|
||||
|
||||
for i, (cx, cy, w, h, obj, label, score) in enumerate(rows):
|
||||
out[0, i, 0:4] = [cx, cy, w, h]
|
||||
out[0, i, 4] = obj
|
||||
out[0, i, 5 + label] = score
|
||||
|
||||
return [out]
|
||||
|
||||
def test_an_anchor_based_head_becomes_normalized_corners(self):
|
||||
outputs = self.anchor_rows([(320.0, 160.0, 64.0, 32.0, 0.8, 3, 0.5)])
|
||||
|
||||
detections = decode_raw_anchor(outputs, 640, 640, 0.25, 0.45)
|
||||
|
||||
self.assertEqual(detections[0][0], 3)
|
||||
self.assertAlmostEqual(detections[0][1], 0.4, places=5)
|
||||
self.assertAlmostEqual(detections[0][2], 144 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][4], 176 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][5], 352 / 640, places=5)
|
||||
|
||||
def test_a_channel_major_export_is_read_by_column_count(self):
|
||||
outputs = self.anchor_rows([(320.0, 160.0, 64.0, 32.0, 1.0, 3, 0.9)])
|
||||
|
||||
detections = decode_raw_anchor(
|
||||
[np.swapaxes(outputs[0], 1, 2)], 640, 640, 0.25, 0.45, columns=85
|
||||
)
|
||||
|
||||
self.assertEqual(detections[0][0], 3)
|
||||
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
|
||||
|
||||
def test_rows_below_the_combined_threshold_are_dropped(self):
|
||||
# 0.4 * 0.5 = 0.2, under the threshold both parts clear on their own
|
||||
outputs = self.anchor_rows([(320.0, 320.0, 40.0, 80.0, 0.4, 3, 0.5)])
|
||||
|
||||
self.assertTrue(np.all(decode_raw_anchor(outputs, 640, 640, 0.25, 0.45) == 0))
|
||||
|
||||
def test_at_most_twenty_detections_are_returned(self):
|
||||
rows = [(20.0 + 24 * i, 320.0, 16.0, 16.0, 1.0, i % 80, 0.9) for i in range(25)]
|
||||
|
||||
detections = decode_raw_anchor(self.anchor_rows(rows), 640, 640, 0.25, 0.45)
|
||||
|
||||
self.assertEqual(detections.shape, (20, 6))
|
||||
self.assertEqual(int((detections[:, 1] > 0).sum()), 20)
|
||||
|
||||
def test_an_nms_in_head_output_is_read_without_running_nms(self):
|
||||
out = np.array(
|
||||
[
|
||||
[
|
||||
[100.0, 100.0, 200.0, 200.0, 0.9, 2.0],
|
||||
[102.0, 102.0, 202.0, 202.0, 0.8, 2.0],
|
||||
[300.0, 300.0, 400.0, 400.0, 0.1, 5.0],
|
||||
]
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
detections = decode_raw_nms_in_head([out], 640, 640, 0.25)
|
||||
|
||||
self.assertEqual(detections[0][0], 2)
|
||||
self.assertAlmostEqual(detections[0][1], 0.9, places=5)
|
||||
self.assertAlmostEqual(detections[0][3], 100 / 640, places=5)
|
||||
self.assertEqual(detections[1][0], 2)
|
||||
self.assertAlmostEqual(detections[1][1], 0.8, places=5)
|
||||
self.assertTrue(np.all(detections[2] == 0))
|
||||
|
||||
empty = np.zeros((1, 0, 6), dtype=np.float32)
|
||||
self.assertTrue(np.all(decode_raw_nms_in_head([empty], 640, 640, 0.25) == 0))
|
||||
|
||||
|
||||
class TestDeepxConfig(unittest.TestCase):
|
||||
def test_a_device_string_resolves_to_an_npu_index(self):
|
||||
for configured, index in (("PCIe:1", 1), ("2", 2), ("", 0)):
|
||||
with self.subTest(device=configured):
|
||||
self.assertEqual(resolve_device(configured), index)
|
||||
|
||||
def test_a_device_that_is_not_an_index_is_rejected(self):
|
||||
"""The whole string has to be an index. Reading only the tail would
|
||||
take the 1 out of "PCIe:0,PCIe:1" and bind to an NPU the config never
|
||||
named, and several NPUs are configured as separate devices entries."""
|
||||
for configured in (
|
||||
"PCIe:the-fast-one",
|
||||
"PCIe:0,PCIe:1",
|
||||
"0,1",
|
||||
"PCIe:0 PCIe:1",
|
||||
"PCIe:-1",
|
||||
"PCIe:",
|
||||
):
|
||||
with self.subTest(device=configured):
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_device(configured)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
DeepxDetectorConfig(type="deepx", device=configured)
|
||||
|
||||
def test_a_bad_device_is_refused_where_the_config_is_parsed(self):
|
||||
"""parse_device builds the detector config to surface a bad device at
|
||||
startup, so the comma-separated form fails there rather than binding a
|
||||
detector process to the wrong NPU."""
|
||||
self.assertEqual(parse_device("deepx:PCIe:1").device, "PCIe:1")
|
||||
|
||||
for raw in ("deepx:PCIe:0,PCIe:1", "deepx:the-fast-one"):
|
||||
with self.subTest(raw=raw), self.assertRaises(DeviceParseError):
|
||||
parse_device(raw)
|
||||
|
||||
def test_a_device_string_builds_this_detector_config(self):
|
||||
"""Frigate turns a `deepx:PCIe:0` entry into the detector config with
|
||||
the model already attached, which is the path app.py takes; a bare
|
||||
constructor call does not exercise it."""
|
||||
config = build_detector_config(
|
||||
parse_device("deepx:PCIe:0"), model_with_type(ModelTypeEnum.yologeneric)
|
||||
)
|
||||
|
||||
self.assertIsInstance(config, DeepxDetectorConfig)
|
||||
self.assertEqual(config.device, "PCIe:0")
|
||||
self.assertEqual(config.model.model_type, ModelTypeEnum.yologeneric)
|
||||
|
||||
def test_the_runtime_manifest_pins_its_wheels_to_the_version(self):
|
||||
"""A PyPI path carries a per-file digest, so bumping DXRT_VERSION has
|
||||
to rewrite the whole URL; a stale one installs the old wheel and fails
|
||||
the sha256 on every user's first start."""
|
||||
self.assertEqual(DEEPX_MANIFEST.version, DXRT_VERSION)
|
||||
|
||||
for artifact in DEEPX_MANIFEST.artifacts:
|
||||
with self.subTest(url=artifact.url):
|
||||
self.assertIn(f"dx_engine-{DXRT_VERSION}-", artifact.url)
|
||||
|
||||
def test_a_model_less_config_still_validates(self):
|
||||
config = DeepxDetectorConfig(type="deepx")
|
||||
|
||||
self.assertIsNone(config.model)
|
||||
|
||||
|
||||
class DeepxDetectorTestCase(unittest.TestCase):
|
||||
def detector(
|
||||
self,
|
||||
model_type=ModelTypeEnum.yologeneric,
|
||||
outputs_info=None,
|
||||
ppu=False,
|
||||
dynamic=False,
|
||||
model_path="/nonexistent/model.dxnn",
|
||||
) -> DeepxDetector:
|
||||
dx_engine = MagicMock()
|
||||
dx_engine.Configuration.ITEM.SERVICE = object()
|
||||
session = dx_engine.InferenceEngine.return_value
|
||||
session.get_output_tensors_info.return_value = (
|
||||
[{"shape": [8400]}] if ppu else outputs_info or []
|
||||
)
|
||||
session.is_ppu.return_value = ppu
|
||||
session.has_dynamic_output.return_value = dynamic
|
||||
|
||||
config = DeepxDetectorConfig(type="deepx")
|
||||
config.model = model_with_type(model_type)
|
||||
config.model.path = model_path
|
||||
|
||||
with (
|
||||
# the detector writes the endpoint into the environment, which the
|
||||
# rest of the suite shares when it runs in one process
|
||||
patch.dict(os.environ),
|
||||
patch.dict(sys.modules, {"dx_engine": dx_engine}),
|
||||
patch.object(DeepxDetector, "activate_dependencies"),
|
||||
patch("os.path.isfile", return_value=True),
|
||||
):
|
||||
return DeepxDetector(config)
|
||||
|
||||
def ppu_detector(
|
||||
self, ppu, layers, model_type=ModelTypeEnum.yologeneric, box_format=None
|
||||
) -> DeepxDetector:
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
|
||||
return self.detector(
|
||||
model_type,
|
||||
ppu=True,
|
||||
model_path=write_dxnn(tmp.name, ppu, layers, box_format=box_format),
|
||||
)
|
||||
|
||||
def detect(self, detector, outputs) -> np.ndarray:
|
||||
detector.session.run.return_value = outputs
|
||||
return detector.detect_raw(np.zeros((1, 640, 640, 3), np.uint8))
|
||||
|
||||
|
||||
class TestDeepxModelType(DeepxDetectorTestCase):
|
||||
def test_a_model_type_with_no_decoder_is_rejected(self):
|
||||
for model_type in (ModelTypeEnum.ssd, ModelTypeEnum.dfine):
|
||||
with (
|
||||
self.subTest(model_type=model_type),
|
||||
self.assertRaisesRegex(ValueError, model_type.value),
|
||||
):
|
||||
self.detector(model_type)
|
||||
|
||||
def test_supported_model_types_are_accepted(self):
|
||||
for model_type, outputs_info in (
|
||||
(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}]),
|
||||
(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}]),
|
||||
):
|
||||
with self.subTest(model_type=model_type):
|
||||
detector = self.detector(model_type, outputs_info)
|
||||
|
||||
self.assertEqual(detector.model_type, model_type)
|
||||
|
||||
|
||||
class TestDeepxDetectorLoad(DeepxDetectorTestCase):
|
||||
def test_the_layout_is_settled_at_load(self):
|
||||
detector = self.detector(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}])
|
||||
self.assertIs(detector.output.layout, YoloLayout.anchor_free)
|
||||
self.assertEqual(detector.output.columns, 84)
|
||||
|
||||
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}])
|
||||
self.assertIs(detector.output.layout, YoloLayout.yolox)
|
||||
|
||||
for model_type in (ModelTypeEnum.yologeneric, ModelTypeEnum.yolox):
|
||||
with self.subTest(model_type=model_type):
|
||||
detector = self.ppu_detector(
|
||||
ANCHOR_FREE_PPU, THREE_SCALE_FREE, model_type=model_type
|
||||
)
|
||||
|
||||
self.assertIs(detector.output.layout, YoloLayout.ppu)
|
||||
self.assertEqual(detector.ppu_layout.scale_count, 3)
|
||||
|
||||
def test_a_model_the_detector_cannot_decode_is_refused_at_load(self):
|
||||
cases = {
|
||||
"Cannot decode DEEPX model": lambda: self.detector(
|
||||
ModelTypeEnum.yologeneric, [{"shape": [1, 8400, 7]}]
|
||||
),
|
||||
"DX-COM 2.4.0": lambda: self.detector(ModelTypeEnum.yologeneric, ppu=True),
|
||||
"face and pose": lambda: self.ppu_detector({"type": 2}, [(80, 80, 1)]),
|
||||
"centre and size or as two corners": lambda: self.ppu_detector(
|
||||
ANCHOR_FREE_PPU, ONE_SCALE_FREE
|
||||
),
|
||||
}
|
||||
|
||||
for reason, load in cases.items():
|
||||
with (
|
||||
self.subTest(reason=reason),
|
||||
self.assertRaisesRegex(ValueError, reason),
|
||||
):
|
||||
load()
|
||||
|
||||
|
||||
class TestDeepxDetectRaw(DeepxDetectorTestCase):
|
||||
def test_a_ppu_record_decodes_by_the_head_in_the_model(self):
|
||||
cases = {
|
||||
"anchor-based, layer 2 of 3 at stride 32, the 373x326 anchor": (
|
||||
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, None),
|
||||
build_ppu_record((0.6, 0.4, 0.3, 0.7), label=5),
|
||||
(5, (0.0, 0.380094, 0.864187, 0.589906)),
|
||||
),
|
||||
"anchor-based, layer 0 of 3 at stride 8, the 16x30 anchor": (
|
||||
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, None),
|
||||
build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0)),
|
||||
(0, (None, 0.116750, None, None)),
|
||||
),
|
||||
"anchor-based, two scales: layer 0 is stride 16, not stride 8": (
|
||||
(ANCHOR_BASED_PPU, TWO_SCALE_ANCHORS, None),
|
||||
build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0)),
|
||||
(0, (0.141156, 0.236031, 0.223844, 0.248969)),
|
||||
),
|
||||
"anchor-free, three scales: cell (10, 9) of stride 32": (
|
||||
(ANCHOR_FREE_PPU, THREE_SCALE_FREE, None),
|
||||
build_ppu_record((1.2, 0.5, 1.0, 0.5), grid=(9, 10, 0, 2), label=7),
|
||||
(7, (0.433782, 0.492043, 0.516218, 0.627957)),
|
||||
),
|
||||
"anchor-free, one scale: the box fields are already pixels": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
|
||||
build_ppu_record((320.0, 160.0, 64.0, 32.0), label=3),
|
||||
(3, (144 / 640, 288 / 640, 176 / 640, 352 / 640)),
|
||||
),
|
||||
"anchor-free, one scale: a sub-pixel box stays sub-pixel": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
|
||||
build_ppu_record((0.6, 0.4, 0.3, 0.7)),
|
||||
(0, (None, 0.45 / 640, None, None)),
|
||||
),
|
||||
"a corner-format head reads the record as two corners": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "corner"),
|
||||
build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2),
|
||||
(2, (50 / 640, 100 / 640, 250 / 640, 300 / 640)),
|
||||
),
|
||||
"a centre-format head reads it as a centre and size": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
|
||||
build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2),
|
||||
(2, (0.0, 0.0, 175 / 640, 250 / 640)),
|
||||
),
|
||||
}
|
||||
|
||||
for head, ((ppu, layers, box_format), record, (label, box)) in cases.items():
|
||||
with self.subTest(head=head):
|
||||
detector = self.ppu_detector(ppu, layers, box_format=box_format)
|
||||
|
||||
detections = self.detect(detector, [record])
|
||||
|
||||
self.assertEqual(detections[0][0], label)
|
||||
for i, expected in enumerate(box, start=2):
|
||||
if expected is not None:
|
||||
self.assertAlmostEqual(detections[0][i], expected, places=5)
|
||||
|
||||
def test_the_strides_come_from_the_grids_in_the_model(self):
|
||||
detector = self.ppu_detector(
|
||||
ANCHOR_FREE_PPU, [(40, 40, 1), (20, 20, 1), (10, 10, 1)]
|
||||
)
|
||||
|
||||
detections = self.detect(
|
||||
detector,
|
||||
[build_ppu_record((1.2, 0.5, 1.0, 0.5), grid=(9, 10, 0, 0), label=7)],
|
||||
)
|
||||
|
||||
# layer 0 is stride 16: centre (11.2, 9.5) * 16, size e * 16 x sqrt(e) * 16
|
||||
self.assertEqual(detections[0][0], 7)
|
||||
self.assertAlmostEqual(detections[0][2], (152 - np.exp(0.5) * 8) / 640, 5)
|
||||
self.assertAlmostEqual(detections[0][3], (179.2 - np.exp(1.0) * 8) / 640, 5)
|
||||
|
||||
def test_the_head_stays_what_the_model_said_across_frames(self):
|
||||
detector = self.ppu_detector(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS)
|
||||
|
||||
first = self.detect(
|
||||
detector, [build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0))]
|
||||
)
|
||||
# layer 0 of 3: stride 8, anchor 16x30
|
||||
self.assertAlmostEqual(first[0][3], 0.116750, places=5)
|
||||
|
||||
second = self.detect(
|
||||
detector, [build_ppu_record((0.5, 0.5, 0.4, 0.4), grid=(3, 4, 0, 1))]
|
||||
)
|
||||
# layer 1 of 3: stride 16, anchor 30x61, not layer 1 of 2
|
||||
self.assertAlmostEqual(second[0][3], 0.0975, places=5)
|
||||
|
||||
detector = self.ppu_detector(
|
||||
ANCHOR_FREE_PPU, ONE_SCALE_FREE, box_format="centre"
|
||||
)
|
||||
record = [build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2)]
|
||||
# centre (100, 50), size 300 x 250: the right edge lands at 250
|
||||
for frame in range(2):
|
||||
with self.subTest(frame=frame):
|
||||
self.assertAlmostEqual(
|
||||
self.detect(detector, record)[0][5], 250 / 640, places=5
|
||||
)
|
||||
|
||||
def test_records_the_head_cannot_place_come_back_empty(self):
|
||||
cases = {
|
||||
"a level or box the anchor table does not carry": (
|
||||
THREE_SCALE_ANCHORS,
|
||||
[build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 2, 5))],
|
||||
),
|
||||
"a scale count Frigate has no anchor table for": (
|
||||
FOUR_SCALE_ANCHORS,
|
||||
[build_ppu_record((0.6, 0.4, 0.3, 0.7))],
|
||||
),
|
||||
"a record below the score threshold": (
|
||||
THREE_SCALE_ANCHORS,
|
||||
[build_ppu_record((0.6, 0.4, 0.3, 0.7), score=0.1)],
|
||||
),
|
||||
"an unexpected record width": (
|
||||
THREE_SCALE_ANCHORS,
|
||||
[np.zeros((1, 3, 16), dtype=np.uint8)],
|
||||
),
|
||||
**{
|
||||
f"no records at all, shaped {shape}": (
|
||||
THREE_SCALE_ANCHORS,
|
||||
[np.zeros(shape, dtype=np.uint8)],
|
||||
)
|
||||
for shape in ((1, 0, PPU_RECORD_SIZE), (0, PPU_RECORD_SIZE), (0,))
|
||||
},
|
||||
}
|
||||
|
||||
for output, (layers, outputs) in cases.items():
|
||||
with self.subTest(output=output):
|
||||
detector = self.ppu_detector(ANCHOR_BASED_PPU, layers)
|
||||
|
||||
self.assertTrue(np.all(self.detect(detector, outputs) == 0))
|
||||
|
||||
def test_a_yolox_raw_head_is_decoded_through_the_grid(self):
|
||||
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}])
|
||||
|
||||
# cell (x 10, y 5) of the stride-8 grid is row 5 * 80 + 10
|
||||
tensor = np.zeros((1, 8400, 85), np.float32)
|
||||
tensor[0, 410, :5] = [0.5, 0.5, np.log(4.0), np.log(2.0), 0.9]
|
||||
tensor[0, 410, 5 + 7] = 0.8
|
||||
|
||||
detections = self.detect(detector, [tensor])
|
||||
|
||||
# centre (84, 44), size 32 x 16
|
||||
self.assertEqual(detections[0][0], 7)
|
||||
self.assertAlmostEqual(detections[0][1], 0.72, places=5)
|
||||
self.assertAlmostEqual(detections[0][2], 36 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][3], 68 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][4], 52 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][5], 100 / 640, places=5)
|
||||
|
||||
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 85, 8400]}])
|
||||
detections = self.detect(detector, [np.swapaxes(tensor, 1, 2)])
|
||||
self.assertAlmostEqual(detections[0][5], 100 / 640, places=5)
|
||||
|
||||
def test_a_raw_head_comes_back_as_frigates_detection_rows(self):
|
||||
detector = self.detector(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}])
|
||||
output = np.zeros((1, 84, 8400), dtype=np.float32)
|
||||
output[0, 0:4, 0] = [320.0, 160.0, 64.0, 32.0]
|
||||
output[0, 4 + 2, 0] = 0.9
|
||||
|
||||
detections = self.detect(detector, [output])
|
||||
|
||||
self.assertEqual(detections.shape, (20, 6))
|
||||
self.assertEqual(detections[0][0], 2)
|
||||
self.assertAlmostEqual(detections[0][1], 0.9, places=5)
|
||||
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
|
||||
@@ -184,6 +184,26 @@ class TestAccelerators(HardwareProbeTestCase):
|
||||
)
|
||||
self.assertFalse(memryx.unlimited)
|
||||
|
||||
def test_each_deepx_node_is_a_unit(self):
|
||||
write(os.path.join(self.dev_root, "dxrt0"))
|
||||
write(os.path.join(self.dev_root, "dxrt1"))
|
||||
|
||||
deepx = self.probe()["deepx"]
|
||||
|
||||
self.assertEqual(
|
||||
[unit.device for unit in deepx.units],
|
||||
["deepx:PCIe:0", "deepx:PCIe:1"],
|
||||
)
|
||||
|
||||
def test_a_deepx_npu_is_unlimited(self):
|
||||
# the host daemon multiplexes, so one module takes several processes
|
||||
write(os.path.join(self.dev_root, "dxrt0"))
|
||||
|
||||
self.assertTrue(self.probe()["deepx"].unlimited)
|
||||
|
||||
def test_no_deepx_is_reported_without_a_node(self):
|
||||
self.assertNotIn("deepx", self.probe())
|
||||
|
||||
def test_a_supported_rockchip_soc_is_reported(self):
|
||||
write(
|
||||
os.path.join(self.proc_root, "device-tree", "compatible"),
|
||||
|
||||
@@ -56,10 +56,13 @@ class EventsPerSecond:
|
||||
self._start = now
|
||||
# compute the (approximate) events in the last n seconds
|
||||
self.expire_timestamps(now)
|
||||
seconds = min(now - self._start, self._last_n_seconds)
|
||||
# avoid divide by zero
|
||||
if seconds == 0:
|
||||
seconds = 1
|
||||
# rate over at least one second (or the whole window, if shorter),
|
||||
# so a burst of events right after start() is not divided by a
|
||||
# tiny window
|
||||
seconds = max(
|
||||
min(now - self._start, self._last_n_seconds),
|
||||
min(1.0, self._last_n_seconds),
|
||||
)
|
||||
return len(self._timestamps) / seconds
|
||||
|
||||
# remove aged out timestamps
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Helpers for the live dashboard's draggable grid layout: reading and seeding
|
||||
* the persisted layout, and measuring rendered tiles.
|
||||
*
|
||||
* DraggableGridLayout persists through useUserPersistence, which namespaces
|
||||
* keys by username, and every write is an async idb put. A test that seeds the
|
||||
* bare key, or seeds before the app's own first write has landed, silently
|
||||
* asserts against a key the app never reads. persistedLayoutKey() closes both
|
||||
* holes, so prefer it over building the key by hand.
|
||||
*
|
||||
* Geometry has its own trap: the grid first lays out against window.innerWidth,
|
||||
* then reflows narrower once useResizeObserver reports the real container.
|
||||
* Tiles measured in separate round-trips can straddle that reflow and disagree
|
||||
* on scale, so cameraBoxes() takes every measurement in one evaluate.
|
||||
*
|
||||
* Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts.
|
||||
*/
|
||||
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export type LayoutItem = {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
export type PersistedLayout = {
|
||||
version: number;
|
||||
naturalAspect: boolean;
|
||||
layout: LayoutItem[];
|
||||
};
|
||||
|
||||
function layoutKeySuffix(group: string): string {
|
||||
return `${group}-draggable-layout`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the app has actually written an envelope to, or undefined while its
|
||||
* first write is still in flight.
|
||||
*/
|
||||
function findWrittenKey(
|
||||
page: Page,
|
||||
group: string,
|
||||
): Promise<string | undefined> {
|
||||
return page.evaluate(
|
||||
(suffix) =>
|
||||
new Promise<string | undefined>((resolve) => {
|
||||
const open = indexedDB.open("keyval-store");
|
||||
open.onsuccess = () => {
|
||||
const store = open.result
|
||||
.transaction("keyval", "readonly")
|
||||
.objectStore("keyval");
|
||||
// getAllKeys and getAll both return in key order, so the indexes align
|
||||
const keys = store.getAllKeys();
|
||||
const values = store.getAll();
|
||||
keys.transaction.oncomplete = () => {
|
||||
open.result.close();
|
||||
const names = keys.result as string[];
|
||||
const stored = values.result as { version?: number }[];
|
||||
const match = names.findIndex(
|
||||
(name, index) =>
|
||||
(name === suffix || name.startsWith(`${suffix}:`)) &&
|
||||
typeof stored[index]?.version === "number",
|
||||
);
|
||||
resolve(match === -1 ? undefined : names[match]);
|
||||
};
|
||||
};
|
||||
open.onerror = () => resolve(undefined);
|
||||
}),
|
||||
layoutKeySuffix(group),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the grid to persist its own layout, then return the key it used.
|
||||
* Waiting for that write is what makes a later seed meaningful: it proves the
|
||||
* key is live, and it rules out the app overwriting the seed a moment later.
|
||||
*/
|
||||
export async function persistedLayoutKey(
|
||||
page: Page,
|
||||
group: string,
|
||||
): Promise<string> {
|
||||
let key: string | undefined;
|
||||
|
||||
await expect
|
||||
.poll(async () => (key = await findWrittenKey(page, group)), {
|
||||
timeout: 10_000,
|
||||
message: `grid never persisted a layout for group "${group}"`,
|
||||
})
|
||||
.not.toBeUndefined();
|
||||
|
||||
return key!;
|
||||
}
|
||||
|
||||
/** Overwrite the stored layout, resolving only once the put has committed. */
|
||||
export function seedLayout(
|
||||
page: Page,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
return page.evaluate(
|
||||
([key, value]) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const open = indexedDB.open("keyval-store");
|
||||
open.onupgradeneeded = () => open.result.createObjectStore("keyval");
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction("keyval", "readwrite");
|
||||
tx.objectStore("keyval").put(value, key as string);
|
||||
tx.oncomplete = () => {
|
||||
open.result.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error);
|
||||
};
|
||||
open.onerror = () => reject(open.error);
|
||||
}),
|
||||
[key, value] as const,
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the stored layout back. Undefined until the app writes it. */
|
||||
export function readLayout(
|
||||
page: Page,
|
||||
key: string,
|
||||
): Promise<PersistedLayout | undefined> {
|
||||
return page.evaluate(
|
||||
(target) =>
|
||||
new Promise((resolve) => {
|
||||
const open = indexedDB.open("keyval-store");
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction("keyval", "readonly");
|
||||
const request = tx.objectStore("keyval").get(target);
|
||||
tx.oncomplete = () => {
|
||||
open.result.close();
|
||||
resolve(request.result);
|
||||
};
|
||||
};
|
||||
open.onerror = () => resolve(undefined);
|
||||
}),
|
||||
key,
|
||||
) as Promise<PersistedLayout | undefined>;
|
||||
}
|
||||
|
||||
export type Box = { w: number; h: number; x: number; y: number };
|
||||
|
||||
/** The card is the player root; the cell is the grid slot it sits in. */
|
||||
export type BoxTarget = "card" | "cell";
|
||||
|
||||
/** One atomic snapshot, or null while any tile is missing or unlaid out. */
|
||||
function snapshotBoxes(
|
||||
page: Page,
|
||||
cameras: readonly string[],
|
||||
target: BoxTarget,
|
||||
): Promise<Record<string, Box> | null> {
|
||||
return page.evaluate(
|
||||
({ cams, target }) => {
|
||||
const boxes: Record<string, Box> = {};
|
||||
|
||||
for (const cam of cams) {
|
||||
const card = document.querySelector(`[data-camera='${cam}']`);
|
||||
const el = target === "cell" ? card?.closest(".p-1") : card;
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect();
|
||||
|
||||
// a re-rendering tile can briefly report no box at all
|
||||
if (!r.width || !r.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y };
|
||||
}
|
||||
|
||||
return boxes;
|
||||
},
|
||||
{ cams: cameras as readonly string[], target },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the given cameras' tiles together, once they have all rendered.
|
||||
* Measuring in one evaluate is what keeps the numbers mutually comparable.
|
||||
*/
|
||||
export async function cameraBoxes<T extends string>(
|
||||
page: Page,
|
||||
cameras: readonly T[],
|
||||
target: BoxTarget = "cell",
|
||||
): Promise<Record<T, Box>> {
|
||||
let boxes: Record<string, Box> | null = null;
|
||||
|
||||
await expect
|
||||
.poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), {
|
||||
timeout: 10_000,
|
||||
message: `${target}s never rendered for ${cameras.join(", ")}`,
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
return boxes as unknown as Record<T, Box>;
|
||||
}
|
||||
@@ -45,6 +45,11 @@ export class LivePage extends BasePage {
|
||||
);
|
||||
}
|
||||
|
||||
/** Edit-layout toggle on the draggable grid (desktop, custom groups). */
|
||||
get editLayoutButton(): Locator {
|
||||
return this.page.getByTestId("toggle-edit-layout");
|
||||
}
|
||||
|
||||
/** Open the right-click context menu on a camera card (desktop only). */
|
||||
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
||||
await this.cameraCard(cameraName).first().click({ button: "right" });
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Live grid aspect modes.
|
||||
*
|
||||
* Bucketed mode (the default) snaps every camera to a wide, landscape or tall
|
||||
* tile, and converts layouts saved by pre-masonry versions instead of
|
||||
* discarding them. Natural mode sizes each tile to its own camera.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { LivePage } from "../pages/live.page";
|
||||
import {
|
||||
cameraBoxes,
|
||||
persistedLayoutKey,
|
||||
readLayout,
|
||||
seedLayout,
|
||||
type LayoutItem,
|
||||
} from "../helpers/grid-layout";
|
||||
|
||||
const GROUP = "outdoor";
|
||||
const GRID_COLS = 96;
|
||||
|
||||
test.describe("Live grid aspect modes @critical", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Draggable grid is desktop-only",
|
||||
);
|
||||
|
||||
test("an ultra-wide camera gets a 32:9 tile in bucketed mode @mobile", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: { backyard: { detect: { width: 2560, height: 720 } } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const { backyard: wide, front_door: normal } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
["backyard", "front_door"] as const,
|
||||
);
|
||||
|
||||
expect(wide.w / wide.h).toBeCloseTo(32 / 9, 1);
|
||||
expect(wide.w / normal.w).toBeCloseTo(2, 1);
|
||||
expect(wide.h).toBeCloseTo(normal.h, 0);
|
||||
});
|
||||
|
||||
test("a letterboxed still image rounds its own corners", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// A portrait camera pillarboxes inside its 8:9 bucket, so the card's
|
||||
// overflow-hidden clip never reaches the picture's corners. The image has
|
||||
// to carry the radius itself or it renders with square edges on the tile.
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const radii = await frigateApp.page.evaluate(() => {
|
||||
const card = document.querySelector("[data-camera='backyard']");
|
||||
const img = card?.querySelector("img");
|
||||
return {
|
||||
card: card ? getComputedStyle(card).borderTopLeftRadius : null,
|
||||
img: img ? getComputedStyle(img).borderTopLeftRadius : null,
|
||||
};
|
||||
});
|
||||
|
||||
expect(radii.card).not.toBe("0px");
|
||||
expect(radii.img).toBe(radii.card);
|
||||
});
|
||||
|
||||
test("a pre-masonry layout is converted, keeping resized tiles", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 0.17/0.18 shape: bare array on a 12-column grid, 4x4 standard tiles.
|
||||
// backyard was manually resized to 8x8 and sits beside front_door's column,
|
||||
// front_door is a standard tile on the row below.
|
||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
||||
await seedLayout(frigateApp.page, key, [
|
||||
{ i: "backyard", x: 4, y: 0, w: 8, h: 8, moved: false, static: false },
|
||||
{ i: "front_door", x: 0, y: 8, w: 4, h: 4, moved: false, static: false },
|
||||
]);
|
||||
await frigateApp.page.reload();
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The conversion is written back on first load, replacing the legacy array
|
||||
// with an envelope. Poll for it: that write is an async idb put.
|
||||
await expect
|
||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(2);
|
||||
|
||||
const stored = (await readLayout(frigateApp.page, key))!;
|
||||
expect(stored).toMatchObject({ version: 2, naturalAspect: false });
|
||||
|
||||
// x and w scale 8x (12 -> 96 columns), y and h scale 18x (4 -> 72 rows per
|
||||
// standard tile), so the manual resize survives instead of snapping back.
|
||||
expect(
|
||||
stored.layout.find((i: LayoutItem) => i.i === "backyard"),
|
||||
).toMatchObject({
|
||||
x: 32,
|
||||
y: 0,
|
||||
w: 64,
|
||||
h: 144,
|
||||
});
|
||||
expect(
|
||||
stored.layout.find((i: LayoutItem) => i.i === "front_door"),
|
||||
).toMatchObject({
|
||||
x: 0,
|
||||
y: 144,
|
||||
w: 32,
|
||||
h: 72,
|
||||
});
|
||||
|
||||
// arrangement on screen: backyard indented, front_door below it
|
||||
const { backyard, front_door: frontDoor } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
["backyard", "front_door"] as const,
|
||||
);
|
||||
expect(backyard.x).toBeGreaterThan(frontDoor.x + frontDoor.w / 2);
|
||||
expect(frontDoor.y).toBeGreaterThan(backyard.y + backyard.h / 2);
|
||||
});
|
||||
|
||||
test("conversion is a pure scale, so odd sizes and positions survive", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The old grid exposed all four resize corners with no aspect constraint,
|
||||
// so a stored tile can be any size. These two are adjacent and non-standard.
|
||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
||||
await seedLayout(frigateApp.page, key, [
|
||||
{ i: "front_door", x: 0, y: 3, w: 5, h: 5 },
|
||||
{ i: "backyard", x: 5, y: 3, w: 7, h: 5 },
|
||||
]);
|
||||
await frigateApp.page.reload();
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(2);
|
||||
|
||||
const stored = (await readLayout(frigateApp.page, key))!;
|
||||
const frontDoor = stored.layout.find(
|
||||
(i: LayoutItem) => i.i === "front_door",
|
||||
)!;
|
||||
const backyard = stored.layout.find((i: LayoutItem) => i.i === "backyard")!;
|
||||
|
||||
expect(frontDoor).toMatchObject({ x: 0, y: 54, w: 40, h: 90 });
|
||||
expect(backyard).toMatchObject({ x: 40, y: 54, w: 56, h: 90 });
|
||||
|
||||
// still adjacent, still inside the grid, still not overlapping
|
||||
expect(frontDoor.x + frontDoor.w).toBe(backyard.x);
|
||||
expect(backyard.x + backyard.w).toBe(GRID_COLS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Masonry live grid -- custom-group draggable layout.
|
||||
*
|
||||
* Verifies natural-aspect tile sizing and that a saved layout the current
|
||||
* version cannot read is regenerated cleanly. The grid renders only for a
|
||||
* custom camera group (here: "outdoor") on desktop; mobile keeps the static
|
||||
* grid, which the @mobile block below guards.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { LivePage } from "../pages/live.page";
|
||||
import {
|
||||
cameraBoxes,
|
||||
persistedLayoutKey,
|
||||
readLayout,
|
||||
seedLayout,
|
||||
} from "../helpers/grid-layout";
|
||||
|
||||
const GROUP = "outdoor"; // custom group: front_door + backyard
|
||||
|
||||
test.describe("Masonry live grid @critical", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Draggable masonry grid is desktop-only",
|
||||
);
|
||||
|
||||
test("custom group renders its cameras in the draggable grid", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("tiles render at their camera's natural aspect ratio", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// backyard is 9:16, which bucketed mode would snap to an 8:9 tile
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await seedLayout(frigateApp.page, "naturalAspectLayout:admin", true);
|
||||
await frigateApp.page.reload();
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const { front_door: landscape, backyard: portrait } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
["front_door", "backyard"] as const,
|
||||
"card",
|
||||
);
|
||||
|
||||
expect(landscape.w / landscape.h).toBeCloseTo(16 / 9, 1);
|
||||
expect(portrait.w / portrait.h).toBeCloseTo(9 / 16, 1);
|
||||
});
|
||||
|
||||
test("dragging a tile does not shove other tiles far away", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await live.editLayoutButton.click();
|
||||
|
||||
const cameras = ["front_door", "backyard"] as const;
|
||||
const { front_door: fixedBefore, backyard: draggedBox } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
cameras,
|
||||
"card",
|
||||
);
|
||||
|
||||
// Drag backyard onto front_door's position (a deliberate collision). With
|
||||
// free-placement + prevent-collision, front_door must NOT be shoved down.
|
||||
const from = {
|
||||
x: draggedBox.x + draggedBox.w / 2,
|
||||
y: draggedBox.y + draggedBox.h / 2,
|
||||
};
|
||||
const to = {
|
||||
x: fixedBefore.x + fixedBefore.w / 2,
|
||||
y: fixedBefore.y + fixedBefore.h / 2,
|
||||
};
|
||||
await frigateApp.page.mouse.move(from.x, from.y);
|
||||
await frigateApp.page.mouse.down();
|
||||
await frigateApp.page.mouse.move(to.x, to.y, { steps: 15 });
|
||||
await frigateApp.page.mouse.up();
|
||||
|
||||
const { front_door: fixedAfter } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
cameras,
|
||||
"card",
|
||||
);
|
||||
// Allow a few px of snap; a collision-push would move it a whole tile down.
|
||||
expect(Math.abs(fixedAfter.y - fixedBefore.y)).toBeLessThan(40);
|
||||
});
|
||||
|
||||
test("resizing a top-row tile preserves its aspect ratio (no pillarboxing)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// A lone top tile has room to grow sideways, which is what exposed the bug:
|
||||
// a top-edge handle let width grow while height stayed clamped at y=0.
|
||||
await frigateApp.installDefaults({
|
||||
config: { camera_groups: { outdoor: { cameras: ["front_door"] } } },
|
||||
});
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await live.editLayoutButton.click();
|
||||
|
||||
const tile = frigateApp.page.locator(".react-grid-item", {
|
||||
has: frigateApp.page.locator("[data-camera='front_door']"),
|
||||
});
|
||||
const only = ["front_door"] as const;
|
||||
const { front_door: before } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
only,
|
||||
"card",
|
||||
);
|
||||
const aspect = before.w / before.h;
|
||||
|
||||
// Regression: if a top-edge handle is exposed, dragging it up/out must NOT
|
||||
// distort the aspect (the old bug grew width while height stayed clamped).
|
||||
const ne = tile.locator(".react-resizable-handle-ne");
|
||||
if (await ne.count()) {
|
||||
await ne.dragTo(tile, {
|
||||
force: true,
|
||||
targetPosition: { x: 1000, y: -160 },
|
||||
});
|
||||
const { front_door: afterNe } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
only,
|
||||
"card",
|
||||
);
|
||||
// It must actually resize (not a silent no-op) AND keep its aspect.
|
||||
expect(afterNe.w).toBeGreaterThan(before.w);
|
||||
expect(Math.abs(afterNe.w / afterNe.h - aspect)).toBeLessThan(0.2);
|
||||
}
|
||||
|
||||
// Positive: growing from the bottom-right corner resizes and keeps aspect.
|
||||
const se = tile.locator(".react-resizable-handle-se");
|
||||
await se.dragTo(tile, { force: true, targetPosition: { x: 1000, y: 520 } });
|
||||
const { front_door: grown } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
only,
|
||||
"card",
|
||||
);
|
||||
expect(grown.w).toBeGreaterThan(before.w);
|
||||
expect(Math.abs(grown.w / grown.h - aspect)).toBeLessThan(0.2);
|
||||
});
|
||||
|
||||
test("the grid keeps its measured width after a back navigation", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// The grid sizes itself from window.innerWidth until its container is
|
||||
// measured. On a warm back navigation nothing re-renders after that
|
||||
// container mounts, so an observer that never attaches leaves every tile
|
||||
// sized against the full window: the layout widens by the sidebar's width
|
||||
// and the rightmost column clips on a full row.
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// total width the tiles span; tracks the width the grid laid out against
|
||||
const span = () =>
|
||||
frigateApp.page.evaluate(() => {
|
||||
const tiles = [...document.querySelectorAll(".react-grid-item")];
|
||||
|
||||
if (!tiles.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rects = tiles.map((tile) => tile.getBoundingClientRect());
|
||||
return +(
|
||||
Math.max(...rects.map((r) => r.right)) -
|
||||
Math.min(...rects.map((r) => r.left))
|
||||
).toFixed(1);
|
||||
});
|
||||
|
||||
let fresh: number | null = null;
|
||||
await expect
|
||||
.poll(async () => (fresh = await span()), { timeout: 10_000 })
|
||||
.not.toBeNull();
|
||||
|
||||
await live.cameraCard("front_door").first().click();
|
||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
||||
|
||||
await frigateApp.page.goBack();
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// the layout must settle back to the measured width, not window.innerWidth
|
||||
await expect
|
||||
.poll(span, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(fresh! + 2);
|
||||
});
|
||||
|
||||
test("a camera added to a saved layout fills an open column", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// one tall tile in the first column; backyard is missing from the layout
|
||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
||||
await seedLayout(frigateApp.page, key, {
|
||||
version: 2,
|
||||
naturalAspect: false,
|
||||
layout: [{ i: "front_door", x: 0, y: 0, w: 32, h: 400 }],
|
||||
});
|
||||
await frigateApp.page.reload();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const stored = await readLayout(frigateApp.page, key);
|
||||
return stored?.layout.find((item) => item.i === "backyard");
|
||||
})
|
||||
.toMatchObject({ x: 32, y: 0 });
|
||||
});
|
||||
|
||||
test("saved layout from an unreadable version regenerates without error", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// A bare array is converted rather than discarded (covered in
|
||||
// live-grid-aspect-modes), so use a version the current grid cannot read.
|
||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
||||
await seedLayout(frigateApp.page, key, {
|
||||
version: 1,
|
||||
naturalAspect: false,
|
||||
layout: [{ i: "front_door", x: 0, y: 0, w: 4, h: 3 }],
|
||||
});
|
||||
|
||||
await frigateApp.page.reload();
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
|
||||
// Grid regenerated; both cameras still render and the error collector
|
||||
// (frigate-test fixture) catches any crash.
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
||||
|
||||
// The app must have replaced the value it could not read. Without this the
|
||||
// test would still pass against a key the app never touches.
|
||||
await expect
|
||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Masonry live grid on mobile @critical @mobile", () => {
|
||||
test("custom group keeps the static grid, with no draggable layout", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, false);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
||||
|
||||
// isMobileOnly routes around DraggableGridLayout entirely, so neither the
|
||||
// grid items nor the edit-layout toggle may appear.
|
||||
await expect(frigateApp.page.locator(".react-grid-item")).toHaveCount(0);
|
||||
await expect(live.editLayoutButton).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,26 @@ import type { Page } from "@playwright/test";
|
||||
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
|
||||
const STREAMING_KEY = "streaming-settings:admin";
|
||||
|
||||
const OUTDOOR_LAYOUT = [
|
||||
// the shape DraggableGridLayout writes
|
||||
const OUTDOOR_LAYOUT = {
|
||||
version: 2,
|
||||
naturalAspect: false,
|
||||
layout: [
|
||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
||||
{ i: "backyard", x: 32, y: 0, w: 32, h: 72 },
|
||||
],
|
||||
};
|
||||
|
||||
const NATURAL_OUTDOOR_LAYOUT = {
|
||||
version: 2,
|
||||
naturalAspect: true,
|
||||
layout: [
|
||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
||||
{ i: "backyard", x: 32, y: 0, w: 24, h: 96 },
|
||||
],
|
||||
};
|
||||
|
||||
const LEGACY_OUTDOOR_LAYOUT = [
|
||||
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
|
||||
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
||||
];
|
||||
@@ -159,6 +178,160 @@ test.describe("UI settings import/export @medium", () => {
|
||||
expect(payload.sections.preferences.playbackRate).toBe(2);
|
||||
});
|
||||
|
||||
test("exports only layouts built for the current tile sizing mode", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// a group not opened since the mode changed still holds a layout from
|
||||
// the other mode, which would import into a mode that cannot show it
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
await writeIdb(frigateApp.page, {
|
||||
[OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT,
|
||||
"default-draggable-layout:admin": NATURAL_OUTDOOR_LAYOUT,
|
||||
"naturalAspectLayout:admin": true,
|
||||
});
|
||||
|
||||
const downloadPromise = frigateApp.page.waitForEvent("download");
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Export Settings" })
|
||||
.click();
|
||||
const download = await downloadPromise;
|
||||
const payload = JSON.parse(readFileSync((await download.path())!, "utf-8"));
|
||||
|
||||
expect(payload.sections.layouts).toEqual({
|
||||
default: NATURAL_OUTDOOR_LAYOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("toggling tile sizing mode clears stored layouts", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "The setting is hidden on phones");
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT });
|
||||
|
||||
await frigateApp.page.locator("#natural-aspect-desktop").click();
|
||||
await frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Enable" })
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY))
|
||||
.toBeNull();
|
||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("round-trips a layout left unconverted by an upgrade", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
||||
// DraggableGridLayout rewrites a pre-0.19 layout only when that group's
|
||||
// dashboard is opened, so exporting first carries the bare array into the
|
||||
// file. Import must accept it back rather than rejecting the whole file.
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
await writeIdb(frigateApp.page, {
|
||||
[OUTDOOR_LAYOUT_KEY]: LEGACY_OUTDOOR_LAYOUT,
|
||||
"playbackRate:admin": 2,
|
||||
});
|
||||
|
||||
const downloadPromise = frigateApp.page.waitForEvent("download");
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Export Settings" })
|
||||
.click();
|
||||
const download = await downloadPromise;
|
||||
const contents = readFileSync((await download.path())!, "utf-8");
|
||||
|
||||
expect(JSON.parse(contents).sections.layouts.outdoor).toEqual(
|
||||
LEGACY_OUTDOOR_LAYOUT,
|
||||
);
|
||||
|
||||
await clearIdb(frigateApp.page);
|
||||
await chooseImportText(frigateApp.page, contents);
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||
LEGACY_OUTDOOR_LAYOUT,
|
||||
);
|
||||
// the rest of the file must survive alongside it
|
||||
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
|
||||
});
|
||||
|
||||
test("legacy layouts import turns natural aspect off so they display", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
||||
// Bare-array layouts only render in bucketed mode; with natural aspect on
|
||||
// they would be discarded and regenerated on the next dashboard visit. The
|
||||
// import applies the mode the layouts were built for, and the file's own
|
||||
// naturalAspectLayout preference must not override that.
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
|
||||
|
||||
await chooseImportFile(
|
||||
frigateApp.page,
|
||||
importPayload({
|
||||
sections: {
|
||||
layouts: { outdoor: LEGACY_OUTDOOR_LAYOUT },
|
||||
streaming: {},
|
||||
preferences: { naturalAspectLayout: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const note = frigateApp.page.getByText(/standard tile sizing/);
|
||||
await expect(note).toBeVisible();
|
||||
|
||||
// the note is about the layouts section, so it follows its switch
|
||||
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
|
||||
await expect(note).toBeHidden();
|
||||
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
|
||||
await expect(note).toBeVisible();
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||
LEGACY_OUTDOOR_LAYOUT,
|
||||
);
|
||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("natural aspect layouts import turns the setting on", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
await chooseImportFile(
|
||||
frigateApp.page,
|
||||
importPayload({
|
||||
sections: {
|
||||
layouts: { outdoor: NATURAL_OUTDOOR_LAYOUT },
|
||||
streaming: {},
|
||||
preferences: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText(/camera aspect ratio tile sizing/),
|
||||
).toBeVisible();
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||
NATURAL_OUTDOOR_LAYOUT,
|
||||
);
|
||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("omits settings that were never stored", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
@@ -190,12 +363,15 @@ test.describe("UI settings import/export @medium", () => {
|
||||
await expect(
|
||||
frigateApp.page.getByText("UI preferences (2 settings)"),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByText(/patio/)).toBeVisible();
|
||||
// patio is layout-only, so its warning follows the layouts section
|
||||
await expect(frigateApp.page.getByText(/patio/)).toBeVisible({
|
||||
visible: !frigateApp.isMobile,
|
||||
});
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||
OUTDOOR_LAYOUT,
|
||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
||||
);
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||
STREAMING_SETTINGS,
|
||||
@@ -206,6 +382,7 @@ test.describe("UI settings import/export @medium", () => {
|
||||
test("hides the unknown-group warning when layouts are switched off", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
// patio is a layout-only group absent from this server, so the warning
|
||||
@@ -235,7 +412,39 @@ test.describe("UI settings import/export @medium", () => {
|
||||
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||
OUTDOOR_LAYOUT,
|
||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
||||
);
|
||||
});
|
||||
|
||||
test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => {
|
||||
test.skip(!frigateApp.isMobile, "Phone-only");
|
||||
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false });
|
||||
|
||||
await chooseImportFile(frigateApp.page, importPayload());
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText(/aren't imported on phones/),
|
||||
).toBeVisible();
|
||||
|
||||
// the section is still listed, but cannot be switched on
|
||||
await expect(
|
||||
frigateApp.page.getByText("Camera group layouts (2 groups)"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'),
|
||||
).toBeDisabled();
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||
STREAMING_SETTINGS,
|
||||
);
|
||||
// a layouts import is what flips this, so it must stay put
|
||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -325,7 +534,7 @@ test.describe("UI settings import/export @medium", () => {
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||
OUTDOOR_LAYOUT,
|
||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
||||
);
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||
STREAMING_SETTINGS,
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
"close": "Close",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"clear": "Clear",
|
||||
"copy": "Copy",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"back": "Back",
|
||||
|
||||
@@ -167,6 +167,11 @@
|
||||
"label": "Always Show Camera Names",
|
||||
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
||||
},
|
||||
"naturalAspectLayout": {
|
||||
"label": "Use Natural Aspect Ratios",
|
||||
"desc": "On camera group live view dashboards, size each tile to its camera's own natural aspect ratio. When disabled, cameras are snapped to a standard wide, landscape, or tall tile shape.",
|
||||
"descNote": "Toggling this setting on or off will clear the stored layout for all camera group live dashboards. Manual reconfiguration will be required."
|
||||
},
|
||||
"liveFallbackTimeout": {
|
||||
"label": "Live Player Fallback Timeout",
|
||||
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
|
||||
@@ -175,12 +180,14 @@
|
||||
"storedLayouts": {
|
||||
"title": "Stored Layouts",
|
||||
"desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
|
||||
"clearAll": "Clear All Layouts"
|
||||
"clearAll": "Clear All Layouts",
|
||||
"clearConfirm": "This will clear the stored layout for every camera group in this browser. This cannot be undone."
|
||||
},
|
||||
"cameraGroupStreaming": {
|
||||
"title": "Camera Group Streaming Settings",
|
||||
"desc": "Streaming settings for each camera group are stored in your browser's local storage.",
|
||||
"clearAll": "Clear All Streaming Settings"
|
||||
"clearAll": "Clear All Streaming Settings",
|
||||
"clearConfirm": "This will clear the streaming settings for every camera group in this browser. This cannot be undone."
|
||||
},
|
||||
"backupRestore": {
|
||||
"title": "Backup & Restore",
|
||||
@@ -195,6 +202,9 @@
|
||||
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
|
||||
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
|
||||
"layouts_one": "Camera group layouts ({{count}} group)",
|
||||
"layoutsPhone": "Camera group layouts aren't imported on phones, which always use the standard grid.",
|
||||
"layoutsModeOn": "These layouts use camera aspect ratio tile sizing, so importing them will also turn on \"Use Natural Aspect Ratios\".",
|
||||
"layoutsModeOff": "These layouts use standard tile sizing, so importing them will also turn off \"Use Natural Aspect Ratios\".",
|
||||
"layouts_other": "Camera group layouts ({{count}} groups)",
|
||||
"streaming_one": "Streaming settings ({{count}} camera)",
|
||||
"streaming_other": "Streaming settings ({{count}} cameras)",
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function CameraImage({
|
||||
)}
|
||||
{!imageLoaded && enabled ? (
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,13 @@ export function ImageShadowOverlay({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent",
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/20 to-transparent",
|
||||
upperClassName,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full bg-gradient-to-t from-black/20 to-transparent",
|
||||
lowerClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { LuFileJson, LuInfo, LuTriangleAlert } from "react-icons/lu";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
importedLayoutsNaturalAspect,
|
||||
ImportSummary,
|
||||
TransferSection,
|
||||
UiSettingsFile,
|
||||
@@ -25,6 +27,7 @@ type ImportUiSettingsDialogProps = {
|
||||
fileName: string;
|
||||
file: UiSettingsFile;
|
||||
summary: ImportSummary;
|
||||
currentNaturalAspect: boolean;
|
||||
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -34,6 +37,7 @@ export default function ImportUiSettingsDialog({
|
||||
fileName,
|
||||
file,
|
||||
summary,
|
||||
currentNaturalAspect,
|
||||
onConfirm,
|
||||
}: ImportUiSettingsDialogProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
@@ -41,7 +45,9 @@ export default function ImportUiSettingsDialog({
|
||||
|
||||
const available = useMemo(
|
||||
() => ({
|
||||
layouts: summary.layoutGroupCount > 0,
|
||||
// phones use the static grid, so a saved grid layout has nothing to
|
||||
// apply to and would only flip the tile sizing mode behind the scenes
|
||||
layouts: !isMobileOnly && summary.layoutGroupCount > 0,
|
||||
streaming: summary.streamingCameraCount > 0,
|
||||
preferences: summary.preferenceCount > 0,
|
||||
}),
|
||||
@@ -90,6 +96,16 @@ export default function ImportUiSettingsDialog({
|
||||
[sections.streaming, summary.unknownCameras],
|
||||
);
|
||||
|
||||
// importing layouts also applies the tile-sizing mode they were built for
|
||||
const layoutsModeChange = useMemo(() => {
|
||||
if (!sections.layouts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = importedLayoutsNaturalAspect(file);
|
||||
return mode === null || mode === currentNaturalAspect ? null : mode;
|
||||
}, [sections.layouts, file, currentNaturalAspect]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setIsImporting(true);
|
||||
await onConfirm(sections);
|
||||
@@ -113,73 +129,102 @@ export default function ImportUiSettingsDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="break-all text-base text-primary-variant">{fileName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("general.backupRestore.importDialog.exportedFrom", {
|
||||
date: exportedDate,
|
||||
version: file.frigate_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg bg-secondary p-3">
|
||||
<LuFileJson className="mt-0.5 size-5 shrink-0 text-secondary-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="break-all text-base font-medium text-primary-variant">
|
||||
{fileName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general.backupRestore.importDialog.exportedFrom", {
|
||||
date: exportedDate,
|
||||
version: file.frigate_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.layouts", {
|
||||
count: summary.layoutGroupCount,
|
||||
})}
|
||||
isChecked={sections.layouts}
|
||||
disabled={!available.layouts || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, layouts: checked }))
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.streaming", {
|
||||
count: summary.streamingCameraCount,
|
||||
})}
|
||||
isChecked={sections.streaming}
|
||||
disabled={!available.streaming || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, streaming: checked }))
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.preferences", {
|
||||
count: summary.preferenceCount,
|
||||
})}
|
||||
isChecked={sections.preferences}
|
||||
disabled={!available.preferences || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, preferences: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.layouts", {
|
||||
count: summary.layoutGroupCount,
|
||||
})}
|
||||
isChecked={sections.layouts}
|
||||
disabled={!available.layouts || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, layouts: checked }))
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.streaming", {
|
||||
count: summary.streamingCameraCount,
|
||||
})}
|
||||
isChecked={sections.streaming}
|
||||
disabled={!available.streaming || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, streaming: checked }))
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.preferences", {
|
||||
count: summary.preferenceCount,
|
||||
})}
|
||||
isChecked={sections.preferences}
|
||||
disabled={!available.preferences || isImporting}
|
||||
onCheckedChange={(checked) =>
|
||||
setSections((prev) => ({ ...prev, preferences: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(visibleUnknownGroups.length > 0 ||
|
||||
visibleUnknownCameras.length > 0) && (
|
||||
<Alert variant="warning">
|
||||
<LuTriangleAlert className="size-5" />
|
||||
<AlertDescription className="space-y-2">
|
||||
{visibleUnknownGroups.length > 0 && (
|
||||
<p>
|
||||
{t("general.backupRestore.importDialog.unknownGroups", {
|
||||
count: visibleUnknownGroups.length,
|
||||
groups: visibleUnknownGroups.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{visibleUnknownCameras.length > 0 && (
|
||||
<p>
|
||||
{t("general.backupRestore.importDialog.unknownCameras", {
|
||||
count: visibleUnknownCameras.length,
|
||||
cameras: visibleUnknownCameras.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isMobileOnly && summary.layoutGroupCount > 0 && (
|
||||
<Alert variant="info">
|
||||
<LuInfo className="size-5" />
|
||||
<AlertDescription>
|
||||
{t("general.backupRestore.importDialog.layoutsPhone")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{layoutsModeChange !== null && (
|
||||
<Alert variant="info">
|
||||
<LuInfo className="size-5" />
|
||||
<AlertDescription>
|
||||
{t(
|
||||
layoutsModeChange
|
||||
? "general.backupRestore.importDialog.layoutsModeOn"
|
||||
: "general.backupRestore.importDialog.layoutsModeOff",
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(visibleUnknownGroups.length > 0 ||
|
||||
visibleUnknownCameras.length > 0) && (
|
||||
<Alert variant="warning">
|
||||
<LuTriangleAlert className="size-5" />
|
||||
<AlertDescription className="space-y-2">
|
||||
{visibleUnknownGroups.length > 0 && (
|
||||
<p>
|
||||
{t("general.backupRestore.importDialog.unknownGroups", {
|
||||
count: visibleUnknownGroups.length,
|
||||
groups: visibleUnknownGroups.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{visibleUnknownCameras.length > 0 && (
|
||||
<p>
|
||||
{t("general.backupRestore.importDialog.unknownCameras", {
|
||||
count: visibleUnknownCameras.length,
|
||||
cameras: visibleUnknownCameras.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -198,7 +243,7 @@ export default function ImportUiSettingsDialog({
|
||||
>
|
||||
{isImporting ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("general.backupRestore.importDialog.confirm")}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -29,22 +29,10 @@ export default function BirdseyeLivePlayer({
|
||||
}: LivePlayerProps) {
|
||||
let player;
|
||||
if (liveMode == "webrtc") {
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
player = <WebRtcPlayer className="size-full" camera="birdseye" pip={pip} />;
|
||||
} else if (liveMode == "mse") {
|
||||
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
||||
player = (
|
||||
<MSEPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
player = <MSEPlayer className="size-full" camera="birdseye" pip={pip} />;
|
||||
} else {
|
||||
player = (
|
||||
<div className="w-5xl text-center text-sm">
|
||||
@@ -55,7 +43,7 @@ export default function BirdseyeLivePlayer({
|
||||
} else if (liveMode == "jsmpeg") {
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
className="flex size-full justify-center overflow-hidden"
|
||||
camera="birdseye"
|
||||
width={birdseyeConfig.width}
|
||||
height={birdseyeConfig.height}
|
||||
@@ -65,22 +53,31 @@ export default function BirdseyeLivePlayer({
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
player = <ActivityIndicator />;
|
||||
player = <ActivityIndicator className="w-full [.bg-black_&]:text-white" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer justify-center",
|
||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
||||
style={
|
||||
{
|
||||
"--pic-ar":
|
||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
||||
<ImageShadowOverlay />
|
||||
</div>
|
||||
</div>
|
||||
<div className="size-full" ref={playerRef}>
|
||||
{player}
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ type LivePlayerProps = {
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
onResetLiveMode?: () => void;
|
||||
onLiveAspectChange?: (aspectRatio: number | undefined) => void;
|
||||
};
|
||||
|
||||
export default function LivePlayer({
|
||||
@@ -80,6 +81,7 @@ export default function LivePlayer({
|
||||
onError,
|
||||
onMicrophoneError,
|
||||
onResetLiveMode,
|
||||
onLiveAspectChange,
|
||||
}: LivePlayerProps) {
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
|
||||
@@ -127,6 +129,37 @@ export default function LivePlayer({
|
||||
// camera live state
|
||||
|
||||
const [liveReady, setLiveReady] = useState(false);
|
||||
const [liveAspect, setLiveAspect] = useState<number | undefined>();
|
||||
|
||||
const handleFullResolution = useCallback(
|
||||
(value: React.SetStateAction<VideoResolutionType>) => {
|
||||
setFullResolution?.(value);
|
||||
|
||||
if (typeof value === "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLiveAspect(
|
||||
value.width && value.height ? value.width / value.height : undefined,
|
||||
);
|
||||
},
|
||||
[setFullResolution],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLiveAspectChange?.(liveReady ? liveAspect : undefined);
|
||||
}, [liveReady, liveAspect, onLiveAspectChange]);
|
||||
|
||||
// The card can be a different shape than the picture (a bucketed tile, or a
|
||||
// still whose detect aspect differs from the stream), so overlays that are
|
||||
// meant to sit on the image have to be fitted to it rather than to the card.
|
||||
const pictureAspect = useMemo(() => {
|
||||
if (liveReady && liveAspect) {
|
||||
return liveAspect;
|
||||
}
|
||||
const { width, height } = cameraConfig.detect;
|
||||
return width && height ? width / height : 16 / 9;
|
||||
}, [liveReady, liveAspect, cameraConfig.detect]);
|
||||
|
||||
const liveReadyRef = useRef(liveReady);
|
||||
const cameraActiveRef = useRef(cameraActive);
|
||||
@@ -262,11 +295,12 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
key={"webrtc_" + key}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
getStats={showStats}
|
||||
setStats={setStats}
|
||||
setFullResolution={handleFullResolution}
|
||||
audioEnabled={playAudio}
|
||||
volume={volume}
|
||||
microphoneEnabled={micEnabled}
|
||||
@@ -282,7 +316,7 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<MSEPlayer
|
||||
key={"mse_" + key}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
audioEnabled={playAudio}
|
||||
@@ -292,7 +326,7 @@ export default function LivePlayer({
|
||||
setStats={setStats}
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
setFullResolution={setFullResolution}
|
||||
setFullResolution={handleFullResolution}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
@@ -308,7 +342,7 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
key={"jsmpeg_" + key}
|
||||
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
className="flex justify-center overflow-hidden"
|
||||
camera={cameraConfig.name}
|
||||
width={cameraConfig.detect.width}
|
||||
height={cameraConfig.detect.height}
|
||||
@@ -325,7 +359,9 @@ export default function LivePlayer({
|
||||
player = null;
|
||||
}
|
||||
} else {
|
||||
player = <ActivityIndicator />;
|
||||
player = (
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -340,10 +376,12 @@ export default function LivePlayer({
|
||||
}}
|
||||
data-camera={cameraConfig.name}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer justify-center outline",
|
||||
// the card owns the corner: overflow-hidden clips the stream, the still
|
||||
// image, and every overlay to this one radius so they stay concentric
|
||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg outline md:rounded-2xl",
|
||||
activeTracking &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady)
|
||||
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
|
||||
? "shadow-severity_alert outline-[3px] outline-severity_alert"
|
||||
: "outline-0 outline-background",
|
||||
"transition-all duration-500",
|
||||
className,
|
||||
@@ -357,10 +395,14 @@ export default function LivePlayer({
|
||||
>
|
||||
{cameraEnabled &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
||||
style={{ "--pic-ar": pictureAspect } as React.CSSProperties}
|
||||
>
|
||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
||||
<ImageShadowOverlay />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{player}
|
||||
{cameraEnabled &&
|
||||
@@ -368,7 +410,7 @@ export default function LivePlayer({
|
||||
(!showStillWithoutActivity || isReEnabling) &&
|
||||
!liveReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -447,7 +489,7 @@ export default function LivePlayer({
|
||||
|
||||
{offline && inDashboard && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
||||
<div>{t("streamOffline.title")}</div>
|
||||
@@ -491,7 +533,7 @@ export default function LivePlayer({
|
||||
)}
|
||||
|
||||
{!cameraEnabled && (
|
||||
<div className="relative flex h-full w-full items-center justify-center rounded-2xl border border-secondary-foreground bg-background_alt">
|
||||
<div className="relative flex h-full w-full items-center justify-center border border-secondary-foreground bg-background_alt">
|
||||
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
|
||||
<LuVideoOff className="mb-2 size-8 md:size-10" />
|
||||
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { LuFolderX } from "react-icons/lu";
|
||||
import { isMobileOnly, isSafari } from "react-device-detect";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { LuPause, LuPlay } from "react-icons/lu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -54,7 +54,7 @@ const CONTROLS_DEFAULT: VideoControls = {
|
||||
snapshot: false,
|
||||
fullscreen: false,
|
||||
};
|
||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||
const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16];
|
||||
const MIN_ITEMS_WRAP = 6;
|
||||
|
||||
type VideoControlsProps = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
TwoWayTalkError,
|
||||
VideoResolutionType,
|
||||
} from "@/types/live";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { webRTCIceServers } from "@/utils/webrtcUtil";
|
||||
@@ -20,6 +21,7 @@ type WebRtcPlayerProps = {
|
||||
pip?: boolean;
|
||||
getStats?: boolean;
|
||||
setStats?: (stats: PlayerStatsType) => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onPlaying?: () => void;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
@@ -36,6 +38,7 @@ export default function WebRtcPlayer({
|
||||
pip = false,
|
||||
getStats = false,
|
||||
setStats,
|
||||
setFullResolution,
|
||||
onPlaying,
|
||||
onError,
|
||||
onMicrophoneError,
|
||||
@@ -342,6 +345,12 @@ export default function WebRtcPlayer({
|
||||
if (videoLoadTimeoutRef.current) {
|
||||
clearTimeout(videoLoadTimeoutRef.current);
|
||||
}
|
||||
if (videoRef.current) {
|
||||
setFullResolution?.({
|
||||
width: videoRef.current.videoWidth,
|
||||
height: videoRef.current.videoHeight,
|
||||
});
|
||||
}
|
||||
onPlaying?.();
|
||||
};
|
||||
|
||||
|
||||
@@ -175,6 +175,21 @@ html {
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* Live masonry grid: only the bottom-right corner resizes, drawn as a corner
|
||||
bracket on the real se handle (drop-shadow keeps it legible over footage). */
|
||||
.grid-layout .react-resizable-handle-se::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-right: 2.5px solid rgba(233, 238, 246, 0.92);
|
||||
border-bottom: 2.5px solid rgba(233, 238, 246, 0.92);
|
||||
border-bottom-right-radius: 3px;
|
||||
filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
border: 3px solid #a00000 !important;
|
||||
opacity: 0.5 !important;
|
||||
|
||||
@@ -54,6 +54,12 @@ export const TRANSFER_KEYS: TransferKey[] = [
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "naturalAspectLayout",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "alertVideos",
|
||||
section: "preferences",
|
||||
@@ -182,13 +188,22 @@ const layoutItemSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// a group whose dashboard has not been opened since upgrading still holds
|
||||
// the pre-0.19 bare array, so both shapes reach the file
|
||||
const storedLayoutSchema = z.union([
|
||||
z.array(layoutItemSchema),
|
||||
z
|
||||
.object({ version: z.number(), layout: z.array(layoutItemSchema) })
|
||||
.passthrough(),
|
||||
]);
|
||||
|
||||
export const uiSettingsFileSchema = z.object({
|
||||
type: z.literal(UI_SETTINGS_FILE_TYPE),
|
||||
version: z.number().int().positive(),
|
||||
exported_at: z.string(),
|
||||
frigate_version: z.string(),
|
||||
sections: z.object({
|
||||
layouts: z.record(z.string(), z.array(layoutItemSchema)),
|
||||
layouts: z.record(z.string(), storedLayoutSchema),
|
||||
streaming: allGroupsStreamingSettingsSchema,
|
||||
preferences: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
@@ -204,6 +219,8 @@ export async function buildExportPayload(
|
||||
const layouts: UiSettingsFile["sections"]["layouts"] = {};
|
||||
let streaming: UiSettingsFile["sections"]["streaming"] = {};
|
||||
const preferences: UiSettingsFile["sections"]["preferences"] = {};
|
||||
const naturalAspect =
|
||||
(await readTransferable("naturalAspectLayout", true, username)) === true;
|
||||
|
||||
await Promise.all(
|
||||
groupNames.map(async (group) => {
|
||||
@@ -213,7 +230,9 @@ export async function buildExportPayload(
|
||||
username,
|
||||
);
|
||||
|
||||
if (value !== undefined) {
|
||||
// a group not opened since the mode changed still holds a layout from
|
||||
// the other mode, which the grid discards, so leave it out of the file
|
||||
if (value !== undefined && layoutIsNatural(value) === naturalAspect) {
|
||||
layouts[group] = value;
|
||||
}
|
||||
}),
|
||||
@@ -384,6 +403,30 @@ export function summarizeImport(
|
||||
};
|
||||
}
|
||||
|
||||
// Bare arrays are pre-masonry bucketed layouts
|
||||
function layoutIsNatural(layout: unknown): boolean {
|
||||
return (
|
||||
typeof layout === "object" &&
|
||||
layout !== null &&
|
||||
!Array.isArray(layout) &&
|
||||
(layout as { naturalAspect?: unknown }).naturalAspect === true
|
||||
);
|
||||
}
|
||||
|
||||
// A layout only renders under the mode that built it, so importing layouts
|
||||
// applies this mode too. Exports hold a single mode.
|
||||
export function importedLayoutsNaturalAspect(
|
||||
file: UiSettingsFile,
|
||||
): boolean | null {
|
||||
const layouts = Object.values(file.sections.layouts);
|
||||
|
||||
if (!layouts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return layouts.some(layoutIsNatural);
|
||||
}
|
||||
|
||||
export function hasImportableContent(summary: ImportSummary): boolean {
|
||||
return (
|
||||
summary.layoutGroupCount > 0 ||
|
||||
@@ -398,6 +441,9 @@ export async function applyImportPayload(
|
||||
username: string | undefined,
|
||||
): Promise<void> {
|
||||
const writes: Promise<void>[] = [];
|
||||
const layoutsMode = sections.layouts
|
||||
? importedLayoutsNaturalAspect(file)
|
||||
: null;
|
||||
|
||||
if (sections.layouts) {
|
||||
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
|
||||
@@ -408,6 +454,15 @@ export async function applyImportPayload(
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
if (layoutsMode !== null) {
|
||||
writes.push(
|
||||
setData(
|
||||
getUserNamespacedKey("naturalAspectLayout", username),
|
||||
layoutsMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const streamingEntry = TRANSFER_KEYS.find(
|
||||
@@ -447,6 +502,12 @@ export async function applyImportPayload(
|
||||
if (sections.preferences) {
|
||||
validPreferenceEntries(file.sections.preferences).forEach(
|
||||
({ entry, value }) => {
|
||||
// the layouts must win this key or they import into a mode that
|
||||
// cannot display them
|
||||
if (entry.key === "naturalAspectLayout" && layoutsMode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
writes.push(setData(storageKey(entry, username), value));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,16 +8,17 @@ import {
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import {
|
||||
Layout,
|
||||
LayoutItem,
|
||||
ResponsiveGridLayout as Responsive,
|
||||
} from "react-grid-layout";
|
||||
import { aspectRatio, getCompactor } from "react-grid-layout/core";
|
||||
import "react-grid-layout/css/styles.css";
|
||||
import "react-resizable/css/styles.css";
|
||||
import {
|
||||
@@ -28,12 +29,11 @@ import {
|
||||
StatsState,
|
||||
VolumeState,
|
||||
} from "@/types/live";
|
||||
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
import { ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { isEqual } from "lodash";
|
||||
import useSWR from "swr";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
|
||||
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
||||
import LivePlayer from "@/components/player/LivePlayer";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
@@ -52,6 +52,43 @@ import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// rowHeight is 1/VERTICAL_RESOLUTION of a column, so h = round(w *
|
||||
// VERTICAL_RESOLUTION / aspect) lands a tile on its camera's aspect. GRID_COLS
|
||||
// also sets resize granularity: the aspect constraint derives height from
|
||||
// width, making one column the smallest step in both axes.
|
||||
const GRID_COLS = 96;
|
||||
const TILE_BASE_W = 32;
|
||||
const TILE_WIDE_W = 64;
|
||||
const VERTICAL_RESOLUTION = 4;
|
||||
const DEFAULT_ASPECT = 16 / 9;
|
||||
// Bucketed tile shapes, matching the aspect-wide / aspect-tall Tailwind utilities.
|
||||
const TILE_ASPECT_WIDE = 32 / 9;
|
||||
const TILE_ASPECT_TALL = 8 / 9;
|
||||
|
||||
// Cells quantize to whole rows/columns, so the card takes the camera's exact
|
||||
// ratio and fits itself inside its cell. --ar and container-type live on the
|
||||
// cell; min() picks whichever axis binds first.
|
||||
const CARD_FIT =
|
||||
"h-auto w-[min(100%,calc(100cqh*var(--ar)))] aspect-[var(--ar)]";
|
||||
|
||||
// Stored coordinates are grid units, so bump this whenever GRID_COLS or
|
||||
// VERTICAL_RESOLUTION changes in a released version.
|
||||
const LAYOUT_VERSION = 2;
|
||||
type PersistedLayout = {
|
||||
version: number;
|
||||
naturalAspect: boolean;
|
||||
layout: Layout;
|
||||
};
|
||||
|
||||
// Without preventCollision, RGL shoves collided tiles down the page and never
|
||||
// compacts them back.
|
||||
const FREE_PLACEMENT_COMPACTOR = getCompactor(null, false, true);
|
||||
|
||||
// 0.17/0.18 stored a bare array on a 12-column grid whose standard tile was
|
||||
// 4x4. Bucketed mode reproduces that geometry, so those layouts convert exactly.
|
||||
const LEGACY_GRID_COLS = 12;
|
||||
const LEGACY_TILE_ROWS = 4;
|
||||
|
||||
type DraggableGridLayoutProps = {
|
||||
cameras: CameraConfig[];
|
||||
cameraGroup: string;
|
||||
@@ -98,6 +135,66 @@ export default function DraggableGridLayout({
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||
|
||||
const aspectRatios = useMemo(() => {
|
||||
const map: { [key: string]: number } = {};
|
||||
if (birdseyeConfig) {
|
||||
map["birdseye"] =
|
||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1);
|
||||
}
|
||||
cameras.forEach((camera) => {
|
||||
map[camera.name] =
|
||||
camera.detect.width / camera.detect.height || DEFAULT_ASPECT;
|
||||
});
|
||||
return map;
|
||||
}, [cameras, birdseyeConfig]);
|
||||
|
||||
const [naturalAspectSetting, , isNaturalAspectLoaded] = useUserPersistence(
|
||||
"naturalAspectLayout",
|
||||
false,
|
||||
);
|
||||
// phones never reach this grid, and the setting is hidden there, so an
|
||||
// imported or stale value must not take effect
|
||||
const naturalAspectLayout = !isMobileOnly && (naturalAspectSetting ?? false);
|
||||
|
||||
// Bucketed mode snaps every camera to one of three tile shapes, matching the
|
||||
// pre-masonry layout; the picture letterboxes inside its bucket.
|
||||
const layoutAspects = useMemo(() => {
|
||||
if (naturalAspectLayout) {
|
||||
return aspectRatios;
|
||||
}
|
||||
const map: { [key: string]: number } = {};
|
||||
Object.entries(aspectRatios).forEach(([name, ratio]) => {
|
||||
map[name] =
|
||||
ratio > ASPECT_WIDE_LAYOUT
|
||||
? TILE_ASPECT_WIDE
|
||||
: ratio < 1
|
||||
? TILE_ASPECT_TALL
|
||||
: DEFAULT_ASPECT;
|
||||
});
|
||||
return map;
|
||||
}, [aspectRatios, naturalAspectLayout]);
|
||||
|
||||
// A live stream can be shaped differently than detect, so the card follows
|
||||
// whatever is on screen and falls back to detect. Cells stay detect-sized, so
|
||||
// only the card resizes.
|
||||
const [liveAspects, setLiveAspects] = useState<{
|
||||
[key: string]: number | undefined;
|
||||
}>({});
|
||||
|
||||
const liveAspectHandlers = useMemo(() => {
|
||||
const map: { [key: string]: (aspectRatio: number | undefined) => void } =
|
||||
{};
|
||||
cameras.forEach((camera) => {
|
||||
map[camera.name] = (aspectRatio) =>
|
||||
setLiveAspects((prev) =>
|
||||
prev[camera.name] === aspectRatio
|
||||
? prev
|
||||
: { ...prev, [camera.name]: aspectRatio },
|
||||
);
|
||||
});
|
||||
return map;
|
||||
}, [cameras]);
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
@@ -115,7 +212,31 @@ export default function DraggableGridLayout({
|
||||
// grid layout
|
||||
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
|
||||
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`);
|
||||
useUserPersistence<PersistedLayout>(`${cameraGroup}-draggable-layout`);
|
||||
|
||||
const readPersistedLayout = useCallback(
|
||||
(stored: PersistedLayout | undefined): Layout | undefined => {
|
||||
if (
|
||||
!stored ||
|
||||
stored.version !== LAYOUT_VERSION ||
|
||||
!Array.isArray(stored.layout)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return stored.layout;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Strips per-item `constraints`, which are functions.
|
||||
const toPersisted = useCallback(
|
||||
(layout: Layout): PersistedLayout => ({
|
||||
version: LAYOUT_VERSION,
|
||||
naturalAspect: naturalAspectLayout,
|
||||
layout: layout.map(({ i, x, y, w, h }) => ({ i, x, y, w, h })),
|
||||
}),
|
||||
[naturalAspectLayout],
|
||||
);
|
||||
|
||||
const [group] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
@@ -140,11 +261,11 @@ export default function DraggableGridLayout({
|
||||
useEffect(() => {
|
||||
setIsEditMode(false);
|
||||
setEditGroup(false);
|
||||
// Reset camera tracking state when group changes to prevent the camera-change
|
||||
// effect from incorrectly overwriting the loaded layout
|
||||
// Keeps the camera-change effect from overwriting the layout we load next.
|
||||
setCurrentCameras(undefined);
|
||||
setCurrentIncludeBirdseye(undefined);
|
||||
setCurrentGridLayout(undefined);
|
||||
setCurrentNaturalAspect(undefined);
|
||||
}, [cameraGroup, setIsEditMode]);
|
||||
|
||||
// camera state
|
||||
@@ -155,21 +276,84 @@ export default function DraggableGridLayout({
|
||||
const [currentGridLayout, setCurrentGridLayout] = useState<
|
||||
Layout | undefined
|
||||
>();
|
||||
const [currentNaturalAspect, setCurrentNaturalAspect] = useState<boolean>();
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(currentLayout: Layout) => {
|
||||
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
|
||||
if (
|
||||
!isGridLayoutLoaded ||
|
||||
!isEqual(readPersistedLayout(gridLayout), currentGridLayout)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// save layout to idb
|
||||
setGridLayout(currentLayout);
|
||||
setGridLayout(toPersisted(currentLayout));
|
||||
setShowCircles(true);
|
||||
},
|
||||
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||
[
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
gridLayout,
|
||||
currentGridLayout,
|
||||
readPersistedLayout,
|
||||
toPersisted,
|
||||
],
|
||||
);
|
||||
|
||||
const dimsFor = useCallback(
|
||||
(name: string) => {
|
||||
const ratio = layoutAspects[name] ?? DEFAULT_ASPECT;
|
||||
const w = ratio >= ASPECT_WIDE_LAYOUT ? TILE_WIDE_W : TILE_BASE_W;
|
||||
const h = Math.max(1, Math.round((w * VERTICAL_RESOLUTION) / ratio));
|
||||
return { w, h };
|
||||
},
|
||||
[layoutAspects],
|
||||
);
|
||||
|
||||
// Rescale a pre-masonry layout onto the current grid. Both axes scale by a
|
||||
// constant, so tiles the user resized keep their size and their arrangement
|
||||
// stays intact. Only meaningful in bucketed mode, where a tile still has the
|
||||
// shape those coordinates assumed.
|
||||
const convertLegacyLayout = useCallback(
|
||||
(stored: unknown): Layout | undefined => {
|
||||
if (naturalAspectLayout || !Array.isArray(stored) || !stored.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const xScale = GRID_COLS / LEGACY_GRID_COLS;
|
||||
const yScale =
|
||||
Math.round((TILE_BASE_W * VERTICAL_RESOLUTION) / DEFAULT_ASPECT) /
|
||||
LEGACY_TILE_ROWS;
|
||||
const converted: LayoutItem[] = [];
|
||||
|
||||
for (const item of stored) {
|
||||
if (
|
||||
!item ||
|
||||
typeof item.i !== "string" ||
|
||||
typeof item.x !== "number" ||
|
||||
typeof item.y !== "number" ||
|
||||
typeof item.w !== "number" ||
|
||||
typeof item.h !== "number"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const w = Math.min(Math.max(1, Math.round(item.w * xScale)), GRID_COLS);
|
||||
converted.push({
|
||||
i: item.i,
|
||||
x: Math.min(Math.max(0, Math.round(item.x * xScale)), GRID_COLS - w),
|
||||
y: Math.max(0, Math.round(item.y * yScale)),
|
||||
w,
|
||||
h: Math.max(1, Math.round(item.h * yScale)),
|
||||
});
|
||||
}
|
||||
|
||||
return converted;
|
||||
},
|
||||
[naturalAspectLayout],
|
||||
);
|
||||
|
||||
const generateLayout = useCallback(
|
||||
(baseLayout: Layout | undefined) => {
|
||||
(baseLayout: Layout | undefined): Layout | undefined => {
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
@@ -179,91 +363,98 @@ export default function DraggableGridLayout({
|
||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||
: cameras.map((camera) => camera?.name || "");
|
||||
|
||||
const optionsMap: LayoutItem[] = baseLayout
|
||||
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||
const existing: LayoutItem[] = baseLayout
|
||||
? baseLayout.filter((layout) => cameraNames.includes(layout.i))
|
||||
: [];
|
||||
const placed = new Set(existing.map((layout) => layout.i));
|
||||
|
||||
cameraNames.forEach((cameraName, index) => {
|
||||
const existingLayout = optionsMap.find(
|
||||
(layout) => layout.i === cameraName,
|
||||
);
|
||||
const tileColumns = GRID_COLS / TILE_BASE_W; // 3 standard columns
|
||||
// Each column starts below every existing tile that overlaps it, so new
|
||||
// cameras fill open columns without overlapping the user's tiles.
|
||||
const colBottoms = Array.from({ length: tileColumns }, (_, c) =>
|
||||
existing.reduce(
|
||||
(max, layout) =>
|
||||
layout.x < (c + 1) * TILE_BASE_W &&
|
||||
layout.x + layout.w > c * TILE_BASE_W
|
||||
? Math.max(max, layout.y + layout.h)
|
||||
: max,
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
// Skip if the camera already exists in the layout
|
||||
if (existingLayout) {
|
||||
const result: LayoutItem[] = [...existing];
|
||||
|
||||
cameraNames.forEach((name) => {
|
||||
if (placed.has(name)) {
|
||||
return;
|
||||
}
|
||||
const { w, h } = dimsFor(name);
|
||||
|
||||
let aspectRatio;
|
||||
let col;
|
||||
|
||||
// Handle "birdseye" camera as a special case
|
||||
if (cameraName === "birdseye") {
|
||||
aspectRatio =
|
||||
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
|
||||
col = 0; // Set birdseye camera in the first column
|
||||
if (w === TILE_BASE_W) {
|
||||
let col = 0;
|
||||
for (let c = 1; c < tileColumns; c++) {
|
||||
if (colBottoms[c] < colBottoms[col]) {
|
||||
col = c;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
i: name,
|
||||
x: col * TILE_BASE_W,
|
||||
y: colBottoms[col],
|
||||
w,
|
||||
h,
|
||||
});
|
||||
colBottoms[col] += h;
|
||||
} else {
|
||||
const camera = cameras.find((cam) => cam.name === cameraName);
|
||||
aspectRatio =
|
||||
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
|
||||
col = index % 3; // Regular cameras distributed across columns
|
||||
let pair = 0;
|
||||
for (let c = 1; c + 1 < tileColumns; c++) {
|
||||
if (
|
||||
Math.max(colBottoms[c], colBottoms[c + 1]) <
|
||||
Math.max(colBottoms[pair], colBottoms[pair + 1])
|
||||
) {
|
||||
pair = c;
|
||||
}
|
||||
}
|
||||
const y = Math.max(colBottoms[pair], colBottoms[pair + 1]);
|
||||
result.push({ i: name, x: pair * TILE_BASE_W, y, w, h });
|
||||
colBottoms[pair] = y + h;
|
||||
colBottoms[pair + 1] = y + h;
|
||||
}
|
||||
|
||||
// Calculate layout options based on aspect ratio
|
||||
const columnsPerPlayer = 4;
|
||||
let height;
|
||||
let width;
|
||||
|
||||
if (aspectRatio < 1) {
|
||||
// Portrait
|
||||
height = 2 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
} else if (aspectRatio > 2) {
|
||||
// Wide
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = 2 * columnsPerPlayer;
|
||||
} else {
|
||||
// Landscape
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
}
|
||||
|
||||
const options = {
|
||||
i: cameraName,
|
||||
x: col * width,
|
||||
y: 0, // don't set y, grid does automatically
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
|
||||
optionsMap.push(options);
|
||||
});
|
||||
|
||||
return optionsMap;
|
||||
return result;
|
||||
},
|
||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
|
||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig, dimsFor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGridLayoutLoaded) {
|
||||
if (gridLayout) {
|
||||
// set current grid layout from loaded, possibly adding new cameras
|
||||
const updatedLayout = generateLayout(gridLayout);
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
// Only save if cameras were added (layout changed)
|
||||
if (!isEqual(updatedLayout, gridLayout)) {
|
||||
setGridLayout(updatedLayout);
|
||||
}
|
||||
// Set camera tracking state so the camera-change effect has a baseline
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
} else {
|
||||
// idb is empty, set it with an initial layout
|
||||
const newLayout = generateLayout(undefined);
|
||||
setCurrentGridLayout(newLayout);
|
||||
setGridLayout(newLayout);
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = readPersistedLayout(gridLayout);
|
||||
const converted = saved ? undefined : convertLegacyLayout(gridLayout);
|
||||
const base = saved ?? converted;
|
||||
|
||||
if (base) {
|
||||
const updatedLayout = generateLayout(base) ?? base;
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
if (converted || !isEqual(updatedLayout, base)) {
|
||||
setGridLayout(toPersisted(updatedLayout));
|
||||
}
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
setCurrentNaturalAspect(
|
||||
converted ? naturalAspectLayout : gridLayout?.naturalAspect,
|
||||
);
|
||||
} else {
|
||||
// empty or incompatible (pre-masonry) data
|
||||
const newLayout = generateLayout(undefined) ?? [];
|
||||
setCurrentGridLayout(newLayout);
|
||||
setGridLayout(toPersisted(newLayout));
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
setCurrentNaturalAspect(naturalAspectLayout);
|
||||
}
|
||||
}, [
|
||||
gridLayout,
|
||||
@@ -272,12 +463,15 @@ export default function DraggableGridLayout({
|
||||
generateLayout,
|
||||
cameras,
|
||||
includeBirdseye,
|
||||
naturalAspectLayout,
|
||||
readPersistedLayout,
|
||||
convertLegacyLayout,
|
||||
toPersisted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
||||
// Skip if currentCameras is undefined (means we just switched groups and
|
||||
// the first useEffect hasn't run yet to set things up)
|
||||
// Only for camera changes within a loaded group; undefined currentCameras
|
||||
// means the load effect above has not run yet.
|
||||
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -289,10 +483,10 @@ export default function DraggableGridLayout({
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
|
||||
// Regenerate layout based on current layout, adding any new cameras
|
||||
const updatedLayout = generateLayout(currentGridLayout);
|
||||
const updatedLayout =
|
||||
generateLayout(currentGridLayout) ?? currentGridLayout ?? [];
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
setGridLayout(updatedLayout);
|
||||
setGridLayout(toPersisted(updatedLayout));
|
||||
}
|
||||
}, [
|
||||
cameras,
|
||||
@@ -303,39 +497,47 @@ export default function DraggableGridLayout({
|
||||
generateLayout,
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
toPersisted,
|
||||
]);
|
||||
|
||||
const [marginValue, setMarginValue] = useState(16);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isNaturalAspectLoaded ||
|
||||
currentNaturalAspect === undefined ||
|
||||
currentNaturalAspect === naturalAspectLayout
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// calculate margin value for browsers that don't have default font size of 16px
|
||||
useLayoutEffect(() => {
|
||||
const calculateRemValue = () => {
|
||||
const htmlElement = document.documentElement;
|
||||
const fontSize = window.getComputedStyle(htmlElement).fontSize;
|
||||
setMarginValue(parseFloat(fontSize));
|
||||
};
|
||||
setCurrentNaturalAspect(naturalAspectLayout);
|
||||
const regenerated = generateLayout(undefined) ?? [];
|
||||
setCurrentGridLayout(regenerated);
|
||||
setGridLayout(toPersisted(regenerated));
|
||||
}, [
|
||||
naturalAspectLayout,
|
||||
isNaturalAspectLoaded,
|
||||
currentNaturalAspect,
|
||||
generateLayout,
|
||||
setGridLayout,
|
||||
toPersisted,
|
||||
]);
|
||||
|
||||
calculateRemValue();
|
||||
const gridContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Commit-time measure: paints the first frame at the real width (no
|
||||
// innerWidth flash), and the setState re-render is what lets
|
||||
// useResizeObserver see a node mounted after the skeleton swap.
|
||||
const [mountWidth, setMountWidth] = useState<number | null>(null);
|
||||
|
||||
const attachGridContainer = useCallback((node: HTMLDivElement | null) => {
|
||||
gridContainerRef.current = node;
|
||||
setMountWidth(node ? node.getBoundingClientRect().width : null);
|
||||
}, []);
|
||||
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ width: containerWidth, height: containerHeight }] =
|
||||
useResizeObserver(gridContainerRef);
|
||||
|
||||
const scrollBarWidth = useMemo(() => {
|
||||
if (containerWidth && containerHeight && containerRef.current) {
|
||||
return (
|
||||
containerRef.current.offsetWidth - containerRef.current.clientWidth
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}, [containerRef, containerHeight, containerWidth]);
|
||||
|
||||
const availableWidth = useMemo(
|
||||
() => (scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth),
|
||||
[containerWidth, scrollBarWidth],
|
||||
);
|
||||
const availableWidth = containerWidth || mountWidth || 0;
|
||||
|
||||
const hasScrollbar = useMemo(() => {
|
||||
if (containerHeight && containerRef.current) {
|
||||
@@ -346,61 +548,10 @@ export default function DraggableGridLayout({
|
||||
}, [containerRef, containerHeight]);
|
||||
|
||||
const cellHeight = useMemo(() => {
|
||||
const aspectRatio = 16 / 9;
|
||||
// subtract container margin, 1 camera takes up at least 4 rows
|
||||
// account for additional margin on bottom of each row
|
||||
return (
|
||||
((availableWidth ?? window.innerWidth) - 2 * marginValue) /
|
||||
12 /
|
||||
aspectRatio -
|
||||
marginValue +
|
||||
marginValue / 4
|
||||
);
|
||||
}, [availableWidth, marginValue]);
|
||||
|
||||
const handleResize = (
|
||||
_layout: Layout,
|
||||
oldLayoutItem: LayoutItem | null,
|
||||
layoutItem: LayoutItem | null,
|
||||
placeholder: LayoutItem | null,
|
||||
) => {
|
||||
if (!oldLayoutItem || !layoutItem || !placeholder) return;
|
||||
|
||||
const heightDiff = layoutItem.h - oldLayoutItem.h;
|
||||
const widthDiff = layoutItem.w - oldLayoutItem.w;
|
||||
const changeCoef = oldLayoutItem.w / oldLayoutItem.h;
|
||||
|
||||
let newWidth, newHeight;
|
||||
|
||||
if (Math.abs(heightDiff) < Math.abs(widthDiff)) {
|
||||
newHeight = Math.round(layoutItem.w / changeCoef);
|
||||
newWidth = Math.round(newHeight * changeCoef);
|
||||
} else {
|
||||
newWidth = Math.round(layoutItem.h * changeCoef);
|
||||
newHeight = Math.round(newWidth / changeCoef);
|
||||
}
|
||||
|
||||
// Ensure dimensions maintain aspect ratio and fit within the grid
|
||||
if (layoutItem.x + newWidth > 12) {
|
||||
newWidth = 12 - layoutItem.x;
|
||||
newHeight = Math.round(newWidth / changeCoef);
|
||||
}
|
||||
|
||||
if (changeCoef == 0.5) {
|
||||
// portrait
|
||||
newHeight = Math.ceil(newHeight / 2) * 2;
|
||||
} else if (changeCoef == 2) {
|
||||
// pano/wide
|
||||
newHeight = Math.ceil(newHeight * 2) / 2;
|
||||
}
|
||||
|
||||
newWidth = Math.round(newHeight * changeCoef);
|
||||
|
||||
layoutItem.w = newWidth;
|
||||
layoutItem.h = newHeight;
|
||||
placeholder.w = layoutItem.w;
|
||||
placeholder.h = layoutItem.h;
|
||||
};
|
||||
const width = availableWidth || window.innerWidth;
|
||||
const columnWidth = width / GRID_COLS;
|
||||
return columnWidth / VERTICAL_RESOLUTION;
|
||||
}, [availableWidth]);
|
||||
|
||||
// audio and stats states
|
||||
|
||||
@@ -503,6 +654,19 @@ export default function DraggableGridLayout({
|
||||
onSaveMuting(true);
|
||||
};
|
||||
|
||||
// RGL's per-item constraint derives height from width, holding each tile at
|
||||
// its camera's aspect while resizing. Constraints are functions, so they live
|
||||
// only on this render copy; toPersisted strips them.
|
||||
const layoutWithConstraints = useMemo(() => {
|
||||
if (!currentGridLayout) {
|
||||
return [] as Layout;
|
||||
}
|
||||
return currentGridLayout.map((item) => ({
|
||||
...item,
|
||||
constraints: [aspectRatio(layoutAspects[item.i] ?? DEFAULT_ASPECT)],
|
||||
}));
|
||||
}, [currentGridLayout, layoutAspects]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
@@ -525,8 +689,8 @@ export default function DraggableGridLayout({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8"
|
||||
ref={gridContainerRef}
|
||||
className="no-scrollbar my-2 select-none overflow-x-hidden pb-8"
|
||||
ref={attachGridContainer}
|
||||
>
|
||||
<EditGroupDialog
|
||||
open={editGroup}
|
||||
@@ -536,28 +700,36 @@ export default function DraggableGridLayout({
|
||||
/>
|
||||
<Responsive
|
||||
className="grid-layout"
|
||||
width={availableWidth ?? window.innerWidth}
|
||||
width={availableWidth || window.innerWidth}
|
||||
layouts={{
|
||||
lg: currentGridLayout,
|
||||
md: currentGridLayout,
|
||||
sm: currentGridLayout,
|
||||
xs: currentGridLayout,
|
||||
xxs: currentGridLayout,
|
||||
lg: layoutWithConstraints,
|
||||
md: layoutWithConstraints,
|
||||
sm: layoutWithConstraints,
|
||||
xs: layoutWithConstraints,
|
||||
xxs: layoutWithConstraints,
|
||||
}}
|
||||
rowHeight={cellHeight}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
|
||||
margin={[marginValue, marginValue]}
|
||||
cols={{
|
||||
lg: GRID_COLS,
|
||||
md: GRID_COLS,
|
||||
sm: GRID_COLS,
|
||||
xs: GRID_COLS,
|
||||
xxs: GRID_COLS,
|
||||
}}
|
||||
margin={[0, 0]}
|
||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||
containerPadding={[0, isEditMode ? 6 : 3]}
|
||||
resizeConfig={{
|
||||
enabled: isEditMode,
|
||||
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [],
|
||||
// se only: top/left handles fight the aspect constraint at a grid
|
||||
// boundary (RGL re-clamps the opposite edge) and distort the tile.
|
||||
handles: isEditMode ? ["se"] : [],
|
||||
}}
|
||||
dragConfig={{
|
||||
enabled: isEditMode,
|
||||
}}
|
||||
onDragStop={handleLayoutChange}
|
||||
onResize={handleResize}
|
||||
onResizeStart={() => setShowCircles(false)}
|
||||
onResizeStop={handleLayoutChange}
|
||||
>
|
||||
@@ -570,22 +742,12 @@ export default function DraggableGridLayout({
|
||||
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||
)}
|
||||
birdseyeConfig={birdseyeConfig}
|
||||
aspectRatio={layoutAspects["birdseye"] ?? DEFAULT_ASPECT}
|
||||
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
||||
onClick={() => onSelectCamera("birdseye")}
|
||||
>
|
||||
{isEditMode && showCircles && <CornerCircles />}
|
||||
</BirdseyeLivePlayerGridItem>
|
||||
></BirdseyeLivePlayerGridItem>
|
||||
)}
|
||||
{cameras.map((camera) => {
|
||||
let grow;
|
||||
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||
grow = `aspect-wide w-full`;
|
||||
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
|
||||
grow = `aspect-tall h-full`;
|
||||
} else {
|
||||
grow = "aspect-video";
|
||||
}
|
||||
const availableStreams = camera.live.streams || {};
|
||||
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
||||
|
||||
@@ -614,7 +776,14 @@ export default function DraggableGridLayout({
|
||||
?.compatibilityMode || false;
|
||||
return (
|
||||
<GridLiveContextMenu
|
||||
className={grow}
|
||||
className={CARD_FIT}
|
||||
aspectRatio={
|
||||
(naturalAspectLayout
|
||||
? liveAspects[camera.name]
|
||||
: undefined) ??
|
||||
layoutAspects[camera.name] ??
|
||||
DEFAULT_ASPECT
|
||||
}
|
||||
key={camera.name}
|
||||
camera={camera.name}
|
||||
streamName={streamName}
|
||||
@@ -653,8 +822,8 @@ export default function DraggableGridLayout({
|
||||
useWebGL={useWebGL}
|
||||
cameraRef={cameraRef}
|
||||
className={cn(
|
||||
"rounded-lg bg-black md:rounded-2xl",
|
||||
grow,
|
||||
"size-full",
|
||||
naturalAspectLayout ? "bg-background" : "bg-black",
|
||||
isEditMode &&
|
||||
showCircles &&
|
||||
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||
@@ -675,8 +844,8 @@ export default function DraggableGridLayout({
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
playAudio={audioStates[camera.name]}
|
||||
volume={volumeStates[camera.name]}
|
||||
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
||||
/>
|
||||
{isEditMode && showCircles && <CornerCircles />}
|
||||
</GridLiveContextMenu>
|
||||
);
|
||||
})}
|
||||
@@ -694,6 +863,7 @@ export default function DraggableGridLayout({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
data-testid="toggle-edit-layout"
|
||||
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
||||
onClick={() =>
|
||||
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
||||
@@ -762,17 +932,6 @@ export default function DraggableGridLayout({
|
||||
);
|
||||
}
|
||||
|
||||
function CornerCircles() {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute left-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
<div className="pointer-events-none absolute right-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
<div className="pointer-events-none absolute bottom-[-4px] right-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
<div className="pointer-events-none absolute bottom-[-4px] left-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type BirdseyeLivePlayerGridItemProps = {
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
@@ -781,6 +940,7 @@ type BirdseyeLivePlayerGridItemProps = {
|
||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||
children?: React.ReactNode;
|
||||
birdseyeConfig: BirdseyeConfig;
|
||||
aspectRatio: number;
|
||||
liveMode: LivePlayerMode;
|
||||
onClick: () => void;
|
||||
};
|
||||
@@ -798,6 +958,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
onTouchEnd,
|
||||
children,
|
||||
birdseyeConfig,
|
||||
aspectRatio: cellAspect,
|
||||
liveMode,
|
||||
onClick,
|
||||
...props
|
||||
@@ -806,7 +967,8 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
style={{ ...style }}
|
||||
className="flex items-center justify-center p-1 [container-type:size]"
|
||||
style={{ ...style, "--ar": cellAspect } as React.CSSProperties}
|
||||
ref={ref}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
@@ -814,7 +976,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<BirdseyeLivePlayer
|
||||
className={className}
|
||||
className={cn(CARD_FIT, className)}
|
||||
birdseyeConfig={birdseyeConfig}
|
||||
liveMode={liveMode}
|
||||
onClick={onClick}
|
||||
@@ -829,6 +991,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
type GridLiveContextMenuProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
aspectRatio?: number;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||
@@ -860,6 +1023,7 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
{
|
||||
className,
|
||||
style,
|
||||
aspectRatio: cameraAspect,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onTouchEnd,
|
||||
@@ -887,7 +1051,8 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
style={{ ...style }}
|
||||
className="flex items-center justify-center p-1 [container-type:size]"
|
||||
style={{ ...style, "--ar": cameraAspect } as React.CSSProperties}
|
||||
ref={ref}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
|
||||
@@ -295,7 +295,7 @@ export default function LiveBirdseyeView({
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<BirdseyeLivePlayer
|
||||
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||
className={fullscreen ? "rounded-none" : ""}
|
||||
birdseyeConfig={config.birdseye}
|
||||
liveMode={preferredLiveMode}
|
||||
containerRef={containerRef}
|
||||
|
||||
@@ -860,7 +860,7 @@ export default function LiveCameraView({
|
||||
)}
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||
className={fullscreen ? "rounded-none" : ""}
|
||||
windowVisible
|
||||
showStillWithoutActivity={false}
|
||||
alwaysShowCameraName={false}
|
||||
|
||||
@@ -410,7 +410,7 @@ export default function LiveDashboardView({
|
||||
|
||||
return (
|
||||
<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 [scrollbar-gutter:stable] md:p-2"
|
||||
ref={containerRef}
|
||||
>
|
||||
{isMobile && (
|
||||
@@ -609,7 +609,7 @@ export default function LiveDashboardView({
|
||||
<LivePlayer
|
||||
cameraRef={cameraRef}
|
||||
key={camera.name}
|
||||
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
|
||||
className={`${grow} bg-black`}
|
||||
windowVisible={
|
||||
windowVisible && visibleCameras.includes(camera.name)
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Button, buttonVariants } from "../../components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
useUserPersistence,
|
||||
deleteUserNamespacedKey,
|
||||
} from "@/hooks/use-user-persistence";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -34,6 +34,17 @@ import {
|
||||
CONTROL_COLUMN_CLASS_NAME,
|
||||
} from "@/components/card/SettingsGroupCard";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
|
||||
import {
|
||||
applyImportPayload,
|
||||
@@ -52,6 +63,8 @@ import {
|
||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
|
||||
|
||||
type ConfirmTarget = "layouts" | "streaming" | "naturalAspect";
|
||||
|
||||
type SwitchSettingRowProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -132,7 +145,7 @@ export default function UiSettingsView() {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const username = auth?.user?.username;
|
||||
|
||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||
const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16];
|
||||
|
||||
const clearStoredLayouts = useCallback(() => {
|
||||
if (!config) {
|
||||
@@ -322,6 +335,10 @@ export default function UiSettingsView() {
|
||||
"displayCameraNames",
|
||||
false,
|
||||
);
|
||||
const [naturalAspect, setNaturalAspect] = useUserPersistence(
|
||||
"naturalAspectLayout",
|
||||
false,
|
||||
);
|
||||
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
||||
@@ -330,6 +347,66 @@ export default function UiSettingsView() {
|
||||
3,
|
||||
);
|
||||
|
||||
const [pendingConfirm, setPendingConfirm] = useState<ConfirmTarget | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const confirmCopy = useCallback(
|
||||
(target: ConfirmTarget) => {
|
||||
// literal keys per branch: a template key would be invisible to
|
||||
// npm run i18n:extract, which CI verifies
|
||||
switch (target) {
|
||||
case "layouts":
|
||||
return {
|
||||
title: t("general.storedLayouts.clearAll"),
|
||||
description: t("general.storedLayouts.clearConfirm"),
|
||||
action: t("button.clear", { ns: "common" }),
|
||||
};
|
||||
case "streaming":
|
||||
return {
|
||||
title: t("general.cameraGroupStreaming.clearAll"),
|
||||
description: t("general.cameraGroupStreaming.clearConfirm"),
|
||||
action: t("button.clear", { ns: "common" }),
|
||||
};
|
||||
case "naturalAspect":
|
||||
return {
|
||||
title: t("general.liveDashboard.naturalAspectLayout.label"),
|
||||
description: t(
|
||||
"general.liveDashboard.naturalAspectLayout.descNote",
|
||||
),
|
||||
action: naturalAspect
|
||||
? t("button.disable", { ns: "common" })
|
||||
: t("button.enable", { ns: "common" }),
|
||||
};
|
||||
}
|
||||
},
|
||||
[naturalAspect, t],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
switch (pendingConfirm) {
|
||||
case "layouts":
|
||||
clearStoredLayouts();
|
||||
break;
|
||||
case "streaming":
|
||||
clearStreamingSettings();
|
||||
break;
|
||||
case "naturalAspect":
|
||||
// a layout only renders in the mode that built it
|
||||
setNaturalAspect(!naturalAspect);
|
||||
clearStoredLayouts();
|
||||
break;
|
||||
}
|
||||
|
||||
setPendingConfirm(null);
|
||||
}, [
|
||||
pendingConfirm,
|
||||
clearStoredLayouts,
|
||||
clearStreamingSettings,
|
||||
naturalAspect,
|
||||
setNaturalAspect,
|
||||
]);
|
||||
|
||||
const liveDashboardSwitchRows = [
|
||||
{
|
||||
id: "auto-live",
|
||||
@@ -352,6 +429,18 @@ export default function UiSettingsView() {
|
||||
checked: cameraNames,
|
||||
onCheckedChange: setCameraName,
|
||||
},
|
||||
// phones use the static grid, so tile sizing has nothing to affect there
|
||||
...(isMobileOnly
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "natural-aspect",
|
||||
label: t("general.liveDashboard.naturalAspectLayout.label"),
|
||||
description: t("general.liveDashboard.naturalAspectLayout.desc"),
|
||||
checked: naturalAspect,
|
||||
onCheckedChange: () => setPendingConfirm("naturalAspect"),
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -420,7 +509,7 @@ export default function UiSettingsView() {
|
||||
id="stored-layouts-clear"
|
||||
aria-label={t("general.storedLayouts.clearAll")}
|
||||
className="w-full md:w-auto"
|
||||
onClick={clearStoredLayouts}
|
||||
onClick={() => setPendingConfirm("layouts")}
|
||||
>
|
||||
{t("general.storedLayouts.clearAll")}
|
||||
</Button>
|
||||
@@ -436,7 +525,7 @@ export default function UiSettingsView() {
|
||||
id="camera-group-streaming-clear"
|
||||
aria-label={t("general.cameraGroupStreaming.clearAll")}
|
||||
className="w-full md:w-auto"
|
||||
onClick={clearStreamingSettings}
|
||||
onClick={() => setPendingConfirm("streaming")}
|
||||
>
|
||||
{t("general.cameraGroupStreaming.clearAll")}
|
||||
</Button>
|
||||
@@ -570,9 +659,41 @@ export default function UiSettingsView() {
|
||||
fileName={pendingImport.name}
|
||||
file={pendingImport.file}
|
||||
summary={pendingImport.summary}
|
||||
currentNaturalAspect={naturalAspect ?? false}
|
||||
onConfirm={handleImportConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={pendingConfirm != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingConfirm(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingConfirm && confirmCopy(pendingConfirm).title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingConfirm && confirmCopy(pendingConfirm).description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{pendingConfirm && confirmCopy(pendingConfirm).action}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user