Compare commits

...
Author SHA1 Message Date
Josh Hawkins 34009aa244 fixes 2026-09-24 13:51:00 -05:00
Josh Hawkins fc39518f71 remove unused 2026-09-24 13:40:25 -05:00
Josh Hawkins 0d6069ccb3 wording tweak 2026-09-24 13:32:57 -05:00
Josh Hawkins bf396e7c4d remove note 2026-09-24 13:27:29 -05:00
Josh Hawkins 2f5c2af8fa clean up 2026-09-24 13:27:29 -05:00
Josh Hawkins f50a785e6a add confirmation dialog for natural aspect switch 2026-09-24 13:27:29 -05:00
Josh Hawkins 2455e8be79 add confirmation dialog for clearing groups and streaming settings 2026-09-24 13:27:29 -05:00
Josh Hawkins 1f5972426e fix merge conflict 2026-09-24 13:27:29 -05:00
Josh Hawkins 5ddcf96756 hide natural aspect and layout import on phones 2026-09-24 13:27:29 -05:00
Josh Hawkins e760868048 rework live dashboard grid layout and add natural mode 2026-09-24 13:26:57 -05:00
007hacky007andGitHub 40f8ba1f7f Offer the full playback rate list on Safari (#24444)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-09-24 10:02:06 -05:00
markfrancisonlyandGitHub 397f5253a5 Rate events over at least one second (#24455)
* Rate events over at least one second

EventsPerSecond.eps() divided the event count by the time since start(),
which can be a few milliseconds right after a restart. Frames buffered
during an ffmpeg restart then report as 100+ fps, and the same happens to
the detector fps. Use a window of at least one second.

* Keep sub-second windows consistent

Floor the divisor at the window length when the window is shorter than a
second, so a caller with a sub-second window still gets its true rate.
2026-09-24 06:28:00 -06:00
A. AhmetGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
af0ba19196 Feat/deepx npu detector (#24336)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* feat(deepx): add DEEPX NPU detector and runtime integration.

* feat(deepx): enforce model_format requirement when ppu is enabled and add integrity checks for driver installation

* Update frigate/detectors/plugins/deepx.py

Public method lacks docstring

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Refactor DEEPX detector tests, support SSD and DAMO-YOLO

* feat(deepx): add anchor-free output decoding and corresponding tests

* Add tests and updates for DEEPX detector and refactor DEEPX accelerator code structure.

* fix: enhance model type validation and update documentation for DEEPX detector

* fix: add support for customizable score and NMS thresholds

* refactor: infer YOLO layout from the model, drop per-detector options and the dxrtd placeholder

* fix: keep only the anchor-free PPU verdict, re-read anchor-based each frame

* Update latency data for DEEPX NPU

* Expanding PPU support for DEEPX and set yolo-generic as default.

* enhance scale count resolution logic

* Extend PPU layout handling and YOLOX support to DEEPX detector

* fix: assume the largest PPU anchor table when the .dxnn has no layout

* Improve PPU decoding and introduce strides handling

* Improve PPU scope and fix box format mismatch

* Fix unnamed node issue that breaks traversal

* fix: update object detection model type description to remove outdated architecture

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-09-21 07:50:44 -05:00
34 changed files with 4201 additions and 339 deletions
+1
View File
@@ -5,3 +5,4 @@
/docker/rockchip/ @MarcA711
/docker/rocm/ @harakas
/docker/hailo8l/ @spanner3003
/docker/deepx/ @sixfab
+131
View File
@@ -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:-}"
+59
View File
@@ -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.
+31
View File
@@ -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).
+93
View File
@@ -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:
+11
View File
@@ -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
+25
View File
@@ -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()
+896
View File
@@ -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)
+20
View File
@@ -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"),
+7 -4
View File
@@ -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
+204
View File
@@ -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>;
}
+5
View File
@@ -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);
});
});
+298
View File
@@ -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,
+1
View File
@@ -131,6 +131,7 @@
"close": "Close",
"expand": "Expand",
"collapse": "Collapse",
"clear": "Clear",
"copy": "Copy",
"copiedToClipboard": "Copied to clipboard",
"back": "Back",
+12 -2
View File
@@ -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)",
+1 -1
View File
@@ -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>
+56 -14
View File
@@ -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">
+2 -2
View File
@@ -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?.();
};
+15
View File
@@ -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;
+63 -2
View File
@@ -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));
},
);
+374 -209
View File
@@ -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}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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)
}
+126 -5
View File
@@ -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>
);
}