mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-07 05:55:27 +03:00
Compare commits
60 Commits
983f1957d9
...
22e936c2d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e936c2d1 | ||
|
|
45213d0420 | ||
|
|
ba4a6a53d7 | ||
|
|
e90079ab2f | ||
|
|
edcf0b0d2c | ||
|
|
578d6391fb | ||
|
|
c9c9c1793d | ||
|
|
a3b10c308b | ||
|
|
540290bba7 | ||
|
|
b02d928056 | ||
|
|
057351e492 | ||
|
|
c7af51376b | ||
|
|
74cfa61952 | ||
|
|
81899ddf57 | ||
|
|
33e2b23f2e | ||
|
|
b5193c9978 | ||
|
|
93443773f4 | ||
|
|
57eb7db5c9 | ||
|
|
eb73277322 | ||
|
|
d3853688ed | ||
|
|
5fd8fc881a | ||
|
|
7000712454 | ||
|
|
72068b6823 | ||
|
|
04233c0574 | ||
|
|
1584400916 | ||
|
|
0d1189f26a | ||
|
|
29eff13a4c | ||
|
|
1162c01b3e | ||
|
|
27b7ef0a7a | ||
|
|
01a7ec1060 | ||
|
|
95b5b89ed9 | ||
|
|
a182385618 | ||
|
|
088e1ad7ef | ||
|
|
011ad8eda7 | ||
|
|
4171efcd79 | ||
|
|
0ea8924727 | ||
|
|
1a1994ca17 | ||
|
|
819e8de172 | ||
|
|
ea246384bf | ||
|
|
d8f70b7fed | ||
|
|
434ef358a2 | ||
|
|
fe269b77b8 | ||
|
|
77831304a7 | ||
|
|
1a6d04fde7 | ||
|
|
4a1b7a1629 | ||
|
|
8eace9c3e7 | ||
|
|
8fc1e97df5 | ||
|
|
0a332cada9 | ||
|
|
ba499201e6 | ||
|
|
c244e6582a | ||
|
|
fff3594553 | ||
|
|
25bfb2c481 | ||
|
|
b7261c8e70 | ||
|
|
ad9092d0da | ||
|
|
20705a3e97 | ||
|
|
f4ac063b37 | ||
|
|
2dcaeb6809 | ||
|
|
962d36323b | ||
|
|
3b81416299 | ||
|
|
7d315c5e6b |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@ -26,7 +26,7 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr
|
|||||||
|
|
||||||
- This PR fixes or closes issue: fixes #
|
- This PR fixes or closes issue: fixes #
|
||||||
- This PR is related to issue:
|
- This PR is related to issue:
|
||||||
- Link to discussion with maintainers (**required** for large/pinned features):
|
- Link to discussion with maintainers (**required** for any large or "planned" features):
|
||||||
|
|
||||||
## For new features
|
## For new features
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/pr_template_check.yml
vendored
2
.github/workflows/pr_template_check.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check PR description against template
|
- name: Check PR description against template
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]', 'weblate'];
|
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]', 'weblate'];
|
||||||
|
|||||||
2
.github/workflows/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
@ -72,7 +72,7 @@ jobs:
|
|||||||
run: npm run e2e
|
run: npm run e2e
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
- name: Upload test artifacts
|
- name: Upload test artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
|
|||||||
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@ -18,9 +18,9 @@ jobs:
|
|||||||
close-issue-message: ""
|
close-issue-message: ""
|
||||||
days-before-stale: 30
|
days-before-stale: 30
|
||||||
days-before-close: 3
|
days-before-close: 3
|
||||||
exempt-draft-pr: true
|
exempt-draft-pr: false
|
||||||
exempt-issue-labels: "pinned,security"
|
exempt-issue-labels: "planned,security"
|
||||||
exempt-pr-labels: "pinned,security,dependencies"
|
exempt-pr-labels: "planned,security,dependencies"
|
||||||
operations-per-run: 120
|
operations-per-run: 120
|
||||||
- name: Print outputs
|
- name: Print outputs
|
||||||
env:
|
env:
|
||||||
|
|||||||
@ -10,11 +10,14 @@ If you've found a bug and want to fix it, go for it. Link to the relevant issue
|
|||||||
|
|
||||||
### New features
|
### New features
|
||||||
|
|
||||||
Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature:
|
A pull request is more than just code — it's a request for the maintainers to review, integrate, and support the change long-term. We're selective about what we take on, and prioritize changes that align with the project's direction and can be responsibly maintained in the long term.
|
||||||
|
|
||||||
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Pinned feature requests are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
|
**Large or highly-requested features** raise the bar even higher. Popularity signals demand, but it doesn't pre-approve any particular implementation. The bigger the change, the higher the long-term cost, and the more important it is that we're aligned on scope and approach before any code is written. A large PR that lands without prior discussion is unlikely to be merged as-is, no matter how well it's implemented.
|
||||||
|
|
||||||
|
Before writing code for a new feature:
|
||||||
|
|
||||||
|
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Feature requests tagged with "planned" are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
|
||||||
2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it.
|
2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it.
|
||||||
3. **Be open to "no".** We try to be thoughtful about what we take on, and sometimes that means saying no to good code if the feature isn't the right fit for the project. These calls are sometimes subjective, and we won't always get them right. We're happy to discuss and reconsider.
|
|
||||||
|
|
||||||
## AI usage policy
|
## AI usage policy
|
||||||
|
|
||||||
@ -39,6 +42,8 @@ We're not trying to gatekeep how you write code. Use whatever tools make you pro
|
|||||||
|
|
||||||
Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term — often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability.
|
Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term — often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability.
|
||||||
|
|
||||||
|
One more thing worth saying directly: most maintainers already have access to the same AI tools you do. A PR that's entirely AI-generated — where the author can't explain the design, debug issues independently, or engage substantively in design discussions — doesn't offer something we couldn't produce ourselves. What makes a contribution genuinely valuable is the human judgment and domain understanding behind it, as well as the engagement during review that shapes it into something we can confidently take on long-term.
|
||||||
|
|
||||||
## Pull request guidelines
|
## Pull request guidelines
|
||||||
|
|
||||||
### Before submitting
|
### Before submitting
|
||||||
|
|||||||
@ -87,43 +87,43 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
|||||||
# intel packages use zst compression so we need to update dpkg
|
# intel packages use zst compression so we need to update dpkg
|
||||||
apt-get install -y dpkg
|
apt-get install -y dpkg
|
||||||
|
|
||||||
# use intel apt intel packages
|
# use intel apt repo for libmfx1 (legacy QSV, pre-Gen12)
|
||||||
wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg
|
wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg
|
||||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" | tee /etc/apt/sources.list.d/intel-gpu-jammy.list
|
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" | tee /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||||
apt-get -qq update
|
apt-get -qq update
|
||||||
|
|
||||||
# intel-media-va-driver-non-free is built from source in the
|
# intel-media-va-driver-non-free is built from source in the
|
||||||
# intel-media-driver Dockerfile stage for Battlemage (Xe2) support
|
# intel-media-driver Dockerfile stage for Battlemage (Xe2) support
|
||||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||||
libmfx1 libmfxgen1 libvpl2
|
libmfx1
|
||||||
|
rm -f /usr/share/keyrings/intel-graphics.gpg
|
||||||
|
rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||||
|
|
||||||
|
# upgrade libva2, oneVPL runtime, and libvpl2 from trixie for Battlemage support
|
||||||
|
echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list
|
||||||
|
apt-get -qq update
|
||||||
|
apt-get -qq install -y -t trixie libva2 libva-drm2 libzstd1
|
||||||
|
apt-get -qq install -y -t trixie libmfx-gen1.2 libvpl2
|
||||||
|
rm -f /etc/apt/sources.list.d/trixie.list
|
||||||
|
apt-get -qq update
|
||||||
apt-get -qq install -y ocl-icd-libopencl1
|
apt-get -qq install -y ocl-icd-libopencl1
|
||||||
|
|
||||||
# install libtbb12 for NPU support
|
# install libtbb12 for NPU support
|
||||||
apt-get -qq install -y libtbb12
|
apt-get -qq install -y libtbb12
|
||||||
|
|
||||||
rm -f /usr/share/keyrings/intel-graphics.gpg
|
# install legacy and standard intel compute packages
|
||||||
rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list
|
|
||||||
|
|
||||||
# install legacy and standard intel icd and level-zero-gpu
|
|
||||||
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
|
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
|
||||||
# newer intel packages (gmmlib 22.9+, igc 2.32+) require libstdc++ >= 13.1 and libzstd >= 1.5.5
|
|
||||||
echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list
|
|
||||||
apt-get -qq update
|
|
||||||
apt-get -qq install -y -t trixie libstdc++6 libzstd1
|
|
||||||
rm -f /etc/apt/sources.list.d/trixie.list
|
|
||||||
apt-get -qq update
|
|
||||||
|
|
||||||
# needed core package
|
# needed core package
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
|
||||||
dpkg -i libigdgmm12_22.9.0_amd64.deb
|
dpkg -i libigdgmm12_22.9.0_amd64.deb
|
||||||
rm libigdgmm12_22.9.0_amd64.deb
|
rm libigdgmm12_22.9.0_amd64.deb
|
||||||
|
|
||||||
# legacy packages
|
# legacy compute-runtime packages
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
|
||||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
|
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
|
||||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
|
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
|
||||||
# standard packages
|
# standard compute-runtime packages
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
|
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
|
||||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
|
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
|
||||||
@ -137,6 +137,10 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
|||||||
dpkg -i *.deb
|
dpkg -i *.deb
|
||||||
rm *.deb
|
rm *.deb
|
||||||
apt-get -qq install -f -y
|
apt-get -qq install -f -y
|
||||||
|
|
||||||
|
# Battlemage uses the xe kernel driver, but the VA-API driver is still iHD.
|
||||||
|
# The oneVPL runtime may look for a driver named after the kernel module.
|
||||||
|
ln -sf /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so /usr/lib/x86_64-linux-gnu/dri/xe_drv_video.so
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||||
|
|||||||
@ -11,7 +11,7 @@ joserfc == 1.2.*
|
|||||||
cryptography == 44.0.*
|
cryptography == 44.0.*
|
||||||
pathvalidate == 3.3.*
|
pathvalidate == 3.3.*
|
||||||
markupsafe == 3.0.*
|
markupsafe == 3.0.*
|
||||||
python-multipart == 0.0.20
|
python-multipart == 0.0.26
|
||||||
# Classification Model Training
|
# Classification Model Training
|
||||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||||
@ -42,7 +42,7 @@ opencv-python-headless == 4.11.0.*
|
|||||||
opencv-contrib-python == 4.11.0.*
|
opencv-contrib-python == 4.11.0.*
|
||||||
scipy == 1.16.*
|
scipy == 1.16.*
|
||||||
# OpenVino & ONNX
|
# OpenVino & ONNX
|
||||||
openvino == 2025.3.*
|
openvino == 2025.4.*
|
||||||
onnxruntime == 1.22.*
|
onnxruntime == 1.22.*
|
||||||
# Embeddings
|
# Embeddings
|
||||||
transformers == 4.45.*
|
transformers == 4.45.*
|
||||||
|
|||||||
@ -32,11 +32,14 @@ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
|
|||||||
FROM deps AS deps-prelim
|
FROM deps AS deps-prelim
|
||||||
|
|
||||||
COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources
|
COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources
|
||||||
RUN apt-get update && \
|
# install_deps.sh upgraded libstdc++6 from trixie for Battlemage; the matching
|
||||||
|
# -dev package must also come from trixie or apt refuses to satisfy it.
|
||||||
|
RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y libnuma1 && \
|
apt-get install -y libnuma1 && \
|
||||||
apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \
|
apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \
|
||||||
# Install C++ standard library headers for HIPRTC kernel compilation fallback
|
apt-get install -qq -y -t trixie libstdc++-14-dev && \
|
||||||
apt-get install -qq -y libstdc++-12-dev && \
|
rm -f /etc/apt/sources.list.d/trixie.list && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /opt/frigate
|
WORKDIR /opt/frigate
|
||||||
|
|||||||
@ -171,7 +171,7 @@ When choosing images to include in the face training set it is recommended to al
|
|||||||
- If it is difficult to make out details in a persons face it will not be helpful in training.
|
- If it is difficult to make out details in a persons face it will not be helpful in training.
|
||||||
- Avoid images with extreme under/over-exposure.
|
- Avoid images with extreme under/over-exposure.
|
||||||
- Avoid blurry / pixelated images.
|
- Avoid blurry / pixelated images.
|
||||||
- Avoid training on infrared (gray-scale). The models are trained on color images and will be able to extract features from gray-scale images.
|
- Avoid training on infrared (gray-scale). The models are trained on color images and will not be able to extract features from gray-scale images.
|
||||||
- Using images of people wearing hats / sunglasses may confuse the model.
|
- Using images of people wearing hats / sunglasses may confuse the model.
|
||||||
- Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting.
|
- Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting.
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,10 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
|
|||||||
|
|
||||||
[Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail.
|
[Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail.
|
||||||
|
|
||||||
|
## [kiosk-monitor](https://github.com/extremeshok/kiosk-monitor)
|
||||||
|
|
||||||
|
[kiosk-monitor](https://github.com/extremeshok/kiosk-monitor) is a Raspberry Pi watchdog that runs Chromium fullscreen on a Frigate dashboard (optionally with VLC on a second monitor for an RTSP camera stream), auto-restarts on frozen screens or unreachable URLs, and ships a Birdseye-aware Chromium helper that auto-sizes the grid to the display.
|
||||||
|
|
||||||
## [Periscope](https://github.com/maksz42/periscope)
|
## [Periscope](https://github.com/maksz42/periscope)
|
||||||
|
|
||||||
[Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS.
|
[Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS.
|
||||||
|
|||||||
@ -111,26 +111,16 @@ TCP ensures that all data packets arrive in the correct order. This is crucial f
|
|||||||
|
|
||||||
You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation.
|
You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation.
|
||||||
|
|
||||||
### Frigate hangs on startup with a "probing detect stream" message in the logs
|
### Frigate is slow to start up with a "probing detect stream" message in the logs
|
||||||
|
|
||||||
On startup, Frigate probes each camera's detect stream with OpenCV to auto-detect its resolution. OpenCV's FFmpeg backend may attempt RTSP over UDP during this probe regardless of the `-rtsp_transport tcp` in your `input_args` or preset. For cameras that do not respond to UDP (common on some Reolink models and others behind firewalls that block UDP), the probe can hang indefinitely and block Frigate from finishing startup, or it can return zeroed-out dimensions that show up as width `0` and height `0` in Camera Probe Info under System Metrics.
|
When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under System Metrics.
|
||||||
|
|
||||||
There are two ways to avoid this:
|
To skip the probe entirely and make startup instant, set `detect.width` and `detect.height` explicitly in your camera config:
|
||||||
|
|
||||||
1. Set `detect.width` and `detect.height` explicitly in your camera config. When both are set, Frigate skips the auto-detect probe entirely:
|
```yaml
|
||||||
|
cameras:
|
||||||
```yaml
|
|
||||||
cameras:
|
|
||||||
my_camera:
|
my_camera:
|
||||||
detect:
|
detect:
|
||||||
width: 1280
|
width: 1280
|
||||||
height: 720
|
height: 720
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Force OpenCV's FFmpeg backend to use TCP for RTSP by setting the environment variable on your Frigate container:
|
|
||||||
|
|
||||||
```
|
|
||||||
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a process-wide setting and applies to all cameras. If you have any cameras that require `preset-rtsp-udp`, use option 1 instead.
|
|
||||||
|
|||||||
6
docs/package-lock.json
generated
6
docs/package-lock.json
generated
@ -10904,9 +10904,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/express/node_modules/path-to-regexp": {
|
"node_modules/express/node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/express/node_modules/range-parser": {
|
"node_modules/express/node_modules/range-parser": {
|
||||||
|
|||||||
@ -146,8 +146,13 @@ def config(request: Request):
|
|||||||
for name, detector in config_obj.detectors.items()
|
for name, detector in config_obj.detectors.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
# remove the mqtt password
|
# remove environment_vars for non-admin users
|
||||||
|
if request.headers.get("remote-role") != "admin":
|
||||||
|
config.pop("environment_vars", None)
|
||||||
|
|
||||||
|
# remove mqtt credentials
|
||||||
config["mqtt"].pop("password", None)
|
config["mqtt"].pop("password", None)
|
||||||
|
config["mqtt"].pop("user", None)
|
||||||
|
|
||||||
# remove the proxy secret
|
# remove the proxy secret
|
||||||
config["proxy"].pop("auth_secret", None)
|
config["proxy"].pop("auth_secret", None)
|
||||||
|
|||||||
@ -36,6 +36,7 @@ from frigate.api.defs.response.chat_response import (
|
|||||||
)
|
)
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.api.event import events
|
from frigate.api.event import events
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||||
from frigate.jobs.vlm_watch import (
|
from frigate.jobs.vlm_watch import (
|
||||||
get_vlm_watch_job,
|
get_vlm_watch_job,
|
||||||
@ -401,9 +402,38 @@ def get_tools() -> JSONResponse:
|
|||||||
return JSONResponse(content={"tools": tools})
|
return JSONResponse(content={"tools": tools})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_zones(
|
||||||
|
zones: List[str],
|
||||||
|
config: FrigateConfig,
|
||||||
|
target_cameras: List[str],
|
||||||
|
) -> List[str]:
|
||||||
|
"""Map zone names to their canonical config keys, case-insensitively.
|
||||||
|
|
||||||
|
LLMs frequently echo a user's casing ("Front Yard") instead of the
|
||||||
|
configured key ("front_yard"). The downstream zone filter is a SQLite GLOB
|
||||||
|
over the JSON-encoded zones column, which is case-sensitive — so an
|
||||||
|
unnormalized name silently returns zero matches. Build a lookup over the
|
||||||
|
relevant cameras' configured zones and substitute when we find a match;
|
||||||
|
unknown names pass through so behavior matches what the model asked for.
|
||||||
|
"""
|
||||||
|
if not zones:
|
||||||
|
return zones
|
||||||
|
|
||||||
|
lookup: Dict[str, str] = {}
|
||||||
|
for camera_id in target_cameras:
|
||||||
|
camera_config = config.cameras.get(camera_id)
|
||||||
|
if camera_config is None:
|
||||||
|
continue
|
||||||
|
for zone_name in camera_config.zones.keys():
|
||||||
|
lookup.setdefault(zone_name.lower(), zone_name)
|
||||||
|
|
||||||
|
return [lookup.get(z.lower(), z) for z in zones]
|
||||||
|
|
||||||
|
|
||||||
async def _execute_search_objects(
|
async def _execute_search_objects(
|
||||||
arguments: Dict[str, Any],
|
arguments: Dict[str, Any],
|
||||||
allowed_cameras: List[str],
|
allowed_cameras: List[str],
|
||||||
|
config: FrigateConfig,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Execute the search_objects tool.
|
Execute the search_objects tool.
|
||||||
@ -437,6 +467,11 @@ async def _execute_search_objects(
|
|||||||
# Convert zones array to comma-separated string if provided
|
# Convert zones array to comma-separated string if provided
|
||||||
zones = arguments.get("zones")
|
zones = arguments.get("zones")
|
||||||
if isinstance(zones, list):
|
if isinstance(zones, list):
|
||||||
|
camera_arg = arguments.get("camera")
|
||||||
|
target_cameras = (
|
||||||
|
[camera_arg] if camera_arg and camera_arg != "all" else allowed_cameras
|
||||||
|
)
|
||||||
|
zones = _resolve_zones(zones, config, target_cameras)
|
||||||
zones = ",".join(zones)
|
zones = ",".join(zones)
|
||||||
elif zones is None:
|
elif zones is None:
|
||||||
zones = "all"
|
zones = "all"
|
||||||
@ -528,6 +563,11 @@ async def _execute_find_similar_objects(
|
|||||||
sub_labels = arguments.get("sub_labels")
|
sub_labels = arguments.get("sub_labels")
|
||||||
zones = arguments.get("zones")
|
zones = arguments.get("zones")
|
||||||
|
|
||||||
|
if zones:
|
||||||
|
zones = _resolve_zones(
|
||||||
|
zones, request.app.frigate_config, cameras or list(allowed_cameras)
|
||||||
|
)
|
||||||
|
|
||||||
similarity_mode = arguments.get("similarity_mode", "fused")
|
similarity_mode = arguments.get("similarity_mode", "fused")
|
||||||
if similarity_mode not in ("visual", "semantic", "fused"):
|
if similarity_mode not in ("visual", "semantic", "fused"):
|
||||||
similarity_mode = "fused"
|
similarity_mode = "fused"
|
||||||
@ -655,7 +695,9 @@ async def execute_tool(
|
|||||||
logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}")
|
logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}")
|
||||||
|
|
||||||
if tool_name == "search_objects":
|
if tool_name == "search_objects":
|
||||||
return await _execute_search_objects(arguments, allowed_cameras)
|
return await _execute_search_objects(
|
||||||
|
arguments, allowed_cameras, request.app.frigate_config
|
||||||
|
)
|
||||||
|
|
||||||
if tool_name == "find_similar_objects":
|
if tool_name == "find_similar_objects":
|
||||||
result = await _execute_find_similar_objects(
|
result = await _execute_find_similar_objects(
|
||||||
@ -835,7 +877,9 @@ async def _execute_tool_internal(
|
|||||||
This is used by the chat completion endpoint to execute tools.
|
This is used by the chat completion endpoint to execute tools.
|
||||||
"""
|
"""
|
||||||
if tool_name == "search_objects":
|
if tool_name == "search_objects":
|
||||||
response = await _execute_search_objects(arguments, allowed_cameras)
|
response = await _execute_search_objects(
|
||||||
|
arguments, allowed_cameras, request.app.frigate_config
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if hasattr(response, "body"):
|
if hasattr(response, "body"):
|
||||||
body_str = response.body.decode("utf-8")
|
body_str = response.body.decode("utf-8")
|
||||||
@ -899,6 +943,9 @@ async def _execute_start_camera_watch(
|
|||||||
|
|
||||||
await require_camera_access(camera, request=request)
|
await require_camera_access(camera, request=request)
|
||||||
|
|
||||||
|
if zones:
|
||||||
|
zones = _resolve_zones(zones, config, [camera])
|
||||||
|
|
||||||
genai_manager = request.app.genai_manager
|
genai_manager = request.app.genai_manager
|
||||||
chat_client = genai_manager.chat_client
|
chat_client = genai_manager.chat_client
|
||||||
if chat_client is None or not chat_client.supports_vision:
|
if chat_client is None or not chat_client.supports_vision:
|
||||||
|
|||||||
@ -754,6 +754,15 @@ def events_search(
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if search_event.camera not in allowed_cameras:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Event not found",
|
||||||
|
},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
thumb_result = context.search_thumbnail(search_event)
|
thumb_result = context.search_thumbnail(search_event)
|
||||||
thumb_ids = {result[0]: result[1] for result in thumb_result}
|
thumb_ids = {result[0]: result[1] for result in thumb_result}
|
||||||
search_results = {
|
search_results = {
|
||||||
|
|||||||
@ -5,13 +5,15 @@ import logging
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
|
import zipfile
|
||||||
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import Iterator, List, Optional
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from pathvalidate import sanitize_filepath
|
from pathvalidate import sanitize_filename, sanitize_filepath
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
@ -361,6 +363,136 @@ def get_export_case(case_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ZIP_STREAM_CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamingZipBuffer:
|
||||||
|
"""File-like sink for ZipFile that exposes written bytes via drain().
|
||||||
|
|
||||||
|
ZipFile writes synchronously into this buffer; the generator drains the
|
||||||
|
queue between writes so StreamingResponse can yield bytes without
|
||||||
|
materializing the whole archive in memory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._queue: deque[bytes] = deque()
|
||||||
|
self._offset = 0
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> int:
|
||||||
|
if data:
|
||||||
|
self._queue.append(bytes(data))
|
||||||
|
self._offset += len(data)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
def tell(self) -> int:
|
||||||
|
return self._offset
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def drain(self) -> Iterator[bytes]:
|
||||||
|
while self._queue:
|
||||||
|
yield self._queue.popleft()
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_archive_name(export: Export, used: set[str]) -> str:
|
||||||
|
base = sanitize_filename(export.name) if export.name else None
|
||||||
|
if not base:
|
||||||
|
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
|
||||||
|
|
||||||
|
candidate = f"{base}.mp4"
|
||||||
|
counter = 1
|
||||||
|
while candidate in used:
|
||||||
|
candidate = f"{base}_{counter}.mp4"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
used.add(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]:
|
||||||
|
"""Yield bytes of a zip archive built from the given exports' mp4 files."""
|
||||||
|
buffer = _StreamingZipBuffer()
|
||||||
|
used_names: set[str] = set()
|
||||||
|
|
||||||
|
# ZIP_STORED: mp4 is already compressed, recompressing wastes CPU for ~0% size win.
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
buffer,
|
||||||
|
mode="w",
|
||||||
|
compression=zipfile.ZIP_STORED,
|
||||||
|
allowZip64=True,
|
||||||
|
) as archive:
|
||||||
|
for export in exports:
|
||||||
|
source = Path(export.video_path)
|
||||||
|
if not source.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
arcname = _unique_archive_name(export, used_names)
|
||||||
|
|
||||||
|
with (
|
||||||
|
archive.open(arcname, mode="w", force_zip64=True) as entry,
|
||||||
|
source.open("rb") as src,
|
||||||
|
):
|
||||||
|
while True:
|
||||||
|
chunk = src.read(_ZIP_STREAM_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
entry.write(chunk)
|
||||||
|
yield from buffer.drain()
|
||||||
|
|
||||||
|
yield from buffer.drain()
|
||||||
|
|
||||||
|
yield from buffer.drain()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/cases/{case_id}/download",
|
||||||
|
dependencies=[Depends(allow_any_authenticated())],
|
||||||
|
summary="Download export case as zip",
|
||||||
|
description="Streams a zip archive containing every completed export's mp4 for the given case.",
|
||||||
|
)
|
||||||
|
def download_export_case(
|
||||||
|
case_id: str,
|
||||||
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
case = ExportCase.get(ExportCase.id == case_id)
|
||||||
|
except DoesNotExist:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Export case not found"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
exports = list(
|
||||||
|
Export.select()
|
||||||
|
.where(
|
||||||
|
Export.export_case == case_id,
|
||||||
|
~Export.in_progress,
|
||||||
|
Export.camera << allowed_cameras,
|
||||||
|
)
|
||||||
|
.order_by(Export.date.asc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exports:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "No exports available to download."},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
archive_base = sanitize_filename(case.name) if case.name else ""
|
||||||
|
if not archive_base:
|
||||||
|
archive_base = case_id
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream_case_archive(exports),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{archive_base}.zip"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/cases/{case_id}",
|
"/cases/{case_id}",
|
||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
|
|||||||
@ -1368,12 +1368,17 @@ def preview_gif(
|
|||||||
file_start = f"preview_{camera_name}-"
|
file_start = f"preview_{camera_name}-"
|
||||||
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
|
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
|
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
|
|
||||||
|
camera_files = [
|
||||||
|
entry.name
|
||||||
|
for entry in os.scandir(preview_dir)
|
||||||
|
if entry.name.startswith(file_start)
|
||||||
|
]
|
||||||
|
camera_files.sort()
|
||||||
|
|
||||||
selected_previews = []
|
selected_previews = []
|
||||||
|
|
||||||
for file in sorted(os.listdir(preview_dir)):
|
for file in camera_files:
|
||||||
if not file.startswith(file_start):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if file < start_file:
|
if file < start_file:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -1550,12 +1555,17 @@ def preview_mp4(
|
|||||||
file_start = f"preview_{camera_name}-"
|
file_start = f"preview_{camera_name}-"
|
||||||
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
|
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
|
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
|
|
||||||
|
camera_files = [
|
||||||
|
entry.name
|
||||||
|
for entry in os.scandir(preview_dir)
|
||||||
|
if entry.name.startswith(file_start)
|
||||||
|
]
|
||||||
|
camera_files.sort()
|
||||||
|
|
||||||
selected_previews = []
|
selected_previews = []
|
||||||
|
|
||||||
for file in sorted(os.listdir(preview_dir)):
|
for file in camera_files:
|
||||||
if not file.startswith(file_start):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if file < start_file:
|
if file < start_file:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@ -148,12 +148,17 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo
|
|||||||
file_start = f"preview_{camera_name}-"
|
file_start = f"preview_{camera_name}-"
|
||||||
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
|
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
|
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
|
|
||||||
|
camera_files = [
|
||||||
|
entry.name
|
||||||
|
for entry in os.scandir(preview_dir)
|
||||||
|
if entry.name.startswith(file_start)
|
||||||
|
]
|
||||||
|
camera_files.sort()
|
||||||
|
|
||||||
selected_previews = []
|
selected_previews = []
|
||||||
|
|
||||||
for file in sorted(os.listdir(preview_dir)):
|
for file in camera_files:
|
||||||
if not file.startswith(file_start):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if file < start_file:
|
if file < start_file:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=[Tags.recordings])
|
router = APIRouter(tags=[Tags.recordings])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/recordings/storage", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def get_recordings_storage_usage(request: Request):
|
def get_recordings_storage_usage(request: Request):
|
||||||
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
|
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
|
||||||
"storage"
|
"storage"
|
||||||
|
|||||||
@ -189,17 +189,6 @@ class FrigateApp:
|
|||||||
except PermissionError:
|
except PermissionError:
|
||||||
logger.error("Unable to write to /config to save DB state")
|
logger.error("Unable to write to /config to save DB state")
|
||||||
|
|
||||||
def cleanup_timeline_db(db: SqliteExtDatabase) -> None:
|
|
||||||
db.execute_sql(
|
|
||||||
"DELETE FROM timeline WHERE source_id NOT IN (SELECT id FROM event);"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(f"{CONFIG_DIR}/.timeline", "w") as f:
|
|
||||||
f.write(str(datetime.datetime.now().timestamp()))
|
|
||||||
except PermissionError:
|
|
||||||
logger.error("Unable to write to /config to save DB state")
|
|
||||||
|
|
||||||
# Migrate DB schema
|
# Migrate DB schema
|
||||||
migrate_db = SqliteExtDatabase(self.config.database.path)
|
migrate_db = SqliteExtDatabase(self.config.database.path)
|
||||||
|
|
||||||
@ -216,11 +205,6 @@ class FrigateApp:
|
|||||||
|
|
||||||
router.run()
|
router.run()
|
||||||
|
|
||||||
# this is a temporary check to clean up user DB from beta
|
|
||||||
# will be removed before final release
|
|
||||||
if not os.path.exists(f"{CONFIG_DIR}/.timeline"):
|
|
||||||
cleanup_timeline_db(migrate_db)
|
|
||||||
|
|
||||||
# check if vacuum needs to be run
|
# check if vacuum needs to be run
|
||||||
if os.path.exists(f"{CONFIG_DIR}/.vacuum"):
|
if os.path.exists(f"{CONFIG_DIR}/.vacuum"):
|
||||||
with open(f"{CONFIG_DIR}/.vacuum") as f:
|
with open(f"{CONFIG_DIR}/.vacuum") as f:
|
||||||
|
|||||||
@ -549,6 +549,14 @@ class WebPushClient(Communicator):
|
|||||||
logger.debug(f"Sending camera monitoring push notification for {camera_name}")
|
logger.debug(f"Sending camera monitoring push notification for {camera_name}")
|
||||||
|
|
||||||
for user in self.web_pushers:
|
for user in self.web_pushers:
|
||||||
|
if not self._user_has_camera_access(user, camera):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping notification for user %s - no access to camera %s",
|
||||||
|
user,
|
||||||
|
camera,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
self.send_push_notification(
|
self.send_push_notification(
|
||||||
user=user,
|
user=user,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
|||||||
ffmpeg = "ffmpeg"
|
ffmpeg = "ffmpeg"
|
||||||
live = "live"
|
live = "live"
|
||||||
motion = "motion" # includes motion and motion masks
|
motion = "motion" # includes motion and motion masks
|
||||||
|
mqtt = "mqtt"
|
||||||
notifications = "notifications"
|
notifications = "notifications"
|
||||||
objects = "objects"
|
objects = "objects"
|
||||||
object_genai = "object_genai"
|
object_genai = "object_genai"
|
||||||
@ -33,6 +34,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
|||||||
lpr = "lpr"
|
lpr = "lpr"
|
||||||
snapshots = "snapshots"
|
snapshots = "snapshots"
|
||||||
timestamp_style = "timestamp_style"
|
timestamp_style = "timestamp_style"
|
||||||
|
ui = "ui"
|
||||||
zones = "zones"
|
zones = "zones"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
|
|||||||
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
||||||
CACHE_DIR = "/tmp/cache"
|
CACHE_DIR = "/tmp/cache"
|
||||||
REPLAY_CAMERA_PREFIX = "_replay_"
|
REPLAY_CAMERA_PREFIX = "_replay_"
|
||||||
REPLAY_DIR = os.path.join(CACHE_DIR, "replay")
|
REPLAY_DIR = os.path.join(CLIPS_DIR, "replay")
|
||||||
PLUS_ENV_VAR = "PLUS_API_KEY"
|
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||||
PLUS_API_HOST = "https://api.frigate.video"
|
PLUS_API_HOST = "https://api.frigate.video"
|
||||||
|
|
||||||
|
|||||||
@ -133,6 +133,61 @@ class FaceRecognizer(ABC):
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def build_class_mean(
|
||||||
|
embs: list[np.ndarray],
|
||||||
|
trim: float = 0.15,
|
||||||
|
outlier_threshold: float = 0.30,
|
||||||
|
min_keep_frac: float = 0.7,
|
||||||
|
max_iters: int = 3,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Build a class-mean embedding with two-layer outlier protection.
|
||||||
|
|
||||||
|
Layer 1 (iterative, vector-wise): drop whole embeddings whose cosine
|
||||||
|
similarity to the current class mean is below ``outlier_threshold``.
|
||||||
|
Catches mislabeled or corrupted training samples (wrong face in the
|
||||||
|
folder, full-frame screenshots, extreme crops) that per-dimension
|
||||||
|
trimming cannot detect.
|
||||||
|
|
||||||
|
Layer 2 (per-dimension): ``scipy.stats.trim_mean`` on the retained set
|
||||||
|
to smooth per-component noise (lighting, expression, alignment jitter).
|
||||||
|
|
||||||
|
Collections with fewer than 5 images bypass outlier rejection — too few
|
||||||
|
samples to establish a reliable class center.
|
||||||
|
"""
|
||||||
|
arr = np.stack(embs, axis=0)
|
||||||
|
|
||||||
|
if len(arr) < 5:
|
||||||
|
return np.asarray(stats.trim_mean(arr, trim, axis=0))
|
||||||
|
|
||||||
|
keep = np.ones(len(arr), dtype=bool)
|
||||||
|
floor = max(5, int(np.ceil(min_keep_frac * len(arr))))
|
||||||
|
|
||||||
|
for _ in range(max_iters):
|
||||||
|
mean = stats.trim_mean(arr[keep], trim, axis=0)
|
||||||
|
m_norm = mean / (np.linalg.norm(mean) + 1e-9)
|
||||||
|
e_norms = arr / (np.linalg.norm(arr, axis=1, keepdims=True) + 1e-9)
|
||||||
|
cos = e_norms @ m_norm
|
||||||
|
new_keep = cos >= outlier_threshold
|
||||||
|
|
||||||
|
if new_keep.sum() < floor:
|
||||||
|
top = np.argsort(-cos)[:floor]
|
||||||
|
new_keep = np.zeros(len(arr), dtype=bool)
|
||||||
|
new_keep[top] = True
|
||||||
|
|
||||||
|
if np.array_equal(new_keep, keep):
|
||||||
|
break
|
||||||
|
keep = new_keep
|
||||||
|
|
||||||
|
dropped = int((~keep).sum())
|
||||||
|
|
||||||
|
if dropped:
|
||||||
|
logger.debug(
|
||||||
|
f"Vector-wise outlier filter dropped {dropped}/{len(arr)} embeddings"
|
||||||
|
)
|
||||||
|
|
||||||
|
return np.asarray(stats.trim_mean(arr[keep], trim, axis=0))
|
||||||
|
|
||||||
|
|
||||||
def similarity_to_confidence(
|
def similarity_to_confidence(
|
||||||
cosine_similarity: float,
|
cosine_similarity: float,
|
||||||
median: float = 0.3,
|
median: float = 0.3,
|
||||||
@ -229,7 +284,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
|||||||
|
|
||||||
for name, embs in face_embeddings_map.items():
|
for name, embs in face_embeddings_map.items():
|
||||||
if embs:
|
if embs:
|
||||||
self.mean_embs[name] = stats.trim_mean(embs, 0.15)
|
self.mean_embs[name] = build_class_mean(embs)
|
||||||
|
|
||||||
logger.debug("Finished building ArcFace model")
|
logger.debug("Finished building ArcFace model")
|
||||||
|
|
||||||
@ -340,7 +395,7 @@ class ArcFaceRecognizer(FaceRecognizer):
|
|||||||
|
|
||||||
for name, embs in face_embeddings_map.items():
|
for name, embs in face_embeddings_map.items():
|
||||||
if embs:
|
if embs:
|
||||||
self.mean_embs[name] = stats.trim_mean(embs, 0.15)
|
self.mean_embs[name] = build_class_mean(embs)
|
||||||
|
|
||||||
logger.debug("Finished building ArcFace model")
|
logger.debug("Finished building ArcFace model")
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
|
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
|
||||||
MIN_RECORDING_DURATION = 10
|
MIN_RECORDING_DURATION = 10
|
||||||
|
MAX_IMAGE_TOKENS = 24000
|
||||||
|
MAX_FRAMES_PER_SECOND = 1
|
||||||
|
|
||||||
|
|
||||||
class ReviewDescriptionProcessor(PostProcessorApi):
|
class ReviewDescriptionProcessor(PostProcessorApi):
|
||||||
@ -60,14 +62,22 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
def calculate_frame_count(
|
def calculate_frame_count(
|
||||||
self,
|
self,
|
||||||
camera: str,
|
camera: str,
|
||||||
|
duration: float,
|
||||||
image_source: ImageSourceEnum = ImageSourceEnum.preview,
|
image_source: ImageSourceEnum = ImageSourceEnum.preview,
|
||||||
height: int = 480,
|
height: int = 480,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Calculate optimal number of frames based on context size, image source, and resolution.
|
"""Calculate optimal number of frames based on event duration, context size,
|
||||||
|
image source, and resolution.
|
||||||
|
|
||||||
Token usage varies by resolution: larger images (ultra-wide aspect ratios) use more tokens.
|
Per-image token cost is asked of the GenAI provider so providers that know
|
||||||
Estimates ~1 token per 1250 pixels. Targets 98% context utilization with safety margin.
|
their model's true cost (e.g. llama.cpp can probe the loaded mmproj) can
|
||||||
Capped at 20 frames.
|
diverge from the default ~1-token-per-1250-pixels heuristic. The frame
|
||||||
|
budget is bounded by:
|
||||||
|
- remaining context window after prompt + response reservations
|
||||||
|
- a fixed MAX_IMAGE_TOKENS ceiling
|
||||||
|
- MAX_FRAMES_PER_SECOND x duration, to avoid drowning short events in
|
||||||
|
near-duplicate frames where the model latches onto the redundant middle
|
||||||
|
and skips the start/end action
|
||||||
"""
|
"""
|
||||||
client = self.genai_manager.description_client
|
client = self.genai_manager.description_client
|
||||||
|
|
||||||
@ -105,14 +115,15 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
width = target_width
|
width = target_width
|
||||||
height = int(target_width / aspect_ratio)
|
height = int(target_width / aspect_ratio)
|
||||||
|
|
||||||
pixels_per_image = width * height
|
tokens_per_image = client.estimate_image_tokens(width, height)
|
||||||
tokens_per_image = pixels_per_image / 1250
|
|
||||||
prompt_tokens = 3800
|
prompt_tokens = 3800
|
||||||
response_tokens = 300
|
response_tokens = 300
|
||||||
available_tokens = context_size - prompt_tokens - response_tokens
|
context_budget = context_size - prompt_tokens - response_tokens
|
||||||
max_frames = int(available_tokens / tokens_per_image)
|
image_token_budget = min(context_budget, MAX_IMAGE_TOKENS)
|
||||||
|
max_frames_by_tokens = int(image_token_budget / tokens_per_image)
|
||||||
return min(max(max_frames, 3), 20)
|
max_frames_by_duration = int(duration * MAX_FRAMES_PER_SECOND)
|
||||||
|
max_frames = min(max_frames_by_tokens, max_frames_by_duration)
|
||||||
|
return max(max_frames, 3)
|
||||||
|
|
||||||
def process_data(
|
def process_data(
|
||||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||||
@ -355,12 +366,17 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
file_start = f"preview_{camera}-"
|
file_start = f"preview_{camera}-"
|
||||||
start_file = f"{file_start}{start_time}.webp"
|
start_file = f"{file_start}{start_time}.webp"
|
||||||
end_file = f"{file_start}{end_time}.webp"
|
end_file = f"{file_start}{end_time}.webp"
|
||||||
|
|
||||||
|
camera_files = [
|
||||||
|
entry.name
|
||||||
|
for entry in os.scandir(preview_dir)
|
||||||
|
if entry.name.startswith(file_start)
|
||||||
|
]
|
||||||
|
camera_files.sort()
|
||||||
|
|
||||||
all_frames: list[str] = []
|
all_frames: list[str] = []
|
||||||
|
|
||||||
for file in sorted(os.listdir(preview_dir)):
|
for file in camera_files:
|
||||||
if not file.startswith(file_start):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if file < start_file:
|
if file < start_file:
|
||||||
if len(all_frames):
|
if len(all_frames):
|
||||||
all_frames[0] = os.path.join(preview_dir, file)
|
all_frames[0] = os.path.join(preview_dir, file)
|
||||||
@ -376,7 +392,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
all_frames.append(os.path.join(preview_dir, file))
|
all_frames.append(os.path.join(preview_dir, file))
|
||||||
|
|
||||||
frame_count = len(all_frames)
|
frame_count = len(all_frames)
|
||||||
desired_frame_count = self.calculate_frame_count(camera)
|
desired_frame_count = self.calculate_frame_count(
|
||||||
|
camera, duration=end_time - start_time
|
||||||
|
)
|
||||||
|
|
||||||
if frame_count <= desired_frame_count:
|
if frame_count <= desired_frame_count:
|
||||||
return all_frames
|
return all_frames
|
||||||
@ -400,7 +418,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
"""Get frames from recordings at specified timestamps."""
|
"""Get frames from recordings at specified timestamps."""
|
||||||
duration = end_time - start_time
|
duration = end_time - start_time
|
||||||
desired_frame_count = self.calculate_frame_count(
|
desired_frame_count = self.calculate_frame_count(
|
||||||
camera, ImageSourceEnum.recordings, height
|
camera, duration, ImageSourceEnum.recordings, height
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate evenly spaced timestamps throughout the duration
|
# Calculate evenly spaced timestamps throughout the duration
|
||||||
|
|||||||
@ -1,21 +1,48 @@
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
|
||||||
|
|
||||||
|
ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=160)]
|
||||||
|
|
||||||
|
|
||||||
class ReviewMetadata(BaseModel):
|
class ReviewMetadata(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore", protected_namespaces=())
|
model_config = ConfigDict(extra="ignore", protected_namespaces=())
|
||||||
|
|
||||||
|
observations: list[ObservationItem] = Field(
|
||||||
|
...,
|
||||||
|
min_length=3,
|
||||||
|
max_length=15,
|
||||||
|
description=(
|
||||||
|
"Enumerate the significant observations across all frames, in "
|
||||||
|
"chronological order, BEFORE composing the scene narrative. "
|
||||||
|
"Include the very start of the activity — for example, a vehicle "
|
||||||
|
"entering the frame or pulling into the driveway — even if it "
|
||||||
|
"lasts only a few frames and the rest of the clip is dominated "
|
||||||
|
"by a longer activity. Include each arrival, departure, motion "
|
||||||
|
"event, object handled, and notable change in position or state. "
|
||||||
|
"Each item is a single concrete fact written as a complete "
|
||||||
|
"sentence. Do not summarize, interpret, or assign meaning here — "
|
||||||
|
"that belongs in the scene field."
|
||||||
|
),
|
||||||
|
)
|
||||||
title: str = Field(
|
title: str = Field(
|
||||||
description="A short title characterizing what took place and where, under 10 words."
|
max_length=80,
|
||||||
|
description="Under 10 words. Name the apparent purpose or outcome of the activity together with the location involved. Do not narrate or list the sequence of actions step by step.",
|
||||||
)
|
)
|
||||||
scene: str = Field(
|
scene: str = Field(
|
||||||
description="A chronological narrative of what happens from start to finish."
|
min_length=150,
|
||||||
|
max_length=600,
|
||||||
|
description="A chronological narrative of what happens from start to finish, drawing directly from the items in observations.",
|
||||||
)
|
)
|
||||||
shortSummary: str = Field(
|
shortSummary: str = Field(
|
||||||
description="A brief 2-sentence summary of the scene, suitable for notifications."
|
min_length=70,
|
||||||
|
max_length=120,
|
||||||
|
description="A brief 2-sentence summary of the scene, suitable for notifications.",
|
||||||
)
|
)
|
||||||
confidence: float = Field(
|
confidence: float = Field(
|
||||||
ge=0.0,
|
ge=0.0,
|
||||||
description="Confidence in the analysis, from 0 to 1.",
|
le=1.0,
|
||||||
|
description="Confidence in the analysis as a decimal between 0.0 and 1.0, where 0.0 means no confidence and 1.0 means complete confidence. Express ONLY as a decimal.",
|
||||||
)
|
)
|
||||||
potential_threat_level: int = Field(
|
potential_threat_level: int = Field(
|
||||||
ge=0,
|
ge=0,
|
||||||
|
|||||||
@ -52,6 +52,12 @@ class OvDetector(DetectionApi):
|
|||||||
self.h = detector_config.model.height
|
self.h = detector_config.model.height
|
||||||
self.w = detector_config.model.width
|
self.w = detector_config.model.width
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loading OpenVINO model %s on device %s",
|
||||||
|
detector_config.model.path,
|
||||||
|
detector_config.device,
|
||||||
|
)
|
||||||
|
|
||||||
self.runner = OpenVINOModelRunner(
|
self.runner = OpenVINOModelRunner(
|
||||||
model_path=detector_config.model.path,
|
model_path=detector_config.model.path,
|
||||||
device=detector_config.device,
|
device=detector_config.device,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
@ -52,6 +53,14 @@ class EmbeddingProcess(FrigateProcess):
|
|||||||
self.stop_event,
|
self.stop_event,
|
||||||
)
|
)
|
||||||
maintainer.start()
|
maintainer.start()
|
||||||
|
maintainer.join()
|
||||||
|
|
||||||
|
# If the maintainer thread exited but no shutdown was requested, it
|
||||||
|
# crashed. Surface as a non-zero exit so the watchdog restarts us
|
||||||
|
# instead of treating the silent thread death as a clean shutdown.
|
||||||
|
if not self.stop_event.is_set():
|
||||||
|
logger.error("Embeddings maintainer thread exited unexpectedly")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
class EmbeddingsContext:
|
class EmbeddingsContext:
|
||||||
|
|||||||
@ -310,6 +310,10 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self._handle_custom_classification_update(topic, payload)
|
self._handle_custom_classification_update(topic, payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if topic == "config/genai":
|
||||||
|
self.config.genai = payload
|
||||||
|
self.genai_manager.update_config(self.config)
|
||||||
|
|
||||||
# Broadcast to all processors — each decides if the topic is relevant
|
# Broadcast to all processors — each decides if the topic is relevant
|
||||||
for processor in self.realtime_processors:
|
for processor in self.realtime_processors:
|
||||||
processor.update_config(topic, payload)
|
processor.update_config(topic, payload)
|
||||||
@ -513,10 +517,16 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
|
for processor in self.post_processors:
|
||||||
|
if isinstance(processor, ObjectDescriptionProcessor):
|
||||||
|
processor.cleanup_event(event_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip the event if not an object
|
# Skip the event if not an object
|
||||||
if event.data.get("type") != "object":
|
if event.data.get("type") != "object":
|
||||||
|
for processor in self.post_processors:
|
||||||
|
if isinstance(processor, ObjectDescriptionProcessor):
|
||||||
|
processor.cleanup_event(event_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract valid thumbnail
|
# Extract valid thumbnail
|
||||||
|
|||||||
@ -205,6 +205,7 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
self.transcription_thread.start()
|
self.transcription_thread.start()
|
||||||
|
|
||||||
self.was_enabled = camera.enabled
|
self.was_enabled = camera.enabled
|
||||||
|
self.was_audio_enabled = camera.audio.enabled
|
||||||
|
|
||||||
def detect_audio(self, audio: np.ndarray) -> None:
|
def detect_audio(self, audio: np.ndarray) -> None:
|
||||||
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
||||||
@ -363,6 +364,17 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
audio_enabled = self.camera_config.audio.enabled
|
||||||
|
if audio_enabled != self.was_audio_enabled:
|
||||||
|
if not audio_enabled:
|
||||||
|
self.logger.debug(
|
||||||
|
f"Disabling audio detections for {self.camera_config.name}, ending events"
|
||||||
|
)
|
||||||
|
self.requestor.send_data(
|
||||||
|
EXPIRE_AUDIO_ACTIVITY, self.camera_config.name
|
||||||
|
)
|
||||||
|
self.was_audio_enabled = audio_enabled
|
||||||
|
|
||||||
self.read_audio()
|
self.read_audio()
|
||||||
|
|
||||||
if self.audio_listener:
|
if self.audio_listener:
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import importlib
|
import importlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -9,6 +10,7 @@ from typing import Any, Callable, Optional
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
||||||
from frigate.const import CLIPS_DIR
|
from frigate.const import CLIPS_DIR
|
||||||
@ -151,9 +153,6 @@ Each line represents a detection state, not necessarily unique individuals. The
|
|||||||
if "other_concerns" in schema.get("required", []):
|
if "other_concerns" in schema.get("required", []):
|
||||||
schema["required"].remove("other_concerns")
|
schema["required"].remove("other_concerns")
|
||||||
|
|
||||||
# OpenAI strict mode requires additionalProperties: false on all objects
|
|
||||||
schema["additionalProperties"] = False
|
|
||||||
|
|
||||||
response_format = {
|
response_format = {
|
||||||
"type": "json_schema",
|
"type": "json_schema",
|
||||||
"json_schema": {
|
"json_schema": {
|
||||||
@ -181,7 +180,36 @@ Each line represents a detection state, not necessarily unique individuals. The
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
metadata = ReviewMetadata.model_validate_json(clean_json)
|
metadata = ReviewMetadata.model_validate_json(clean_json)
|
||||||
|
except ValidationError as ve:
|
||||||
|
# Constraint violations (length, item count, ranges) are logged
|
||||||
|
# at debug and the response is kept anyway — a slightly
|
||||||
|
# off-spec answer is still usable, and dropping the whole
|
||||||
|
# response loses the narrative content the model produced.
|
||||||
|
for err in ve.errors():
|
||||||
|
loc = ".".join(str(p) for p in err["loc"]) or "<root>"
|
||||||
|
logger.debug(
|
||||||
|
"Review metadata soft validation: %s — %s (input: %r)",
|
||||||
|
loc,
|
||||||
|
err["msg"],
|
||||||
|
err.get("input"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = json.loads(clean_json)
|
||||||
|
except json.JSONDecodeError as je:
|
||||||
|
logger.error("Failed to parse review description JSON: %s", je)
|
||||||
|
return None
|
||||||
|
# observations and confidence are required on the model; fill an empty default
|
||||||
|
# if the response omitted it so attribute access stays safe.
|
||||||
|
raw.setdefault("observations", [])
|
||||||
|
raw.setdefault("confidence", 0.0)
|
||||||
|
metadata = ReviewMetadata.model_construct(**raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to parse review description as the response did not match expected format. {e}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
# Normalize confidence if model returned a percentage (e.g. 85 instead of 0.85)
|
# Normalize confidence if model returned a percentage (e.g. 85 instead of 0.85)
|
||||||
if metadata.confidence > 1.0:
|
if metadata.confidence > 1.0:
|
||||||
metadata.confidence = min(metadata.confidence / 100.0, 1.0)
|
metadata.confidence = min(metadata.confidence / 100.0, 1.0)
|
||||||
@ -194,10 +222,7 @@ Each line represents a detection state, not necessarily unique individuals. The
|
|||||||
metadata.time = review_data["start"]
|
metadata.time = review_data["start"]
|
||||||
return metadata
|
return metadata
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# rarely LLMs can fail to follow directions on output format
|
logger.error(f"Failed to post-process review metadata: {e}")
|
||||||
logger.warning(
|
|
||||||
f"Failed to parse review description as the response did not match expected format. {e}"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@ -344,6 +369,14 @@ Guidelines:
|
|||||||
"""Get the context window size for this provider in tokens."""
|
"""Get the context window size for this provider in tokens."""
|
||||||
return 4096
|
return 4096
|
||||||
|
|
||||||
|
def estimate_image_tokens(self, width: int, height: int) -> float:
|
||||||
|
"""Estimate prompt tokens consumed by a single image of the given dimensions.
|
||||||
|
|
||||||
|
Default heuristic: ~1 token per 1250 pixels. Providers that can measure or
|
||||||
|
know their model's exact image-token cost should override.
|
||||||
|
"""
|
||||||
|
return (width * height) / 1250
|
||||||
|
|
||||||
def embed(
|
def embed(
|
||||||
self,
|
self,
|
||||||
texts: list[str] | None = None,
|
texts: list[str] | None = None,
|
||||||
|
|||||||
@ -136,22 +136,44 @@ class GeminiClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
gemini_messages.append(
|
parts: list[types.Part] = []
|
||||||
types.Content(
|
if content:
|
||||||
role="model", parts=[types.Part.from_text(text=content)]
|
parts.append(types.Part.from_text(text=content))
|
||||||
|
for tc in msg.get("tool_calls") or []:
|
||||||
|
func = tc.get("function") or {}
|
||||||
|
tc_name = func.get("name") or ""
|
||||||
|
tc_args: Any = func.get("arguments")
|
||||||
|
if isinstance(tc_args, str):
|
||||||
|
try:
|
||||||
|
tc_args = json.loads(tc_args)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
tc_args = {}
|
||||||
|
if not isinstance(tc_args, dict):
|
||||||
|
tc_args = {}
|
||||||
|
if tc_name:
|
||||||
|
parts.append(
|
||||||
|
types.Part.from_function_call(
|
||||||
|
name=tc_name, args=tc_args
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if not parts:
|
||||||
|
parts.append(types.Part.from_text(text=" "))
|
||||||
|
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||||
elif role == "tool":
|
elif role == "tool":
|
||||||
# Handle tool response
|
# Handle tool response
|
||||||
function_response = {
|
response_payload = (
|
||||||
"name": msg.get("name", ""),
|
content if isinstance(content, dict) else {"result": content}
|
||||||
"response": content,
|
)
|
||||||
}
|
|
||||||
gemini_messages.append(
|
gemini_messages.append(
|
||||||
types.Content(
|
types.Content(
|
||||||
role="function",
|
role="function",
|
||||||
parts=[
|
parts=[
|
||||||
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
|
types.Part.from_function_response(
|
||||||
|
name=msg.get("name")
|
||||||
|
or msg.get("tool_call_id")
|
||||||
|
or "",
|
||||||
|
response=response_payload,
|
||||||
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -343,22 +365,44 @@ class GeminiClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
gemini_messages.append(
|
parts: list[types.Part] = []
|
||||||
types.Content(
|
if content:
|
||||||
role="model", parts=[types.Part.from_text(text=content)]
|
parts.append(types.Part.from_text(text=content))
|
||||||
|
for tc in msg.get("tool_calls") or []:
|
||||||
|
func = tc.get("function") or {}
|
||||||
|
tc_name = func.get("name") or ""
|
||||||
|
tc_args: Any = func.get("arguments")
|
||||||
|
if isinstance(tc_args, str):
|
||||||
|
try:
|
||||||
|
tc_args = json.loads(tc_args)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
tc_args = {}
|
||||||
|
if not isinstance(tc_args, dict):
|
||||||
|
tc_args = {}
|
||||||
|
if tc_name:
|
||||||
|
parts.append(
|
||||||
|
types.Part.from_function_call(
|
||||||
|
name=tc_name, args=tc_args
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if not parts:
|
||||||
|
parts.append(types.Part.from_text(text=" "))
|
||||||
|
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||||
elif role == "tool":
|
elif role == "tool":
|
||||||
# Handle tool response
|
# Handle tool response
|
||||||
function_response = {
|
response_payload = (
|
||||||
"name": msg.get("name", ""),
|
content if isinstance(content, dict) else {"result": content}
|
||||||
"response": content,
|
)
|
||||||
}
|
|
||||||
gemini_messages.append(
|
gemini_messages.append(
|
||||||
types.Content(
|
types.Content(
|
||||||
role="function",
|
role="function",
|
||||||
parts=[
|
parts=[
|
||||||
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
|
types.Part.from_function_response(
|
||||||
|
name=msg.get("name")
|
||||||
|
or msg.get("tool_call_id")
|
||||||
|
or "",
|
||||||
|
response=response_payload,
|
||||||
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -42,6 +42,9 @@ class LlamaCppClient(GenAIClient):
|
|||||||
_supports_vision: bool
|
_supports_vision: bool
|
||||||
_supports_audio: bool
|
_supports_audio: bool
|
||||||
_supports_tools: bool
|
_supports_tools: bool
|
||||||
|
_image_token_cache: dict[tuple[int, int], int]
|
||||||
|
_text_baseline_tokens: int | None
|
||||||
|
_media_marker: str
|
||||||
|
|
||||||
def _init_provider(self) -> str | None:
|
def _init_provider(self) -> str | None:
|
||||||
"""Initialize the client and query model metadata from the server."""
|
"""Initialize the client and query model metadata from the server."""
|
||||||
@ -52,6 +55,9 @@ class LlamaCppClient(GenAIClient):
|
|||||||
self._supports_vision = False
|
self._supports_vision = False
|
||||||
self._supports_audio = False
|
self._supports_audio = False
|
||||||
self._supports_tools = False
|
self._supports_tools = False
|
||||||
|
self._image_token_cache = {}
|
||||||
|
self._text_baseline_tokens = None
|
||||||
|
self._media_marker = "<__media__>"
|
||||||
|
|
||||||
base_url = (
|
base_url = (
|
||||||
self.genai_config.base_url.rstrip("/")
|
self.genai_config.base_url.rstrip("/")
|
||||||
@ -137,6 +143,13 @@ class LlamaCppClient(GenAIClient):
|
|||||||
chat_caps = props.get("chat_template_caps", {})
|
chat_caps = props.get("chat_template_caps", {})
|
||||||
self._supports_tools = chat_caps.get("supports_tools", False)
|
self._supports_tools = chat_caps.get("supports_tools", False)
|
||||||
|
|
||||||
|
# Media marker for multimodal embeddings; the server randomizes this
|
||||||
|
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
|
||||||
|
# from /props rather than hardcoding "<__media__>".
|
||||||
|
media_marker = props.get("media_marker")
|
||||||
|
if isinstance(media_marker, str) and media_marker:
|
||||||
|
self._media_marker = media_marker
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
||||||
configured_model,
|
configured_model,
|
||||||
@ -272,6 +285,91 @@ class LlamaCppClient(GenAIClient):
|
|||||||
return self._context_size
|
return self._context_size
|
||||||
return 4096
|
return 4096
|
||||||
|
|
||||||
|
def estimate_image_tokens(self, width: int, height: int) -> float:
|
||||||
|
"""Probe the llama.cpp server to learn the model's image-token cost at the
|
||||||
|
requested dimensions.
|
||||||
|
|
||||||
|
llama.cpp's image tokenization is a deterministic function of dimensions and
|
||||||
|
the loaded mmproj, so the result is cached per (width, height) for the
|
||||||
|
lifetime of the process. Falls back to the base pixel heuristic if the
|
||||||
|
server is unreachable or the response is malformed.
|
||||||
|
"""
|
||||||
|
if self.provider is None:
|
||||||
|
return super().estimate_image_tokens(width, height)
|
||||||
|
|
||||||
|
cached = self._image_token_cache.get((width, height))
|
||||||
|
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
try:
|
||||||
|
baseline = self._probe_baseline_tokens()
|
||||||
|
with_image = self._probe_image_prompt_tokens(width, height)
|
||||||
|
tokens = max(1, with_image - baseline)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"llama.cpp image-token probe failed for %dx%d (%s); using heuristic",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return super().estimate_image_tokens(width, height)
|
||||||
|
|
||||||
|
self._image_token_cache[(width, height)] = tokens
|
||||||
|
logger.debug(
|
||||||
|
"llama.cpp model '%s' uses ~%d tokens for %dx%d images",
|
||||||
|
self.genai_config.model,
|
||||||
|
tokens,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def _probe_baseline_tokens(self) -> int:
|
||||||
|
"""Return prompt_tokens for a minimal text-only request. Cached after first call."""
|
||||||
|
if self._text_baseline_tokens is not None:
|
||||||
|
return self._text_baseline_tokens
|
||||||
|
|
||||||
|
self._text_baseline_tokens = self._probe_prompt_tokens(
|
||||||
|
[{"type": "text", "text": "."}]
|
||||||
|
)
|
||||||
|
return self._text_baseline_tokens
|
||||||
|
|
||||||
|
def _probe_image_prompt_tokens(self, width: int, height: int) -> int:
|
||||||
|
"""Return prompt_tokens for a single synthetic image plus minimal text."""
|
||||||
|
img = Image.new("RGB", (width, height), (128, 128, 128))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=60)
|
||||||
|
encoded = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||||
|
return self._probe_prompt_tokens(
|
||||||
|
[
|
||||||
|
{"type": "text", "text": "."},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _probe_prompt_tokens(self, content: list[dict[str, Any]]) -> int:
|
||||||
|
"""POST a 1-token chat completion and return reported prompt_tokens.
|
||||||
|
|
||||||
|
Uses a generous timeout to absorb a cold model load on the first probe
|
||||||
|
when the server lazily loads models on demand (e.g. llama-swap).
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"model": self.genai_config.model,
|
||||||
|
"messages": [{"role": "user", "content": content}],
|
||||||
|
"max_tokens": 1,
|
||||||
|
}
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.provider}/v1/chat/completions",
|
||||||
|
json=payload,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return int(response.json()["usage"]["prompt_tokens"])
|
||||||
|
|
||||||
def _build_payload(
|
def _build_payload(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@ -376,10 +474,11 @@ class LlamaCppClient(GenAIClient):
|
|||||||
jpeg_bytes = _to_jpeg(img)
|
jpeg_bytes = _to_jpeg(img)
|
||||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||||
# prompt_string must contain <__media__> placeholder for image tokenization
|
# prompt_string must contain the server's media marker placeholder.
|
||||||
|
# The marker is randomized per server startup (read from /props).
|
||||||
content.append(
|
content.append(
|
||||||
{
|
{
|
||||||
"prompt_string": "<__media__>\n",
|
"prompt_string": f"{self._media_marker}\n",
|
||||||
"multimodal_data": [encoded], # type: ignore[dict-item]
|
"multimodal_data": [encoded], # type: ignore[dict-item]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -113,6 +113,15 @@ class OllamaClient(GenAIClient):
|
|||||||
schema = response_format.get("json_schema", {}).get("schema")
|
schema = response_format.get("json_schema", {}).get("schema")
|
||||||
if schema:
|
if schema:
|
||||||
ollama_options["format"] = self._clean_schema_for_ollama(schema)
|
ollama_options["format"] = self._clean_schema_for_ollama(schema)
|
||||||
|
logger.debug(
|
||||||
|
"Ollama generate request: model=%s, prompt_len=%s, image_count=%s, "
|
||||||
|
"has_format=%s, options=%s",
|
||||||
|
self.genai_config.model,
|
||||||
|
len(prompt),
|
||||||
|
len(images) if images else 0,
|
||||||
|
"format" in ollama_options,
|
||||||
|
{k: v for k, v in ollama_options.items() if k != "format"},
|
||||||
|
)
|
||||||
result = self.provider.generate(
|
result = self.provider.generate(
|
||||||
self.genai_config.model,
|
self.genai_config.model,
|
||||||
prompt,
|
prompt,
|
||||||
@ -120,9 +129,24 @@ class OllamaClient(GenAIClient):
|
|||||||
**ollama_options,
|
**ollama_options,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Ollama tokens used: eval_count={result.get('eval_count')}, prompt_eval_count={result.get('prompt_eval_count')}"
|
"Ollama generate response: done=%s, done_reason=%s, eval_count=%s, "
|
||||||
|
"prompt_eval_count=%s, response_len=%s",
|
||||||
|
result.get("done"),
|
||||||
|
result.get("done_reason"),
|
||||||
|
result.get("eval_count"),
|
||||||
|
result.get("prompt_eval_count"),
|
||||||
|
len(result.get("response", "") or ""),
|
||||||
)
|
)
|
||||||
return str(result["response"]).strip()
|
response_text = str(result["response"]).strip()
|
||||||
|
if not response_text:
|
||||||
|
logger.warning(
|
||||||
|
"Ollama returned a blank response for model %s (done_reason=%s, "
|
||||||
|
"eval_count=%s). Check model output, ensure thinking is disabled.",
|
||||||
|
self.genai_config.model,
|
||||||
|
result.get("done_reason"),
|
||||||
|
result.get("eval_count"),
|
||||||
|
)
|
||||||
|
return response_text
|
||||||
except (
|
except (
|
||||||
TimeoutException,
|
TimeoutException,
|
||||||
ResponseError,
|
ResponseError,
|
||||||
|
|||||||
@ -73,14 +73,39 @@ class OpenAIClient(GenAIClient):
|
|||||||
**self.genai_config.runtime_options,
|
**self.genai_config.runtime_options,
|
||||||
}
|
}
|
||||||
if response_format:
|
if response_format:
|
||||||
|
# OpenAI strict mode requires additionalProperties: false on the schema
|
||||||
|
if response_format.get("type") == "json_schema" and response_format.get(
|
||||||
|
"json_schema", {}
|
||||||
|
).get("strict"):
|
||||||
|
schema = response_format.get("json_schema", {}).get("schema")
|
||||||
|
if isinstance(schema, dict):
|
||||||
|
schema["additionalProperties"] = False
|
||||||
request_params["response_format"] = response_format
|
request_params["response_format"] = response_format
|
||||||
|
|
||||||
result = self.provider.chat.completions.create(**request_params)
|
result = self.provider.chat.completions.create(**request_params)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
result is not None
|
result is not None
|
||||||
and hasattr(result, "choices")
|
and hasattr(result, "choices")
|
||||||
and len(result.choices) > 0
|
and len(result.choices) > 0
|
||||||
):
|
):
|
||||||
return str(result.choices[0].message.content.strip())
|
message = result.choices[0].message
|
||||||
|
content = message.content
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
# When reasoning is enabled for some OpenAI backends the actual response
|
||||||
|
# is incorrectly placed in reasoning_content instead of content.
|
||||||
|
# This is buggy/incorrect behavior — reasoning should not be
|
||||||
|
# enabled for these models.
|
||||||
|
reasoning_content = getattr(message, "reasoning_content", None)
|
||||||
|
if reasoning_content:
|
||||||
|
logger.warning(
|
||||||
|
"Response content was empty but reasoning_content was provided; "
|
||||||
|
"reasoning appears to be enabled and should be disabled for this model."
|
||||||
|
)
|
||||||
|
content = reasoning_content
|
||||||
|
|
||||||
|
return str(content.strip()) if content else None
|
||||||
return None
|
return None
|
||||||
except (TimeoutException, Exception) as e:
|
except (TimeoutException, Exception) as e:
|
||||||
logger.warning("OpenAI returned an error: %s", str(e))
|
logger.warning("OpenAI returned an error: %s", str(e))
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import os
|
|||||||
import queue
|
import queue
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
import traceback
|
import traceback
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@ -19,6 +18,7 @@ import numpy as np
|
|||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig
|
from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig
|
||||||
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
|
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
|
||||||
|
from frigate.output.ws_auth import ws_has_camera_access
|
||||||
from frigate.util.image import (
|
from frigate.util.image import (
|
||||||
SharedMemoryFrameManager,
|
SharedMemoryFrameManager,
|
||||||
copy_yuv_to_position,
|
copy_yuv_to_position,
|
||||||
@ -236,12 +236,14 @@ class BroadcastThread(threading.Thread):
|
|||||||
converter: FFMpegConverter,
|
converter: FFMpegConverter,
|
||||||
websocket_server: Any,
|
websocket_server: Any,
|
||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
|
config: FrigateConfig,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.camera = camera
|
self.camera = camera
|
||||||
self.converter = converter
|
self.converter = converter
|
||||||
self.websocket_server = websocket_server
|
self.websocket_server = websocket_server
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
self.config = config
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
@ -256,6 +258,7 @@ class BroadcastThread(threading.Thread):
|
|||||||
if (
|
if (
|
||||||
not ws.terminated
|
not ws.terminated
|
||||||
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
||||||
|
and ws_has_camera_access(ws, self.camera, self.config)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
ws.send(buf, binary=True)
|
ws.send(buf, binary=True)
|
||||||
@ -806,7 +809,11 @@ class Birdseye:
|
|||||||
config.birdseye.restream,
|
config.birdseye.restream,
|
||||||
)
|
)
|
||||||
self.broadcaster = BroadcastThread(
|
self.broadcaster = BroadcastThread(
|
||||||
"birdseye", self.converter, websocket_server, stop_event
|
"birdseye",
|
||||||
|
self.converter,
|
||||||
|
websocket_server,
|
||||||
|
stop_event,
|
||||||
|
config,
|
||||||
)
|
)
|
||||||
self.birdseye_manager = BirdsEyeFrameManager(self.config, stop_event)
|
self.birdseye_manager = BirdsEyeFrameManager(self.config, stop_event)
|
||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
@ -874,7 +881,7 @@ class Birdseye:
|
|||||||
coordinates = self.birdseye_manager.get_camera_coordinates()
|
coordinates = self.birdseye_manager.get_camera_coordinates()
|
||||||
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
|
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
|
||||||
if self._idle_interval:
|
if self._idle_interval:
|
||||||
now = time.monotonic()
|
now = datetime.datetime.now().timestamp()
|
||||||
is_idle = len(self.birdseye_manager.camera_layout) == 0
|
is_idle = len(self.birdseye_manager.camera_layout) == 0
|
||||||
if (
|
if (
|
||||||
is_idle
|
is_idle
|
||||||
|
|||||||
@ -7,7 +7,8 @@ import threading
|
|||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from frigate.config import CameraConfig, FfmpegConfig
|
from frigate.config import CameraConfig, FfmpegConfig, FrigateConfig
|
||||||
|
from frigate.output.ws_auth import ws_has_camera_access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -102,12 +103,14 @@ class BroadcastThread(threading.Thread):
|
|||||||
converter: FFMpegConverter,
|
converter: FFMpegConverter,
|
||||||
websocket_server: Any,
|
websocket_server: Any,
|
||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
|
config: FrigateConfig,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.camera = camera
|
self.camera = camera
|
||||||
self.converter = converter
|
self.converter = converter
|
||||||
self.websocket_server = websocket_server
|
self.websocket_server = websocket_server
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
self.config = config
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
@ -122,6 +125,7 @@ class BroadcastThread(threading.Thread):
|
|||||||
if (
|
if (
|
||||||
not ws.terminated
|
not ws.terminated
|
||||||
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
||||||
|
and ws_has_camera_access(ws, self.camera, self.config)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
ws.send(buf, binary=True)
|
ws.send(buf, binary=True)
|
||||||
@ -135,7 +139,11 @@ class BroadcastThread(threading.Thread):
|
|||||||
|
|
||||||
class JsmpegCamera:
|
class JsmpegCamera:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, config: CameraConfig, stop_event: MpEvent, websocket_server: Any
|
self,
|
||||||
|
config: CameraConfig,
|
||||||
|
frigate_config: FrigateConfig,
|
||||||
|
stop_event: MpEvent,
|
||||||
|
websocket_server: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.input: queue.Queue[bytes] = queue.Queue(maxsize=config.detect.fps)
|
self.input: queue.Queue[bytes] = queue.Queue(maxsize=config.detect.fps)
|
||||||
@ -154,7 +162,11 @@ class JsmpegCamera:
|
|||||||
config.live.quality,
|
config.live.quality,
|
||||||
)
|
)
|
||||||
self.broadcaster = BroadcastThread(
|
self.broadcaster = BroadcastThread(
|
||||||
config.name or "", self.converter, websocket_server, stop_event
|
config.name or "",
|
||||||
|
self.converter,
|
||||||
|
websocket_server,
|
||||||
|
stop_event,
|
||||||
|
frigate_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.converter.start()
|
self.converter.start()
|
||||||
|
|||||||
@ -32,6 +32,7 @@ from frigate.const import (
|
|||||||
from frigate.output.birdseye import Birdseye
|
from frigate.output.birdseye import Birdseye
|
||||||
from frigate.output.camera import JsmpegCamera
|
from frigate.output.camera import JsmpegCamera
|
||||||
from frigate.output.preview import PreviewRecorder
|
from frigate.output.preview import PreviewRecorder
|
||||||
|
from frigate.output.ws_auth import ws_has_camera_access
|
||||||
from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame
|
from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame
|
||||||
from frigate.util.process import FrigateProcess
|
from frigate.util.process import FrigateProcess
|
||||||
|
|
||||||
@ -102,7 +103,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
) -> None:
|
) -> None:
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
jsmpeg_cameras[camera] = JsmpegCamera(
|
||||||
camera_config, self.stop_event, websocket_server
|
camera_config, self.config, self.stop_event, websocket_server
|
||||||
)
|
)
|
||||||
preview_recorders[camera] = PreviewRecorder(camera_config)
|
preview_recorders[camera] = PreviewRecorder(camera_config)
|
||||||
preview_write_times[camera] = 0
|
preview_write_times[camera] = 0
|
||||||
@ -262,6 +263,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
# send camera frame to ffmpeg process if websockets are connected
|
# send camera frame to ffmpeg process if websockets are connected
|
||||||
if any(
|
if any(
|
||||||
ws.environ["PATH_INFO"].endswith(camera)
|
ws.environ["PATH_INFO"].endswith(camera)
|
||||||
|
and ws_has_camera_access(ws, camera, self.config)
|
||||||
for ws in websocket_server.manager
|
for ws in websocket_server.manager
|
||||||
):
|
):
|
||||||
# write to the converter for the camera if clients are listening to the specific camera
|
# write to the converter for the camera if clients are listening to the specific camera
|
||||||
@ -275,6 +277,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
self.config.birdseye.restream
|
self.config.birdseye.restream
|
||||||
or any(
|
or any(
|
||||||
ws.environ["PATH_INFO"].endswith("birdseye")
|
ws.environ["PATH_INFO"].endswith("birdseye")
|
||||||
|
and ws_has_camera_access(ws, "birdseye", self.config)
|
||||||
for ws in websocket_server.manager
|
for ws in websocket_server.manager
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -346,6 +349,13 @@ def move_preview_frames(loc: str) -> None:
|
|||||||
if not os.path.exists(preview_holdover):
|
if not os.path.exists(preview_holdover):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not os.access(preview_holdover, os.R_OK | os.W_OK):
|
||||||
|
logger.error(
|
||||||
|
"Insufficient permissions on preview restart cache at %s",
|
||||||
|
preview_holdover,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
shutil.move(preview_holdover, preview_cache)
|
shutil.move(preview_holdover, preview_cache)
|
||||||
except shutil.Error:
|
except shutil.Error:
|
||||||
logger.error("Failed to restore preview cache.")
|
logger.error("Failed to restore preview cache.")
|
||||||
|
|||||||
@ -361,14 +361,17 @@ class PreviewRecorder:
|
|||||||
small_frame,
|
small_frame,
|
||||||
cv2.COLOR_YUV2BGR_I420,
|
cv2.COLOR_YUV2BGR_I420,
|
||||||
)
|
)
|
||||||
cv2.imwrite(
|
cache_path = get_cache_image_name(self.camera_name, frame_time)
|
||||||
get_cache_image_name(self.camera_name, frame_time),
|
|
||||||
|
if not cv2.imwrite(
|
||||||
|
cache_path,
|
||||||
small_frame,
|
small_frame,
|
||||||
[
|
[
|
||||||
int(cv2.IMWRITE_WEBP_QUALITY),
|
int(cv2.IMWRITE_WEBP_QUALITY),
|
||||||
PREVIEW_QUALITY_WEBP[self.config.record.preview.quality],
|
PREVIEW_QUALITY_WEBP[self.config.record.preview.quality],
|
||||||
],
|
],
|
||||||
)
|
):
|
||||||
|
logger.error("Failed to write preview frame to %s", cache_path)
|
||||||
|
|
||||||
def write_data(
|
def write_data(
|
||||||
self,
|
self,
|
||||||
|
|||||||
43
frigate/output/ws_auth.py
Normal file
43
frigate/output/ws_auth.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Authorization helpers for JSMPEG websocket clients."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def _get_valid_ws_roles(ws: Any, config: FrigateConfig) -> list[str]:
|
||||||
|
role_header = ws.environ.get("HTTP_REMOTE_ROLE", "")
|
||||||
|
roles = [
|
||||||
|
role.strip()
|
||||||
|
for role in role_header.split(config.proxy.separator)
|
||||||
|
if role.strip()
|
||||||
|
]
|
||||||
|
return [role for role in roles if role in config.auth.roles]
|
||||||
|
|
||||||
|
|
||||||
|
def ws_has_camera_access(ws: Any, camera_name: str, config: FrigateConfig) -> bool:
|
||||||
|
"""Return True when a websocket client is authorized for the camera path."""
|
||||||
|
roles = _get_valid_ws_roles(ws, config)
|
||||||
|
|
||||||
|
if not roles:
|
||||||
|
return False
|
||||||
|
|
||||||
|
roles_dict = config.auth.roles
|
||||||
|
|
||||||
|
# Birdseye is a composite stream, so only users with unrestricted access
|
||||||
|
# should receive it.
|
||||||
|
if camera_name == "birdseye":
|
||||||
|
return any(role == "admin" or not roles_dict.get(role) for role in roles)
|
||||||
|
|
||||||
|
all_camera_names = set(config.cameras.keys())
|
||||||
|
|
||||||
|
for role in roles:
|
||||||
|
if role == "admin" or not roles_dict.get(role):
|
||||||
|
return True
|
||||||
|
|
||||||
|
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||||
|
if camera_name in allowed_cameras:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@ -28,7 +28,7 @@ from frigate.ffmpeg_presets import (
|
|||||||
EncodeTypeEnum,
|
EncodeTypeEnum,
|
||||||
parse_preset_hardware_acceleration_encode,
|
parse_preset_hardware_acceleration_encode,
|
||||||
)
|
)
|
||||||
from frigate.models import Export, Previews, Recordings
|
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
||||||
from frigate.util.time import is_current_hour
|
from frigate.util.time import is_current_hour
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -347,6 +347,122 @@ class RecordingExporter(threading.Thread):
|
|||||||
# return in iso format
|
# return in iso format
|
||||||
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
def _chapter_metadata_path(self) -> str:
|
||||||
|
return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt")
|
||||||
|
|
||||||
|
def _build_chapter_metadata_file(self, recordings: list) -> Optional[str]:
|
||||||
|
"""Write an FFmpeg metadata file with chapters for review items in range.
|
||||||
|
|
||||||
|
Chapter offsets are computed in *output time*: the VOD endpoint
|
||||||
|
concatenates recording clips back-to-back, so wall-clock gaps
|
||||||
|
between recordings collapse in the produced video. We walk the
|
||||||
|
same recording rows that feed the playlist and convert each
|
||||||
|
review item's wall-clock boundaries into output-time offsets.
|
||||||
|
Returns ``None`` when there are no recordings, no review items,
|
||||||
|
or any chapter would have zero output duration.
|
||||||
|
"""
|
||||||
|
if not recordings:
|
||||||
|
return None
|
||||||
|
|
||||||
|
windows: list[tuple[float, float, float]] = []
|
||||||
|
output_offset = 0.0
|
||||||
|
for rec in recordings:
|
||||||
|
clipped_start = max(float(rec.start_time), float(self.start_time))
|
||||||
|
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||||
|
if clipped_end <= clipped_start:
|
||||||
|
continue
|
||||||
|
windows.append((clipped_start, clipped_end, output_offset))
|
||||||
|
output_offset += clipped_end - clipped_start
|
||||||
|
|
||||||
|
if not windows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
review_rows = list(
|
||||||
|
ReviewSegment.select(
|
||||||
|
ReviewSegment.start_time,
|
||||||
|
ReviewSegment.end_time,
|
||||||
|
ReviewSegment.severity,
|
||||||
|
ReviewSegment.data,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
ReviewSegment.start_time.between(self.start_time, self.end_time)
|
||||||
|
| ReviewSegment.end_time.between(self.start_time, self.end_time)
|
||||||
|
| (
|
||||||
|
(self.start_time > ReviewSegment.start_time)
|
||||||
|
& (self.end_time < ReviewSegment.end_time)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(ReviewSegment.camera == self.camera)
|
||||||
|
.order_by(ReviewSegment.start_time.asc())
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to query review segments for export %s", self.export_id
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not review_rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0])
|
||||||
|
|
||||||
|
def wall_to_output(t: float) -> float:
|
||||||
|
t = max(float(self.start_time), min(float(self.end_time), t))
|
||||||
|
for w_start, w_end, w_offset in windows:
|
||||||
|
if t < w_start:
|
||||||
|
return w_offset
|
||||||
|
if t <= w_end:
|
||||||
|
return w_offset + (t - w_start)
|
||||||
|
return total_output
|
||||||
|
|
||||||
|
chapter_blocks: list[str] = []
|
||||||
|
for review in review_rows:
|
||||||
|
start_out = wall_to_output(float(review.start_time))
|
||||||
|
end_out = wall_to_output(float(review.end_time))
|
||||||
|
|
||||||
|
# Drop chapters that fall entirely in a recording gap, or are
|
||||||
|
# too short to be navigable in a player.
|
||||||
|
if end_out - start_out < 1.0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = review.data or {}
|
||||||
|
labels: list[str] = []
|
||||||
|
for obj in data.get("objects") or []:
|
||||||
|
label = str(obj).split("-")[0]
|
||||||
|
if label and label not in labels:
|
||||||
|
labels.append(label)
|
||||||
|
|
||||||
|
title = str(review.severity).capitalize()
|
||||||
|
if labels:
|
||||||
|
title = f"{title}: {', '.join(labels)}"
|
||||||
|
|
||||||
|
chapter_blocks.append(
|
||||||
|
"[CHAPTER]\n"
|
||||||
|
"TIMEBASE=1/1000\n"
|
||||||
|
f"START={int(start_out * 1000)}\n"
|
||||||
|
f"END={int(end_out * 1000)}\n"
|
||||||
|
f"title={title}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not chapter_blocks:
|
||||||
|
return None
|
||||||
|
|
||||||
|
meta_path = self._chapter_metadata_path()
|
||||||
|
try:
|
||||||
|
with open(meta_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(";FFMETADATA1\n")
|
||||||
|
f.write("\n".join(chapter_blocks))
|
||||||
|
f.write("\n")
|
||||||
|
except OSError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to write chapter metadata file for export %s", self.export_id
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return meta_path
|
||||||
|
|
||||||
def save_thumbnail(self, id: str) -> str:
|
def save_thumbnail(self, id: str) -> str:
|
||||||
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
|
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
|
||||||
|
|
||||||
@ -451,15 +567,7 @@ class RecordingExporter(threading.Thread):
|
|||||||
if type(internal_port) is str:
|
if type(internal_port) is str:
|
||||||
internal_port = int(internal_port.split(":")[-1])
|
internal_port = int(internal_port.split(":")[-1])
|
||||||
|
|
||||||
playlist_lines: list[str] = []
|
recordings = list(
|
||||||
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
|
|
||||||
playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
|
||||||
ffmpeg_input = (
|
|
||||||
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# get full set of recordings
|
|
||||||
export_recordings = (
|
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
Recordings.start_time,
|
Recordings.start_time,
|
||||||
Recordings.end_time,
|
Recordings.end_time,
|
||||||
@ -474,16 +582,23 @@ class RecordingExporter(threading.Thread):
|
|||||||
)
|
)
|
||||||
.where(Recordings.camera == self.camera)
|
.where(Recordings.camera == self.camera)
|
||||||
.order_by(Recordings.start_time.asc())
|
.order_by(Recordings.start_time.asc())
|
||||||
|
.iterator()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use pagination to process records in chunks
|
playlist_lines: list[str] = []
|
||||||
|
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
|
||||||
|
playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
||||||
|
ffmpeg_input = (
|
||||||
|
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Chunk the recording rows into pages so each playlist line
|
||||||
|
# references a bounded sub-range rather than the full export.
|
||||||
page_size = 1000
|
page_size = 1000
|
||||||
num_pages = (export_recordings.count() + page_size - 1) // page_size
|
for i in range(0, len(recordings), page_size):
|
||||||
|
chunk = recordings[i : i + page_size]
|
||||||
for page in range(1, num_pages + 1):
|
|
||||||
playlist = export_recordings.paginate(page, page_size)
|
|
||||||
playlist_lines.append(
|
playlist_lines.append(
|
||||||
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(playlist[0].start_time)}/end/{float(playlist[-1].end_time)}/index.m3u8'"
|
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(chunk[0].start_time)}/end/{float(chunk[-1].end_time)}/index.m3u8'"
|
||||||
)
|
)
|
||||||
|
|
||||||
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
|
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
|
||||||
@ -504,8 +619,12 @@ class RecordingExporter(threading.Thread):
|
|||||||
)
|
)
|
||||||
).split(" ")
|
).split(" ")
|
||||||
else:
|
else:
|
||||||
|
chapters_path = self._build_chapter_metadata_file(recordings)
|
||||||
|
chapter_args = (
|
||||||
|
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
|
||||||
|
)
|
||||||
ffmpeg_cmd = (
|
ffmpeg_cmd = (
|
||||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart"
|
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
|
||||||
).split(" ")
|
).split(" ")
|
||||||
|
|
||||||
# add metadata
|
# add metadata
|
||||||
@ -691,6 +810,8 @@ class RecordingExporter(threading.Thread):
|
|||||||
ffmpeg_cmd, playlist_lines, step="encoding_retry"
|
ffmpeg_cmd, playlist_lines, step="encoding_retry"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Path(self._chapter_metadata_path()).unlink(missing_ok=True)
|
||||||
|
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"
|
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"
|
||||||
|
|||||||
@ -23,6 +23,26 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
assert response_json == self.test_stats
|
assert response_json == self.test_stats
|
||||||
|
|
||||||
|
def test_recordings_storage_requires_admin(self):
|
||||||
|
stats = Mock(spec=StatsEmitter)
|
||||||
|
stats.get_latest_stats.return_value = self.test_stats
|
||||||
|
app = super().create_app(stats)
|
||||||
|
app.storage_maintainer = Mock()
|
||||||
|
app.storage_maintainer.calculate_camera_usages.return_value = {
|
||||||
|
"front_door": {"usage": 2.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
response = client.get(
|
||||||
|
"/recordings/storage",
|
||||||
|
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
response = client.get("/recordings/storage")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["front_door"]["usage_percent"] == 25.0
|
||||||
|
|
||||||
def test_config_set_in_memory_replaces_objects_track_list(self):
|
def test_config_set_in_memory_replaces_objects_track_list(self):
|
||||||
self.minimal_config["cameras"]["front_door"]["objects"] = {
|
self.minimal_config["cameras"]["front_door"]["objects"] = {
|
||||||
"track": ["person", "car"],
|
"track": ["person", "car"],
|
||||||
|
|||||||
@ -219,6 +219,25 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert events[0]["id"] == event_id
|
assert events[0]["id"] == event_id
|
||||||
|
|
||||||
|
def test_similarity_search_hides_unauthorized_anchor_event(self):
|
||||||
|
mock_embeddings = Mock()
|
||||||
|
self.app.frigate_config.semantic_search.enabled = True
|
||||||
|
self.app.embeddings = mock_embeddings
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
super().insert_mock_event("hidden.anchor", camera="back_door")
|
||||||
|
response = client.get(
|
||||||
|
"/events/search",
|
||||||
|
params={
|
||||||
|
"search_type": "similarity",
|
||||||
|
"event_id": "hidden.anchor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["message"] == "Event not found"
|
||||||
|
mock_embeddings.search_thumbnail.assert_not_called()
|
||||||
|
|
||||||
def test_get_good_event(self):
|
def test_get_good_event(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
|
|||||||
@ -145,9 +145,12 @@ class TestExecuteFindSimilarObjects(unittest.TestCase):
|
|||||||
embeddings=embeddings,
|
embeddings=embeddings,
|
||||||
frigate_config=SimpleNamespace(
|
frigate_config=SimpleNamespace(
|
||||||
semantic_search=SimpleNamespace(enabled=semantic_enabled),
|
semantic_search=SimpleNamespace(enabled=semantic_enabled),
|
||||||
|
cameras={"driveway": object()},
|
||||||
|
auth=SimpleNamespace(roles={"admin": [], "viewer": ["driveway"]}),
|
||||||
|
proxy=SimpleNamespace(separator=","),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return SimpleNamespace(app=app)
|
return SimpleNamespace(app=app, headers={})
|
||||||
|
|
||||||
def test_semantic_search_disabled_returns_error(self):
|
def test_semantic_search_disabled_returns_error(self):
|
||||||
req = self._make_request(semantic_enabled=False)
|
req = self._make_request(semantic_enabled=False)
|
||||||
@ -180,7 +183,7 @@ class TestExecuteFindSimilarObjects(unittest.TestCase):
|
|||||||
_execute_find_similar_objects(
|
_execute_find_similar_objects(
|
||||||
req,
|
req,
|
||||||
{"event_id": "anchor", "cameras": ["nonexistent_cam"]},
|
{"event_id": "anchor", "cameras": ["nonexistent_cam"]},
|
||||||
allowed_cameras=["nonexistent_cam"],
|
allowed_cameras=["driveway"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(result["results"], [])
|
self.assertEqual(result["results"], [])
|
||||||
|
|||||||
57
frigate/test/test_output_ws_auth.py
Normal file
57
frigate/test/test_output_ws_auth.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""Tests for JSMPEG websocket authorization."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.output.ws_auth import ws_has_camera_access
|
||||||
|
|
||||||
|
|
||||||
|
class TestWsHasCameraAccess(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config = FrigateConfig(
|
||||||
|
mqtt={"host": "mqtt"},
|
||||||
|
auth={"roles": {"limited_user": ["front_door"]}},
|
||||||
|
cameras={
|
||||||
|
"front_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
},
|
||||||
|
"back_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_ws(self, role: str):
|
||||||
|
return SimpleNamespace(environ={"HTTP_REMOTE_ROLE": role})
|
||||||
|
|
||||||
|
def test_restricted_role_only_gets_allowed_camera(self):
|
||||||
|
ws = self._make_ws("limited_user")
|
||||||
|
self.assertTrue(ws_has_camera_access(ws, "front_door", self.config))
|
||||||
|
self.assertFalse(ws_has_camera_access(ws, "back_door", self.config))
|
||||||
|
|
||||||
|
def test_unrestricted_role_can_access_any_camera(self):
|
||||||
|
ws = self._make_ws("viewer")
|
||||||
|
self.assertTrue(ws_has_camera_access(ws, "front_door", self.config))
|
||||||
|
self.assertTrue(ws_has_camera_access(ws, "back_door", self.config))
|
||||||
|
|
||||||
|
def test_birdseye_requires_unrestricted_access(self):
|
||||||
|
self.assertTrue(
|
||||||
|
ws_has_camera_access(self._make_ws("admin"), "birdseye", self.config)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
ws_has_camera_access(self._make_ws("viewer"), "birdseye", self.config)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config)
|
||||||
|
)
|
||||||
29
frigate/test/test_webpush_camera_monitoring.py
Normal file
29
frigate/test/test_webpush_camera_monitoring.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Tests for camera monitoring notification authorization."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from frigate.comms.webpush import WebPushClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestCameraMonitoringNotifications(unittest.TestCase):
|
||||||
|
def test_send_camera_monitoring_filters_by_camera_access(self):
|
||||||
|
client = WebPushClient.__new__(WebPushClient)
|
||||||
|
client.config = SimpleNamespace(
|
||||||
|
cameras={"front_door": SimpleNamespace(friendly_name=None)}
|
||||||
|
)
|
||||||
|
client.web_pushers = {"allowed": [], "denied": []}
|
||||||
|
client.user_cameras = {"allowed": {"front_door"}, "denied": set()}
|
||||||
|
client.check_registrations = MagicMock()
|
||||||
|
client.cleanup_registrations = MagicMock()
|
||||||
|
client.send_push_notification = MagicMock()
|
||||||
|
|
||||||
|
client.send_camera_monitoring(
|
||||||
|
{"camera": "front_door", "message": "Monitoring condition met"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(client.send_push_notification.call_count, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
client.send_push_notification.call_args.kwargs["user"], "allowed"
|
||||||
|
)
|
||||||
@ -24,8 +24,12 @@ from frigate.log import redirect_output_to_logger, suppress_stderr_during
|
|||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.types import ModelStatusTypesEnum
|
from frigate.types import ModelStatusTypesEnum
|
||||||
from frigate.util.downloader import ModelDownloader
|
from frigate.util.downloader import ModelDownloader
|
||||||
from frigate.util.file import get_event_thumbnail_bytes
|
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
|
||||||
from frigate.util.image import get_image_from_recording
|
from frigate.util.image import (
|
||||||
|
calculate_region,
|
||||||
|
get_image_from_recording,
|
||||||
|
relative_box_to_absolute,
|
||||||
|
)
|
||||||
from frigate.util.process import FrigateProcess
|
from frigate.util.process import FrigateProcess
|
||||||
|
|
||||||
BATCH_SIZE = 16
|
BATCH_SIZE = 16
|
||||||
@ -713,7 +717,7 @@ def collect_object_classification_examples(
|
|||||||
This function:
|
This function:
|
||||||
1. Queries events for the specified label
|
1. Queries events for the specified label
|
||||||
2. Selects 100 balanced events across different cameras and times
|
2. Selects 100 balanced events across different cameras and times
|
||||||
3. Retrieves thumbnails for selected events (with 33% center crop applied)
|
3. Crops each event's clean snapshot around the object bounding box
|
||||||
4. Selects 24 most visually distinct thumbnails
|
4. Selects 24 most visually distinct thumbnails
|
||||||
5. Saves to dataset directory
|
5. Saves to dataset directory
|
||||||
|
|
||||||
@ -832,29 +836,80 @@ def _select_balanced_events(
|
|||||||
|
|
||||||
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
|
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Extract thumbnails from events and save to disk.
|
Extract a training image for each event.
|
||||||
|
|
||||||
|
Preferred path: load the full-frame clean snapshot and crop around the
|
||||||
|
stored bounding box with the same calculate_region(..., max(w, h), 1.0)
|
||||||
|
call the live ObjectClassificationProcessor uses, so wizard examples
|
||||||
|
are framed like inference-time inputs.
|
||||||
|
|
||||||
|
Fallback: if no clean snapshot exists (snapshots disabled, or only a
|
||||||
|
legacy annotated JPG is on disk), center-crop the stored thumbnail
|
||||||
|
using a step ladder sized from the box/region area ratio.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
events: List of Event objects
|
events: List of Event objects
|
||||||
output_dir: Directory to save thumbnails
|
output_dir: Directory to save crops
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of paths to successfully extracted thumbnail images
|
List of paths to successfully extracted images
|
||||||
"""
|
"""
|
||||||
thumbnail_paths = []
|
image_paths = []
|
||||||
|
|
||||||
for idx, event in enumerate(events):
|
for idx, event in enumerate(events):
|
||||||
try:
|
try:
|
||||||
thumbnail_bytes = get_event_thumbnail_bytes(event)
|
img = _load_event_classification_crop(event)
|
||||||
|
if img is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
resized = cv2.resize(img, (224, 224))
|
||||||
|
output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg")
|
||||||
|
cv2.imwrite(output_path, resized)
|
||||||
|
image_paths.append(output_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to extract image for event {event.id}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return image_paths
|
||||||
|
|
||||||
|
|
||||||
|
def _load_event_classification_crop(event: Event) -> np.ndarray | None:
|
||||||
|
"""Prefer a snapshot-based object crop; fall back to a center-cropped thumbnail."""
|
||||||
|
if event.data and "box" in event.data:
|
||||||
|
snapshot, _ = load_event_snapshot_image(event, clean_only=True)
|
||||||
|
if snapshot is not None:
|
||||||
|
abs_box = relative_box_to_absolute(snapshot.shape, event.data["box"])
|
||||||
|
if abs_box is not None:
|
||||||
|
xmin, ymin, xmax, ymax = abs_box
|
||||||
|
box_w = xmax - xmin
|
||||||
|
box_h = ymax - ymin
|
||||||
|
if box_w > 0 and box_h > 0:
|
||||||
|
x1, y1, x2, y2 = calculate_region(
|
||||||
|
snapshot.shape,
|
||||||
|
xmin,
|
||||||
|
ymin,
|
||||||
|
xmax,
|
||||||
|
ymax,
|
||||||
|
max(box_w, box_h),
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
cropped = snapshot[y1:y2, x1:x2]
|
||||||
|
if cropped.size > 0:
|
||||||
|
return cropped
|
||||||
|
|
||||||
|
thumbnail_bytes = get_event_thumbnail_bytes(event)
|
||||||
|
if not thumbnail_bytes:
|
||||||
|
return None
|
||||||
|
|
||||||
if thumbnail_bytes:
|
|
||||||
nparr = np.frombuffer(thumbnail_bytes, np.uint8)
|
nparr = np.frombuffer(thumbnail_bytes, np.uint8)
|
||||||
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
if img is None or img.size == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
if img is not None:
|
|
||||||
height, width = img.shape[:2]
|
height, width = img.shape[:2]
|
||||||
|
|
||||||
crop_size = 1.0
|
crop_size = 1.0
|
||||||
|
|
||||||
if event.data and "box" in event.data and "region" in event.data:
|
if event.data and "box" in event.data and "region" in event.data:
|
||||||
box = event.data["box"]
|
box = event.data["box"]
|
||||||
region = event.data["region"]
|
region = event.data["region"]
|
||||||
@ -862,7 +917,6 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
|
|||||||
if len(box) == 4 and len(region) == 4:
|
if len(box) == 4 and len(region) == 4:
|
||||||
box_w, box_h = box[2], box[3]
|
box_w, box_h = box[2], box[3]
|
||||||
region_w, region_h = region[2], region[3]
|
region_w, region_h = region[2], region[3]
|
||||||
|
|
||||||
box_area = (box_w * box_h) / (region_w * region_h)
|
box_area = (box_w * box_h) / (region_w * region_h)
|
||||||
|
|
||||||
if box_area < 0.05:
|
if box_area < 0.05:
|
||||||
@ -878,20 +932,10 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
|
|||||||
|
|
||||||
crop_width = int(width * crop_size)
|
crop_width = int(width * crop_size)
|
||||||
crop_height = int(height * crop_size)
|
crop_height = int(height * crop_size)
|
||||||
|
|
||||||
x1 = (width - crop_width) // 2
|
x1 = (width - crop_width) // 2
|
||||||
y1 = (height - crop_height) // 2
|
y1 = (height - crop_height) // 2
|
||||||
x2 = x1 + crop_width
|
cropped = img[y1 : y1 + crop_height, x1 : x1 + crop_width]
|
||||||
y2 = y1 + crop_height
|
if cropped.size == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
cropped = img[y1:y2, x1:x2]
|
return cropped
|
||||||
resized = cv2.resize(cropped, (224, 224))
|
|
||||||
output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg")
|
|
||||||
cv2.imwrite(output_path, resized)
|
|
||||||
thumbnail_paths.append(output_path)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Failed to extract thumbnail for event {event.id}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return thumbnail_paths
|
|
||||||
|
|||||||
@ -711,8 +711,11 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
|
|||||||
else:
|
else:
|
||||||
format_entries = None
|
format_entries = None
|
||||||
|
|
||||||
ffprobe_cmd = [
|
def run(rtsp_transport: Optional[str] = None) -> sp.CompletedProcess:
|
||||||
ffmpeg.ffprobe_path,
|
cmd = [ffmpeg.ffprobe_path]
|
||||||
|
if rtsp_transport:
|
||||||
|
cmd += ["-rtsp_transport", rtsp_transport]
|
||||||
|
cmd += [
|
||||||
"-timeout",
|
"-timeout",
|
||||||
"1000000",
|
"1000000",
|
||||||
"-print_format",
|
"-print_format",
|
||||||
@ -720,14 +723,32 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
|
|||||||
"-show_entries",
|
"-show_entries",
|
||||||
f"stream={stream_entries}",
|
f"stream={stream_entries}",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add format entries for detailed mode
|
|
||||||
if detailed and format_entries:
|
if detailed and format_entries:
|
||||||
ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"])
|
cmd.extend(["-show_entries", f"format={format_entries}"])
|
||||||
|
cmd.extend(["-loglevel", "error", clean_path])
|
||||||
|
try:
|
||||||
|
return sp.run(cmd, capture_output=True, timeout=6)
|
||||||
|
except sp.TimeoutExpired as e:
|
||||||
|
logger.info(
|
||||||
|
"ffprobe timed out while probing %s (transport=%s)",
|
||||||
|
clean_camera_user_pass(path),
|
||||||
|
rtsp_transport or "default",
|
||||||
|
)
|
||||||
|
return sp.CompletedProcess(
|
||||||
|
args=cmd,
|
||||||
|
returncode=1,
|
||||||
|
stdout=e.stdout or b"",
|
||||||
|
stderr=(e.stderr or b"") + b"\nffprobe timed out",
|
||||||
|
)
|
||||||
|
|
||||||
ffprobe_cmd.extend(["-loglevel", "error", clean_path])
|
result = run()
|
||||||
|
|
||||||
return sp.run(ffprobe_cmd, capture_output=True)
|
# For RTSP: retry with explicit TCP transport if the first attempt failed
|
||||||
|
# (default UDP may be blocked)
|
||||||
|
if result.returncode != 0 and clean_path.startswith("rtsp://"):
|
||||||
|
result = run(rtsp_transport="tcp")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
|
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
|
||||||
@ -807,10 +828,15 @@ async def get_video_properties(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
async def probe_with_ffprobe(
|
async def probe_with_ffprobe(
|
||||||
url: str,
|
url: str,
|
||||||
|
rtsp_transport: Optional[str] = None,
|
||||||
) -> tuple[bool, int, int, Optional[str], float]:
|
) -> tuple[bool, int, int, Optional[str], float]:
|
||||||
"""Fallback using ffprobe: returns (valid, width, height, codec, duration)."""
|
"""Fallback using ffprobe: returns (valid, width, height, codec, duration)."""
|
||||||
cmd = [
|
cmd = [ffmpeg.ffprobe_path]
|
||||||
ffmpeg.ffprobe_path,
|
if rtsp_transport:
|
||||||
|
cmd += ["-rtsp_transport", rtsp_transport]
|
||||||
|
cmd += [
|
||||||
|
"-rw_timeout",
|
||||||
|
"5000000",
|
||||||
"-v",
|
"-v",
|
||||||
"quiet",
|
"quiet",
|
||||||
"-print_format",
|
"-print_format",
|
||||||
@ -819,11 +845,23 @@ async def get_video_properties(
|
|||||||
"-show_streams",
|
"-show_streams",
|
||||||
url,
|
url,
|
||||||
]
|
]
|
||||||
|
proc = None
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
)
|
)
|
||||||
stdout, _ = await proc.communicate()
|
try:
|
||||||
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=6)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info(
|
||||||
|
"ffprobe timed out while probing %s (transport=%s)",
|
||||||
|
clean_camera_user_pass(url),
|
||||||
|
rtsp_transport or "default",
|
||||||
|
)
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
return False, 0, 0, None, -1
|
||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
return False, 0, 0, None, -1
|
return False, 0, 0, None, -1
|
||||||
|
|
||||||
@ -872,13 +910,27 @@ async def get_video_properties(
|
|||||||
cap.release()
|
cap.release()
|
||||||
return valid, width, height, fourcc, duration
|
return valid, width, height, fourcc, duration
|
||||||
|
|
||||||
# try cv2 first
|
is_rtsp = url.startswith("rtsp://")
|
||||||
|
|
||||||
|
if is_rtsp:
|
||||||
|
# skip cv2 for RTSP: its FFmpeg backend has a hardcoded ~30s internal
|
||||||
|
# timeout that cannot be shortened per-call, and ffprobe bounded by
|
||||||
|
# -rw_timeout handles RTSP probing reliably
|
||||||
|
has_video, width, height, fourcc, duration = await probe_with_ffprobe(url)
|
||||||
|
else:
|
||||||
|
# try cv2 first for local files, HTTP, RTMP
|
||||||
has_video, width, height, fourcc, duration = probe_with_cv2(url)
|
has_video, width, height, fourcc, duration = probe_with_cv2(url)
|
||||||
|
|
||||||
# fallback to ffprobe if needed
|
# fallback to ffprobe if needed
|
||||||
if not has_video or (get_duration and duration < 0):
|
if not has_video or (get_duration and duration < 0):
|
||||||
has_video, width, height, fourcc, duration = await probe_with_ffprobe(url)
|
has_video, width, height, fourcc, duration = await probe_with_ffprobe(url)
|
||||||
|
|
||||||
|
# last resort for RTSP: try TCP transport, since default UDP may be blocked
|
||||||
|
if (not has_video or (get_duration and duration < 0)) and is_rtsp:
|
||||||
|
has_video, width, height, fourcc, duration = await probe_with_ffprobe(
|
||||||
|
url, rtsp_transport="tcp"
|
||||||
|
)
|
||||||
|
|
||||||
result: dict[str, Any] = {"has_valid_video": has_video}
|
result: dict[str, Any] = {"has_valid_video": has_video}
|
||||||
if has_video:
|
if has_video:
|
||||||
result.update({"width": width, "height": height})
|
result.update({"width": width, "height": height})
|
||||||
|
|||||||
@ -24,7 +24,7 @@ from frigate.config.camera.updater import (
|
|||||||
)
|
)
|
||||||
from frigate.const import PROCESS_PRIORITY_HIGH
|
from frigate.const import PROCESS_PRIORITY_HIGH
|
||||||
from frigate.log import LogPipe
|
from frigate.log import LogPipe
|
||||||
from frigate.util.builtin import EventsPerSecond
|
from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list
|
||||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||||
from frigate.util.image import (
|
from frigate.util.image import (
|
||||||
FrameManager,
|
FrameManager,
|
||||||
@ -34,6 +34,23 @@ from frigate.util.process import FrigateProcess
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# all built-in record presets use this segment_time
|
||||||
|
DEFAULT_RECORD_SEGMENT_TIME = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _get_record_segment_time(config: CameraConfig) -> int:
|
||||||
|
"""Extract -segment_time from the camera's record output args."""
|
||||||
|
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
|
||||||
|
|
||||||
|
if record_args and record_args[0].startswith("preset"):
|
||||||
|
return DEFAULT_RECORD_SEGMENT_TIME
|
||||||
|
|
||||||
|
try:
|
||||||
|
idx = record_args.index("-segment_time")
|
||||||
|
return int(record_args[idx + 1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return DEFAULT_RECORD_SEGMENT_TIME
|
||||||
|
|
||||||
|
|
||||||
def capture_frames(
|
def capture_frames(
|
||||||
ffmpeg_process: sp.Popen[Any],
|
ffmpeg_process: sp.Popen[Any],
|
||||||
@ -164,6 +181,12 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.latest_cache_segment_time: float = 0
|
self.latest_cache_segment_time: float = 0
|
||||||
self.record_enable_time: datetime | None = None
|
self.record_enable_time: datetime | None = None
|
||||||
|
|
||||||
|
# `valid` segments are published with the segment's start time, so the
|
||||||
|
# gap between consecutive publishes can reach 2 * segment_time. Pad the
|
||||||
|
# staleness threshold so it's never tighter than that worst case.
|
||||||
|
segment_time = _get_record_segment_time(self.config)
|
||||||
|
self.record_stale_threshold = max(120, 2 * segment_time + 30)
|
||||||
|
|
||||||
# Stall tracking (based on last processed frame)
|
# Stall tracking (based on last processed frame)
|
||||||
self._stall_timestamps: deque[float] = deque()
|
self._stall_timestamps: deque[float] = deque()
|
||||||
self._stall_active: bool = False
|
self._stall_active: bool = False
|
||||||
@ -317,16 +340,16 @@ class CameraWatchdog(threading.Thread):
|
|||||||
if camera != self.config.name:
|
if camera != self.config.name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if topic.endswith(RecordingsDataTypeEnum.valid.value):
|
if topic.endswith(RecordingsDataTypeEnum.invalid.value):
|
||||||
self.logger.debug(
|
|
||||||
f"Latest valid recording segment time on {camera}: {segment_time}"
|
|
||||||
)
|
|
||||||
self.latest_valid_segment_time = segment_time
|
|
||||||
elif topic.endswith(RecordingsDataTypeEnum.invalid.value):
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
f"Invalid recording segment detected for {camera} at {segment_time}"
|
f"Invalid recording segment detected for {camera} at {segment_time}"
|
||||||
)
|
)
|
||||||
self.latest_invalid_segment_time = segment_time
|
self.latest_invalid_segment_time = segment_time
|
||||||
|
elif topic.endswith(RecordingsDataTypeEnum.valid.value):
|
||||||
|
self.logger.debug(
|
||||||
|
f"Latest valid recording segment time on {camera}: {segment_time}"
|
||||||
|
)
|
||||||
|
self.latest_valid_segment_time = segment_time
|
||||||
elif topic.endswith(RecordingsDataTypeEnum.latest.value):
|
elif topic.endswith(RecordingsDataTypeEnum.latest.value):
|
||||||
if segment_time is not None:
|
if segment_time is not None:
|
||||||
self.latest_cache_segment_time = segment_time
|
self.latest_cache_segment_time = segment_time
|
||||||
@ -413,16 +436,17 @@ class CameraWatchdog(threading.Thread):
|
|||||||
|
|
||||||
# ensure segments are still being created and that they have valid video data
|
# ensure segments are still being created and that they have valid video data
|
||||||
# Skip checks during grace period to allow segments to start being created
|
# Skip checks during grace period to allow segments to start being created
|
||||||
|
stale_window = timedelta(seconds=self.record_stale_threshold)
|
||||||
cache_stale = not in_grace_period and now_utc > (
|
cache_stale = not in_grace_period and now_utc > (
|
||||||
latest_cache_dt + timedelta(seconds=120)
|
latest_cache_dt + stale_window
|
||||||
)
|
)
|
||||||
valid_stale = not in_grace_period and now_utc > (
|
valid_stale = not in_grace_period and now_utc > (
|
||||||
latest_valid_dt + timedelta(seconds=120)
|
latest_valid_dt + stale_window
|
||||||
)
|
)
|
||||||
invalid_stale_condition = (
|
invalid_stale_condition = (
|
||||||
self.latest_invalid_segment_time > 0
|
self.latest_invalid_segment_time > 0
|
||||||
and not in_grace_period
|
and not in_grace_period
|
||||||
and now_utc > (latest_invalid_dt + timedelta(seconds=120))
|
and now_utc > (latest_invalid_dt + stale_window)
|
||||||
and self.latest_valid_segment_time
|
and self.latest_valid_segment_time
|
||||||
<= self.latest_invalid_segment_time
|
<= self.latest_invalid_segment_time
|
||||||
)
|
)
|
||||||
@ -439,7 +463,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"{reason} for {self.config.name} in the last 120s. Restarting the ffmpeg record process..."
|
f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..."
|
||||||
)
|
)
|
||||||
p["process"] = start_or_restart_ffmpeg(
|
p["process"] = start_or_restart_ffmpeg(
|
||||||
p["cmd"],
|
p["cmd"],
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class MonitoredProcess:
|
|||||||
restart_timestamps: deque[float] = field(
|
restart_timestamps: deque[float] = field(
|
||||||
default_factory=lambda: deque(maxlen=MAX_RESTARTS)
|
default_factory=lambda: deque(maxlen=MAX_RESTARTS)
|
||||||
)
|
)
|
||||||
|
clean_exit_logged: bool = False
|
||||||
|
|
||||||
def is_restarting_too_fast(self, now: float) -> bool:
|
def is_restarting_too_fast(self, now: float) -> bool:
|
||||||
while (
|
while (
|
||||||
@ -72,7 +73,9 @@ class FrigateWatchdog(threading.Thread):
|
|||||||
|
|
||||||
exitcode = entry.process.exitcode
|
exitcode = entry.process.exitcode
|
||||||
if exitcode == 0:
|
if exitcode == 0:
|
||||||
|
if not entry.clean_exit_logged:
|
||||||
logger.info("Process %s exited cleanly, not restarting", entry.name)
|
logger.info("Process %s exited cleanly, not restarting", entry.name)
|
||||||
|
entry.clean_exit_logged = True
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
376
testing-scripts/analyze_recording_keyframes.py
Normal file
376
testing-scripts/analyze_recording_keyframes.py
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Analyze keyframe and timestamp structure of Frigate recording segments.
|
||||||
|
|
||||||
|
This is a diagnostic tool for investigating seek precision / GOP behavior on
|
||||||
|
recorded segments. It does not modify anything.
|
||||||
|
|
||||||
|
ffprobe is only available inside the Frigate container, at
|
||||||
|
/usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe
|
||||||
|
This script auto-resolves that path from the DEFAULT_FFMPEG_VERSION env var
|
||||||
|
(or falls back to scanning /usr/lib/ffmpeg/*/bin/ffprobe). Pass --ffprobe to
|
||||||
|
override if needed.
|
||||||
|
|
||||||
|
All recording segments on the filesystem are in UTC. The --timestamp flag
|
||||||
|
expects a UTC Unix timestamp.
|
||||||
|
|
||||||
|
Typical use:
|
||||||
|
# Inside the Frigate container (or wherever recordings are mounted)
|
||||||
|
python3 analyze_recording_keyframes.py <camera_name>
|
||||||
|
|
||||||
|
# Analyze 10 most recent segments
|
||||||
|
python3 analyze_recording_keyframes.py <camera_name> --count 10
|
||||||
|
|
||||||
|
# Locate the segment that contains a specific UTC Unix timestamp and
|
||||||
|
# show it plus surrounding segments
|
||||||
|
python3 analyze_recording_keyframes.py <camera> --timestamp 1713471234.567
|
||||||
|
|
||||||
|
# Custom recordings directory
|
||||||
|
python3 analyze_recording_keyframes.py <camera> --recordings-dir /media/frigate/recordings
|
||||||
|
|
||||||
|
# Override the ffprobe path explicitly
|
||||||
|
python3 analyze_recording_keyframes.py <camera> --ffprobe /usr/lib/ffmpeg/7.0/bin/ffprobe
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from statistics import mean, median, stdev
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ffprobe_path(override: str | None) -> str:
|
||||||
|
"""Resolve the ffprobe binary path.
|
||||||
|
|
||||||
|
Inside the Frigate container, ffprobe lives at
|
||||||
|
/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe — the exact version
|
||||||
|
depends on the image build and is exposed as an env var.
|
||||||
|
"""
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
version = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
|
||||||
|
if version:
|
||||||
|
path = f"/usr/lib/ffmpeg/{version}/bin/ffprobe"
|
||||||
|
if Path(path).is_file():
|
||||||
|
return path
|
||||||
|
# Fall back to scanning the Frigate ffmpeg install root.
|
||||||
|
for candidate in sorted(Path("/usr/lib/ffmpeg").glob("*/bin/ffprobe")):
|
||||||
|
if candidate.is_file():
|
||||||
|
return str(candidate)
|
||||||
|
print(
|
||||||
|
"Could not locate ffprobe. Pass --ffprobe <path> or set "
|
||||||
|
"DEFAULT_FFMPEG_VERSION.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def find_recent_segments(recordings_dir: Path, camera: str, count: int) -> list[Path]:
|
||||||
|
"""Return the N most recent .mp4 segments for the given camera.
|
||||||
|
|
||||||
|
Expected layout: <recordings_dir>/<YYYY-MM-DD>/<HH>/<camera>/<MM>.<SS>.mp4
|
||||||
|
"""
|
||||||
|
pattern = f"*/*/{camera}/*.mp4"
|
||||||
|
segments = sorted(recordings_dir.glob(pattern))
|
||||||
|
return segments[-count:]
|
||||||
|
|
||||||
|
|
||||||
|
def find_segments_near_timestamp(
|
||||||
|
recordings_dir: Path, camera: str, target_ts: float, count: int
|
||||||
|
) -> tuple[list[Path], Path | None]:
|
||||||
|
"""Return `count` segments centered on the one containing `target_ts`.
|
||||||
|
|
||||||
|
Also returns the specific segment that should contain the timestamp, so
|
||||||
|
callers can highlight it in output.
|
||||||
|
"""
|
||||||
|
pattern = f"*/*/{camera}/*.mp4"
|
||||||
|
with_ts: list[tuple[float, Path]] = []
|
||||||
|
for seg in sorted(recordings_dir.glob(pattern)):
|
||||||
|
ts = filename_to_timestamp(seg)
|
||||||
|
if ts is not None:
|
||||||
|
with_ts.append((ts, seg))
|
||||||
|
|
||||||
|
if not with_ts:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
# Largest filename_ts that is <= target_ts — that's the segment that
|
||||||
|
# should contain the timestamp (Frigate catalogs segments by filename).
|
||||||
|
target_idx = -1
|
||||||
|
for i, (ts, _) in enumerate(with_ts):
|
||||||
|
if ts <= target_ts:
|
||||||
|
target_idx = i
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if target_idx < 0:
|
||||||
|
# target_ts is before the earliest segment we have — just return the
|
||||||
|
# first `count` segments so the user can see what's available.
|
||||||
|
window = with_ts[:count]
|
||||||
|
return [seg for _, seg in window], None
|
||||||
|
|
||||||
|
half = count // 2
|
||||||
|
start = max(0, target_idx - half)
|
||||||
|
end = min(len(with_ts), start + count)
|
||||||
|
start = max(0, end - count)
|
||||||
|
|
||||||
|
window = with_ts[start:end]
|
||||||
|
return [seg for _, seg in window], with_ts[target_idx][1]
|
||||||
|
|
||||||
|
|
||||||
|
def filename_to_timestamp(segment: Path) -> float | None:
|
||||||
|
"""Parse the wall-clock time from Frigate's segment path layout."""
|
||||||
|
try:
|
||||||
|
date = segment.parent.parent.parent.name # YYYY-MM-DD
|
||||||
|
hour = segment.parent.parent.name # HH
|
||||||
|
mm_ss = segment.stem # MM.SS
|
||||||
|
minute, second = mm_ss.split(".")
|
||||||
|
dt = datetime.datetime.strptime(
|
||||||
|
f"{date} {hour}:{minute}:{second}",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
).replace(tzinfo=datetime.timezone.utc)
|
||||||
|
return dt.timestamp()
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_ffprobe(ffprobe: str, args: list[str]) -> dict:
|
||||||
|
"""Run ffprobe and return parsed JSON, or empty dict on failure."""
|
||||||
|
result = subprocess.run(
|
||||||
|
[ffprobe, "-v", "error", *args, "-of", "json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" ffprobe error: {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_format_info(ffprobe: str, segment: Path) -> tuple[dict, dict]:
|
||||||
|
"""Return (format_dict, stream_dict) for the first video stream."""
|
||||||
|
data = run_ffprobe(
|
||||||
|
ffprobe,
|
||||||
|
[
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration,start_time",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=codec_name,profile,r_frame_rate,width,height",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
str(segment),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
fmt = data.get("format", {})
|
||||||
|
streams = data.get("streams") or [{}]
|
||||||
|
return fmt, streams[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_packets(ffprobe: str, segment: Path) -> list[dict]:
|
||||||
|
"""Return video packets with pts_time and flags."""
|
||||||
|
data = run_ffprobe(
|
||||||
|
ffprobe,
|
||||||
|
[
|
||||||
|
"-select_streams",
|
||||||
|
"v",
|
||||||
|
"-show_entries",
|
||||||
|
"packet=pts_time,dts_time,flags",
|
||||||
|
str(segment),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return data.get("packets", [])
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(ffprobe: str, segment: Path, highlight: bool = False) -> None:
|
||||||
|
marker = " <-- contains target timestamp" if highlight else ""
|
||||||
|
print(f"\n=== {segment} ==={marker}")
|
||||||
|
|
||||||
|
fmt, stream = get_format_info(ffprobe, segment)
|
||||||
|
duration = float(fmt.get("duration", 0) or 0)
|
||||||
|
start_time = float(fmt.get("start_time", 0) or 0)
|
||||||
|
codec = stream.get("codec_name", "?")
|
||||||
|
profile = stream.get("profile", "?")
|
||||||
|
width = stream.get("width", "?")
|
||||||
|
height = stream.get("height", "?")
|
||||||
|
fps = stream.get("r_frame_rate", "?/1")
|
||||||
|
|
||||||
|
filename_ts = filename_to_timestamp(segment)
|
||||||
|
filename_iso = (
|
||||||
|
datetime.datetime.fromtimestamp(
|
||||||
|
filename_ts, tz=datetime.timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
if filename_ts is not None
|
||||||
|
else "?"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Codec: {codec} ({profile}) {width}x{height} {fps}")
|
||||||
|
print(f" Filename time: {filename_ts} ({filename_iso})")
|
||||||
|
print(f" Format duration: {duration:.3f}s")
|
||||||
|
print(f" Format start: {start_time:.3f}s (PTS offset of first packet)")
|
||||||
|
|
||||||
|
packets = get_video_packets(ffprobe, segment)
|
||||||
|
if not packets:
|
||||||
|
print(" (no video packets)")
|
||||||
|
return
|
||||||
|
|
||||||
|
keyframe_times: list[float] = []
|
||||||
|
first_pts: float | None = None
|
||||||
|
last_pts: float | None = None
|
||||||
|
|
||||||
|
for pkt in packets:
|
||||||
|
pts_str = pkt.get("pts_time")
|
||||||
|
if pts_str is None or pts_str == "N/A":
|
||||||
|
continue
|
||||||
|
pts = float(pts_str)
|
||||||
|
if first_pts is None:
|
||||||
|
first_pts = pts
|
||||||
|
last_pts = pts
|
||||||
|
if "K" in pkt.get("flags", ""):
|
||||||
|
keyframe_times.append(pts)
|
||||||
|
|
||||||
|
total_packets = len(packets)
|
||||||
|
kf_count = len(keyframe_times)
|
||||||
|
|
||||||
|
print(f" Video packets: {total_packets}")
|
||||||
|
print(f" Keyframes: {kf_count}")
|
||||||
|
if first_pts is not None and last_pts is not None:
|
||||||
|
print(
|
||||||
|
f" Packet PTS: first={first_pts:.3f}s last={last_pts:.3f}s "
|
||||||
|
f"span={last_pts - first_pts:.3f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if keyframe_times:
|
||||||
|
print(
|
||||||
|
f" Keyframe PTS: first={keyframe_times[0]:.3f}s "
|
||||||
|
f"last={keyframe_times[-1]:.3f}s"
|
||||||
|
)
|
||||||
|
formatted = ", ".join(f"{t:.3f}" for t in keyframe_times)
|
||||||
|
print(f" Keyframe times: [{formatted}]")
|
||||||
|
|
||||||
|
if len(keyframe_times) >= 2:
|
||||||
|
gaps = [b - a for a, b in zip(keyframe_times, keyframe_times[1:])]
|
||||||
|
avg_fps_estimate = (
|
||||||
|
total_packets / (last_pts - first_pts)
|
||||||
|
if last_pts and first_pts is not None and last_pts > first_pts
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" GOP gaps (s): min={min(gaps):.3f} max={max(gaps):.3f} "
|
||||||
|
f"mean={mean(gaps):.3f} median={median(gaps):.3f}"
|
||||||
|
)
|
||||||
|
if len(gaps) > 1:
|
||||||
|
print(f" stdev={stdev(gaps):.3f}")
|
||||||
|
print(
|
||||||
|
f" Est. mean GOP: ~{mean(gaps) * avg_fps_estimate:.1f} frames"
|
||||||
|
if avg_fps_estimate
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
if max(gaps) > 5:
|
||||||
|
print(
|
||||||
|
" !! Max GOP > 5s — consistent with adaptive/smart codec "
|
||||||
|
"(even if 'Smart Codec' is off in the UI, some cameras still "
|
||||||
|
"produce irregular GOPs under specific encoder profiles)"
|
||||||
|
)
|
||||||
|
elif kf_count == 1:
|
||||||
|
print(" !! Only one keyframe in segment — very long GOP")
|
||||||
|
|
||||||
|
# Report how well filename time aligns with first-packet PTS.
|
||||||
|
# (Filename time is what Frigate uses as recording.start_time in the DB.)
|
||||||
|
if filename_ts is not None and first_pts is not None:
|
||||||
|
print(
|
||||||
|
f" Notes: first packet PTS is {first_pts:.3f}s into the file; "
|
||||||
|
f"Frigate treats filename time as PTS=0 for seek math."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("camera", help="Camera name (matches the recordings subfolder)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--count",
|
||||||
|
type=int,
|
||||||
|
default=5,
|
||||||
|
help="Number of most recent segments to analyze (default: 5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--recordings-dir",
|
||||||
|
default="/media/frigate/recordings",
|
||||||
|
help="Path to the recordings directory (default: /media/frigate/recordings)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ffprobe",
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Full path to the ffprobe binary. Defaults to the Frigate-bundled "
|
||||||
|
"binary at /usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timestamp",
|
||||||
|
type=float,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Unix timestamp (UTC seconds, decimals allowed) to locate. The "
|
||||||
|
"script finds the segment that should contain this time and "
|
||||||
|
"analyzes it plus surrounding segments (count controls the "
|
||||||
|
"window). All on-disk segments are stored in UTC, so pass a UTC "
|
||||||
|
"Unix timestamp."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
ffprobe = resolve_ffprobe_path(args.ffprobe)
|
||||||
|
|
||||||
|
recordings_dir = Path(args.recordings_dir)
|
||||||
|
if not recordings_dir.is_dir():
|
||||||
|
print(
|
||||||
|
f"Recordings directory not found: {recordings_dir}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_segment: Path | None = None
|
||||||
|
if args.timestamp is not None:
|
||||||
|
segments, target_segment = find_segments_near_timestamp(
|
||||||
|
recordings_dir, args.camera, args.timestamp, args.count
|
||||||
|
)
|
||||||
|
target_iso = datetime.datetime.fromtimestamp(
|
||||||
|
args.timestamp, tz=datetime.timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
mode = f"around timestamp {args.timestamp} ({target_iso})"
|
||||||
|
else:
|
||||||
|
segments = find_recent_segments(recordings_dir, args.camera, args.count)
|
||||||
|
mode = "most recent"
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
print(
|
||||||
|
f"No segments found for camera '{args.camera}' under {recordings_dir}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.timestamp is not None and target_segment is None:
|
||||||
|
print(
|
||||||
|
f"!! Target timestamp {args.timestamp} is before the earliest "
|
||||||
|
f"segment on disk; showing the earliest available segments instead.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Analyzing {len(segments)} {mode} segment(s) for camera "
|
||||||
|
f"'{args.camera}' under {recordings_dir} (ffprobe: {ffprobe})"
|
||||||
|
)
|
||||||
|
for segment in segments:
|
||||||
|
analyze(ffprobe, segment, highlight=(segment == target_segment))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
783
testing-scripts/face_dataset.py
Normal file
783
testing-scripts/face_dataset.py
Normal file
@ -0,0 +1,783 @@
|
|||||||
|
"""
|
||||||
|
Face recognition investigation script.
|
||||||
|
|
||||||
|
Standalone replica of Frigate's ArcFace pipeline (see
|
||||||
|
frigate/data_processing/common/face/model.py and
|
||||||
|
frigate/embeddings/onnx/face_embedding.py) for analyzing a face collection
|
||||||
|
outside the running service. Useful for:
|
||||||
|
|
||||||
|
- Diagnosing why a person's collection produces false positives
|
||||||
|
- Finding outlier/contaminating training images
|
||||||
|
- Inspecting the effect of the shipped vector-wise outlier filter
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
- Core pipeline: LandmarkAligner, ArcFaceEmbedder, arcface_preprocess,
|
||||||
|
similarity_to_confidence, blur_reduction — all mirroring the production
|
||||||
|
code exactly
|
||||||
|
- Default run: summarize positive and negative sets against a baseline
|
||||||
|
trim_mean class representation
|
||||||
|
- Optional diagnostics (flags): vector-outlier filter behavior, degenerate
|
||||||
|
"tiny crop" embedding clustering, and multi-identity contamination
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 face_investigate.py \\
|
||||||
|
--positive <positive_folder> \\
|
||||||
|
--negative <negative_folder> \\
|
||||||
|
[--model-cache /path/to/model_cache] \\
|
||||||
|
[--vector-outlier] [--degenerate] [--contamination]
|
||||||
|
|
||||||
|
The positive folder should contain training images for a single identity
|
||||||
|
(same layout as FACE_DIR/<name>/*.webp). The negative folder should contain
|
||||||
|
runtime crops to test against — a mix of true matches and misfires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
from PIL import Image
|
||||||
|
from scipy import stats
|
||||||
|
|
||||||
|
ARCFACE_INPUT_SIZE = 112
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replicated Frigate pipeline
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _process_image_frigate(image: np.ndarray) -> Image.Image:
|
||||||
|
"""Mirror BaseEmbedding._process_image for an ndarray input.
|
||||||
|
|
||||||
|
NOTE: Frigate passes the output of `cv2.imread` (BGR) directly in. PIL's
|
||||||
|
`Image.fromarray` does NOT reorder channels, so the embedder effectively
|
||||||
|
receives a BGR-ordered tensor. We replicate that faithfully here. (Tested
|
||||||
|
— swapping to RGB produces near-identical embeddings; this model is
|
||||||
|
robust to channel order.)
|
||||||
|
"""
|
||||||
|
return Image.fromarray(image)
|
||||||
|
|
||||||
|
|
||||||
|
def arcface_preprocess(image_bgr: np.ndarray) -> np.ndarray:
|
||||||
|
"""Mirror ArcfaceEmbedding._preprocess_inputs."""
|
||||||
|
pil = _process_image_frigate(image_bgr)
|
||||||
|
|
||||||
|
width, height = pil.size
|
||||||
|
if width != ARCFACE_INPUT_SIZE or height != ARCFACE_INPUT_SIZE:
|
||||||
|
if width > height:
|
||||||
|
new_height = int(((height / width) * ARCFACE_INPUT_SIZE) // 4 * 4)
|
||||||
|
pil = pil.resize((ARCFACE_INPUT_SIZE, new_height))
|
||||||
|
else:
|
||||||
|
new_width = int(((width / height) * ARCFACE_INPUT_SIZE) // 4 * 4)
|
||||||
|
pil = pil.resize((new_width, ARCFACE_INPUT_SIZE))
|
||||||
|
|
||||||
|
og = np.array(pil).astype(np.float32)
|
||||||
|
og_h, og_w, channels = og.shape
|
||||||
|
|
||||||
|
frame = np.zeros(
|
||||||
|
(ARCFACE_INPUT_SIZE, ARCFACE_INPUT_SIZE, channels), dtype=np.float32
|
||||||
|
)
|
||||||
|
x_center = (ARCFACE_INPUT_SIZE - og_w) // 2
|
||||||
|
y_center = (ARCFACE_INPUT_SIZE - og_h) // 2
|
||||||
|
frame[y_center : y_center + og_h, x_center : x_center + og_w] = og
|
||||||
|
|
||||||
|
frame = (frame / 127.5) - 1.0
|
||||||
|
frame = np.transpose(frame, (2, 0, 1))
|
||||||
|
frame = np.expand_dims(frame, axis=0)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
class LandmarkAligner:
|
||||||
|
"""Mirror FaceRecognizer.align_face."""
|
||||||
|
|
||||||
|
def __init__(self, landmark_model_path: str):
|
||||||
|
if not os.path.exists(landmark_model_path):
|
||||||
|
raise FileNotFoundError(landmark_model_path)
|
||||||
|
self.detector = cv2.face.createFacemarkLBF()
|
||||||
|
self.detector.loadModel(landmark_model_path)
|
||||||
|
|
||||||
|
def align(
|
||||||
|
self, image: np.ndarray, out_w: int, out_h: int
|
||||||
|
) -> tuple[np.ndarray, dict]:
|
||||||
|
land_image = (
|
||||||
|
cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
|
||||||
|
)
|
||||||
|
_, lands = self.detector.fit(
|
||||||
|
land_image, np.array([(0, 0, land_image.shape[1], land_image.shape[0])])
|
||||||
|
)
|
||||||
|
landmarks = lands[0][0]
|
||||||
|
|
||||||
|
leftEyePts = landmarks[42:48]
|
||||||
|
rightEyePts = landmarks[36:42]
|
||||||
|
leftEyeCenter = leftEyePts.mean(axis=0).astype("int")
|
||||||
|
rightEyeCenter = rightEyePts.mean(axis=0).astype("int")
|
||||||
|
|
||||||
|
dY = rightEyeCenter[1] - leftEyeCenter[1]
|
||||||
|
dX = rightEyeCenter[0] - leftEyeCenter[0]
|
||||||
|
angle = np.degrees(np.arctan2(dY, dX)) - 180
|
||||||
|
dist = float(np.sqrt((dX**2) + (dY**2)))
|
||||||
|
|
||||||
|
desiredRightEyeX = 1.0 - 0.35
|
||||||
|
desiredDist = (desiredRightEyeX - 0.35) * out_w
|
||||||
|
scale = desiredDist / dist if dist > 0 else 1.0
|
||||||
|
|
||||||
|
eyesCenter = (
|
||||||
|
int((leftEyeCenter[0] + rightEyeCenter[0]) // 2),
|
||||||
|
int((leftEyeCenter[1] + rightEyeCenter[1]) // 2),
|
||||||
|
)
|
||||||
|
M = cv2.getRotationMatrix2D(eyesCenter, angle, scale)
|
||||||
|
tX = out_w * 0.5
|
||||||
|
tY = out_h * 0.35
|
||||||
|
M[0, 2] += tX - eyesCenter[0]
|
||||||
|
M[1, 2] += tY - eyesCenter[1]
|
||||||
|
|
||||||
|
aligned = cv2.warpAffine(
|
||||||
|
image, M, (out_w, out_h), flags=cv2.INTER_CUBIC
|
||||||
|
)
|
||||||
|
info = dict(
|
||||||
|
angle=float(angle),
|
||||||
|
eye_dist_px=dist,
|
||||||
|
scale=float(scale),
|
||||||
|
landmarks=landmarks,
|
||||||
|
)
|
||||||
|
return aligned, info
|
||||||
|
|
||||||
|
|
||||||
|
class ArcFaceEmbedder:
|
||||||
|
def __init__(self, model_path: str):
|
||||||
|
self.session = ort.InferenceSession(
|
||||||
|
model_path, providers=["CPUExecutionProvider"]
|
||||||
|
)
|
||||||
|
self.input_name = self.session.get_inputs()[0].name
|
||||||
|
|
||||||
|
def embed(self, image_bgr: np.ndarray) -> np.ndarray:
|
||||||
|
tensor = arcface_preprocess(image_bgr)
|
||||||
|
out = self.session.run(None, {self.input_name: tensor})[0]
|
||||||
|
return out.squeeze()
|
||||||
|
|
||||||
|
|
||||||
|
def similarity_to_confidence(
|
||||||
|
cos_sim: float,
|
||||||
|
median: float = 0.3,
|
||||||
|
range_width: float = 0.6,
|
||||||
|
slope_factor: float = 12,
|
||||||
|
) -> float:
|
||||||
|
slope = slope_factor / range_width
|
||||||
|
return float(1.0 / (1.0 + np.exp(-slope * (cos_sim - median))))
|
||||||
|
|
||||||
|
|
||||||
|
def laplacian_variance(image: np.ndarray) -> float:
|
||||||
|
return float(cv2.Laplacian(image, cv2.CV_64F).var())
|
||||||
|
|
||||||
|
|
||||||
|
def blur_reduction(variance: float) -> float:
|
||||||
|
if variance < 120:
|
||||||
|
return 0.06
|
||||||
|
elif variance < 160:
|
||||||
|
return 0.04
|
||||||
|
elif variance < 200:
|
||||||
|
return 0.02
|
||||||
|
elif variance < 250:
|
||||||
|
return 0.01
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def cosine(a: np.ndarray, b: np.ndarray) -> float:
|
||||||
|
denom = np.linalg.norm(a) * np.linalg.norm(b)
|
||||||
|
if denom == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(np.dot(a, b) / denom)
|
||||||
|
|
||||||
|
|
||||||
|
def l2(v: np.ndarray) -> np.ndarray:
|
||||||
|
return v / (np.linalg.norm(v) + 1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sample loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FaceSample:
|
||||||
|
path: str
|
||||||
|
shape: tuple[int, int]
|
||||||
|
embedding: np.ndarray
|
||||||
|
blur_var: float
|
||||||
|
align_info: dict
|
||||||
|
|
||||||
|
|
||||||
|
def load_folder(
|
||||||
|
folder: str, aligner: LandmarkAligner, embedder: ArcFaceEmbedder
|
||||||
|
) -> list[FaceSample]:
|
||||||
|
samples: list[FaceSample] = []
|
||||||
|
names = sorted(os.listdir(folder))
|
||||||
|
for name in names:
|
||||||
|
if name.startswith("."):
|
||||||
|
continue
|
||||||
|
path = os.path.join(folder, name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
img = cv2.imread(path)
|
||||||
|
if img is None:
|
||||||
|
print(f" [skip unreadable] {name}")
|
||||||
|
continue
|
||||||
|
aligned, info = aligner.align(img, img.shape[1], img.shape[0])
|
||||||
|
emb = embedder.embed(aligned)
|
||||||
|
samples.append(
|
||||||
|
FaceSample(
|
||||||
|
path=path,
|
||||||
|
shape=(img.shape[1], img.shape[0]),
|
||||||
|
embedding=emb,
|
||||||
|
blur_var=laplacian_variance(img),
|
||||||
|
align_info=info,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
def trimmed_mean(embs: Iterable[np.ndarray], trim: float = 0.15) -> np.ndarray:
|
||||||
|
arr = np.stack(list(embs), axis=0)
|
||||||
|
return stats.trim_mean(arr, trim, axis=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Baseline analyses (always run)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_positive(samples: list[FaceSample], mean_emb: np.ndarray) -> None:
|
||||||
|
"""Summary of training set: per-sample cos to class mean, intra-class stats.
|
||||||
|
|
||||||
|
Outliers with cos far below the rest are likely degrading the mean —
|
||||||
|
they'd be the first candidates the shipped vector-outlier filter drops.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print(f"POSITIVE SET ANALYSIS ({len(samples)} images)")
|
||||||
|
print("=" * 78)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for s in samples:
|
||||||
|
cs = cosine(s.embedding, mean_emb)
|
||||||
|
conf = similarity_to_confidence(cs)
|
||||||
|
red = blur_reduction(s.blur_var)
|
||||||
|
rows.append(
|
||||||
|
dict(
|
||||||
|
name=os.path.basename(s.path),
|
||||||
|
shape=f"{s.shape[0]}x{s.shape[1]}",
|
||||||
|
eye_px=s.align_info["eye_dist_px"],
|
||||||
|
angle=s.align_info["angle"] + 180,
|
||||||
|
blur=s.blur_var,
|
||||||
|
cos=cs,
|
||||||
|
conf=conf,
|
||||||
|
red=red,
|
||||||
|
adj_conf=max(0.0, conf - red),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r["cos"])
|
||||||
|
sims = np.array([r["cos"] for r in rows])
|
||||||
|
print(
|
||||||
|
f"\nCosine-to-trimmed-mean: mean={sims.mean():.3f} std={sims.std():.3f} "
|
||||||
|
f"min={sims.min():.3f} max={sims.max():.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n-- Worst matches (bottom 10, most likely hurting the mean) --")
|
||||||
|
print(
|
||||||
|
f"{'cos':>6} {'conf':>6} {'blur':>7} {'eyes':>6} "
|
||||||
|
f"{'angle':>6} {'shape':>9} name"
|
||||||
|
)
|
||||||
|
for r in rows[:10]:
|
||||||
|
print(
|
||||||
|
f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} "
|
||||||
|
f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n-- Best matches (top 5) --")
|
||||||
|
for r in rows[-5:][::-1]:
|
||||||
|
print(
|
||||||
|
f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} "
|
||||||
|
f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pairwise analysis — flags embeddings poorly correlated with the rest
|
||||||
|
print("\n-- Pairwise intra-class similarity (mean cos vs. other positives) --")
|
||||||
|
embs = np.stack([s.embedding for s in samples], axis=0)
|
||||||
|
norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9)
|
||||||
|
sim_matrix = norms @ norms.T
|
||||||
|
np.fill_diagonal(sim_matrix, np.nan)
|
||||||
|
mean_pairwise = np.nanmean(sim_matrix, axis=1)
|
||||||
|
names = [os.path.basename(s.path) for s in samples]
|
||||||
|
ordered = sorted(zip(names, mean_pairwise), key=lambda t: t[1])
|
||||||
|
print(f"{'mean_cos':>9} name")
|
||||||
|
for nm, mp in ordered[:10]:
|
||||||
|
print(f"{mp:9.3f} {nm}")
|
||||||
|
print(f"\n overall mean pairwise cos: {np.nanmean(sim_matrix):.3f}")
|
||||||
|
print(f" median pairwise cos: {np.nanmedian(sim_matrix):.3f}")
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_negative(
|
||||||
|
neg_samples: list[FaceSample],
|
||||||
|
mean_emb: np.ndarray,
|
||||||
|
pos_samples: list[FaceSample],
|
||||||
|
) -> None:
|
||||||
|
"""Score each negative against the class mean, then show its top-3
|
||||||
|
nearest positives. High-scoring negatives that match specific outlier
|
||||||
|
positives hint at training-set contamination.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print(f"NEGATIVE SET ANALYSIS ({len(neg_samples)} images)")
|
||||||
|
print("=" * 78)
|
||||||
|
print(
|
||||||
|
f"\n{'cos':>6} {'conf':>6} {'red':>5} {'adj':>5} "
|
||||||
|
f"{'blur':>7} {'eyes':>6} {'shape':>9} name"
|
||||||
|
)
|
||||||
|
for s in neg_samples:
|
||||||
|
cs = cosine(s.embedding, mean_emb)
|
||||||
|
conf = similarity_to_confidence(cs)
|
||||||
|
red = blur_reduction(s.blur_var)
|
||||||
|
print(
|
||||||
|
f"{cs:6.3f} {conf:6.3f} {red:5.2f} {max(0, conf - red):5.2f} "
|
||||||
|
f"{s.blur_var:7.1f} {s.align_info['eye_dist_px']:6.1f} "
|
||||||
|
f"{s.shape[0]}x{s.shape[1]:<5} {os.path.basename(s.path)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n-- For each negative, top-3 most similar positives --")
|
||||||
|
pos_embs = np.stack([p.embedding for p in pos_samples])
|
||||||
|
pos_norm = pos_embs / (np.linalg.norm(pos_embs, axis=1, keepdims=True) + 1e-9)
|
||||||
|
for s in neg_samples:
|
||||||
|
v = s.embedding / (np.linalg.norm(s.embedding) + 1e-9)
|
||||||
|
sims = pos_norm @ v
|
||||||
|
idx = np.argsort(-sims)[:3]
|
||||||
|
print(f"\n {os.path.basename(s.path)}:")
|
||||||
|
for i in idx:
|
||||||
|
print(
|
||||||
|
f" {sims[i]:6.3f} {os.path.basename(pos_samples[i].path)} "
|
||||||
|
f"blur={pos_samples[i].blur_var:.1f} "
|
||||||
|
f"eyes={pos_samples[i].align_info['eye_dist_px']:.1f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Optional diagnostics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def vector_outlier_test(
|
||||||
|
pos: list[FaceSample], neg: list[FaceSample], base_trim: float = 0.15
|
||||||
|
) -> None:
|
||||||
|
"""Measure the shipped vector-wise outlier filter at various thresholds.
|
||||||
|
|
||||||
|
The production filter at `build_class_mean` in
|
||||||
|
frigate/data_processing/common/face/model.py uses T=0.30. This test
|
||||||
|
sweeps T so you can see which images would be dropped on a new collection
|
||||||
|
and how that affects the negative scores.
|
||||||
|
|
||||||
|
Algorithm: iteratively recompute trim_mean on the kept set, drop any
|
||||||
|
embedding with cos < T to that mean, repeat until converged. Floor at
|
||||||
|
50% of the collection to avoid collapse.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("VECTOR-WISE OUTLIER PRE-FILTER — layered on trim_mean(0.15)")
|
||||||
|
print("=" * 78)
|
||||||
|
|
||||||
|
all_embs = np.stack([s.embedding for s in pos])
|
||||||
|
|
||||||
|
def iterative_mean(
|
||||||
|
embs: np.ndarray,
|
||||||
|
threshold: float,
|
||||||
|
iters: int = 3,
|
||||||
|
min_keep_frac: float = 0.5,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
keep = np.ones(len(embs), dtype=bool)
|
||||||
|
floor = max(5, int(np.ceil(min_keep_frac * len(embs))))
|
||||||
|
for _ in range(iters):
|
||||||
|
m = stats.trim_mean(embs[keep], base_trim, axis=0)
|
||||||
|
m_norm = m / (np.linalg.norm(m) + 1e-9)
|
||||||
|
e_norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9)
|
||||||
|
cos_to_mean = e_norms @ m_norm
|
||||||
|
new_keep = cos_to_mean >= threshold
|
||||||
|
if new_keep.sum() < floor:
|
||||||
|
top_idx = np.argsort(-cos_to_mean)[:floor]
|
||||||
|
new_keep = np.zeros_like(new_keep)
|
||||||
|
new_keep[top_idx] = True
|
||||||
|
if np.array_equal(new_keep, keep):
|
||||||
|
break
|
||||||
|
keep = new_keep
|
||||||
|
final = stats.trim_mean(embs[keep], base_trim, axis=0)
|
||||||
|
return final, keep
|
||||||
|
|
||||||
|
provisional = stats.trim_mean(all_embs, base_trim, axis=0)
|
||||||
|
p_norm = provisional / (np.linalg.norm(provisional) + 1e-9)
|
||||||
|
e_norms_all = all_embs / (np.linalg.norm(all_embs, axis=1, keepdims=True) + 1e-9)
|
||||||
|
cos_to_prov = e_norms_all @ p_norm
|
||||||
|
print("\nDistribution of cos(positive, provisional trim_mean):")
|
||||||
|
print(
|
||||||
|
f" min={cos_to_prov.min():.3f} p10={np.percentile(cos_to_prov, 10):.3f} "
|
||||||
|
f"p25={np.percentile(cos_to_prov, 25):.3f} "
|
||||||
|
f"median={np.median(cos_to_prov):.3f} "
|
||||||
|
f"p75={np.percentile(cos_to_prov, 75):.3f} max={cos_to_prov.max():.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
baseline_mean = stats.trim_mean(all_embs, base_trim, axis=0)
|
||||||
|
baseline_pos = np.array([cosine(p.embedding, baseline_mean) for p in pos])
|
||||||
|
baseline_neg = (
|
||||||
|
np.array([cosine(n.embedding, baseline_mean) for n in neg])
|
||||||
|
if neg
|
||||||
|
else np.array([])
|
||||||
|
)
|
||||||
|
baseline_conf_neg = np.array(
|
||||||
|
[similarity_to_confidence(c) for c in baseline_neg]
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\nBaseline (trim_mean only, {len(pos)} images):"
|
||||||
|
f"\n pos cos min={baseline_pos.min():.3f} "
|
||||||
|
f"mean={baseline_pos.mean():.3f} max={baseline_pos.max():.3f}"
|
||||||
|
)
|
||||||
|
if len(neg):
|
||||||
|
print(
|
||||||
|
f" neg cos min={baseline_neg.min():.3f} "
|
||||||
|
f"mean={baseline_neg.mean():.3f} max={baseline_neg.max():.3f}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" neg conf min={baseline_conf_neg.min():.3f} "
|
||||||
|
f"mean={baseline_conf_neg.mean():.3f} max={baseline_conf_neg.max():.3f}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" margin (pos.min - neg.max): "
|
||||||
|
f"{baseline_pos.min() - baseline_neg.max():+.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\nIterative (refine mean → drop vectors with cos<T → repeat):")
|
||||||
|
print(
|
||||||
|
f"\n{'T':>5} {'kept':>6} {'pos min':>7} {'pos mean':>8} "
|
||||||
|
f"{'neg max':>7} {'neg mean':>8} {'neg conf.max':>12} {'margin':>7}"
|
||||||
|
)
|
||||||
|
for T in [0.15, 0.20, 0.25, 0.28, 0.30, 0.33, 0.36, 0.40]:
|
||||||
|
mean, keep = iterative_mean(all_embs, T)
|
||||||
|
pos_sims = np.array([cosine(p.embedding, mean) for p in pos])
|
||||||
|
neg_sims = (
|
||||||
|
np.array([cosine(n.embedding, mean) for n in neg])
|
||||||
|
if neg
|
||||||
|
else np.array([])
|
||||||
|
)
|
||||||
|
neg_conf = np.array([similarity_to_confidence(c) for c in neg_sims])
|
||||||
|
margin = pos_sims.min() - (neg_sims.max() if len(neg_sims) else 0)
|
||||||
|
print(
|
||||||
|
f"{T:5.2f} {int(keep.sum()):>3}/{len(pos):<2} "
|
||||||
|
f"{pos_sims.min():7.3f} {pos_sims.mean():8.3f} "
|
||||||
|
f"{neg_sims.max() if len(neg_sims) else float('nan'):7.3f} "
|
||||||
|
f"{neg_sims.mean() if len(neg_sims) else float('nan'):8.3f} "
|
||||||
|
f"{neg_conf.max() if len(neg_conf) else float('nan'):12.3f} "
|
||||||
|
f"{margin:+7.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show which images get dropped at the shipped threshold + neighbors
|
||||||
|
for T_show in (0.25, 0.30, 0.33):
|
||||||
|
_, keep = iterative_mean(all_embs, T_show)
|
||||||
|
print(
|
||||||
|
f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:"
|
||||||
|
)
|
||||||
|
final_mean = stats.trim_mean(all_embs[keep], base_trim, axis=0)
|
||||||
|
m_n = final_mean / (np.linalg.norm(final_mean) + 1e-9)
|
||||||
|
for i, (p, k) in enumerate(zip(pos, keep)):
|
||||||
|
if not k:
|
||||||
|
e_n = p.embedding / (np.linalg.norm(p.embedding) + 1e-9)
|
||||||
|
cos_final = float(e_n @ m_n)
|
||||||
|
print(
|
||||||
|
f" cos_to_clean_mean={cos_final:6.3f} "
|
||||||
|
f"shape={p.shape[0]}x{p.shape[1]} "
|
||||||
|
f"eyes={p.align_info['eye_dist_px']:6.1f} "
|
||||||
|
f"blur={p.blur_var:7.1f} "
|
||||||
|
f"{os.path.basename(p.path)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def degenerate_embedding_test(
|
||||||
|
pos: list[FaceSample], neg: list[FaceSample]
|
||||||
|
) -> None:
|
||||||
|
"""Detect whether negatives and low-quality positives share a degenerate
|
||||||
|
'tiny/noisy face' region of the embedding space.
|
||||||
|
|
||||||
|
Signal: if neg-to-neg cos is higher than pos-to-pos cos, the negatives
|
||||||
|
aren't really per-identity embeddings — they're dominated by upsample /
|
||||||
|
low-resolution artifacts that all map to a similar corner of embedding
|
||||||
|
space regardless of who the face belongs to.
|
||||||
|
|
||||||
|
Also rebuilds the mean using only high-intra-similarity positives to
|
||||||
|
show whether a cleaner training set separates the negatives.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("DEGENERATE-EMBEDDING TEST")
|
||||||
|
print("=" * 78)
|
||||||
|
|
||||||
|
pos_embs = np.stack([l2(s.embedding) for s in pos])
|
||||||
|
neg_embs = np.stack([l2(s.embedding) for s in neg])
|
||||||
|
|
||||||
|
nn = neg_embs @ neg_embs.T
|
||||||
|
np.fill_diagonal(nn, np.nan)
|
||||||
|
pp = pos_embs @ pos_embs.T
|
||||||
|
np.fill_diagonal(pp, np.nan)
|
||||||
|
pn = pos_embs @ neg_embs.T
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n neg<->neg mean cos : {np.nanmean(nn):.3f} "
|
||||||
|
f"(how tightly negatives cluster together)"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" pos<->pos mean cos : {np.nanmean(pp):.3f} "
|
||||||
|
f"(how tightly positives cluster)"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" pos<->neg mean cos : {pn.mean():.3f} "
|
||||||
|
f"(cross-class — should be low for a clean class)"
|
||||||
|
)
|
||||||
|
if np.nanmean(nn) > np.nanmean(pp):
|
||||||
|
print(
|
||||||
|
"\n >> neg<->neg > pos<->pos: negatives cluster more tightly than\n"
|
||||||
|
" positives. This is the degenerate-embedding signature —\n"
|
||||||
|
" upsampled tiny crops share a common 'face-like blob' region\n"
|
||||||
|
" regardless of identity."
|
||||||
|
)
|
||||||
|
|
||||||
|
mean_intra = np.nanmean(pp, axis=1)
|
||||||
|
for thresh in (0.30, 0.33, 0.36):
|
||||||
|
keep = mean_intra >= thresh
|
||||||
|
if keep.sum() < 5:
|
||||||
|
continue
|
||||||
|
clean_embs = [pos[i].embedding for i in range(len(pos)) if keep[i]]
|
||||||
|
clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0)
|
||||||
|
neg_scores = np.array([cosine(n.embedding, clean_mean) for n in neg])
|
||||||
|
neg_confs = np.array([similarity_to_confidence(c) for c in neg_scores])
|
||||||
|
pos_scores = np.array(
|
||||||
|
[
|
||||||
|
cosine(pos[i].embedding, clean_mean)
|
||||||
|
for i in range(len(pos))
|
||||||
|
if keep[i]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"\n mean_intra >= {thresh}: keeping {int(keep.sum())}/{len(pos)} positives"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" pos cos vs mean : min={pos_scores.min():.3f} "
|
||||||
|
f"mean={pos_scores.mean():.3f} max={pos_scores.max():.3f}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" neg cos vs mean : min={neg_scores.min():.3f} "
|
||||||
|
f"mean={neg_scores.mean():.3f} max={neg_scores.max():.3f}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" neg conf : min={neg_confs.min():.3f} "
|
||||||
|
f"mean={neg_confs.mean():.3f} max={neg_confs.max():.3f}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" margin (pos.min - neg.max): "
|
||||||
|
f"{pos_scores.min() - neg_scores.max():+.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def contamination_analysis(
|
||||||
|
pos: list[FaceSample], neg: list[FaceSample]
|
||||||
|
) -> None:
|
||||||
|
"""Check whether the positive collection contains a second identity.
|
||||||
|
|
||||||
|
Two signals:
|
||||||
|
(a) Per-positive: if an image is closer to at least one negative than
|
||||||
|
to the rest of the positive class, it's likely a mislabeled face.
|
||||||
|
(b) 2-means split of the positive embeddings: if one cluster center
|
||||||
|
lands close to the negative mean, that cluster is a contaminating
|
||||||
|
sub-identity that's pulling the class mean toward the negatives.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("CONTAMINATION ANALYSIS")
|
||||||
|
print("=" * 78)
|
||||||
|
|
||||||
|
pos_embs = np.stack([l2(s.embedding) for s in pos])
|
||||||
|
neg_embs = np.stack([l2(s.embedding) for s in neg])
|
||||||
|
pos_names = [os.path.basename(s.path) for s in pos]
|
||||||
|
|
||||||
|
pos_pos = pos_embs @ pos_embs.T
|
||||||
|
np.fill_diagonal(pos_pos, np.nan)
|
||||||
|
pos_neg = pos_embs @ neg_embs.T
|
||||||
|
|
||||||
|
mean_intra = np.nanmean(pos_pos, axis=1)
|
||||||
|
max_to_neg = pos_neg.max(axis=1)
|
||||||
|
mean_to_neg = pos_neg.mean(axis=1)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"\nPositives closer to a negative than to their own class avg"
|
||||||
|
"\n(these are candidates for mislabeled images):"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} "
|
||||||
|
f"{'delta':>6} name"
|
||||||
|
)
|
||||||
|
rows = list(zip(pos_names, max_to_neg, mean_to_neg, mean_intra))
|
||||||
|
rows.sort(key=lambda r: -(r[1] - r[3]))
|
||||||
|
for nm, mxn, mnn, mi in rows[:15]:
|
||||||
|
delta = mxn - mi
|
||||||
|
marker = " <<" if delta > 0 else ""
|
||||||
|
print(f"{mxn:7.3f} {mnn:8.3f} {mi:10.3f} {delta:6.3f} {nm}{marker}")
|
||||||
|
|
||||||
|
# 2-means in cosine space (no sklearn dependency).
|
||||||
|
print("\n2-means split of positive embeddings (cosine space):")
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
best = None
|
||||||
|
for _ in range(5):
|
||||||
|
idx = rng.choice(len(pos_embs), 2, replace=False)
|
||||||
|
centers = pos_embs[idx].copy()
|
||||||
|
for _ in range(50):
|
||||||
|
sims = pos_embs @ centers.T
|
||||||
|
labels = np.argmax(sims, axis=1)
|
||||||
|
new_centers = np.stack(
|
||||||
|
[
|
||||||
|
l2(pos_embs[labels == k].mean(axis=0))
|
||||||
|
if np.any(labels == k)
|
||||||
|
else centers[k]
|
||||||
|
for k in range(2)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if np.allclose(new_centers, centers):
|
||||||
|
break
|
||||||
|
centers = new_centers
|
||||||
|
tight = float(np.mean([sims[i, labels[i]] for i in range(len(labels))]))
|
||||||
|
if best is None or tight > best[0]:
|
||||||
|
best = (tight, labels.copy(), centers.copy())
|
||||||
|
|
||||||
|
_, labels, centers = best
|
||||||
|
sizes = [int((labels == k).sum()) for k in range(2)]
|
||||||
|
neg_mean = l2(neg_embs.mean(axis=0))
|
||||||
|
print(
|
||||||
|
f" cluster 0: size={sizes[0]:>2} "
|
||||||
|
f"center<->other_center_cos={float(centers[0] @ centers[1]):.3f} "
|
||||||
|
f"center<->neg_mean_cos={float(centers[0] @ neg_mean):.3f}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" cluster 1: size={sizes[1]:>2} "
|
||||||
|
f"center<->neg_mean_cos={float(centers[1] @ neg_mean):.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
neg_aligned = 0 if centers[0] @ neg_mean > centers[1] @ neg_mean else 1
|
||||||
|
print(
|
||||||
|
f"\n cluster {neg_aligned} is more similar to the negatives — "
|
||||||
|
f"its members are the contamination candidates:"
|
||||||
|
)
|
||||||
|
for i, lbl in enumerate(labels):
|
||||||
|
if lbl == neg_aligned:
|
||||||
|
print(
|
||||||
|
f" max_to_neg={max_to_neg[i]:.3f} "
|
||||||
|
f"mean_intra={mean_intra[i]:.3f} {pos_names[i]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
keep_mask = labels != neg_aligned
|
||||||
|
if keep_mask.sum() >= 3:
|
||||||
|
clean_embs = [pos[i].embedding for i in range(len(pos)) if keep_mask[i]]
|
||||||
|
clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0)
|
||||||
|
print(
|
||||||
|
f"\n Rebuilding class mean from the OTHER cluster "
|
||||||
|
f"({keep_mask.sum()} images):"
|
||||||
|
)
|
||||||
|
print(f" {'cos':>6} {'conf':>6} name")
|
||||||
|
for n in neg:
|
||||||
|
cs = cosine(n.embedding, clean_mean)
|
||||||
|
cf = similarity_to_confidence(cs)
|
||||||
|
print(f" {cs:6.3f} {cf:6.3f} {os.path.basename(n.path)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Analyze a face recognition collection outside Frigate.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
ap.add_argument("--positive", required=True, help="Training folder for one identity")
|
||||||
|
ap.add_argument(
|
||||||
|
"--negative",
|
||||||
|
default=None,
|
||||||
|
help="Runtime-crop folder to score against (optional)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--model-cache",
|
||||||
|
default="/config/model_cache",
|
||||||
|
help="Directory containing facedet/arcface.onnx and facedet/landmarkdet.yaml",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--trim",
|
||||||
|
type=float,
|
||||||
|
default=0.15,
|
||||||
|
help="trim_mean proportion (Frigate uses 0.15)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--vector-outlier",
|
||||||
|
action="store_true",
|
||||||
|
help="Sweep the vector-wise outlier filter threshold",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--degenerate",
|
||||||
|
action="store_true",
|
||||||
|
help="Test whether negatives share a degenerate embedding region",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--contamination",
|
||||||
|
action="store_true",
|
||||||
|
help="Check whether the positive folder contains a second identity",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
arcface_path = os.path.join(args.model_cache, "facedet", "arcface.onnx")
|
||||||
|
landmark_path = os.path.join(args.model_cache, "facedet", "landmarkdet.yaml")
|
||||||
|
for p in (arcface_path, landmark_path):
|
||||||
|
if not os.path.exists(p):
|
||||||
|
print(f"ERROR: model file not found: {p}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Loading ArcFace from {arcface_path}")
|
||||||
|
embedder = ArcFaceEmbedder(arcface_path)
|
||||||
|
print(f"Loading landmark model from {landmark_path}")
|
||||||
|
aligner = LandmarkAligner(landmark_path)
|
||||||
|
|
||||||
|
print(f"\nLoading positives from {args.positive} ...")
|
||||||
|
pos = load_folder(args.positive, aligner, embedder)
|
||||||
|
print(f" {len(pos)} positives loaded")
|
||||||
|
|
||||||
|
neg: list[FaceSample] = []
|
||||||
|
if args.negative:
|
||||||
|
print(f"\nLoading negatives from {args.negative} ...")
|
||||||
|
neg = load_folder(args.negative, aligner, embedder)
|
||||||
|
print(f" {len(neg)} negatives loaded")
|
||||||
|
|
||||||
|
if not pos:
|
||||||
|
print("no positive samples — aborting")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
mean_emb = trimmed_mean([s.embedding for s in pos], trim=args.trim)
|
||||||
|
summarize_positive(pos, mean_emb)
|
||||||
|
if neg:
|
||||||
|
summarize_negative(neg, mean_emb, pos)
|
||||||
|
|
||||||
|
if args.vector_outlier:
|
||||||
|
vector_outlier_test(pos, neg, args.trim)
|
||||||
|
if args.degenerate and neg:
|
||||||
|
degenerate_embedding_test(pos, neg)
|
||||||
|
if args.contamination and neg:
|
||||||
|
contamination_analysis(pos, neg)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
54
web/e2e/fixtures/mock-data/debug-replay.ts
Normal file
54
web/e2e/fixtures/mock-data/debug-replay.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Debug replay status factory.
|
||||||
|
*
|
||||||
|
* The Replay page polls /api/debug_replay/status every 1s via SWR.
|
||||||
|
* The no-session state shows an empty state; the active state
|
||||||
|
* renders the live camera image + debug toggles + objects/messages
|
||||||
|
* tabs. Used by replay.spec.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DebugReplayStatus = {
|
||||||
|
active: boolean;
|
||||||
|
replay_camera: string | null;
|
||||||
|
source_camera: string | null;
|
||||||
|
start_time: number | null;
|
||||||
|
end_time: number | null;
|
||||||
|
live_ready: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function noSessionStatus(): DebugReplayStatus {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
replay_camera: null,
|
||||||
|
source_camera: null,
|
||||||
|
start_time: null,
|
||||||
|
end_time: null,
|
||||||
|
live_ready: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeSessionStatus(
|
||||||
|
opts: {
|
||||||
|
camera?: string;
|
||||||
|
sourceCamera?: string;
|
||||||
|
startTime?: number;
|
||||||
|
endTime?: number;
|
||||||
|
liveReady?: boolean;
|
||||||
|
} = {},
|
||||||
|
): DebugReplayStatus {
|
||||||
|
const {
|
||||||
|
camera = "front_door",
|
||||||
|
sourceCamera = "front_door",
|
||||||
|
startTime = Date.now() / 1000 - 3600,
|
||||||
|
endTime = Date.now() / 1000 - 1800,
|
||||||
|
liveReady = true,
|
||||||
|
} = opts;
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
replay_camera: camera,
|
||||||
|
source_camera: sourceCamera,
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: endTime,
|
||||||
|
live_ready: liveReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
web/e2e/fixtures/mock-data/faces.ts
Normal file
45
web/e2e/fixtures/mock-data/faces.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Face library factories.
|
||||||
|
*
|
||||||
|
* The /api/faces endpoint returns a record keyed by collection name
|
||||||
|
* with the list of face image filenames. Grouped training attempts
|
||||||
|
* live under the "train" key with filenames of the form
|
||||||
|
* `${event_id}-${timestamp}-${label}-${score}.webp`.
|
||||||
|
*
|
||||||
|
* Used by face-library.spec.ts and chat.spec.ts (attachment chip).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FacesMock = Record<string, string[]>;
|
||||||
|
|
||||||
|
export function basicFacesMock(): FacesMock {
|
||||||
|
return {
|
||||||
|
alice: ["alice-1.webp", "alice-2.webp"],
|
||||||
|
bob: ["bob-1.webp"],
|
||||||
|
charlie: ["charlie-1.webp"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyFacesMock(): FacesMock {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a grouped recent-recognition training attempt to an existing
|
||||||
|
* faces mock. The grouping key on the backend is the event id — so
|
||||||
|
* images with the same event-id prefix render as one dialog-able card.
|
||||||
|
*/
|
||||||
|
export function withGroupedTrainingAttempt(
|
||||||
|
base: FacesMock,
|
||||||
|
opts: {
|
||||||
|
eventId: string;
|
||||||
|
attempts: Array<{ timestamp: number; label: string; score: number }>;
|
||||||
|
},
|
||||||
|
): FacesMock {
|
||||||
|
const trainImages = opts.attempts.map(
|
||||||
|
(a) => `${opts.eventId}-${a.timestamp}-${a.label}-${a.score}.webp`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
train: [...(base.train ?? []), ...trainImages],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -113,11 +113,12 @@ export class ApiMocker {
|
|||||||
route.fulfill({ json: [] }),
|
route.fulfill({ json: [] }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sub-labels and attributes (for explore filters)
|
// Sub-labels and attributes (for explore filters).
|
||||||
await this.page.route("**/api/sub_labels", (route) =>
|
// Use trailing ** so query-string variants (e.g. ?split_joined=1) match.
|
||||||
|
await this.page.route("**/api/sub_labels**", (route) =>
|
||||||
route.fulfill({ json: [] }),
|
route.fulfill({ json: [] }),
|
||||||
);
|
);
|
||||||
await this.page.route("**/api/labels", (route) =>
|
await this.page.route("**/api/labels**", (route) =>
|
||||||
route.fulfill({ json: ["person", "car"] }),
|
route.fulfill({ json: ["person", "car"] }),
|
||||||
);
|
);
|
||||||
await this.page.route("**/api/*/attributes", (route) =>
|
await this.page.route("**/api/*/attributes", (route) =>
|
||||||
|
|||||||
25
web/e2e/helpers/clipboard.ts
Normal file
25
web/e2e/helpers/clipboard.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Clipboard read helper for e2e tests.
|
||||||
|
*
|
||||||
|
* Clipboard API requires a browser permission in headless mode.
|
||||||
|
* grantClipboardPermissions() must be called before any readClipboard()
|
||||||
|
* attempt. Used by logs.spec.ts (Copy button) and config-editor.spec.ts
|
||||||
|
* (Copy button).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BrowserContext, Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant clipboard-read + clipboard-write permissions on the context.
|
||||||
|
* Call in beforeEach or at the top of a test before the Copy action.
|
||||||
|
*/
|
||||||
|
export async function grantClipboardPermissions(
|
||||||
|
context: BrowserContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current clipboard contents via the page's navigator.clipboard. */
|
||||||
|
export async function readClipboard(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(async () => await navigator.clipboard.readText());
|
||||||
|
}
|
||||||
58
web/e2e/helpers/monaco.ts
Normal file
58
web/e2e/helpers/monaco.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Monaco editor DOM helpers for e2e tests.
|
||||||
|
*
|
||||||
|
* Monaco is imported as a module-local object in the app and is NOT
|
||||||
|
* exposed on window; we drive + read through the rendered DOM and
|
||||||
|
* keyboard instead. Used by config-editor.spec.ts only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current visible text of the first Monaco editor on the
|
||||||
|
* page. Monaco virtualizes long files — this reads only the rendered
|
||||||
|
* lines. For short configs (our mocks) that's the full content.
|
||||||
|
*/
|
||||||
|
export async function getMonacoVisibleText(page: Page): Promise<string> {
|
||||||
|
return page.locator(".monaco-editor .view-lines").first().innerText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus the editor and replace its full content with `value` via
|
||||||
|
* keyboard. Uses Ctrl+A (Cmd+A on macOS Playwright is equivalent)
|
||||||
|
* + Delete + type. Works cross-platform because Playwright normalizes.
|
||||||
|
*/
|
||||||
|
export async function replaceMonacoValue(
|
||||||
|
page: Page,
|
||||||
|
value: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const editor = page.locator(".monaco-editor").first();
|
||||||
|
await editor.click();
|
||||||
|
await page.keyboard.press("ControlOrMeta+A");
|
||||||
|
await page.keyboard.press("Delete");
|
||||||
|
// Use `type` with zero delay — Monaco handles each key.
|
||||||
|
await page.keyboard.type(value, { delay: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the editor shows at least one error-severity
|
||||||
|
* marker. Monaco renders error underlines as `.squiggly-error` in
|
||||||
|
* the `.view-overlays` layer.
|
||||||
|
*/
|
||||||
|
export async function hasErrorMarkers(page: Page): Promise<boolean> {
|
||||||
|
const count = await page.locator(".monaco-editor .squiggly-error").count();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll until an error marker appears. Monaco schedules marker updates
|
||||||
|
* asynchronously after content changes (debounce + schema validation).
|
||||||
|
*/
|
||||||
|
export async function waitForErrorMarker(
|
||||||
|
page: Page,
|
||||||
|
timeoutMs: number = 10_000,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(() => hasErrorMarkers(page), { timeout: timeoutMs })
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
41
web/e2e/helpers/overlay-interaction.ts
Normal file
41
web/e2e/helpers/overlay-interaction.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Overlay interaction helpers for Radix-based UI tests.
|
||||||
|
*
|
||||||
|
* These helpers exist to guard the class of bugs fixed by de-duping
|
||||||
|
* `@radix-ui/react-dismissable-layer` across the tree: body pointer-events
|
||||||
|
* getting stuck, dropdown typeahead breaking, tooltips re-popping after a
|
||||||
|
* dropdown closes, and related nested-overlay regressions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that `<body>` is interactive (no stuck `pointer-events: none`).
|
||||||
|
*
|
||||||
|
* Call after closing any overlay. This is the fast secondary assertion —
|
||||||
|
* test specs should also assert a user-visible behavior like "a button
|
||||||
|
* responded to a click" so the test fails on meaningful breakage rather
|
||||||
|
* than just a CSS invariant.
|
||||||
|
*/
|
||||||
|
export async function expectBodyInteractive(page: Page) {
|
||||||
|
const stuck = await page.evaluate(
|
||||||
|
() => document.body.style.pointerEvents === "none",
|
||||||
|
);
|
||||||
|
expect(stuck, "body.style.pointer-events stuck after overlay close").toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until the `<body>` is no longer marked with `pointer-events: none`.
|
||||||
|
*
|
||||||
|
* Useful right after closing an overlay when Radix's cleanup runs in the
|
||||||
|
* next frame. Throws if the style does not clear within `timeoutMs`.
|
||||||
|
*/
|
||||||
|
export async function waitForBodyInteractive(page: Page, timeoutMs = 2000) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.body.style.pointerEvents !== "none",
|
||||||
|
null,
|
||||||
|
{ timeout: timeoutMs },
|
||||||
|
);
|
||||||
|
}
|
||||||
65
web/e2e/helpers/ws-frames.ts
Normal file
65
web/e2e/helpers/ws-frames.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket frame capture helper.
|
||||||
|
*
|
||||||
|
* The ws-mocker intercepts the /ws route, so Playwright's page-level
|
||||||
|
* `websocket` event never fires. This helper patches client-side
|
||||||
|
* WebSocket.prototype.send before any app code runs and mirrors every
|
||||||
|
* sent frame into a window-level array the test can read back.
|
||||||
|
*
|
||||||
|
* Used by live.spec.ts (feature toggles, PTZ preset commands) and
|
||||||
|
* config-editor.spec.ts (restart command via useRestart).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export type CapturedFrame = string;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__sentWsFrames: CapturedFrame[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch WebSocket.prototype.send to capture every outbound frame into
|
||||||
|
* window.__sentWsFrames. Must be called BEFORE page.goto().
|
||||||
|
*/
|
||||||
|
export async function installWsFrameCapture(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.__sentWsFrames = [];
|
||||||
|
const origSend = WebSocket.prototype.send;
|
||||||
|
WebSocket.prototype.send = function (data) {
|
||||||
|
try {
|
||||||
|
window.__sentWsFrames.push(
|
||||||
|
typeof data === "string" ? data : "(binary)",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore — best-effort tracing
|
||||||
|
}
|
||||||
|
return origSend.call(this, data);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read all captured frames at call time. */
|
||||||
|
export async function readWsFrames(page: Page): Promise<CapturedFrame[]> {
|
||||||
|
return page.evaluate(() => window.__sentWsFrames ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll until at least one captured frame matches the predicate.
|
||||||
|
* Throws via expect if the frame never arrives within timeout.
|
||||||
|
*/
|
||||||
|
export async function waitForWsFrame(
|
||||||
|
page: Page,
|
||||||
|
matcher: (frame: CapturedFrame) => boolean,
|
||||||
|
opts: { timeout?: number; message?: string } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const { timeout = 2_000, message } = opts;
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readWsFrames(page)).some(matcher), {
|
||||||
|
timeout,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
@ -79,7 +79,20 @@ export class WsMocker {
|
|||||||
this.send("model_state", JSON.stringify({}));
|
this.send("model_state", JSON.stringify({}));
|
||||||
}
|
}
|
||||||
if (data.topic === "embeddingsReindexProgress") {
|
if (data.topic === "embeddingsReindexProgress") {
|
||||||
this.send("embeddings_reindex_progress", JSON.stringify(null));
|
// Send a completed reindex state so Explore renders when
|
||||||
|
// semantic_search.enabled is true. A null payload leaves the page
|
||||||
|
// in a permanent loading spinner because !reindexState is truthy.
|
||||||
|
this.send(
|
||||||
|
"embeddings_reindex_progress",
|
||||||
|
JSON.stringify({
|
||||||
|
status: "completed",
|
||||||
|
processed_objects: 0,
|
||||||
|
total_objects: 0,
|
||||||
|
thumbnails: 0,
|
||||||
|
descriptions: 0,
|
||||||
|
time_remaining: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (data.topic === "birdseyeLayout") {
|
if (data.topic === "birdseyeLayout") {
|
||||||
this.send("birdseye_layout", JSON.stringify(null));
|
this.send("birdseye_layout", JSON.stringify(null));
|
||||||
|
|||||||
55
web/e2e/pages/live.page.ts
Normal file
55
web/e2e/pages/live.page.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Live dashboard + single-camera page object.
|
||||||
|
*
|
||||||
|
* Encapsulates selectors and viewport-conditional openers for the
|
||||||
|
* Live route. Does NOT own assertions — specs call expect on the
|
||||||
|
* locators returned from these getters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Locator, Page } from "@playwright/test";
|
||||||
|
import { BasePage } from "./base.page";
|
||||||
|
|
||||||
|
export class LivePage extends BasePage {
|
||||||
|
constructor(page: Page, isDesktop: boolean) {
|
||||||
|
super(page, isDesktop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The camera card wrapper on the dashboard, keyed by camera name. */
|
||||||
|
cameraCard(name: string): Locator {
|
||||||
|
return this.page.locator(`[data-camera='${name}']`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back button on the single-camera view header (desktop text). */
|
||||||
|
get backButton(): Locator {
|
||||||
|
return this.page.getByText("Back", { exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** History button on the single-camera view header (desktop text). */
|
||||||
|
get historyButton(): Locator {
|
||||||
|
return this.page.getByText("History", { exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All CameraFeatureToggle elements (active + inactive). */
|
||||||
|
get featureToggles(): Locator {
|
||||||
|
// Use div selector to exclude NavItem anchor elements that share the same classes.
|
||||||
|
return this.page.locator(
|
||||||
|
"div.flex.flex-col.items-center.justify-center.bg-selected, div.flex.flex-col.items-center.justify-center.bg-secondary",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the active (bg-selected) feature toggles. */
|
||||||
|
get activeFeatureToggles(): Locator {
|
||||||
|
// Use div selector to exclude NavItem anchor elements that share the same classes.
|
||||||
|
return this.page.locator(
|
||||||
|
"div.flex.flex-col.items-center.justify-center.bg-selected",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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" });
|
||||||
|
return this.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
}
|
||||||
52
web/e2e/pages/review.page.ts
Normal file
52
web/e2e/pages/review.page.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Review/events page object.
|
||||||
|
*
|
||||||
|
* Encapsulates severity tab, filter bar, calendar, and mobile filter
|
||||||
|
* drawer selectors. Does NOT own assertions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Locator, Page } from "@playwright/test";
|
||||||
|
import { BasePage } from "./base.page";
|
||||||
|
|
||||||
|
export class ReviewPage extends BasePage {
|
||||||
|
constructor(page: Page, isDesktop: boolean) {
|
||||||
|
super(page, isDesktop);
|
||||||
|
}
|
||||||
|
|
||||||
|
get alertsTab(): Locator {
|
||||||
|
return this.page.getByLabel("Alerts");
|
||||||
|
}
|
||||||
|
|
||||||
|
get detectionsTab(): Locator {
|
||||||
|
return this.page.getByLabel("Detections");
|
||||||
|
}
|
||||||
|
|
||||||
|
get motionTab(): Locator {
|
||||||
|
return this.page.getByRole("radio", { name: "Motion" });
|
||||||
|
}
|
||||||
|
|
||||||
|
get camerasFilterTrigger(): Locator {
|
||||||
|
return this.page.getByRole("button", { name: /cameras/i }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
get calendarTrigger(): Locator {
|
||||||
|
return this.page.getByRole("button", { name: /24 hours|calendar|date/i });
|
||||||
|
}
|
||||||
|
|
||||||
|
get showReviewedToggle(): Locator {
|
||||||
|
return this.page.getByRole("button", { name: /reviewed/i });
|
||||||
|
}
|
||||||
|
|
||||||
|
get reviewItems(): Locator {
|
||||||
|
return this.page.locator(".review-item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The filter popover content (desktop) or drawer (mobile). */
|
||||||
|
get filterOverlay(): Locator {
|
||||||
|
return this.page
|
||||||
|
.locator(
|
||||||
|
'[data-radix-popper-content-wrapper], [role="dialog"], [data-vaul-drawer]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,10 +14,6 @@
|
|||||||
*
|
*
|
||||||
* @mobile rule: every .spec.ts under specs/ (not specs/_meta/) must
|
* @mobile rule: every .spec.ts under specs/ (not specs/_meta/) must
|
||||||
* contain at least one test title or describe with the substring "@mobile".
|
* contain at least one test title or describe with the substring "@mobile".
|
||||||
*
|
|
||||||
* Specs in PENDING_REWRITE are exempt from all rules until they are
|
|
||||||
* rewritten with proper assertions and mobile coverage. Remove each
|
|
||||||
* entry when its spec is updated.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
@ -28,24 +24,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const SPECS_DIR = resolve(__dirname, "..", "specs");
|
const SPECS_DIR = resolve(__dirname, "..", "specs");
|
||||||
const META_PREFIX = resolve(SPECS_DIR, "_meta");
|
const META_PREFIX = resolve(SPECS_DIR, "_meta");
|
||||||
|
|
||||||
// Specs exempt from lint rules until they are rewritten with proper
|
|
||||||
// assertions and mobile coverage. Remove each entry when its spec is updated.
|
|
||||||
const PENDING_REWRITE = new Set([
|
|
||||||
"auth.spec.ts",
|
|
||||||
"chat.spec.ts",
|
|
||||||
"classification.spec.ts",
|
|
||||||
"config-editor.spec.ts",
|
|
||||||
"explore.spec.ts",
|
|
||||||
"export.spec.ts",
|
|
||||||
"face-library.spec.ts",
|
|
||||||
"live.spec.ts",
|
|
||||||
"logs.spec.ts",
|
|
||||||
"navigation.spec.ts",
|
|
||||||
"replay.spec.ts",
|
|
||||||
"review.spec.ts",
|
|
||||||
"system.spec.ts",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const BANNED_PATTERNS = [
|
const BANNED_PATTERNS = [
|
||||||
{
|
{
|
||||||
name: "page.waitForTimeout",
|
name: "page.waitForTimeout",
|
||||||
@ -62,14 +40,12 @@ const BANNED_PATTERNS = [
|
|||||||
{
|
{
|
||||||
name: "conditional count() assertion",
|
name: "conditional count() assertion",
|
||||||
regex: /\bif\s*\(\s*\(?\s*await\s+[^)]*\.count\s*\(\s*\)\s*\)?\s*[><=!]/,
|
regex: /\bif\s*\(\s*\(?\s*await\s+[^)]*\.count\s*\(\s*\)\s*\)?\s*[><=!]/,
|
||||||
advice:
|
advice: "Assertions must be unconditional. Use expect(...).toHaveCount(n).",
|
||||||
"Assertions must be unconditional. Use expect(...).toHaveCount(n).",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "vacuous textContent length assertion",
|
name: "vacuous textContent length assertion",
|
||||||
regex: /expect\([^)]*\.length\)\.toBeGreaterThan\(0\)/,
|
regex: /expect\([^)]*\.length\)\.toBeGreaterThan\(0\)/,
|
||||||
advice:
|
advice: "Assert specific content, not that some text exists.",
|
||||||
"Assert specific content, not that some text exists.",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -89,8 +65,6 @@ function walk(dir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lintFile(file) {
|
function lintFile(file) {
|
||||||
const basename = file.split("/").pop();
|
|
||||||
if (PENDING_REWRITE.has(basename)) return [];
|
|
||||||
if (file.includes("/specs/settings/")) return [];
|
if (file.includes("/specs/settings/")) return [];
|
||||||
|
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|||||||
@ -1,147 +1,110 @@
|
|||||||
/**
|
/**
|
||||||
* Auth and cross-cutting tests -- HIGH tier.
|
* Auth and role tests -- HIGH tier.
|
||||||
*
|
*
|
||||||
* Tests protected route access for admin/viewer roles,
|
* Admin access to /system, /config, /logs; viewer access denied
|
||||||
* access denied page rendering, viewer nav restrictions,
|
* markers (via i18n heading, not a data-testid we don't own);
|
||||||
* and all routes smoke test.
|
* viewer nav restrictions; all-routes smoke.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
test.describe("Auth - Admin Access @high", () => {
|
test.describe("Auth — admin access @high", () => {
|
||||||
test("admin can access /system and sees system tabs", async ({
|
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/system");
|
await frigateApp.goto("/system");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
await frigateApp.page.waitForTimeout(3000);
|
|
||||||
// System page should have named tab buttons
|
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access /config and Monaco editor loads", async ({
|
test("admin /config renders Monaco editor", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/config");
|
await frigateApp.goto("/config");
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
await expect(
|
||||||
const editor = frigateApp.page.locator(
|
frigateApp.page
|
||||||
".monaco-editor, [data-keybinding-context]",
|
.locator(".monaco-editor, [data-keybinding-context]")
|
||||||
);
|
.first(),
|
||||||
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access /logs and sees service tabs", async ({
|
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/logs");
|
await frigateApp.goto("/logs");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin sees Classification nav on desktop", async ({ frigateApp }) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator('a[href="/classification"]'),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Auth - Viewer Restrictions @high", () => {
|
test.describe("Auth — viewer restrictions @high", () => {
|
||||||
test("viewer sees Access Denied on /system", async ({ frigateApp, page }) => {
|
for (const path of ["/system", "/config", "/logs"]) {
|
||||||
|
test(`viewer on ${path} sees AccessDenied`, async ({ frigateApp }) => {
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
await page.goto("/system");
|
await frigateApp.page.goto(path);
|
||||||
await page.waitForTimeout(2000);
|
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||||
// Should show "Access Denied" text
|
await expect(
|
||||||
await expect(page.getByText("Access Denied")).toBeVisible({
|
frigateApp.page.getByRole("heading", {
|
||||||
timeout: 5_000,
|
level: 2,
|
||||||
|
name: /access denied/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("viewer sees cameras on /", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
|
await frigateApp.page.goto("/");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator("[data-camera='front_door']"),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("viewer sees Access Denied on /config", async ({ frigateApp, page }) => {
|
test("viewer sees severity tabs on /review", async ({ frigateApp }) => {
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
await page.goto("/config");
|
await frigateApp.page.goto("/review");
|
||||||
await page.waitForTimeout(2000);
|
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
||||||
await expect(page.getByText("Access Denied")).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("viewer sees Access Denied on /logs", async ({ frigateApp, page }) => {
|
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
|
||||||
await page.goto("/logs");
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await expect(page.getByText("Access Denied")).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("viewer can access Live page and sees cameras", async ({
|
|
||||||
frigateApp,
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
|
||||||
await page.goto("/");
|
|
||||||
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
await expect(page.locator("[data-camera='front_door']")).toBeVisible({
|
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("viewer can access Review page and sees severity tabs", async ({
|
test("viewer can access all non-admin routes without AccessDenied", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
|
||||||
await page.goto("/review");
|
|
||||||
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
await expect(page.getByLabel("Alerts")).toBeVisible({ timeout: 5_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("viewer can access all main user routes without crash", async ({
|
|
||||||
frigateApp,
|
|
||||||
page,
|
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
const routes = ["/", "/review", "/explore", "/export", "/settings"];
|
const routes = ["/", "/review", "/explore", "/export", "/settings"];
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
await page.goto(route);
|
await frigateApp.page.goto(route);
|
||||||
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: /access denied/i,
|
||||||
|
}),
|
||||||
|
).toHaveCount(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Auth - All Routes Smoke @high", () => {
|
test.describe("Auth — viewer nav restrictions (desktop) @high", () => {
|
||||||
test("all user routes render without crash", async ({ frigateApp }) => {
|
test.skip(({ frigateApp }) => frigateApp.isMobile, "Sidebar only on desktop");
|
||||||
const routes = ["/", "/review", "/explore", "/export", "/settings"];
|
|
||||||
for (const route of routes) {
|
test("viewer sidebar hides admin routes", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
|
await frigateApp.page.goto("/");
|
||||||
|
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||||
|
for (const href of ["/system", "/config", "/logs"]) {
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator(`aside a[href='${href}']`),
|
||||||
|
).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Auth — all routes smoke @high @mobile", () => {
|
||||||
|
test("every common route renders #pageRoot", async ({ frigateApp }) => {
|
||||||
|
for (const route of ["/", "/review", "/explore", "/export", "/settings"]) {
|
||||||
await frigateApp.goto(route);
|
await frigateApp.goto(route);
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin routes render with specific content", async ({ frigateApp }) => {
|
|
||||||
// System page should have tab controls
|
|
||||||
await frigateApp.goto("/system");
|
|
||||||
await frigateApp.page.waitForTimeout(3000);
|
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Logs page should have service tabs
|
|
||||||
await frigateApp.goto("/logs");
|
|
||||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,34 +1,311 @@
|
|||||||
/**
|
/**
|
||||||
* Chat page tests -- MEDIUM tier.
|
* Chat page tests -- MEDIUM tier.
|
||||||
*
|
*
|
||||||
* Tests chat interface rendering, input area, and example prompt buttons.
|
* Starting state, NDJSON streaming contract (not SSE), assistant
|
||||||
|
* bubble grows as chunks arrive, error path, and mobile viewport.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||||
|
|
||||||
test.describe("Chat Page @medium", () => {
|
/**
|
||||||
test("chat page renders without crash", async ({ frigateApp }) => {
|
* Install a window.fetch override on the page so that POSTs to
|
||||||
await frigateApp.goto("/chat");
|
* chat/completion resolve with a real ReadableStream that emits the
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
* given chunks over time. This is the only way to validate
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
* chunk-by-chunk rendering through Playwright — page.route() does not
|
||||||
|
* support streaming responses.
|
||||||
|
*
|
||||||
|
* Must be called BEFORE frigateApp.goto(). The override also exposes
|
||||||
|
* `__chatRequests` on window so tests can assert the outgoing body.
|
||||||
|
*/
|
||||||
|
async function installChatStreamOverride(
|
||||||
|
app: FrigateApp,
|
||||||
|
chunks: Array<Record<string, unknown>>,
|
||||||
|
opts: { chunkDelayMs?: number; status?: number } = {},
|
||||||
|
) {
|
||||||
|
const { chunkDelayMs = 40, status = 200 } = opts;
|
||||||
|
await app.page.addInitScript(
|
||||||
|
({ chunks, chunkDelayMs, status }) => {
|
||||||
|
(window as unknown as { __chatRequests: unknown[] }).__chatRequests = [];
|
||||||
|
const origFetch = window.fetch;
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: (input as Request).url;
|
||||||
|
if (url.includes("chat/completion")) {
|
||||||
|
const body =
|
||||||
|
init?.body instanceof String || typeof init?.body === "string"
|
||||||
|
? JSON.parse(init!.body as string)
|
||||||
|
: null;
|
||||||
|
(
|
||||||
|
window as unknown as { __chatRequests: unknown[] }
|
||||||
|
).__chatRequests.push({ url, body });
|
||||||
|
if (status !== 200) {
|
||||||
|
return new Response(JSON.stringify({ error: "boom" }), {
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("chat page has interactive input or buttons", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/chat");
|
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
const interactive = frigateApp.page.locator("input, textarea, button");
|
|
||||||
const count = await interactive.count();
|
|
||||||
expect(count).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("chat input accepts text", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/chat");
|
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
const input = frigateApp.page.locator("input, textarea").first();
|
|
||||||
if (await input.isVisible().catch(() => false)) {
|
|
||||||
await input.fill("What cameras detected a person today?");
|
|
||||||
const value = await input.inputValue();
|
|
||||||
expect(value.length).toBeGreaterThan(0);
|
|
||||||
}
|
}
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await new Promise((r) => setTimeout(r, chunkDelayMs));
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(JSON.stringify(chunk) + "\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(stream, { status: 200 });
|
||||||
|
}
|
||||||
|
return origFetch.call(window, input as RequestInfo, init);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ chunks, chunkDelayMs, status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Chat — starting state @medium", () => {
|
||||||
|
test("empty message list renders ChatStartingState with title and input", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.goto("/chat");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { level: 1 }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(frigateApp.page.getByPlaceholder(/ask/i)).toBeVisible();
|
||||||
|
// Four quick-reply buttons from starting_requests.*
|
||||||
|
const quickReplies = frigateApp.page.locator(
|
||||||
|
"button:has-text('Show recent events'), button:has-text('Show camera status'), button:has-text('What happened'), button:has-text('Watch')",
|
||||||
|
);
|
||||||
|
await expect(quickReplies.first()).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Chat — streaming @medium", () => {
|
||||||
|
test("submission POSTs to chat/completion with stream: true", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installChatStreamOverride(frigateApp, [
|
||||||
|
{ type: "content", delta: "Hel" },
|
||||||
|
{ type: "content", delta: "lo" },
|
||||||
|
]);
|
||||||
|
await frigateApp.goto("/chat");
|
||||||
|
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
await input.fill("hello chat");
|
||||||
|
await input.press("Enter");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
frigateApp.page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as unknown as { __chatRequests: unknown[] })
|
||||||
|
.__chatRequests?.length ?? 0,
|
||||||
|
),
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const request = await frigateApp.page.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as unknown as {
|
||||||
|
__chatRequests: Array<{
|
||||||
|
url: string;
|
||||||
|
body: { stream: boolean; messages: Array<{ content: string }> };
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
).__chatRequests[0],
|
||||||
|
);
|
||||||
|
expect(request.body.stream).toBe(true);
|
||||||
|
expect(
|
||||||
|
request.body.messages[request.body.messages.length - 1].content,
|
||||||
|
).toBe("hello chat");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("NDJSON content chunks accumulate in the assistant bubble", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installChatStreamOverride(
|
||||||
|
frigateApp,
|
||||||
|
[
|
||||||
|
{ type: "content", delta: "Hel" },
|
||||||
|
{ type: "content", delta: "lo, " },
|
||||||
|
{ type: "content", delta: "world!" },
|
||||||
|
],
|
||||||
|
{ chunkDelayMs: 50 },
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/chat");
|
||||||
|
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
await input.fill("greet me");
|
||||||
|
await input.press("Enter");
|
||||||
|
|
||||||
|
await expect(frigateApp.page.getByText(/Hello, world!/i)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tool_calls chunks render a ToolCallsGroup", async ({ frigateApp }) => {
|
||||||
|
await installChatStreamOverride(frigateApp, [
|
||||||
|
{
|
||||||
|
type: "tool_calls",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_1",
|
||||||
|
name: "search_objects",
|
||||||
|
arguments: { label: "person" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "content", delta: "Searching for people." },
|
||||||
|
]);
|
||||||
|
await frigateApp.goto("/chat");
|
||||||
|
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
await input.fill("find people");
|
||||||
|
await input.press("Enter");
|
||||||
|
|
||||||
|
// ToolCallsGroup normalizes "search_objects" → "Search Objects" via
|
||||||
|
// normalizeName(). Match the rendered display label instead.
|
||||||
|
await expect(frigateApp.page.getByText(/search objects/i)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText(/searching for people/i),
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Chat — stop @medium", () => {
|
||||||
|
test("Stop button aborts an in-flight stream and freezes the partial message", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// A long chunk sequence with big delays gives us time to hit Stop.
|
||||||
|
await installChatStreamOverride(
|
||||||
|
frigateApp,
|
||||||
|
[
|
||||||
|
{ type: "content", delta: "First chunk. " },
|
||||||
|
{ type: "content", delta: "Second chunk. " },
|
||||||
|
{ type: "content", delta: "Third chunk. " },
|
||||||
|
],
|
||||||
|
{ chunkDelayMs: 300 },
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/chat");
|
||||||
|
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
await input.fill("slow response please");
|
||||||
|
await input.press("Enter");
|
||||||
|
|
||||||
|
// Wait for the first chunk to render
|
||||||
|
await expect(frigateApp.page.getByText(/First chunk\./)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Stop button is a destructive rounded button shown while isLoading.
|
||||||
|
// It contains only an FaStop SVG icon (no visible text). Find it by the
|
||||||
|
// destructive variant class or fall back to aria-label.
|
||||||
|
const stopBtn = frigateApp.page
|
||||||
|
.locator("button.bg-destructive, button[class*='destructive']")
|
||||||
|
.first();
|
||||||
|
await stopBtn.click({ timeout: 3_000 }).catch(async () => {
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("button", { name: /stop|cancel/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Third chunk should never appear.
|
||||||
|
await expect(frigateApp.page.getByText(/Third chunk\./)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Chat — error @medium", () => {
|
||||||
|
test("non-OK response renders an error banner", async ({ frigateApp }) => {
|
||||||
|
await installChatStreamOverride(frigateApp, [], { status: 500 });
|
||||||
|
await frigateApp.goto("/chat");
|
||||||
|
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
await input.fill("trigger error");
|
||||||
|
await input.press("Enter");
|
||||||
|
// The error banner is a role="alert" paragraph; target by role so we
|
||||||
|
// don't collide with the user-message bubble that contains "trigger
|
||||||
|
// error" (which would match /error/ in strict mode).
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("alert").filter({
|
||||||
|
hasText: /boom|something went wrong/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Chat — attachment chip @medium", () => {
|
||||||
|
test("attaching an event renders a ChatAttachmentChip", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// The chat starts with an empty message list (ChatStartingState).
|
||||||
|
// After sending a message, ChatEntry with the paperclip button appears.
|
||||||
|
// We use the stream override so the first message completes quickly.
|
||||||
|
await installChatStreamOverride(frigateApp, [
|
||||||
|
{ type: "content", delta: "Done." },
|
||||||
|
]);
|
||||||
|
await frigateApp.goto("/chat");
|
||||||
|
|
||||||
|
// Send a first message to transition out of ChatStartingState so the
|
||||||
|
// full ChatEntry (with the paperclip) is visible.
|
||||||
|
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
await input.fill("hello");
|
||||||
|
await input.press("Enter");
|
||||||
|
// Wait for the assistant response to complete so isLoading becomes false
|
||||||
|
// and the paperclip button is re-enabled.
|
||||||
|
await expect(frigateApp.page.getByText(/Done\./i)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The paperclip button has aria-label from t("attachment_picker_placeholder")
|
||||||
|
// = "Attach an event".
|
||||||
|
const paperclip = frigateApp.page
|
||||||
|
.getByRole("button", { name: /attach an event/i })
|
||||||
|
.first();
|
||||||
|
await expect(paperclip).toBeVisible({ timeout: 5_000 });
|
||||||
|
await paperclip.click();
|
||||||
|
|
||||||
|
// The popover shows a paste input with placeholder "Or paste event ID".
|
||||||
|
const idInput = frigateApp.page
|
||||||
|
.locator('input[placeholder*="event" i], input[aria-label*="attach" i]')
|
||||||
|
.first();
|
||||||
|
await expect(idInput).toBeVisible({ timeout: 3_000 });
|
||||||
|
await idInput.fill("test-event-1");
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("button", { name: /^attach$/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// The ChatAttachmentChip renders in the composer area. It shows an
|
||||||
|
// activity indicator while loading event data (event_ids API not mocked),
|
||||||
|
// so assert on the chip container being present in the composer.
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator(
|
||||||
|
"[class*='inline-flex'][class*='rounded-lg'][class*='border']",
|
||||||
|
),
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Chat — mobile @medium @mobile", () => {
|
||||||
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
test("chat input is focusable at mobile viewport", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/chat");
|
||||||
|
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
await input.focus();
|
||||||
|
await expect(input).toBeFocused();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,33 +1,228 @@
|
|||||||
/**
|
/**
|
||||||
* Classification page tests -- MEDIUM tier.
|
* Classification page tests -- MEDIUM tier.
|
||||||
*
|
*
|
||||||
* Tests model selection view rendering and interactive elements.
|
* Model list driven by config.classification.custom + per-model
|
||||||
|
* dataset fetches. Admin-only access.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
test.describe("Classification @medium", () => {
|
const CUSTOM_MODELS = {
|
||||||
test("classification page renders without crash", async ({ frigateApp }) => {
|
object_classifier: {
|
||||||
|
name: "object_classifier",
|
||||||
|
object_config: { objects: ["person"], classification_type: "sub_label" },
|
||||||
|
},
|
||||||
|
state_classifier: {
|
||||||
|
name: "state_classifier",
|
||||||
|
state_config: { cameras: { front_door: { crop: [0, 0, 1, 1] } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function installDatasetRoute(
|
||||||
|
app: { page: import("@playwright/test").Page },
|
||||||
|
name: string,
|
||||||
|
body: Record<string, unknown> = { categories: {} },
|
||||||
|
) {
|
||||||
|
await app.page.route(
|
||||||
|
new RegExp(`/api/classification/${name}/dataset`),
|
||||||
|
(route) => route.fulfill({ json: body }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installTrainRoute(
|
||||||
|
app: { page: import("@playwright/test").Page },
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
await app.page.route(
|
||||||
|
new RegExp(`/api/classification/${name}/train`),
|
||||||
|
(route) => route.fulfill({ json: [] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Classification — model list @medium", () => {
|
||||||
|
test("custom models render by name", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: { classification: { custom: CUSTOM_MODELS } },
|
||||||
|
});
|
||||||
|
await installDatasetRoute(frigateApp, "object_classifier");
|
||||||
|
await installDatasetRoute(frigateApp, "state_classifier");
|
||||||
await frigateApp.goto("/classification");
|
await frigateApp.goto("/classification");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
|
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("classification page shows content and controls", async ({
|
test("empty custom map renders without crash", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
await frigateApp.installDefaults({
|
||||||
}) => {
|
config: { classification: { custom: {} } },
|
||||||
|
});
|
||||||
await frigateApp.goto("/classification");
|
await frigateApp.goto("/classification");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
const text = await frigateApp.page.textContent("#pageRoot");
|
timeout: 10_000,
|
||||||
expect(text?.length).toBeGreaterThan(0);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("classification page has interactive elements", async ({
|
test("toggling to states view switches the rendered card set", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: { classification: { custom: CUSTOM_MODELS } },
|
||||||
|
});
|
||||||
|
await installDatasetRoute(frigateApp, "object_classifier");
|
||||||
|
await installDatasetRoute(frigateApp, "state_classifier");
|
||||||
await frigateApp.goto("/classification");
|
await frigateApp.goto("/classification");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
// Objects is default — object_classifier visible, state_classifier hidden.
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
|
||||||
const count = await buttons.count();
|
timeout: 10_000,
|
||||||
expect(count).toBeGreaterThanOrEqual(0);
|
});
|
||||||
|
await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0);
|
||||||
|
|
||||||
|
// Click the "states" toggle. Radix ToggleGroup type="single" uses role="radio".
|
||||||
|
const statesToggle = frigateApp.page
|
||||||
|
.getByRole("radio", { name: /state/i })
|
||||||
|
.first();
|
||||||
|
await expect(statesToggle).toBeVisible({ timeout: 5_000 });
|
||||||
|
await statesToggle.click();
|
||||||
|
|
||||||
|
await expect(frigateApp.page.getByText("state_classifier")).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
await expect(frigateApp.page.getByText("object_classifier")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Classification — model detail navigation @medium", () => {
|
||||||
|
test("clicking a model card opens ModelTrainingView", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: { classification: { custom: CUSTOM_MODELS } },
|
||||||
|
});
|
||||||
|
await installDatasetRoute(frigateApp, "object_classifier");
|
||||||
|
await installDatasetRoute(frigateApp, "state_classifier");
|
||||||
|
await installTrainRoute(frigateApp, "object_classifier");
|
||||||
|
await frigateApp.goto("/classification");
|
||||||
|
|
||||||
|
const objectCard = frigateApp.page.getByText("object_classifier").first();
|
||||||
|
await expect(objectCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
await objectCard.click();
|
||||||
|
|
||||||
|
// ModelTrainingView renders a Back button (aria-label "Back").
|
||||||
|
// useOverlayState stores the selected model in window.history.state
|
||||||
|
// (not the URL), so we verify the state transition via the DOM.
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("button", { name: /back/i }),
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// The model grid is no longer shown; state_classifier card is gone.
|
||||||
|
await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Classification — delete model (desktop) @medium", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Delete action menu is desktop-focused",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("deleting a model fires DELETE + PUT /config/set", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
let deleteCalled = false;
|
||||||
|
let configSetCalled = false;
|
||||||
|
|
||||||
|
// installDefaults must run first because Playwright matches routes in
|
||||||
|
// LIFO order — routes registered after installDefaults take precedence
|
||||||
|
// over the generic catch-all registered inside it.
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: { classification: { custom: CUSTOM_MODELS } },
|
||||||
|
});
|
||||||
|
await installDatasetRoute(frigateApp, "object_classifier");
|
||||||
|
await installDatasetRoute(frigateApp, "state_classifier");
|
||||||
|
|
||||||
|
// Register spy routes after installDefaults so they win over the catch-all.
|
||||||
|
await frigateApp.page.route(
|
||||||
|
/\/api\/classification\/object_classifier$/,
|
||||||
|
async (route) => {
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
deleteCalled = true;
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await frigateApp.page.route("**/api/config/set", async (route) => {
|
||||||
|
if (route.request().method() === "PUT") configSetCalled = true;
|
||||||
|
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/classification");
|
||||||
|
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The card-level actions menu (FiMoreVertical three-dot icon) is a
|
||||||
|
// DropdownMenuTrigger with asChild on a BlurredIconButton div.
|
||||||
|
// Radix forwards aria-haspopup="menu" to the child element.
|
||||||
|
// Scope the selector to the model card grid to avoid hitting the
|
||||||
|
// settings sidebar trigger.
|
||||||
|
const cardGrid = frigateApp.page.locator(".grid.auto-rows-max");
|
||||||
|
await expect(cardGrid).toBeVisible({ timeout: 5_000 });
|
||||||
|
const trigger = cardGrid.locator('[aria-haspopup="menu"]').first();
|
||||||
|
await expect(trigger).toBeVisible({ timeout: 5_000 });
|
||||||
|
await trigger.click();
|
||||||
|
const deleteItem = frigateApp.page
|
||||||
|
.getByRole("menuitem", { name: /delete/i })
|
||||||
|
.first();
|
||||||
|
await expect(deleteItem).toBeVisible({ timeout: 5_000 });
|
||||||
|
await deleteItem.click();
|
||||||
|
|
||||||
|
// Confirm the AlertDialog.
|
||||||
|
const alert = frigateApp.page.getByRole("alertdialog");
|
||||||
|
await expect(alert).toBeVisible({ timeout: 5_000 });
|
||||||
|
await alert
|
||||||
|
.getByRole("button", { name: /delete|confirm/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect.poll(() => deleteCalled, { timeout: 5_000 }).toBe(true);
|
||||||
|
await expect.poll(() => configSetCalled, { timeout: 5_000 }).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Classification — admin only @medium", () => {
|
||||||
|
test("viewer navigating to /classification is redirected to access-denied", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
|
await frigateApp.page.goto("/classification");
|
||||||
|
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||||
|
await expect(frigateApp.page).toHaveURL(/\/unauthorized/, {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: /access denied/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Classification — mobile @medium @mobile", () => {
|
||||||
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
test("page renders at mobile viewport", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: { classification: { custom: CUSTOM_MODELS } },
|
||||||
|
});
|
||||||
|
await installDatasetRoute(frigateApp, "object_classifier");
|
||||||
|
await installDatasetRoute(frigateApp, "state_classifier");
|
||||||
|
await frigateApp.goto("/classification");
|
||||||
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,44 +1,276 @@
|
|||||||
/**
|
/**
|
||||||
* Config Editor page tests -- MEDIUM tier.
|
* Config Editor tests -- MEDIUM tier.
|
||||||
*
|
*
|
||||||
* Tests Monaco editor loading, YAML content rendering,
|
* Monaco load + value, Save (config/save?save_option=saveonly),
|
||||||
* save button presence, and copy button interaction.
|
* Save error path, Save and Restart (WS frame via useRestart),
|
||||||
|
* Copy (clipboard), schema markers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames";
|
||||||
|
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
|
||||||
|
import {
|
||||||
|
getMonacoVisibleText,
|
||||||
|
replaceMonacoValue,
|
||||||
|
waitForErrorMarker,
|
||||||
|
} from "../helpers/monaco";
|
||||||
|
|
||||||
test.describe("Config Editor @medium", () => {
|
const SAMPLE_CONFIG =
|
||||||
test("config editor loads Monaco editor with content", async ({
|
"mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n";
|
||||||
frigateApp,
|
|
||||||
}) => {
|
async function installSaveRoute(
|
||||||
|
app: { page: import("@playwright/test").Page },
|
||||||
|
status: number,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<{
|
||||||
|
capturedUrl: () => string | null;
|
||||||
|
capturedBody: () => string | null;
|
||||||
|
}> {
|
||||||
|
let lastUrl: string | null = null;
|
||||||
|
let lastBody: string | null = null;
|
||||||
|
await app.page.route("**/api/config/save**", async (route) => {
|
||||||
|
lastUrl = route.request().url();
|
||||||
|
lastBody = route.request().postData();
|
||||||
|
await route.fulfill({ status, json: body });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
capturedUrl: () => lastUrl,
|
||||||
|
capturedBody: () => lastBody,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Config Editor — Monaco @medium", () => {
|
||||||
|
test("editor loads with mocked configRaw content", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
await frigateApp.goto("/config");
|
await frigateApp.goto("/config");
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
// Monaco editor should render with a specific class
|
{ timeout: 15_000 },
|
||||||
const editor = frigateApp.page.locator(
|
);
|
||||||
".monaco-editor, [data-keybinding-context]",
|
// Assert via DOM-rendered visible text (Monaco virtualizes — works
|
||||||
|
// for short configs which covers our mocked content).
|
||||||
|
await expect
|
||||||
|
.poll(() => getMonacoVisibleText(frigateApp.page), { timeout: 10_000 })
|
||||||
|
.toContain("front_door");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Config Editor — Save @medium", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Save button copy is desktop-visible (hidden md:block)",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("clicking Save Only POSTs config/save?save_option=saveonly", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
|
const capture = await installSaveRoute(frigateApp, 200, {
|
||||||
|
message: "Config saved",
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/config");
|
||||||
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await frigateApp.page.getByLabel("Save Only").click();
|
||||||
|
await expect
|
||||||
|
.poll(() => capture.capturedUrl(), { timeout: 5_000 })
|
||||||
|
.toMatch(/config\/save\?save_option=saveonly/);
|
||||||
|
// Body is the raw YAML as text/plain
|
||||||
|
await expect
|
||||||
|
.poll(() => capture.capturedBody(), { timeout: 5_000 })
|
||||||
|
.toContain("front_door");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Save error shows the server message in the error area", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
|
await installSaveRoute(frigateApp, 400, {
|
||||||
|
message: "Invalid field `cameras.front_door`",
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/config");
|
||||||
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await frigateApp.page.getByLabel("Save Only").click();
|
||||||
|
await expect(frigateApp.page.getByText(/Invalid field/i)).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Config Editor — Save and Restart @medium", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Save and Restart button copy is desktop-visible",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Save and Restart opens dialog; confirm sends WS restart frame", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
|
await installSaveRoute(frigateApp, 200, { message: "Saved" });
|
||||||
|
await installWsFrameCapture(frigateApp.page);
|
||||||
|
|
||||||
|
await frigateApp.goto("/config");
|
||||||
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await frigateApp.page.getByLabel("Save & Restart").click();
|
||||||
|
const dialog = frigateApp.page.getByRole("alertdialog");
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: /restart/i }).click();
|
||||||
|
await waitForWsFrame(
|
||||||
|
frigateApp.page,
|
||||||
|
(frame) => frame.includes('"restart"') || frame.includes("restart"),
|
||||||
|
{ message: "useRestart should send a WS frame on the restart topic" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelling the restart dialog leaves body interactive", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
|
await installSaveRoute(frigateApp, 200, { message: "Saved" });
|
||||||
|
|
||||||
|
await frigateApp.goto("/config");
|
||||||
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await frigateApp.page.getByLabel("Save & Restart").click();
|
||||||
|
const dialog = frigateApp.page.getByRole("alertdialog");
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
await dialog.getByRole("button", { name: /cancel/i }).click();
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator(".monaco-editor").first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Config Editor — Copy @medium", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Copy button copy is desktop-visible",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Copy places the editor value in the clipboard", async ({
|
||||||
|
frigateApp,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await grantClipboardPermissions(context);
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
|
await frigateApp.goto("/config");
|
||||||
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await frigateApp.page.getByLabel("Copy Config").click();
|
||||||
|
await expect
|
||||||
|
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
|
||||||
|
.toContain("front_door");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Config Editor — schema markers @medium", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Schema validation assumes focused desktop editing",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("invalid YAML renders at least one error marker in the DOM", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
|
await frigateApp.goto("/config");
|
||||||
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace editor contents with clearly invalid YAML via keyboard.
|
||||||
|
await replaceMonacoValue(
|
||||||
|
frigateApp.page,
|
||||||
|
"this is not: [yaml: and has {unbalanced",
|
||||||
|
);
|
||||||
|
// Monaco debounces marker evaluation; the .squiggly-error decoration
|
||||||
|
// appears asynchronously in the .view-overlays layer.
|
||||||
|
await waitForErrorMarker(frigateApp.page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Config Editor — Cmd+S keyboard shortcut @medium", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Keyboard save shortcut is desktop-only",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Cmd/Ctrl+S fires the same config/save POST as the Save button", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
|
const capture = await installSaveRoute(frigateApp, 200, {
|
||||||
|
message: "Saved",
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/config");
|
||||||
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Focus the editor so Monaco's keybinding receives the shortcut.
|
||||||
|
await frigateApp.page.locator(".monaco-editor").first().click();
|
||||||
|
await frigateApp.page.keyboard.press("ControlOrMeta+s");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => capture.capturedUrl(), { timeout: 5_000 })
|
||||||
|
.toMatch(/config\/save\?save_option=saveonly/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Config Editor — Safe Mode auto-validation @medium", () => {
|
||||||
|
test("safe-mode config auto-posts on mount and shows the inline error", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Thread safe_mode: true through the config override, then stub
|
||||||
|
// config/save to return a validation error. The page's
|
||||||
|
// initialValidationRef effect runs on mount and POSTs
|
||||||
|
// config/save?save_option=saveonly with the raw config; the 400
|
||||||
|
// surfaces through setError.
|
||||||
|
// installDefaults must come first so our specific route wins (LIFO).
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: { safe_mode: true } as unknown as Record<string, unknown>,
|
||||||
|
configRaw: "cameras:\n front_door:\n ffmpeg: {}\n",
|
||||||
|
});
|
||||||
|
let autoSaveCalled = false;
|
||||||
|
await frigateApp.page.route("**/api/config/save**", async (route) => {
|
||||||
|
autoSaveCalled = true;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 400,
|
||||||
|
json: { message: "safe-mode validation failure" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await frigateApp.goto("/config");
|
||||||
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await expect.poll(() => autoSaveCalled, { timeout: 10_000 }).toBe(true);
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText(/safe-mode validation failure/i),
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Config Editor — mobile @medium @mobile", () => {
|
||||||
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
test("editor renders at narrow viewport", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
|
await frigateApp.goto("/config");
|
||||||
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("config editor has action buttons", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/config");
|
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
|
||||||
const buttons = frigateApp.page.locator("button");
|
|
||||||
const count = await buttons.count();
|
|
||||||
expect(count).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("config editor button clicks do not crash", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/config");
|
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
|
||||||
// Find buttons with SVG icons (copy, save, etc.)
|
|
||||||
const iconButtons = frigateApp.page.locator("button:has(svg)");
|
|
||||||
const count = await iconButtons.count();
|
|
||||||
if (count > 0) {
|
|
||||||
// Click the first icon button (likely copy)
|
|
||||||
await iconButtons.first().click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,97 +1,265 @@
|
|||||||
/**
|
/**
|
||||||
* Explore page tests -- HIGH tier.
|
* Explore page tests -- HIGH tier.
|
||||||
*
|
*
|
||||||
* Tests search input with text entry and clearing, camera filter popover
|
* Search input, Enter submission, camera filter popover (desktop),
|
||||||
* opening with camera names, and content rendering with mock events.
|
* event grid rendering with mocked events, mobile filter drawer.
|
||||||
|
*
|
||||||
|
* DEVIATION NOTES (from original plan):
|
||||||
|
*
|
||||||
|
* 1. Search input: InputWithTags is only rendered when
|
||||||
|
* config.semantic_search.enabled is true. Tests that exercise the search
|
||||||
|
* input override the config accordingly, using model:"genai" (not in the
|
||||||
|
* JINA_EMBEDDING_MODELS list) so the page skips local model-state checks
|
||||||
|
* and renders without waiting for model-download WS messages.
|
||||||
|
*
|
||||||
|
* 2. Filter buttons (Cameras, Labels, More Filters): SearchFilterGroup is
|
||||||
|
* only rendered when hasExistingSearch is true. Tests navigate with a URL
|
||||||
|
* param (?labels=person) to surface the filter bar.
|
||||||
|
*
|
||||||
|
* 3. Cameras button: accessible name is "Cameras Filter" (aria-label), not
|
||||||
|
* "All Cameras" (inner text). Use getByLabel("Cameras Filter").
|
||||||
|
*
|
||||||
|
* 4. Labels: button accessible name is "Labels" (aria-label). With
|
||||||
|
* ?labels=person, the text shows "Person" rather than "All Labels".
|
||||||
|
* Use getByLabel("Labels").
|
||||||
|
*
|
||||||
|
* 5. Sub-labels / Zones: These live inside the "More Filters" dialog
|
||||||
|
* (SearchFilterDialog), not as standalone top-level buttons. The Zones
|
||||||
|
* test opens "More Filters" and asserts zone content from config.
|
||||||
|
*
|
||||||
|
* 6. similarity_search_id URL param: This param does not exist in the app.
|
||||||
|
* The correct entrypoint for similarity search is
|
||||||
|
* ?search_type=similarity&event_id=<id>. The test uses this URL and
|
||||||
|
* polls for the resulting API request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
|
||||||
test.describe("Explore Page - Search @high", () => {
|
// Semantic search config override used by multiple tests. Using model:
|
||||||
test("explore page renders with filter buttons", async ({ frigateApp }) => {
|
// "genai" (not in JINA_EMBEDDING_MODELS) sets isGenaiEmbeddings=true, which
|
||||||
await frigateApp.goto("/explore");
|
// skips local model-state checks and lets the page render without waiting for
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
// individual model download WS messages. The WS mocker returns a completed
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
// reindexState so !reindexState is false and the loading gate clears.
|
||||||
await expect(buttons.first()).toBeVisible({ timeout: 10_000 });
|
const SEMANTIC_SEARCH_CONFIG = {
|
||||||
});
|
semantic_search: { enabled: true, model: "genai" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
test("search input accepts text and can be cleared", async ({
|
// ---------------------------------------------------------------------------
|
||||||
frigateApp,
|
// Search input (semantic_search must be enabled)
|
||||||
}) => {
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe("Explore — search @high", () => {
|
||||||
|
test("search input accepts text and clears", async ({ frigateApp }) => {
|
||||||
|
// Enable semantic search so InputWithTags renders.
|
||||||
|
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||||
await frigateApp.goto("/explore");
|
await frigateApp.goto("/explore");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const searchInput = frigateApp.page.locator("input").first();
|
const searchInput = frigateApp.page.locator("input").first();
|
||||||
if (await searchInput.isVisible()) {
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
await searchInput.fill("person");
|
await searchInput.fill("person");
|
||||||
await expect(searchInput).toHaveValue("person");
|
await expect(searchInput).toHaveValue("person");
|
||||||
await searchInput.fill("");
|
await searchInput.fill("");
|
||||||
await expect(searchInput).toHaveValue("");
|
await expect(searchInput).toHaveValue("");
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("search input submits on Enter", async ({ frigateApp }) => {
|
test("Enter submission does not crash the page", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||||
await frigateApp.goto("/explore");
|
await frigateApp.goto("/explore");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const searchInput = frigateApp.page.locator("input").first();
|
const searchInput = frigateApp.page.locator("input").first();
|
||||||
if (await searchInput.isVisible()) {
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
await searchInput.fill("car in driveway");
|
await searchInput.fill("car in driveway");
|
||||||
await searchInput.press("Enter");
|
await searchInput.press("Enter");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
// Page should not crash after search submit
|
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Explore Page - Filters @high", () => {
|
// ---------------------------------------------------------------------------
|
||||||
test("camera filter button opens popover with camera names (desktop)", async ({
|
// Filter bar — desktop only
|
||||||
frigateApp,
|
// Filter buttons appear once hasExistingSearch is true (URL params present).
|
||||||
}) => {
|
// ---------------------------------------------------------------------------
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
test.describe("Explore — filters (desktop) @high", () => {
|
||||||
return;
|
test.skip(({ frigateApp }) => frigateApp.isMobile, "Desktop popovers");
|
||||||
}
|
|
||||||
await frigateApp.goto("/explore");
|
test("Cameras popover lists configured cameras", async ({ frigateApp }) => {
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
// Navigate with a labels filter param so the filter bar renders.
|
||||||
const camerasBtn = frigateApp.page.getByRole("button", {
|
await frigateApp.goto("/explore?labels=person");
|
||||||
name: /cameras/i,
|
// CamerasFilterButton has aria-label="Cameras Filter". Use getByLabel to
|
||||||
});
|
// match against the accessible name (not the inner "All Cameras" text).
|
||||||
if (await camerasBtn.isVisible().catch(() => false)) {
|
const camerasBtn = frigateApp.page.getByLabel("Cameras Filter").first();
|
||||||
|
await expect(camerasBtn).toBeVisible({ timeout: 10_000 });
|
||||||
await camerasBtn.click();
|
await camerasBtn.click();
|
||||||
await frigateApp.page.waitForTimeout(500);
|
// DropdownMenu on desktop wraps content in data-radix-popper-content-wrapper.
|
||||||
const popover = frigateApp.page.locator(
|
const popover = frigateApp.page.locator(
|
||||||
"[data-radix-popper-content-wrapper]",
|
"[data-radix-popper-content-wrapper]",
|
||||||
);
|
);
|
||||||
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
|
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
|
||||||
// Camera names from config should be in the popover
|
|
||||||
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filter button opens and closes overlay cleanly", async ({
|
test("Labels filter lists labels from config", async ({ frigateApp }) => {
|
||||||
|
// Navigate with an existing search so the filter bar renders.
|
||||||
|
await frigateApp.goto("/explore?labels=person");
|
||||||
|
// GeneralFilterButton has aria-label="Labels". With ?labels=person the
|
||||||
|
// button text shows "Person" (the selected label), but the aria-label
|
||||||
|
// remains "Labels".
|
||||||
|
const labelsBtn = frigateApp.page.getByLabel("Labels").first();
|
||||||
|
await expect(labelsBtn).toBeVisible({ timeout: 10_000 });
|
||||||
|
await labelsBtn.click();
|
||||||
|
// PlatformAwareDialog renders on desktop as a dropdown/popover overlay.
|
||||||
|
const overlay = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||||
|
);
|
||||||
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
// "person" is already selected (it's in the URL); assert it appears in
|
||||||
|
// the overlay content.
|
||||||
|
await expect(overlay.first().getByText(/person/i)).toBeVisible();
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sub-labels filter renders inside More Filters dialog", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/explore");
|
// Sub-labels live inside SearchFilterDialog ("More Filters" button).
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
// With sub_labels mocked as [], the section still renders its heading.
|
||||||
const firstButton = frigateApp.page.locator("#pageRoot button").first();
|
await frigateApp.page.route("**/api/sub_labels**", (route) =>
|
||||||
await expect(firstButton).toBeVisible({ timeout: 5_000 });
|
route.fulfill({ json: [] }),
|
||||||
await firstButton.click();
|
);
|
||||||
await frigateApp.page.waitForTimeout(500);
|
await frigateApp.goto("/explore?labels=person");
|
||||||
|
const moreBtn = frigateApp.page.getByLabel("More Filters").first();
|
||||||
|
await expect(moreBtn).toBeVisible({ timeout: 10_000 });
|
||||||
|
await moreBtn.click();
|
||||||
|
const overlay = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||||
|
);
|
||||||
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
// "Sub Labels" section heading always renders inside the dialog.
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText(/sub.?label/i).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Zones filter lists configured zones inside More Filters dialog", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Override config to guarantee a known zone on front_door.
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: {
|
||||||
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
zones: {
|
||||||
|
front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/explore?labels=person");
|
||||||
|
const moreBtn = frigateApp.page.getByLabel("More Filters").first();
|
||||||
|
await expect(moreBtn).toBeVisible({ timeout: 10_000 });
|
||||||
|
await moreBtn.click();
|
||||||
|
const overlay = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||||
|
);
|
||||||
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
await expect(frigateApp.page.getByText(/front.?yard/i)).toBeVisible();
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
await frigateApp.page.waitForTimeout(300);
|
|
||||||
// Page is still functional after open/close cycle
|
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Explore Page - Content @high", () => {
|
// ---------------------------------------------------------------------------
|
||||||
test("explore page shows content with mock events", async ({
|
// Content
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe("Explore — content @high", () => {
|
||||||
|
test("page renders with mock events", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/explore");
|
||||||
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator("#pageRoot button").first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty events renders without crash", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ events: [] });
|
||||||
|
await frigateApp.goto("/explore");
|
||||||
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search fires a /api/events request with the query", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||||
|
const eventsRequests: string[] = [];
|
||||||
|
frigateApp.page.on("request", (req) => {
|
||||||
|
const url = req.url();
|
||||||
|
if (/\/api\/events/.test(url)) eventsRequests.push(url);
|
||||||
|
});
|
||||||
await frigateApp.goto("/explore");
|
await frigateApp.goto("/explore");
|
||||||
await frigateApp.page.waitForTimeout(3000);
|
const searchInput = frigateApp.page.locator("input").first();
|
||||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
expect(pageText?.length).toBeGreaterThan(0);
|
|
||||||
|
const before = eventsRequests.length;
|
||||||
|
await searchInput.fill("person in driveway");
|
||||||
|
await searchInput.press("Enter");
|
||||||
|
await expect
|
||||||
|
.poll(() => eventsRequests.length > before, { timeout: 5_000 })
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Similarity search URL param
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe("Explore — similarity search (desktop) @high", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Similarity trigger is hover-based; desktop-focused",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("URL similarity search params fetch events", async ({ frigateApp }) => {
|
||||||
|
const eventsRequests: string[] = [];
|
||||||
|
frigateApp.page.on("request", (req) => {
|
||||||
|
const url = req.url();
|
||||||
|
if (/\/api\/events/.test(url)) eventsRequests.push(url);
|
||||||
|
});
|
||||||
|
// The app uses search_type=similarity&event_id=<id> (not
|
||||||
|
// similarity_search_id). This exercises the same similarity search code
|
||||||
|
// path as clicking "Find Similar" on a thumbnail.
|
||||||
|
// Use a valid event-id format (timestamp.fractional-alphanumeric).
|
||||||
|
await frigateApp.goto(
|
||||||
|
"/explore?search_type=similarity&event_id=1712412000.000000-abc123",
|
||||||
|
);
|
||||||
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
// Poll to allow any pending SWR fetch to complete and be captured.
|
||||||
|
await expect
|
||||||
|
.poll(() => eventsRequests.length, { timeout: 5_000 })
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe("Explore — mobile @high @mobile", () => {
|
||||||
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
test("search input is focusable at mobile viewport", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||||
|
await frigateApp.goto("/explore");
|
||||||
|
const searchInput = frigateApp.page.locator("input").first();
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
|
await searchInput.focus();
|
||||||
|
await expect(searchInput).toBeFocused();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,114 @@
|
|||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import {
|
||||||
|
expectBodyInteractive,
|
||||||
|
waitForBodyInteractive,
|
||||||
|
} from "../helpers/overlay-interaction";
|
||||||
|
|
||||||
|
test.describe("Export Page - Delete race @high", () => {
|
||||||
|
// Empirical guard for radix-ui/primitives#3445: when a modal DropdownMenu
|
||||||
|
// opens an AlertDialog and the AlertDialog's confirm action causes the
|
||||||
|
// parent's optimistic cache update to unmount the card, we want to know
|
||||||
|
// whether the deduped react-dismissable-layer (1.1.11) handles the
|
||||||
|
// pointer-events stack cleanup or whether `modal={false}` is still
|
||||||
|
// required on the DropdownMenu. The classic "canonical" pattern, distinct
|
||||||
|
// from the FaceSelectionDialog auto-unmount race already covered by
|
||||||
|
// face-library.spec.ts.
|
||||||
|
test("deleting an export via dropdown→alert→confirm leaves body interactive", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
if (frigateApp.isMobile) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialExports = [
|
||||||
|
{
|
||||||
|
id: "export-race-001",
|
||||||
|
camera: "front_door",
|
||||||
|
name: "Race - Test Export",
|
||||||
|
date: 1775490731.3863528,
|
||||||
|
video_path: "/exports/export-race-001.mp4",
|
||||||
|
thumb_path: "/exports/export-race-001-thumb.jpg",
|
||||||
|
in_progress: false,
|
||||||
|
export_case_id: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
exports: initialExports,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flip /api/export to empty after the delete POST is observed so the
|
||||||
|
// page's SWR mutate sees the export gone.
|
||||||
|
await frigateApp.page.route("**/api/export**", async (route) => {
|
||||||
|
const payload = deleted ? [] : initialExports;
|
||||||
|
await route.fulfill({ json: payload });
|
||||||
|
});
|
||||||
|
await frigateApp.page.route("**/api/exports/delete", async (route) => {
|
||||||
|
deleted = true;
|
||||||
|
const delayMs = Number(
|
||||||
|
(globalThis as { process?: { env?: Record<string, string> } }).process
|
||||||
|
?.env?.DELETE_DELAY_MS ?? "100",
|
||||||
|
);
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await frigateApp.goto("/export");
|
||||||
|
await expect(frigateApp.page.getByText("Race - Test Export")).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open the kebab menu on the export card. The kebab uses the
|
||||||
|
// (misleading) aria-label "Edit name" from ExportCard's source — it
|
||||||
|
// wraps the FiMoreVertical icon. There is exactly one such button on
|
||||||
|
// the page once we have a single export rendered.
|
||||||
|
const kebab = frigateApp.page
|
||||||
|
.getByRole("button", { name: /edit name/i })
|
||||||
|
.first();
|
||||||
|
await expect(kebab).toBeVisible({ timeout: 5_000 });
|
||||||
|
await kebab.click();
|
||||||
|
|
||||||
|
const menu = frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
// Delete Export
|
||||||
|
await menu
|
||||||
|
.getByRole("menuitem", { name: /delete export/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// AlertDialog at page level. The confirm button's accessible name is
|
||||||
|
// "Delete Export" (its aria-label), the visible text is just "Delete".
|
||||||
|
const confirm = frigateApp.page.getByRole("alertdialog");
|
||||||
|
await expect(confirm).toBeVisible({ timeout: 3_000 });
|
||||||
|
await confirm
|
||||||
|
.getByRole("button", { name: /^delete export$/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// The card optimistically disappears, the dialog closes, and body
|
||||||
|
// pointer-events must come unstuck.
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText("Race - Test Export"),
|
||||||
|
).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
await waitForBodyInteractive(frigateApp.page, 5_000);
|
||||||
|
await expectBodyInteractive(frigateApp.page);
|
||||||
|
|
||||||
|
// Sanity: another page-level button still responds.
|
||||||
|
const newCase = frigateApp.page.getByRole("button", { name: /new case/i });
|
||||||
|
await expect(newCase).toBeVisible({ timeout: 3_000 });
|
||||||
|
await newCase.click();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("dialog").filter({ hasText: /create case/i }),
|
||||||
|
).toBeVisible({ timeout: 3_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("Export Page - Overview @high", () => {
|
test.describe("Export Page - Overview @high", () => {
|
||||||
test("renders uncategorized exports and case cards from mock data", async ({
|
test("renders uncategorized exports and case cards from mock data", async ({
|
||||||
|
|||||||
@ -1,32 +1,542 @@
|
|||||||
/**
|
/**
|
||||||
* Face Library page tests -- MEDIUM tier.
|
* Face Library page tests -- HIGH tier.
|
||||||
*
|
*
|
||||||
* Tests face grid rendering, empty state, and interactive controls.
|
* Collection selector, face tiles, grouped recent-recognition dialog
|
||||||
|
* (migrated from radix-overlay-regressions.spec.ts), and mobile
|
||||||
|
* library selector.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { type Locator } from "@playwright/test";
|
||||||
|
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||||
|
import {
|
||||||
|
basicFacesMock,
|
||||||
|
emptyFacesMock,
|
||||||
|
withGroupedTrainingAttempt,
|
||||||
|
} from "../fixtures/mock-data/faces";
|
||||||
|
import {
|
||||||
|
expectBodyInteractive,
|
||||||
|
waitForBodyInteractive,
|
||||||
|
} from "../helpers/overlay-interaction";
|
||||||
|
|
||||||
test.describe("Face Library @medium", () => {
|
const GROUPED_EVENT_ID = "1775487131.3863528-abc123";
|
||||||
test("face library page renders without crash", async ({ frigateApp }) => {
|
|
||||||
|
function groupedFacesMock() {
|
||||||
|
return withGroupedTrainingAttempt(basicFacesMock(), {
|
||||||
|
eventId: GROUPED_EVENT_ID,
|
||||||
|
attempts: [
|
||||||
|
{ timestamp: 1775487131.3863528, label: "unknown", score: 0.95 },
|
||||||
|
{ timestamp: 1775487132.3863528, label: "unknown", score: 0.91 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installGroupedFaces(app: FrigateApp) {
|
||||||
|
await app.api.install({
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: GROUPED_EVENT_ID,
|
||||||
|
label: "person",
|
||||||
|
sub_label: null,
|
||||||
|
camera: "front_door",
|
||||||
|
start_time: 1775487131.3863528,
|
||||||
|
end_time: 1775487161.3863528,
|
||||||
|
false_positive: false,
|
||||||
|
zones: ["front_yard"],
|
||||||
|
thumbnail: null,
|
||||||
|
has_clip: true,
|
||||||
|
has_snapshot: true,
|
||||||
|
retain_indefinitely: false,
|
||||||
|
plus_id: null,
|
||||||
|
model_hash: "abc123",
|
||||||
|
detector_type: "cpu",
|
||||||
|
model_type: "ssd",
|
||||||
|
data: {
|
||||||
|
top_score: 0.92,
|
||||||
|
score: 0.92,
|
||||||
|
region: [0.1, 0.1, 0.5, 0.8],
|
||||||
|
box: [0.2, 0.15, 0.45, 0.75],
|
||||||
|
area: 0.18,
|
||||||
|
ratio: 0.6,
|
||||||
|
type: "object",
|
||||||
|
path_data: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
faces: groupedFacesMock(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGroupedFaceDialog(app: FrigateApp): Promise<Locator> {
|
||||||
|
await installGroupedFaces(app);
|
||||||
|
await app.goto("/faces");
|
||||||
|
const groupedImage = app.page
|
||||||
|
.locator('img[src*="clips/faces/train/"]')
|
||||||
|
.first();
|
||||||
|
const groupedCard = groupedImage.locator("xpath=..");
|
||||||
|
await expect(groupedImage).toBeVisible({ timeout: 5_000 });
|
||||||
|
await groupedCard.click();
|
||||||
|
const dialog = app.page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: app.page.locator('img[src*="clips/faces/train/"]') })
|
||||||
|
.first();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(dialog.locator('img[src*="clips/faces/train/"]')).toHaveCount(2);
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the LibrarySelector dropdown (the single button at the top-left of
|
||||||
|
* the Face Library page) and returns the dropdown menu locator.
|
||||||
|
*
|
||||||
|
* The LibrarySelector is a single DropdownMenu whose trigger shows the
|
||||||
|
* current tab name + count (e.g. "Recent Recognitions (0)"). Named face
|
||||||
|
* collections (alice, bob, charlie) are items inside this dropdown.
|
||||||
|
*/
|
||||||
|
async function openLibraryDropdown(app: FrigateApp): Promise<Locator> {
|
||||||
|
// The trigger is the first button on the page with a parenthesised count.
|
||||||
|
const trigger = app.page
|
||||||
|
.getByRole("button")
|
||||||
|
.filter({ hasText: /\(\d+\)/ })
|
||||||
|
.first();
|
||||||
|
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||||
|
await trigger.click();
|
||||||
|
const menu = app.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Face Library — collection selector @high", () => {
|
||||||
|
test("selector shows named face collections", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||||
|
await frigateApp.goto("/faces");
|
||||||
|
// Named collections appear in the LibrarySelector dropdown.
|
||||||
|
const menu = await openLibraryDropdown(frigateApp);
|
||||||
|
await expect(menu.getByText(/alice/i).first()).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty state renders when no faces exist", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ faces: emptyFacesMock() });
|
||||||
await frigateApp.goto("/faces");
|
await frigateApp.goto("/faces");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator('img[src*="/clips/faces/"]'),
|
||||||
|
).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("face library shows empty state with no faces", async ({
|
test("tiles render for each named collection", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/faces");
|
await frigateApp.goto("/faces");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
// Open the dropdown — collections list shows "alice (2)" and "bob (1)".
|
||||||
// With empty faces mock, should show empty state or content
|
const menu = await openLibraryDropdown(frigateApp);
|
||||||
const text = await frigateApp.page.textContent("#pageRoot");
|
await expect(
|
||||||
expect(text?.length).toBeGreaterThan(0);
|
menu.locator('[role="menuitem"]').filter({ hasText: /alice/i }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(
|
||||||
test("face library has interactive buttons", async ({ frigateApp }) => {
|
menu.locator('[role="menuitem"]').filter({ hasText: /bob/i }).first(),
|
||||||
await frigateApp.goto("/faces");
|
).toBeVisible();
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
});
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
});
|
||||||
const count = await buttons.count();
|
|
||||||
expect(count).toBeGreaterThanOrEqual(0);
|
test.describe("Face Library — delete flow (desktop) @high", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Delete action menu is desktop-focused",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("deleting a collection fires POST /faces/<name>/delete", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
let deleteUrl: string | null = null;
|
||||||
|
let deleteBody: unknown = null;
|
||||||
|
// Install base mocks first, then register our more-specific route AFTER
|
||||||
|
// so it takes priority over the ApiMocker catch-all (Playwright LIFO order).
|
||||||
|
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||||
|
await frigateApp.page.route(
|
||||||
|
/\/api\/faces\/[^/]+\/delete/,
|
||||||
|
async (route) => {
|
||||||
|
deleteUrl = route.request().url();
|
||||||
|
deleteBody = route.request().postDataJSON();
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/faces");
|
||||||
|
|
||||||
|
// Open the LibrarySelector dropdown and click the trash icon next
|
||||||
|
// to the alice row. The trash icon is a ghost-variant Button inside
|
||||||
|
// the DropdownMenuItem — it becomes visible on hover/focus.
|
||||||
|
const menu = await openLibraryDropdown(frigateApp);
|
||||||
|
const aliceRow = menu
|
||||||
|
.locator('[role="menuitem"]')
|
||||||
|
.filter({ hasText: /alice/i })
|
||||||
|
.first();
|
||||||
|
await expect(aliceRow).toBeVisible({ timeout: 5_000 });
|
||||||
|
// Hover first to make hover-only opacity-0 buttons visible.
|
||||||
|
await aliceRow.hover();
|
||||||
|
// The icon buttons have no aria-label or title. The row renders exactly
|
||||||
|
// two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2
|
||||||
|
// (delete). This order is determined by FaceLibrary.tsx and is stable.
|
||||||
|
const trashBtn = aliceRow.locator("button").nth(1);
|
||||||
|
await trashBtn.click();
|
||||||
|
|
||||||
|
// The delete confirmation is a Dialog (not AlertDialog) in this flow.
|
||||||
|
const dialog = frigateApp.page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
await dialog
|
||||||
|
.getByRole("button", { name: /delete/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => deleteUrl, { timeout: 5_000 })
|
||||||
|
.toMatch(/\/faces\/alice\/delete/);
|
||||||
|
expect(deleteBody).toMatchObject({ ids: expect.any(Array) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Face Library — rename flow (desktop) @high", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Rename action menu is desktop-focused",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("renaming a collection fires PUT /faces/<name>/rename", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
let renameUrl: string | null = null;
|
||||||
|
let renameBody: unknown = null;
|
||||||
|
// Install base mocks first, then register our more-specific route AFTER
|
||||||
|
// so it takes priority over the ApiMocker catch-all (Playwright LIFO order).
|
||||||
|
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||||
|
await frigateApp.page.route(
|
||||||
|
/\/api\/faces\/[^/]+\/rename/,
|
||||||
|
async (route) => {
|
||||||
|
renameUrl = route.request().url();
|
||||||
|
renameBody = route.request().postDataJSON();
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/faces");
|
||||||
|
|
||||||
|
// Open the LibrarySelector dropdown and click the pencil (rename) icon
|
||||||
|
// next to alice. The icon is a ghost Button inside the DropdownMenuItem.
|
||||||
|
const menu = await openLibraryDropdown(frigateApp);
|
||||||
|
const aliceRow = menu
|
||||||
|
.locator('[role="menuitem"]')
|
||||||
|
.filter({ hasText: /alice/i })
|
||||||
|
.first();
|
||||||
|
await expect(aliceRow).toBeVisible({ timeout: 5_000 });
|
||||||
|
await aliceRow.hover();
|
||||||
|
// The icon buttons have no aria-label or title. The row renders exactly
|
||||||
|
// two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2
|
||||||
|
// (delete). This order is determined by FaceLibrary.tsx and is stable.
|
||||||
|
const pencilBtn = aliceRow.locator("button").nth(0);
|
||||||
|
await pencilBtn.click();
|
||||||
|
|
||||||
|
// TextEntryDialog — fill the input and confirm.
|
||||||
|
const dialog = frigateApp.page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
await dialog.locator("input").first().fill("alice_renamed");
|
||||||
|
await dialog
|
||||||
|
.getByRole("button", { name: /save|rename|confirm/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => renameUrl, { timeout: 5_000 })
|
||||||
|
.toMatch(/\/faces\/alice\/rename/);
|
||||||
|
expect(renameBody).toEqual({ new_name: "alice_renamed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Face Library — upload flow @high", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Upload button has no accessible text on mobile — icon-only on narrow viewports",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Upload button opens the upload dialog", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||||
|
await frigateApp.goto("/faces");
|
||||||
|
|
||||||
|
// Navigate to the alice tab by opening the dropdown and clicking alice.
|
||||||
|
const menu = await openLibraryDropdown(frigateApp);
|
||||||
|
await menu
|
||||||
|
.locator('[role="menuitem"]')
|
||||||
|
.filter({ hasText: /alice/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// After switching to alice, the Upload Image button appears in the toolbar.
|
||||||
|
const uploadBtn = frigateApp.page
|
||||||
|
.getByRole("button")
|
||||||
|
.filter({ hasText: /upload/i })
|
||||||
|
.first();
|
||||||
|
await expect(uploadBtn).toBeVisible({ timeout: 5_000 });
|
||||||
|
await uploadBtn.click();
|
||||||
|
|
||||||
|
// UploadImageDialog renders a file input + confirm button.
|
||||||
|
const dialog = frigateApp.page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(dialog.locator('input[type="file"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("FaceSelectionDialog @high", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Grouped dropdown flow is desktop-only",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("reclassify dropdown selects a name and closes cleanly", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Migrated from radix-overlay-regressions.spec.ts.
|
||||||
|
const dialog = await openGroupedFaceDialog(frigateApp);
|
||||||
|
const triggers = dialog.locator('[aria-haspopup="menu"]');
|
||||||
|
await expect(triggers).toHaveCount(2);
|
||||||
|
|
||||||
|
await triggers.first().click();
|
||||||
|
const menu = frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
|
||||||
|
|
||||||
|
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
const tooltipVisible = await frigateApp.page
|
||||||
|
.locator('[role="tooltip"]')
|
||||||
|
.filter({ hasText: /train face/i })
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
expect(
|
||||||
|
tooltipVisible,
|
||||||
|
"Train Face tooltip popped after dropdown closed — focus-restore regression",
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("second dropdown open accepts typeahead keyboard input", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Migrated from radix-overlay-regressions.spec.ts.
|
||||||
|
const dialog = await openGroupedFaceDialog(frigateApp);
|
||||||
|
const triggers = dialog.locator('[aria-haspopup="menu"]');
|
||||||
|
await expect(triggers).toHaveCount(2);
|
||||||
|
|
||||||
|
await triggers.first().click();
|
||||||
|
let menu = frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||||
|
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
|
||||||
|
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
await triggers.nth(1).click();
|
||||||
|
menu = frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await frigateApp.page.keyboard.press("c");
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
frigateApp.page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.activeElement?.textContent?.trim().toLowerCase() ?? "",
|
||||||
|
),
|
||||||
|
{ timeout: 2_000 },
|
||||||
|
)
|
||||||
|
.toMatch(/^charlie/);
|
||||||
|
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifying the last image in a group leaves body interactive", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Regression guard for the stuck body pointer-events bug when the
|
||||||
|
// last image in a grouped-recognition detail Dialog is classified.
|
||||||
|
// Tracked upstream at radix-ui/primitives#3445.
|
||||||
|
//
|
||||||
|
// Root cause: when the user clicks a FaceSelectionDialog menu item,
|
||||||
|
// the modal DropdownMenu enters its exit animation (Radix's Presence
|
||||||
|
// keeps it in the DOM with data-state="closed" until animationend).
|
||||||
|
// While that is in flight the classify axios resolves, SWR removes
|
||||||
|
// the image from /api/faces, the parent's map no longer renders the
|
||||||
|
// grouped card, and React unmounts the subtree — including the still-
|
||||||
|
// animating DropdownMenu's Presence container. DismissableLayer's
|
||||||
|
// shared modal-layer stack can't reconcile the interrupted exit, so
|
||||||
|
// the `body { pointer-events: none }` entry it put on mount is never
|
||||||
|
// popped and the rest of the UI becomes unclickable.
|
||||||
|
//
|
||||||
|
// The fix is `modal={false}` on the FaceSelectionDialog's
|
||||||
|
// DropdownMenu (desktop path only). With modal=false the DropdownMenu
|
||||||
|
// never puts an entry on DismissableLayer's body-pointer-events stack
|
||||||
|
// in the first place, so there's nothing to leak when its Presence is
|
||||||
|
// torn down mid-animation. The Radix-community-documented workaround
|
||||||
|
// for #3445.
|
||||||
|
//
|
||||||
|
// The bug only reproduces when the mock resolves fast enough that
|
||||||
|
// the parent unmounts before the dropdown's exit animation finishes.
|
||||||
|
// Measured window via a 3x sweep on the pre-fix build: 0–200 ms
|
||||||
|
// triggers it; 300 ms+ no longer reproduces. Production LAN networks
|
||||||
|
// sit comfortably inside the bad window, while `npm run dev` seems
|
||||||
|
// to mask it via React StrictMode's double-effect scheduling.
|
||||||
|
const EVENT_ID = "1775487131.3863528-race";
|
||||||
|
const initialFaces = withGroupedTrainingAttempt(basicFacesMock(), {
|
||||||
|
eventId: EVENT_ID,
|
||||||
|
attempts: [
|
||||||
|
{ timestamp: 1775487131.3863528, label: "unknown", score: 0.95 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let classified = false;
|
||||||
|
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
faces: initialFaces,
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: EVENT_ID,
|
||||||
|
label: "person",
|
||||||
|
sub_label: null,
|
||||||
|
camera: "front_door",
|
||||||
|
start_time: 1775487131.3863528,
|
||||||
|
end_time: 1775487161.3863528,
|
||||||
|
false_positive: false,
|
||||||
|
zones: ["front_yard"],
|
||||||
|
thumbnail: null,
|
||||||
|
has_clip: true,
|
||||||
|
has_snapshot: true,
|
||||||
|
retain_indefinitely: false,
|
||||||
|
plus_id: null,
|
||||||
|
model_hash: "abc123",
|
||||||
|
detector_type: "cpu",
|
||||||
|
model_type: "ssd",
|
||||||
|
data: {
|
||||||
|
top_score: 0.92,
|
||||||
|
score: 0.92,
|
||||||
|
region: [0.1, 0.1, 0.5, 0.8],
|
||||||
|
box: [0.2, 0.15, 0.45, 0.75],
|
||||||
|
area: 0.18,
|
||||||
|
ratio: 0.6,
|
||||||
|
type: "object",
|
||||||
|
path_data: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-route /api/faces to flip to the "train empty" payload once the
|
||||||
|
// classify POST has been received. Registered AFTER installDefaults so
|
||||||
|
// Playwright's LIFO route matching hits this handler first.
|
||||||
|
await frigateApp.page.route("**/api/faces", async (route) => {
|
||||||
|
const payload = classified ? basicFacesMock() : initialFaces;
|
||||||
|
await route.fulfill({ json: payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hold the classify POST briefly. The race opens when the parent
|
||||||
|
// unmounts before the dropdown's exit animation finishes (~200ms
|
||||||
|
// in Radix). 100ms keeps us comfortably inside that window and
|
||||||
|
// reliably triggered the bug in a 3x sweep across 0/50/100/200ms
|
||||||
|
// on the pre-fix build. CLASSIFY_DELAY_MS overrides for local sweeps.
|
||||||
|
const delayMs = Number(
|
||||||
|
(globalThis as { process?: { env?: Record<string, string> } }).process
|
||||||
|
?.env?.CLASSIFY_DELAY_MS ?? "100",
|
||||||
|
);
|
||||||
|
await frigateApp.page.route(
|
||||||
|
"**/api/faces/train/*/classify",
|
||||||
|
async (route) => {
|
||||||
|
classified = true;
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await frigateApp.goto("/faces");
|
||||||
|
|
||||||
|
// Open the grouped detail Dialog.
|
||||||
|
const groupedImage = frigateApp.page
|
||||||
|
.locator('img[src*="clips/faces/train/"]')
|
||||||
|
.first();
|
||||||
|
await expect(groupedImage).toBeVisible({ timeout: 5_000 });
|
||||||
|
await groupedImage.locator("xpath=..").click();
|
||||||
|
const dialog = frigateApp.page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({
|
||||||
|
has: frigateApp.page.locator('img[src*="clips/faces/train/"]'),
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Single attempt → single `+` trigger.
|
||||||
|
const triggers = dialog.locator('[aria-haspopup="menu"]');
|
||||||
|
await expect(triggers).toHaveCount(1);
|
||||||
|
await triggers.first().click();
|
||||||
|
|
||||||
|
const menu = frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||||
|
await menu.getByRole("menuitem", { name: /^alice$/i }).click();
|
||||||
|
|
||||||
|
// The Dialog must leave the tree cleanly, and body must recover.
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Give Radix's exit animation + cleanup a comfortable margin on top of
|
||||||
|
// the ~300ms simulated network delay.
|
||||||
|
await waitForBodyInteractive(frigateApp.page, 5_000);
|
||||||
|
await expectBodyInteractive(frigateApp.page);
|
||||||
|
|
||||||
|
// User-visible confirmation: click something outside the dialog
|
||||||
|
// and assert it actually responds.
|
||||||
|
const librarySelector = frigateApp.page
|
||||||
|
.getByRole("button")
|
||||||
|
.filter({ hasText: /\(\d+\)/ })
|
||||||
|
.first();
|
||||||
|
await librarySelector.click();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 3_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Face Library — mobile @high @mobile", () => {
|
||||||
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
test("mobile library selector dropdown closes cleanly on Escape", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Migrated from radix-overlay-regressions.spec.ts.
|
||||||
|
await installGroupedFaces(frigateApp);
|
||||||
|
await frigateApp.goto("/faces");
|
||||||
|
|
||||||
|
const selector = frigateApp.page
|
||||||
|
.getByRole("button")
|
||||||
|
.filter({ hasText: /\(\d+\)/ })
|
||||||
|
.first();
|
||||||
|
await expect(selector).toBeVisible({ timeout: 5_000 });
|
||||||
|
await selector.click();
|
||||||
|
|
||||||
|
const menu = frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
await waitForBodyInteractive(frigateApp.page);
|
||||||
|
await expectBodyInteractive(frigateApp.page);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,59 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* Live page tests -- CRITICAL tier.
|
* Live page tests -- CRITICAL tier.
|
||||||
*
|
*
|
||||||
* Tests camera dashboard rendering, camera card clicks, single camera view
|
* Dashboard grid, single-camera controls, feature toggles (with WS
|
||||||
* with named controls, feature toggle behavior, context menu, and mobile layout.
|
* frame assertions), context menu, birdseye, and mobile layout.
|
||||||
|
* Also absorbs the PTZ preset-dropdown regression tests from the
|
||||||
|
* now-deleted ptz-overlay.spec.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { LivePage } from "../pages/live.page";
|
||||||
|
import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames";
|
||||||
|
import {
|
||||||
|
expectBodyInteractive,
|
||||||
|
waitForBodyInteractive,
|
||||||
|
} from "../helpers/overlay-interaction";
|
||||||
|
|
||||||
|
const PTZ_CAMERA = "front_door";
|
||||||
|
const PRESET_NAMES = ["home", "driveway", "front_porch"];
|
||||||
|
|
||||||
test.describe("Live Dashboard @critical", () => {
|
test.describe("Live Dashboard @critical", () => {
|
||||||
test("dashboard renders all configured cameras by name", async ({
|
test("every configured camera renders on the dashboard", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
|
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
|
||||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||||
await expect(
|
await expect(live.cameraCard(cam)).toBeVisible({ timeout: 10_000 });
|
||||||
frigateApp.page.locator(`[data-camera='${cam}']`),
|
|
||||||
).toBeVisible({ timeout: 10_000 });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking camera card opens single camera view via hash", async ({
|
test("clicking a camera card opens the single-camera view via hash", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const card = frigateApp.page.locator("[data-camera='front_door']").first();
|
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
|
||||||
await card.click({ timeout: 10_000 });
|
await live.cameraCard("front_door").first().click({ timeout: 10_000 });
|
||||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("back button returns from single camera to dashboard", async ({
|
test("birdseye route renders without crash", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// First navigate to dashboard so there's history to go back to
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
// Click a camera to enter single view
|
|
||||||
const card = frigateApp.page.locator("[data-camera='front_door']").first();
|
|
||||||
await card.click({ timeout: 10_000 });
|
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
// Now click Back to return to dashboard
|
|
||||||
const backBtn = frigateApp.page.getByText("Back", { exact: true });
|
|
||||||
if (await backBtn.isVisible().catch(() => false)) {
|
|
||||||
await backBtn.click();
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
}
|
|
||||||
// Should be back on the dashboard with cameras visible
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator("[data-camera='front_door']"),
|
|
||||||
).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("birdseye view loads without crash", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/#birdseye");
|
await frigateApp.goto("/#birdseye");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||||
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("empty group shows fallback content", async ({ frigateApp }) => {
|
test("empty group shows fallback content", async ({ frigateApp }) => {
|
||||||
@ -63,191 +51,239 @@ test.describe("Live Dashboard @critical", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Live Single Camera - Controls @critical", () => {
|
test.describe("Live Single Camera — desktop controls @critical", () => {
|
||||||
test("single camera view shows Back and History buttons (desktop)", async ({
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Desktop-only header controls",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("single-camera view shows Back and History buttons", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip(); // On mobile, buttons may show icons only
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
await frigateApp.goto("/#front_door");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const live = new LivePage(frigateApp.page, true);
|
||||||
// Back and History are visible text buttons in the header
|
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
|
||||||
await expect(
|
await expect(live.historyButton).toBeVisible();
|
||||||
frigateApp.page.getByText("Back", { exact: true }),
|
|
||||||
).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("History", { exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single camera view shows feature toggle icons (desktop)", async ({
|
test("feature toggles render (at least 3)", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
await frigateApp.goto("/#front_door");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const live = new LivePage(frigateApp.page, true);
|
||||||
// Feature toggles are CameraFeatureToggle components rendered as divs
|
// Wait for the single-camera header to render before counting toggles.
|
||||||
// with bg-selected (active) or bg-secondary (inactive) classes
|
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
|
||||||
// Count the toggles - should have at least detect, recording, snapshots
|
await expect(live.featureToggles.first()).toBeVisible({ timeout: 5_000 });
|
||||||
const toggles = frigateApp.page.locator(
|
const count = await live.featureToggles.count();
|
||||||
".flex.flex-col.items-center.justify-center.bg-selected, .flex.flex-col.items-center.justify-center.bg-secondary",
|
|
||||||
);
|
|
||||||
const count = await toggles.count();
|
|
||||||
expect(count).toBeGreaterThanOrEqual(3);
|
expect(count).toBeGreaterThanOrEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking a feature toggle changes its visual state (desktop)", async ({
|
test("clicking a feature toggle sends the matching WS frame", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
await installWsFrameCapture(frigateApp.page);
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
await frigateApp.goto("/#front_door");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const live = new LivePage(frigateApp.page, true);
|
||||||
// Find active toggles (bg-selected class = feature is ON)
|
// Wait for feature toggles to render (WS camera_activity must arrive first).
|
||||||
const activeToggles = frigateApp.page.locator(
|
await expect(live.activeFeatureToggles.first()).toBeVisible({
|
||||||
".flex.flex-col.items-center.justify-center.bg-selected",
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
const activeBefore = await live.activeFeatureToggles.count();
|
||||||
|
expect(activeBefore).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await live.activeFeatureToggles.first().click();
|
||||||
|
|
||||||
|
// The toggle dispatches a frame on <camera>/<feature>/set — match on
|
||||||
|
// front_door/ prefix + /set suffix (any feature).
|
||||||
|
await waitForWsFrame(
|
||||||
|
frigateApp.page,
|
||||||
|
(frame) => frame.includes("front_door/") && frame.includes("/set"),
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"feature toggle should dispatch a <camera>/<feature>/set frame",
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const initialCount = await activeToggles.count();
|
|
||||||
if (initialCount > 0) {
|
|
||||||
// Click the first active toggle to disable it
|
|
||||||
await activeToggles.first().click();
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
// After WS mock echoes back new state, count should decrease
|
|
||||||
const newCount = await activeToggles.count();
|
|
||||||
expect(newCount).toBeLessThan(initialCount);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("settings gear button opens dropdown (desktop)", async ({
|
test("keyboard shortcut f does not crash", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
// Find the gear icon button (last button-like element in header)
|
|
||||||
// The settings gear opens a dropdown with Stream, Play in background, etc.
|
|
||||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
|
||||||
const count = await gearButtons.count();
|
|
||||||
// Click the last one (gear icon is typically last in the header)
|
|
||||||
if (count > 0) {
|
|
||||||
await gearButtons.last().click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
// A dropdown or drawer should appear
|
|
||||||
const overlay = frigateApp.page.locator(
|
|
||||||
'[role="menu"], [data-radix-menu-content], [role="dialog"]',
|
|
||||||
);
|
|
||||||
const visible = await overlay
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
if (visible) {
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keyboard shortcut f does not crash on desktop", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
await frigateApp.page.keyboard.press("f");
|
await frigateApp.page.keyboard.press("f");
|
||||||
await frigateApp.page.waitForTimeout(500);
|
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||||
|
// Note: headless Chromium rejects fullscreen requests without a user
|
||||||
|
// gesture, so document.fullscreenElement cannot be asserted reliably
|
||||||
|
// in e2e. We assert the keypress doesn't crash the app; real
|
||||||
|
// fullscreen behavior is covered by manual testing.
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings gear opens a dropdown with Stream/Play menu items", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
// Wait for the single-camera view to render — use the Back button
|
||||||
|
// as a deterministic marker.
|
||||||
|
const live = new LivePage(frigateApp.page, true);
|
||||||
|
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// The gear icon button is the last button-like element in the
|
||||||
|
// single-camera header. Clicking it opens a Radix dropdown.
|
||||||
|
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||||
|
const count = await gearButtons.count();
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
await gearButtons.last().click();
|
||||||
|
|
||||||
|
const menu = frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Live Context Menu (desktop) @critical", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Right-click is desktop-only",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("right-click opens the context menu", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
const live = new LivePage(frigateApp.page, true);
|
||||||
|
const menu = await live.openContextMenuOn("front_door");
|
||||||
|
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context menu closes on Escape and leaves body interactive", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
const live = new LivePage(frigateApp.page, true);
|
||||||
|
const menu = await live.openContextMenuOn("front_door");
|
||||||
|
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
await expect(menu).not.toBeVisible();
|
||||||
|
await waitForBodyInteractive(frigateApp.page);
|
||||||
|
await expectBodyInteractive(frigateApp.page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Live PTZ preset dropdown @critical", () => {
|
||||||
|
// Migrated from ptz-overlay.spec.ts. Guards:
|
||||||
|
// 1. After selecting a preset, the "Presets" tooltip must not re-pop.
|
||||||
|
// 2. Keyboard shortcuts after close should not re-open the dropdown.
|
||||||
|
|
||||||
|
test("selecting a preset closes menu cleanly and does not re-open on keyboard", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
test.skip(frigateApp.isMobile, "PTZ preset dropdown is desktop-only");
|
||||||
|
|
||||||
|
await frigateApp.api.install({
|
||||||
|
config: {
|
||||||
|
cameras: {
|
||||||
|
[PTZ_CAMERA]: { onvif: { host: "10.0.0.50" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
name: PTZ_CAMERA,
|
||||||
|
features: ["pt", "zoom"],
|
||||||
|
presets: PRESET_NAMES,
|
||||||
|
profiles: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await installWsFrameCapture(frigateApp.page);
|
||||||
|
await frigateApp.goto(`/#${PTZ_CAMERA}`);
|
||||||
|
|
||||||
|
const presetTrigger = frigateApp.page.getByRole("button", {
|
||||||
|
name: /presets/i,
|
||||||
|
});
|
||||||
|
await expect(presetTrigger.first()).toBeVisible({ timeout: 5_000 });
|
||||||
|
await presetTrigger.first().click();
|
||||||
|
|
||||||
|
const menu = frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
await menu.getByRole("menuitem", { name: PRESET_NAMES[0] }).first().click();
|
||||||
|
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
await waitForWsFrame(
|
||||||
|
frigateApp.page,
|
||||||
|
(frame) =>
|
||||||
|
frame.includes(`"${PTZ_CAMERA}/ptz"`) &&
|
||||||
|
frame.includes(`preset_${PRESET_NAMES[0]}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForBodyInteractive(frigateApp.page);
|
||||||
|
await expectBodyInteractive(frigateApp.page);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
frigateApp.page
|
||||||
|
.locator('[role="tooltip"]')
|
||||||
|
.filter({ hasText: /presets/i })
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false),
|
||||||
|
{ timeout: 1_000 },
|
||||||
|
)
|
||||||
|
.toBe(false);
|
||||||
|
|
||||||
|
await frigateApp.page.keyboard.press("ArrowUp");
|
||||||
|
await frigateApp.page.keyboard.press("Space");
|
||||||
|
await frigateApp.page.keyboard.press("Enter");
|
||||||
|
await expect
|
||||||
|
.poll(() => menu.isVisible().catch(() => false), { timeout: 1_000 })
|
||||||
|
.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Live mobile layout @critical @mobile", () => {
|
||||||
|
test("mobile dashboard has no sidebar and renders cameras", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
await expect(frigateApp.page.locator("aside")).toHaveCount(0);
|
||||||
|
const live = new LivePage(frigateApp.page, false);
|
||||||
|
await expect(live.cameraCard("front_door")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile camera tap opens single view", async ({ frigateApp }) => {
|
||||||
|
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
const live = new LivePage(frigateApp.page, false);
|
||||||
|
await live.cameraCard("front_door").first().click({ timeout: 10_000 });
|
||||||
|
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile onvif single-camera view loads without freezing body", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||||
|
// Migrated from ptz-overlay.spec.ts — dismissable-layer dedupe smoke test.
|
||||||
|
await frigateApp.api.install({
|
||||||
|
config: {
|
||||||
|
cameras: { [PTZ_CAMERA]: { onvif: { host: "10.0.0.50" } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
name: PTZ_CAMERA,
|
||||||
|
features: ["pt", "zoom"],
|
||||||
|
presets: PRESET_NAMES,
|
||||||
|
profiles: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await frigateApp.goto(`/#${PTZ_CAMERA}`);
|
||||||
|
await expectBodyInteractive(frigateApp.page);
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Live Single Camera - Mobile Controls @critical", () => {
|
|
||||||
test("mobile camera view has settings drawer trigger", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (!frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
// On mobile, settings gear opens a drawer
|
|
||||||
// The button has aria-label with the camera name like "front_door Settings"
|
|
||||||
const buttons = frigateApp.page.locator("button:has(svg)");
|
|
||||||
const count = await buttons.count();
|
|
||||||
expect(count).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Live Context Menu @critical", () => {
|
|
||||||
test("right-click on camera opens context menu on desktop", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
const card = frigateApp.page.locator("[data-camera='front_door']").first();
|
|
||||||
await card.waitFor({ state: "visible", timeout: 10_000 });
|
|
||||||
await card.click({ button: "right" });
|
|
||||||
const contextMenu = frigateApp.page.locator(
|
|
||||||
'[role="menu"], [data-radix-menu-content]',
|
|
||||||
);
|
|
||||||
await expect(contextMenu.first()).toBeVisible({ timeout: 5_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("context menu closes on escape", async ({ frigateApp }) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
const card = frigateApp.page.locator("[data-camera='front_door']").first();
|
|
||||||
await card.waitFor({ state: "visible", timeout: 10_000 });
|
|
||||||
await card.click({ button: "right" });
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
|
||||||
await frigateApp.page.waitForTimeout(300);
|
|
||||||
const contextMenu = frigateApp.page.locator(
|
|
||||||
'[role="menu"], [data-radix-menu-content]',
|
|
||||||
);
|
|
||||||
await expect(contextMenu).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Live Mobile Layout @critical", () => {
|
|
||||||
test("mobile renders cameras without sidebar", async ({ frigateApp }) => {
|
|
||||||
if (!frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
await expect(frigateApp.page.locator("aside")).not.toBeVisible();
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator("[data-camera='front_door']"),
|
|
||||||
).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mobile camera click opens single camera view", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (!frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
const card = frigateApp.page.locator("[data-camera='front_door']").first();
|
|
||||||
await card.click({ timeout: 10_000 });
|
|
||||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,75 +1,222 @@
|
|||||||
/**
|
/**
|
||||||
* Logs page tests -- MEDIUM tier.
|
* Logs page tests -- MEDIUM tier.
|
||||||
*
|
*
|
||||||
* Tests service tab switching by name, copy/download buttons,
|
* Service tabs (with real /logs/<service> JSON contract),
|
||||||
* and websocket message feed tab.
|
* log content render, Copy (clipboard), Download (assert
|
||||||
|
* ?download=true request fired), mobile tab selector.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
|
||||||
|
|
||||||
test.describe("Logs Page - Service Tabs @medium", () => {
|
function logsJsonBody(lines: string[]) {
|
||||||
test("logs page renders with named service tabs", async ({ frigateApp }) => {
|
return { lines, totalLines: lines.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Logs — service tabs @medium", () => {
|
||||||
|
test("frigate tab renders by default with mocked log lines", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: logsJsonBody([
|
||||||
|
"[2026-04-06 10:00:00] INFO: Frigate started",
|
||||||
|
"[2026-04-06 10:00:01] INFO: Cameras loaded",
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Silence the streaming fetch so it doesn't hang the test.
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||||
|
route.fulfill({ status: 200, body: "" }),
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/logs");
|
||||||
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
await expect(frigateApp.page.getByText(/Frigate started/)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching to go2rtc fires a GET to /logs/go2rtc", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
let go2rtcCalled = false;
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||||
|
route.fulfill({ json: logsJsonBody(["frigate line"]) }),
|
||||||
|
);
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/go2rtc(\?|$)/, (route) => {
|
||||||
|
if (!route.request().url().includes("stream=true")) {
|
||||||
|
go2rtcCalled = true;
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: logsJsonBody(["go2rtc line"]) });
|
||||||
|
});
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/.*\?stream=true/, (route) =>
|
||||||
|
route.fulfill({ status: 200, body: "" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await frigateApp.goto("/logs");
|
||||||
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc");
|
||||||
|
await expect(go2rtcTab).toBeVisible();
|
||||||
|
await go2rtcTab.click();
|
||||||
|
await expect.poll(() => go2rtcCalled, { timeout: 5_000 }).toBe(true);
|
||||||
|
await expect(go2rtcTab).toHaveAttribute("data-state", "on");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Logs — actions @medium", () => {
|
||||||
|
test("Copy button writes current logs to clipboard", async ({
|
||||||
|
frigateApp,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await grantClipboardPermissions(context);
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: logsJsonBody([
|
||||||
|
"[2026-04-06 10:00:00] INFO: Frigate started",
|
||||||
|
"[2026-04-06 10:00:01] INFO: Cameras loaded",
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||||
|
route.fulfill({ status: 200, body: "" }),
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/logs");
|
||||||
|
await expect(frigateApp.page.getByText(/Frigate started/)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard");
|
||||||
|
await expect(copyBtn).toBeVisible({ timeout: 5_000 });
|
||||||
|
await copyBtn.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
|
||||||
|
.toContain("Frigate started");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Download button fires GET /logs/<service>?download=true", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
let downloadCalled = false;
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => {
|
||||||
|
if (route.request().url().includes("download=true")) {
|
||||||
|
downloadCalled = true;
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: logsJsonBody(["frigate line"]) });
|
||||||
|
});
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||||
|
route.fulfill({ status: 200, body: "" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await frigateApp.goto("/logs");
|
||||||
|
const downloadBtn = frigateApp.page.getByLabel("Download Logs");
|
||||||
|
await expect(downloadBtn).toBeVisible({ timeout: 5_000 });
|
||||||
|
await downloadBtn.click();
|
||||||
|
await expect.poll(() => downloadCalled, { timeout: 5_000 }).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Logs — websocket tab @medium", () => {
|
||||||
|
test("switching to websocket tab renders WsMessageFeed container", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||||
|
route.fulfill({ json: logsJsonBody(["frigate line"]) }),
|
||||||
|
);
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||||
|
route.fulfill({ status: 200, body: "" }),
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/logs");
|
||||||
|
const wsTab = frigateApp.page.getByLabel("Select websocket");
|
||||||
|
await expect(wsTab).toBeVisible({ timeout: 5_000 });
|
||||||
|
await wsTab.click();
|
||||||
|
await expect(wsTab).toHaveAttribute("data-state", "on", { timeout: 5_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Logs — streaming @medium", () => {
|
||||||
|
test("streamed log lines appear in the viewport", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => {
|
||||||
|
if (route.request().url().includes("stream=true")) {
|
||||||
|
// Intercepted below via addInitScript fetch override.
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
json: logsJsonBody(["[2026-04-06 10:00:00] INFO: initial batch line"]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override window.fetch so the /api/logs/frigate?stream=true request
|
||||||
|
// resolves with a real ReadableStream that emits chunks over time.
|
||||||
|
// This is the only way to validate streaming-append behavior through
|
||||||
|
// Playwright — route.fulfill() cannot return a stream.
|
||||||
|
// NOTE: The app calls fetch('api/logs/...') with a relative URL (no
|
||||||
|
// leading slash), so we match both relative and absolute forms.
|
||||||
|
await frigateApp.page.addInitScript(() => {
|
||||||
|
const origFetch = window.fetch;
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: (input as Request).url;
|
||||||
|
if (url.includes("api/logs/frigate") && url.includes("stream=true")) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
"[2026-04-06 10:00:02] INFO: streamed line one\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
"[2026-04-06 10:00:03] INFO: streamed line two\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(stream, { status: 200 });
|
||||||
|
}
|
||||||
|
return origFetch.call(window, input as RequestInfo, init);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await frigateApp.goto("/logs");
|
||||||
|
// The initial batch line is parsed by LogLineData and its content is
|
||||||
|
// rendered in a .log-content cell — assert against that element.
|
||||||
|
await expect(frigateApp.page.getByText("initial batch line")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await expect(frigateApp.page.getByText(/streamed line one/)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await expect(frigateApp.page.getByText(/streamed line two/)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Logs — mobile @medium @mobile", () => {
|
||||||
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
test("service tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||||
|
route.fulfill({ json: logsJsonBody(["frigate line"]) }),
|
||||||
|
);
|
||||||
|
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||||
|
route.fulfill({ status: 200, body: "" }),
|
||||||
|
);
|
||||||
await frigateApp.goto("/logs");
|
await frigateApp.goto("/logs");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
// Service tabs have aria-label="Select {service}"
|
|
||||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("switching to go2rtc tab changes active tab", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/logs");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc");
|
|
||||||
if (await go2rtcTab.isVisible().catch(() => false)) {
|
|
||||||
await go2rtcTab.click();
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
await expect(go2rtcTab).toHaveAttribute("data-state", "on");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching to websocket tab shows message feed", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/logs");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const wsTab = frigateApp.page.getByLabel("Select websocket");
|
|
||||||
if (await wsTab.isVisible().catch(() => false)) {
|
|
||||||
await wsTab.click();
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
await expect(wsTab).toHaveAttribute("data-state", "on");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Logs Page - Actions @medium", () => {
|
|
||||||
test("copy to clipboard button is present and clickable", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/logs");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard");
|
|
||||||
if (await copyBtn.isVisible().catch(() => false)) {
|
|
||||||
await copyBtn.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
// Should trigger clipboard copy (toast may appear)
|
|
||||||
}
|
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("download logs button is present", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/logs");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const downloadBtn = frigateApp.page.getByLabel("Download Logs");
|
|
||||||
if (await downloadBtn.isVisible().catch(() => false)) {
|
|
||||||
await expect(downloadBtn).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs page displays log content text", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/logs");
|
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
const text = await frigateApp.page.textContent("#pageRoot");
|
|
||||||
expect(text?.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,103 +1,78 @@
|
|||||||
/**
|
/**
|
||||||
* Navigation tests -- CRITICAL tier.
|
* Navigation tests -- CRITICAL tier.
|
||||||
*
|
*
|
||||||
* Tests sidebar (desktop) and bottombar (mobile) navigation,
|
* Covers sidebar (desktop) / bottombar (mobile) link set, conditional
|
||||||
* conditional nav items, settings menus, and their actual behaviors.
|
* nav items (faces, chat, classification), settings menu navigation,
|
||||||
|
* unknown-route redirect to /, and mobile-specific nav behaviors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import { BasePage } from "../pages/base.page";
|
import { BasePage } from "../pages/base.page";
|
||||||
|
|
||||||
test.describe("Navigation @critical", () => {
|
const PRIMARY_ROUTES = ["/review", "/explore", "/export"] as const;
|
||||||
test("app loads and renders page root", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logo is visible and links to home", async ({ frigateApp }) => {
|
test.describe("Navigation — primary links @critical", () => {
|
||||||
if (frigateApp.isMobile) {
|
test("every primary link is visible and navigates", async ({
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
const base = new BasePage(frigateApp.page, true);
|
|
||||||
const logo = base.sidebar.locator('a[href="/"]').first();
|
|
||||||
await expect(logo).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("all primary nav links are present and navigate", async ({
|
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const routes = ["/review", "/explore", "/export"];
|
for (const route of PRIMARY_ROUTES) {
|
||||||
for (const route of routes) {
|
|
||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.locator(`a[href="${route}"]`).first(),
|
frigateApp.page.locator(`a[href="${route}"]`).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
// Verify clicking each one actually navigates
|
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
||||||
for (const route of routes) {
|
for (const route of PRIMARY_ROUTES) {
|
||||||
await base.navigateTo(route);
|
await base.navigateTo(route);
|
||||||
await expect(frigateApp.page).toHaveURL(new RegExp(route));
|
await expect(frigateApp.page).toHaveURL(new RegExp(route));
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop sidebar is visible, mobile bottombar is visible", async ({
|
test("logo links home on desktop", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
test.skip(frigateApp.isMobile, "Sidebar logo is desktop-only");
|
||||||
}) => {
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.goto("/");
|
await frigateApp.page.locator("aside a[href='/']").first().click();
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||||
if (!frigateApp.isMobile) {
|
|
||||||
await expect(base.sidebar).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(base.sidebar).not.toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("navigate between all main pages without crash", async ({
|
test("unknown route redirects to /", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
|
||||||
const pageRoot = frigateApp.page.locator("#pageRoot");
|
|
||||||
|
|
||||||
await base.navigateTo("/review");
|
|
||||||
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
|
|
||||||
await base.navigateTo("/explore");
|
|
||||||
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
|
|
||||||
await base.navigateTo("/export");
|
|
||||||
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
|
|
||||||
await base.navigateTo("/review");
|
|
||||||
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unknown route redirects to home", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.page.goto("/nonexistent-route");
|
await frigateApp.page.goto("/nonexistent-route");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||||
const url = frigateApp.page.url();
|
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||||
const hasPageRoot = await frigateApp.page
|
await expect(
|
||||||
.locator("#pageRoot")
|
frigateApp.page.locator("[data-camera='front_door']"),
|
||||||
.isVisible()
|
).toBeVisible({ timeout: 10_000 });
|
||||||
.catch(() => false);
|
|
||||||
expect(url.endsWith("/") || hasPageRoot).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Navigation - Conditional Items @critical", () => {
|
test.describe("Navigation — conditional items @critical", () => {
|
||||||
test("Faces nav hidden when face_recognition disabled", async ({
|
test("/faces is hidden when face_recognition.enabled is false", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
await expect(frigateApp.page.locator('a[href="/faces"]')).not.toBeVisible();
|
await expect(
|
||||||
|
frigateApp.page.locator('a[href="/faces"]').first(),
|
||||||
|
).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Chat nav hidden when genai model is none", async ({ frigateApp }) => {
|
test("/faces is visible when face_recognition.enabled is true (desktop)", async ({
|
||||||
if (frigateApp.isMobile) {
|
frigateApp,
|
||||||
test.skip();
|
}) => {
|
||||||
return;
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
}
|
await frigateApp.installDefaults({
|
||||||
|
config: { face_recognition: { enabled: true } },
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator('a[href="/faces"]').first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/chat is hidden when genai.model is none (desktop)", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
await frigateApp.installDefaults({
|
await frigateApp.installDefaults({
|
||||||
config: {
|
config: {
|
||||||
genai: {
|
genai: {
|
||||||
@ -109,119 +84,83 @@ test.describe("Navigation - Conditional Items @critical", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
await expect(frigateApp.page.locator('a[href="/chat"]')).not.toBeVisible();
|
await expect(
|
||||||
|
frigateApp.page.locator('a[href="/chat"]').first(),
|
||||||
|
).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Faces nav visible when face_recognition enabled on desktop", async ({
|
test("/chat is visible when genai.model is set (desktop)", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: { face_recognition: { enabled: true } },
|
|
||||||
});
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
await expect(page.locator('a[href="/faces"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Chat nav visible when genai model set on desktop", async ({
|
|
||||||
frigateApp,
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.installDefaults({
|
await frigateApp.installDefaults({
|
||||||
config: { genai: { enabled: true, model: "llava" } },
|
config: { genai: { enabled: true, model: "llava" } },
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
await expect(page.locator('a[href="/chat"]')).toBeVisible();
|
await expect(
|
||||||
|
frigateApp.page.locator('a[href="/chat"]').first(),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Classification nav visible for admin on desktop", async ({
|
test("/classification is visible for admin on desktop", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
await expect(page.locator('a[href="/classification"]')).toBeVisible();
|
await expect(
|
||||||
|
frigateApp.page.locator('a[href="/classification"]').first(),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Navigation - Settings Menu @critical", () => {
|
test.describe("Navigation — settings menu (desktop) @critical", () => {
|
||||||
test("settings gear opens menu with navigation items (desktop)", async ({
|
test.skip(
|
||||||
frigateApp,
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
}) => {
|
"Sidebar settings menu is desktop-only",
|
||||||
if (frigateApp.isMobile) {
|
);
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
// Settings gear is in the sidebar bottom section, a div with cursor-pointer
|
|
||||||
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
|
|
||||||
const gearIcon = sidebarBottom
|
|
||||||
.locator("div[class*='cursor-pointer']")
|
|
||||||
.first();
|
|
||||||
await expect(gearIcon).toBeVisible({ timeout: 5_000 });
|
|
||||||
await gearIcon.click();
|
|
||||||
// Menu should open - look for the "Settings" menu item by aria-label
|
|
||||||
await expect(frigateApp.page.getByLabel("Settings")).toBeVisible({
|
|
||||||
timeout: 3_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings menu items navigate to correct routes (desktop)", async ({
|
const TARGETS = [
|
||||||
frigateApp,
|
{ label: "Settings", url: /\/settings/ },
|
||||||
}) => {
|
{ label: "System metrics", url: /\/system/ },
|
||||||
if (frigateApp.isMobile) {
|
{ label: "System logs", url: /\/logs/ },
|
||||||
test.skip();
|
{ label: "Configuration Editor", url: /\/config/ },
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targets = [
|
|
||||||
{ label: "Settings", url: "/settings" },
|
|
||||||
{ label: "System metrics", url: "/system" },
|
|
||||||
{ label: "System logs", url: "/logs" },
|
|
||||||
{ label: "Configuration Editor", url: "/config" },
|
|
||||||
];
|
];
|
||||||
for (const target of targets) {
|
|
||||||
|
for (const target of TARGETS) {
|
||||||
|
test(`menu → ${target.label} navigates`, async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const gearIcon = frigateApp.page
|
const gear = frigateApp.page
|
||||||
.locator("aside .mb-8 div[class*='cursor-pointer']")
|
.locator("aside .mb-8 div[class*='cursor-pointer']")
|
||||||
.first();
|
.first();
|
||||||
await gearIcon.click();
|
await gear.click();
|
||||||
await frigateApp.page.waitForTimeout(300);
|
await frigateApp.page.getByLabel(target.label).click();
|
||||||
const menuItem = frigateApp.page.getByLabel(target.label);
|
await expect(frigateApp.page).toHaveURL(target.url);
|
||||||
if (await menuItem.isVisible().catch(() => false)) {
|
});
|
||||||
await menuItem.click();
|
|
||||||
await expect(frigateApp.page).toHaveURL(
|
|
||||||
new RegExp(target.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Navigation — mobile @critical @mobile", () => {
|
||||||
|
test("mobile bottombar visible, sidebar not rendered", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
await expect(frigateApp.page.locator("aside")).toHaveCount(0);
|
||||||
|
for (const route of PRIMARY_ROUTES) {
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator(`a[href="${route}"]`).first(),
|
||||||
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("account button in sidebar is clickable (desktop)", async ({
|
test("mobile nav survives route change", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
|
const reviewLink = frigateApp.page.locator('a[href="/review"]').first();
|
||||||
const items = sidebarBottom.locator("div[class*='cursor-pointer']");
|
await reviewLink.click();
|
||||||
const count = await items.count();
|
await expect(frigateApp.page).toHaveURL(/\/review/);
|
||||||
if (count >= 2) {
|
await expect(
|
||||||
await items.nth(1).click();
|
frigateApp.page.locator('a[href="/review"]').first(),
|
||||||
await frigateApp.page.waitForTimeout(500);
|
).toBeVisible();
|
||||||
}
|
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,23 +1,304 @@
|
|||||||
/**
|
/**
|
||||||
* Replay page tests -- LOW tier.
|
* Replay page tests -- MEDIUM tier.
|
||||||
*
|
*
|
||||||
* Tests replay page rendering and basic interactivity.
|
* /replay is the admin debug replay page (not a recordings player).
|
||||||
|
* Polls /api/debug_replay/status, renders a no-session state when
|
||||||
|
* inactive, and a live camera image + debug toggles + Stop controls
|
||||||
|
* when active.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import {
|
||||||
|
activeSessionStatus,
|
||||||
|
noSessionStatus,
|
||||||
|
} from "../fixtures/mock-data/debug-replay";
|
||||||
|
|
||||||
test.describe("Replay Page @low", () => {
|
async function installStatusRoute(
|
||||||
test("replay page renders without crash", async ({ frigateApp }) => {
|
app: { page: import("@playwright/test").Page },
|
||||||
|
body: unknown,
|
||||||
|
) {
|
||||||
|
await app.page.route("**/api/debug_replay/status", (route) =>
|
||||||
|
route.fulfill({ json: body }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Replay — no active session @medium", () => {
|
||||||
|
test("empty state renders heading + Go to History button", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installStatusRoute(frigateApp, noSessionStatus());
|
||||||
await frigateApp.goto("/replay");
|
await frigateApp.goto("/replay");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
frigateApp.page.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: /No Active Replay Session/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
const goButton = frigateApp.page.getByRole("button", {
|
||||||
|
name: /Go to History|Go to Recordings/i,
|
||||||
|
});
|
||||||
|
await expect(goButton).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replay page has interactive controls", async ({ frigateApp }) => {
|
test("clicking Go to History navigates to /review", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installStatusRoute(frigateApp, noSessionStatus());
|
||||||
await frigateApp.goto("/replay");
|
await frigateApp.goto("/replay");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(
|
||||||
const buttons = frigateApp.page.locator("button");
|
frigateApp.page.getByRole("heading", {
|
||||||
const count = await buttons.count();
|
level: 2,
|
||||||
expect(count).toBeGreaterThan(0);
|
name: /No Active Replay Session/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("button", { name: /Go to History|Go to Recordings/i })
|
||||||
|
.click();
|
||||||
|
await expect(frigateApp.page).toHaveURL(/\/review/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Replay — active session @medium", () => {
|
||||||
|
test("active status renders the Debug Replay side panel", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
// Three tabs (Debug / Objects / Messages) in TabsList
|
||||||
|
await expect(frigateApp.page.locator('[role="tab"]')).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("debug toggles render with bbox ON by default", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
const bbox = frigateApp.page.locator("#debug-bbox");
|
||||||
|
await expect(bbox).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(bbox).toHaveAttribute("aria-checked", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking bbox toggle flips aria-checked", async ({ frigateApp }) => {
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
const bbox = frigateApp.page.locator("#debug-bbox");
|
||||||
|
await expect(bbox).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(bbox).toHaveAttribute("aria-checked", "true");
|
||||||
|
await bbox.click();
|
||||||
|
await expect(bbox).toHaveAttribute("aria-checked", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Configuration button opens the configuration dialog (desktop)", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
test.skip(frigateApp.isMobile, "Desktop: button has visible text label");
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// On desktop the span is visible and gives the button an accessible name.
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("button", { name: /configuration/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const dialog = frigateApp.page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Configuration button opens the configuration dialog (mobile)", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
test.skip(!frigateApp.isMobile, "Mobile: button is icon-only");
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// On mobile the Configuration button text span is hidden (md:inline).
|
||||||
|
// It is the first button inside the right-side action group div
|
||||||
|
// (the flex container that holds Config + Stop, sibling of the Back button).
|
||||||
|
const actionGroup = frigateApp.page.locator(
|
||||||
|
".flex.items-center.gap-2 button",
|
||||||
|
);
|
||||||
|
await actionGroup.first().click();
|
||||||
|
|
||||||
|
const dialog = frigateApp.page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Objects tab renders with the camera_activity objects list", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Send an activity payload with a person object on front_door.
|
||||||
|
// Must be called after goto() so the WS connection is established.
|
||||||
|
await frigateApp.ws.sendCameraActivity({
|
||||||
|
front_door: {
|
||||||
|
objects: [
|
||||||
|
{
|
||||||
|
label: "person",
|
||||||
|
score: 0.95,
|
||||||
|
box: [0.1, 0.1, 0.5, 0.8],
|
||||||
|
area: 0.2,
|
||||||
|
ratio: 0.6,
|
||||||
|
region: [0.05, 0.05, 0.6, 0.85],
|
||||||
|
current_zones: [],
|
||||||
|
id: "obj-person-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to Objects tab (labelled "Object List" in i18n).
|
||||||
|
const objectsTab = frigateApp.page.getByRole("tab", {
|
||||||
|
name: /object/i,
|
||||||
|
});
|
||||||
|
await objectsTab.click();
|
||||||
|
await expect(objectsTab).toHaveAttribute("data-state", "active", {
|
||||||
|
timeout: 3_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The object row renders the label.
|
||||||
|
await expect(frigateApp.page.getByText(/person/i).first()).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Messages tab renders WsMessageFeed container", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
const messagesTab = frigateApp.page.getByRole("tab", {
|
||||||
|
name: /messages/i,
|
||||||
|
});
|
||||||
|
await messagesTab.click();
|
||||||
|
await expect(messagesTab).toHaveAttribute("data-state", "active", {
|
||||||
|
timeout: 3_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bbox info popover opens and closes cleanly", async ({ frigateApp }) => {
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
// The bbox row has an info icon popover trigger next to its label.
|
||||||
|
// The trigger is a div (not button) wrapping LuInfo with an sr-only
|
||||||
|
// "Info" span. Target it by the sr-only text content.
|
||||||
|
const infoTrigger = frigateApp.page
|
||||||
|
.locator("span.sr-only", { hasText: /info/i })
|
||||||
|
.first();
|
||||||
|
await expect(infoTrigger).toBeVisible({ timeout: 10_000 });
|
||||||
|
// Click the parent div (the actual trigger)
|
||||||
|
await infoTrigger.locator("..").click();
|
||||||
|
|
||||||
|
const popover = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper]",
|
||||||
|
);
|
||||||
|
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
await expect(popover.first()).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Replay — stop flow (desktop) @medium", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Desktop button has accessible 'Stop Replay' name",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Stop Replay opens confirm dialog; confirm POSTs debug_replay/stop", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
let stopCalled = false;
|
||||||
|
await frigateApp.page.route("**/api/debug_replay/stop", async (route) => {
|
||||||
|
if (route.request().method() === "POST") stopCalled = true;
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("button", { name: /stop replay/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const dialog = frigateApp.page.getByRole("alertdialog");
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 3_000 });
|
||||||
|
await dialog
|
||||||
|
.getByRole("button", { name: /stop|confirm/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect.poll(() => stopCalled, { timeout: 5_000 }).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Replay — stop button (mobile) @medium @mobile", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => !frigateApp.isMobile,
|
||||||
|
"Mobile-only icon-button variant",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("tapping the icon-only stop button opens the confirm dialog", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// On mobile the Stop button is an icon (LuSquare) inside an
|
||||||
|
// AlertDialogTrigger. It's the last button in the top bar's
|
||||||
|
// right-side action group (Back is on the left). Target by
|
||||||
|
// position within the top-bar flex container.
|
||||||
|
const topRightButtons = frigateApp.page
|
||||||
|
.locator(".min-h-12 button, .md\\:min-h-16 button")
|
||||||
|
.filter({ hasNot: frigateApp.page.getByLabel("Back") });
|
||||||
|
const lastButton = topRightButtons.last();
|
||||||
|
await expect(lastButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await lastButton.click();
|
||||||
|
|
||||||
|
const dialog = frigateApp.page.getByRole("alertdialog");
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 3_000 });
|
||||||
|
await dialog.getByRole("button", { name: /cancel/i }).click();
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Replay — mobile @medium @mobile", () => {
|
||||||
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
test("no-session state renders at mobile viewport", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installStatusRoute(frigateApp, noSessionStatus());
|
||||||
|
await frigateApp.goto("/replay");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: /No Active Replay Session/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,200 +1,230 @@
|
|||||||
/**
|
/**
|
||||||
* Review/Events page tests -- CRITICAL tier.
|
* Review/Events page tests -- CRITICAL tier.
|
||||||
*
|
*
|
||||||
* Tests severity tab switching by name (Alerts/Detections/Motion),
|
* Severity tabs, filter popovers, calendar, show-reviewed toggle,
|
||||||
* filter popover opening with camera names, show reviewed toggle,
|
* timeline, and the nested-overlay regression migrated from
|
||||||
* calendar button, and filter button interactions.
|
* radix-overlay-regressions.spec.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import { BasePage } from "../pages/base.page";
|
import { BasePage } from "../pages/base.page";
|
||||||
|
import { ReviewPage } from "../pages/review.page";
|
||||||
|
import {
|
||||||
|
expectBodyInteractive,
|
||||||
|
waitForBodyInteractive,
|
||||||
|
} from "../helpers/overlay-interaction";
|
||||||
|
|
||||||
test.describe("Review Page - Severity Tabs @critical", () => {
|
test.describe("Review — severity tabs @critical", () => {
|
||||||
test("severity tabs render with Alerts, Detections, Motion", async ({
|
test("tabs render with Alerts default-on", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||||
|
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(review.detectionsTab).toBeVisible();
|
||||||
|
await expect(review.motionTab).toBeVisible();
|
||||||
|
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking Detections flips data-state", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||||
|
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||||
|
await review.detectionsTab.click();
|
||||||
|
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
|
||||||
|
await expect(review.alertsTab).toHaveAttribute("data-state", "off");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking Motion flips data-state", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||||
|
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||||
|
await review.motionTab.click();
|
||||||
|
await expect(review.motionTab).toHaveAttribute("data-state", "on");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching back to Alerts works", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||||
|
await review.detectionsTab.click();
|
||||||
|
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
|
||||||
|
await review.alertsTab.click();
|
||||||
|
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching tabs updates active data-state (client-side filter)", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
// The severity tabs filter the already-fetched review data client-side;
|
||||||
|
// they do not trigger a new /api/review network request. This test
|
||||||
|
// verifies the state-change assertion that the tab switch takes effect.
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||||
timeout: 10_000,
|
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
|
||||||
await expect(frigateApp.page.getByLabel("Detections")).toBeVisible();
|
await review.detectionsTab.click();
|
||||||
// Motion uses role="radio" to distinguish from other Motion elements
|
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
|
||||||
await expect(
|
await expect(review.alertsTab).toHaveAttribute("data-state", "off");
|
||||||
frigateApp.page.getByRole("radio", { name: "Motion" }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Alerts tab is active by default", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const alertsTab = frigateApp.page.getByLabel("Alerts");
|
|
||||||
await expect(alertsTab).toHaveAttribute("data-state", "on");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking Detections tab makes it active and deactivates Alerts", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const alertsTab = frigateApp.page.getByLabel("Alerts");
|
|
||||||
const detectionsTab = frigateApp.page.getByLabel("Detections");
|
|
||||||
|
|
||||||
await detectionsTab.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
|
|
||||||
await expect(detectionsTab).toHaveAttribute("data-state", "on");
|
|
||||||
await expect(alertsTab).toHaveAttribute("data-state", "off");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking Motion tab makes it active", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const motionTab = frigateApp.page.getByRole("radio", { name: "Motion" });
|
|
||||||
await motionTab.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
await expect(motionTab).toHaveAttribute("data-state", "on");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching back to Alerts from Detections works", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
await frigateApp.page.getByLabel("Detections").click();
|
|
||||||
await frigateApp.page.waitForTimeout(300);
|
|
||||||
await frigateApp.page.getByLabel("Alerts").click();
|
|
||||||
await frigateApp.page.waitForTimeout(300);
|
|
||||||
|
|
||||||
await expect(frigateApp.page.getByLabel("Alerts")).toHaveAttribute(
|
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Review Page - Filters @critical", () => {
|
test.describe("Review — filters (desktop) @critical", () => {
|
||||||
test("All Cameras filter button opens popover with camera names", async ({
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Filter bar differs on mobile",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Cameras popover lists configured camera names", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
const review = new ReviewPage(frigateApp.page, true);
|
||||||
|
await expect(review.camerasFilterTrigger).toBeVisible({ timeout: 5_000 });
|
||||||
const camerasBtn = frigateApp.page.getByRole("button", {
|
await review.camerasFilterTrigger.click();
|
||||||
name: /cameras/i,
|
await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 });
|
||||||
});
|
|
||||||
await expect(camerasBtn).toBeVisible({ timeout: 5_000 });
|
|
||||||
await camerasBtn.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Popover should open with camera names from config
|
|
||||||
const popover = frigateApp.page.locator(
|
|
||||||
"[data-radix-popper-content-wrapper]",
|
|
||||||
);
|
|
||||||
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
|
|
||||||
// Camera names should be present
|
|
||||||
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
||||||
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Show Reviewed toggle is clickable", async ({ frigateApp }) => {
|
test("closing the Cameras popover with Escape leaves body interactive", async ({
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
const showReviewed = frigateApp.page.getByRole("button", {
|
|
||||||
name: /reviewed/i,
|
|
||||||
});
|
|
||||||
if (await showReviewed.isVisible().catch(() => false)) {
|
|
||||||
await showReviewed.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
// Toggle should change state
|
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Last 24 Hours calendar button opens date picker", async ({
|
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Migrated from radix-overlay-regressions.spec.ts.
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
const review = new ReviewPage(frigateApp.page, true);
|
||||||
|
await review.camerasFilterTrigger.click();
|
||||||
const calendarBtn = frigateApp.page.getByRole("button", {
|
await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 });
|
||||||
name: /24 hours|calendar|date/i,
|
|
||||||
});
|
|
||||||
if (await calendarBtn.isVisible().catch(() => false)) {
|
|
||||||
await calendarBtn.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
// Popover should open
|
|
||||||
const popover = frigateApp.page.locator(
|
|
||||||
"[data-radix-popper-content-wrapper]",
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
await popover
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false)
|
|
||||||
) {
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
}
|
await expect(review.filterOverlay).not.toBeVisible({ timeout: 3_000 });
|
||||||
}
|
await waitForBodyInteractive(frigateApp.page);
|
||||||
|
await expectBodyInteractive(frigateApp.page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Filter button opens filter popover", async ({ frigateApp }) => {
|
test("Labels are shown inside the General Filter dialog", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Labels are surfaced inside the "Filter" button's GeneralFilterContent
|
||||||
|
// dialog, not as a standalone top-level button. We open that dialog and
|
||||||
|
// confirm labels from the camera config are listed there.
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
const filterBtn = frigateApp.page
|
||||||
|
.getByRole("button", { name: /^filter$/i })
|
||||||
const filterBtn = frigateApp.page.getByRole("button", {
|
.first();
|
||||||
name: /^filter$/i,
|
await expect(filterBtn).toBeVisible({ timeout: 5_000 });
|
||||||
});
|
|
||||||
if (await filterBtn.isVisible().catch(() => false)) {
|
|
||||||
await filterBtn.click();
|
await filterBtn.click();
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
// Popover or dialog should open
|
const overlay = frigateApp.page.locator(
|
||||||
const popover = frigateApp.page.locator(
|
|
||||||
"[data-radix-popper-content-wrapper], [role='dialog']",
|
"[data-radix-popper-content-wrapper], [role='dialog']",
|
||||||
);
|
);
|
||||||
if (
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
await popover
|
// The default mock config for front_door tracks "person"
|
||||||
.first()
|
await expect(overlay.first().getByText(/person/i)).toBeVisible();
|
||||||
.isVisible()
|
|
||||||
.catch(() => false)
|
|
||||||
) {
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Review Page - Timeline @critical", () => {
|
test("Zones popover lists configured zones inside the General Filter dialog", async ({
|
||||||
test("review page has timeline with time markers (desktop)", async ({
|
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
// Override config to guarantee a known zone on front_door.
|
||||||
test.skip();
|
await frigateApp.installDefaults({
|
||||||
return;
|
config: {
|
||||||
}
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
zones: {
|
||||||
|
front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const filterBtn = frigateApp.page
|
||||||
// Timeline renders time labels like "4:30 PM"
|
.getByRole("button", { name: /^filter$/i })
|
||||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
.first();
|
||||||
expect(pageText).toMatch(/[AP]M/);
|
await expect(filterBtn).toBeVisible({ timeout: 5_000 });
|
||||||
|
await filterBtn.click();
|
||||||
|
|
||||||
|
const overlay = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper], [role='dialog']",
|
||||||
|
);
|
||||||
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
await expect(overlay.first().getByText(/front.?yard/i)).toBeVisible();
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Calendar trigger opens a date picker popover", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
const review = new ReviewPage(frigateApp.page, true);
|
||||||
|
await expect(review.calendarTrigger).toBeVisible({ timeout: 5_000 });
|
||||||
|
await review.calendarTrigger.click();
|
||||||
|
|
||||||
|
// react-day-picker v9 renders a role="grid" calendar with day cells
|
||||||
|
// as buttons inside gridcells (e.g. "Wednesday, April 1st, 2026").
|
||||||
|
// The calendar is placed directly in the DOM (not always inside a
|
||||||
|
// Radix popper wrapper), so scope by the grid role instead.
|
||||||
|
const calendarGrid = frigateApp.page.locator('[role="grid"]').first();
|
||||||
|
await expect(calendarGrid).toBeVisible({ timeout: 3_000 });
|
||||||
|
const dayButton = calendarGrid.locator('[role="gridcell"] button').first();
|
||||||
|
await expect(dayButton).toBeVisible({ timeout: 3_000 });
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Show Reviewed switch flips its checked state", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// "Show Reviewed" is a Radix Switch (role=switch), not a button.
|
||||||
|
// It filters review data client-side; it does not trigger a new
|
||||||
|
// /api/review network request. Verify the switch state toggles.
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
const showReviewedSwitch = frigateApp.page.getByRole("switch", {
|
||||||
|
name: /show reviewed/i,
|
||||||
|
});
|
||||||
|
await expect(showReviewedSwitch).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Record initial checked state and click to toggle
|
||||||
|
const initialChecked =
|
||||||
|
await showReviewedSwitch.getAttribute("aria-checked");
|
||||||
|
await showReviewedSwitch.click();
|
||||||
|
const flippedChecked = initialChecked === "true" ? "false" : "true";
|
||||||
|
await expect(showReviewedSwitch).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
flippedChecked,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Review Page - Navigation @critical", () => {
|
test.describe("Review — timeline (desktop) @critical", () => {
|
||||||
test("navigate to review from live page works", async ({ frigateApp }) => {
|
test.skip(
|
||||||
await frigateApp.goto("/");
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
"Timeline not shown on mobile",
|
||||||
await base.navigateTo("/review");
|
);
|
||||||
await expect(frigateApp.page).toHaveURL(/\/review/);
|
|
||||||
// Severity tabs should be visible
|
test("timeline renders time markers", async ({ frigateApp }) => {
|
||||||
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
await frigateApp.goto("/review");
|
||||||
timeout: 10_000,
|
await expect
|
||||||
});
|
.poll(
|
||||||
|
async () => (await frigateApp.page.textContent("#pageRoot")) ?? "",
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.toMatch(/[AP]M|\d+:\d+/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Review — mobile @critical @mobile", () => {
|
||||||
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
test("severity tabs render on mobile", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
const review = new ReviewPage(frigateApp.page, false);
|
||||||
|
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(review.detectionsTab).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("back navigation returns to Live", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
const base = new BasePage(frigateApp.page, false);
|
||||||
|
await base.navigateTo("/review");
|
||||||
|
await expect(frigateApp.page).toHaveURL(/\/review/);
|
||||||
|
await base.navigateTo("/");
|
||||||
|
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,15 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* System page tests -- MEDIUM tier.
|
* System page tests -- MEDIUM tier (promoted to cover migrated
|
||||||
|
* RestartDialog test from radix-overlay-regressions.spec.ts).
|
||||||
*
|
*
|
||||||
* Tests system page rendering with tabs and tab switching.
|
* Tab switching, version + last-refreshed display, and the
|
||||||
* Navigates to /system#general explicitly so useHashState resolves
|
* RestartDialog cancel flow.
|
||||||
* the tab state deterministically.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import {
|
||||||
|
expectBodyInteractive,
|
||||||
|
waitForBodyInteractive,
|
||||||
|
} from "../helpers/overlay-interaction";
|
||||||
|
|
||||||
test.describe("System Page @medium", () => {
|
test.describe("System — tabs @medium", () => {
|
||||||
test("system page renders with tab buttons", async ({ frigateApp }) => {
|
test("general tab is active by default via #general hash", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
@ -20,7 +26,7 @@ test.describe("System Page @medium", () => {
|
|||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("general tab is active when navigated via hash", async ({
|
test("Storage tab activates and deactivates General", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
@ -29,18 +35,6 @@ test.describe("System Page @medium", () => {
|
|||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking Storage tab activates it and deactivates General", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/system#general");
|
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
await frigateApp.page.getByLabel("Select storage").click();
|
await frigateApp.page.getByLabel("Select storage").click();
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
@ -53,29 +47,22 @@ test.describe("System Page @medium", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking Cameras tab activates it and deactivates General", async ({
|
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await frigateApp.page.getByLabel("Select cameras").click();
|
await frigateApp.page.getByLabel("Select cameras").click();
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
);
|
);
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
|
||||||
"data-state",
|
|
||||||
"off",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("system page shows version and last refreshed", async ({
|
test("general tab shows version and last-refreshed", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
@ -87,4 +74,164 @@ test.describe("System Page @medium", () => {
|
|||||||
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
|
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
|
||||||
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
|
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("storage tab renders content after switching", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.goto("/system#general");
|
||||||
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await frigateApp.page.getByLabel("Select storage").click();
|
||||||
|
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
|
// On desktop, tab buttons render text labels so the word "storage"
|
||||||
|
// always appears in #pageRoot after switching. On mobile, tabs are
|
||||||
|
// icon-only, so we verify the general-tab content disappears instead
|
||||||
|
// (the storage tab's metrics section is hidden but general is gone).
|
||||||
|
if (!frigateApp.isMobile) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => (await frigateApp.page.textContent("#pageRoot")) ?? "",
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.toMatch(/storage|mount|disk|used|free/i);
|
||||||
|
} else {
|
||||||
|
// Mobile: tab activation (data-state "on") already asserted above.
|
||||||
|
// Additionally confirm general tab is no longer the active tab.
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByLabel("Select general"),
|
||||||
|
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/system#general");
|
||||||
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await frigateApp.page.getByLabel("Select cameras").click();
|
||||||
|
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
|
// Cameras tab lists every camera from config/stats. The default
|
||||||
|
// mock has front_door, backyard, garage.
|
||||||
|
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||||
|
await expect(
|
||||||
|
frigateApp.page
|
||||||
|
.getByText(new RegExp(cam.replace("_", ".?"), "i"))
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enrichments tab renders when semantic search is enabled", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Override config to guarantee the enrichments tab is present.
|
||||||
|
// System.tsx shows the tab when semantic_search.enabled === true.
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: { semantic_search: { enabled: true } },
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/system#general");
|
||||||
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
|
||||||
|
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
|
||||||
|
await enrichTab.click();
|
||||||
|
await expect(enrichTab).toHaveAttribute("data-state", "on", {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("System — RestartDialog @medium", () => {
|
||||||
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Sidebar menu is desktop-only",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("cancelling restart leaves body interactive", async ({ frigateApp }) => {
|
||||||
|
// Migrated from radix-overlay-regressions.spec.ts.
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
|
||||||
|
const sidebarTriggers = frigateApp.page
|
||||||
|
.locator('[role="complementary"] [aria-haspopup="menu"]')
|
||||||
|
.or(frigateApp.page.locator('aside [aria-haspopup="menu"]'));
|
||||||
|
const triggerCount = await sidebarTriggers.count();
|
||||||
|
expect(triggerCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
let opened = false;
|
||||||
|
for (let i = 0; i < triggerCount; i++) {
|
||||||
|
const trigger = sidebarTriggers.nth(i);
|
||||||
|
await trigger.click().catch(() => {});
|
||||||
|
const restartItem = frigateApp.page
|
||||||
|
.getByRole("menuitem", { name: /restart/i })
|
||||||
|
.first();
|
||||||
|
const visible = await expect(restartItem)
|
||||||
|
.toBeVisible({ timeout: 300 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (visible) {
|
||||||
|
await restartItem.click();
|
||||||
|
opened = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await frigateApp.page.keyboard.press("Escape").catch(() => {});
|
||||||
|
}
|
||||||
|
expect(opened).toBe(true);
|
||||||
|
|
||||||
|
const cancel = frigateApp.page.getByRole("button", { name: /cancel/i });
|
||||||
|
await expect(cancel).toBeVisible({ timeout: 3_000 });
|
||||||
|
await cancel.click();
|
||||||
|
|
||||||
|
await waitForBodyInteractive(frigateApp.page);
|
||||||
|
await expectBodyInteractive(frigateApp.page);
|
||||||
|
|
||||||
|
const postCancelTrigger = sidebarTriggers.first();
|
||||||
|
await postCancelTrigger.click();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page
|
||||||
|
.locator('[role="menu"], [data-radix-menu-content]')
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 3_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("System — mobile @medium @mobile", () => {
|
||||||
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/system#general");
|
||||||
|
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.goto("/system#general");
|
||||||
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await frigateApp.page.getByLabel("Select storage").click();
|
||||||
|
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
764
web/package-lock.json
generated
764
web/package-lock.json
generated
@ -12,14 +12,14 @@
|
|||||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@melloware/react-logviewer": "^6.1.2",
|
"@melloware/react-logviewer": "^6.1.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.6",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.6",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
@ -54,7 +54,7 @@
|
|||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"konva": "^10.2.3",
|
"konva": "^10.2.3",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.18.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"monaco-yaml": "^5.4.1",
|
"monaco-yaml": "^5.4.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@ -1515,17 +1515,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-alert-dialog": {
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||||
"integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==",
|
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.1",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
"@radix-ui/react-context": "1.1.1",
|
"@radix-ui/react-context": "1.1.2",
|
||||||
"@radix-ui/react-dialog": "1.1.6",
|
"@radix-ui/react-dialog": "1.1.15",
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
"@radix-ui/react-slot": "1.1.2"
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -1542,126 +1542,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": {
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
"integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/primitive": "1.1.1",
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
|
||||||
"@radix-ui/react-context": "1.1.1",
|
|
||||||
"@radix-ui/react-dismissable-layer": "1.1.5",
|
|
||||||
"@radix-ui/react-focus-guards": "1.1.1",
|
|
||||||
"@radix-ui/react-focus-scope": "1.1.2",
|
|
||||||
"@radix-ui/react-id": "1.1.0",
|
|
||||||
"@radix-ui/react-portal": "1.1.4",
|
|
||||||
"@radix-ui/react-presence": "1.1.2",
|
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
|
||||||
"@radix-ui/react-slot": "1.1.2",
|
|
||||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
|
||||||
"aria-hidden": "^1.2.4",
|
|
||||||
"react-remove-scroll": "^2.6.3"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": {
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dismissable-layer": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/primitive": "1.1.1",
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
|
||||||
"@radix-ui/react-use-escape-keydown": "1.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-focus-scope": {
|
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
"integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==",
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-portal": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
|
||||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-escape-keydown": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
@ -1672,6 +1563,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
@ -2113,17 +2027,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-context-menu": {
|
"node_modules/@radix-ui/react-context-menu": {
|
||||||
"version": "2.2.6",
|
"version": "2.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
|
||||||
"integrity": "sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==",
|
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.1",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
"@radix-ui/react-context": "1.1.1",
|
"@radix-ui/react-context": "1.1.2",
|
||||||
"@radix-ui/react-menu": "2.1.6",
|
"@radix-ui/react-menu": "2.1.16",
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2140,6 +2054,99 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-dialog": {
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||||
@ -2197,21 +2204,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": {
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
@ -2398,18 +2390,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||||
"version": "2.1.6",
|
"version": "2.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
|
||||||
"integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==",
|
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.1",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
"@radix-ui/react-context": "1.1.1",
|
"@radix-ui/react-context": "1.1.2",
|
||||||
"@radix-ui/react-id": "1.1.0",
|
"@radix-ui/react-id": "1.1.1",
|
||||||
"@radix-ui/react-menu": "2.1.6",
|
"@radix-ui/react-menu": "2.1.16",
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2426,10 +2418,106 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-focus-guards": {
|
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
"integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==",
|
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2505,20 +2593,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-hover-card": {
|
"node_modules/@radix-ui/react-hover-card": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
|
||||||
"integrity": "sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==",
|
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.1",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
"@radix-ui/react-context": "1.1.1",
|
"@radix-ui/react-context": "1.1.2",
|
||||||
"@radix-ui/react-dismissable-layer": "1.1.5",
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
"@radix-ui/react-popper": "1.2.2",
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
"@radix-ui/react-portal": "1.1.4",
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
"@radix-ui/react-presence": "1.1.2",
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2535,17 +2623,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": {
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-presence": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
"integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.1",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
|
||||||
"@radix-ui/react-use-escape-keydown": "1.1.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2562,14 +2668,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-portal": {
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
|
||||||
"version": "1.1.4",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
"integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2586,13 +2691,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-escape-keydown": {
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
"integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==",
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0"
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2604,6 +2710,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-icons": {
|
"node_modules/@radix-ui/react-icons": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
|
||||||
@ -2678,27 +2799,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-menu": {
|
"node_modules/@radix-ui/react-menu": {
|
||||||
"version": "2.1.6",
|
"version": "2.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||||
"integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==",
|
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.1",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
"@radix-ui/react-collection": "1.1.2",
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
"@radix-ui/react-context": "1.1.1",
|
"@radix-ui/react-context": "1.1.2",
|
||||||
"@radix-ui/react-direction": "1.1.0",
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
"@radix-ui/react-dismissable-layer": "1.1.5",
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
"@radix-ui/react-focus-guards": "1.1.1",
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
"@radix-ui/react-focus-scope": "1.1.2",
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
"@radix-ui/react-id": "1.1.0",
|
"@radix-ui/react-id": "1.1.1",
|
||||||
"@radix-ui/react-popper": "1.2.2",
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
"@radix-ui/react-portal": "1.1.4",
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
"@radix-ui/react-presence": "1.1.2",
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
"@radix-ui/react-roving-focus": "1.1.2",
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
"@radix-ui/react-slot": "1.1.2",
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
"aria-hidden": "^1.2.4",
|
"aria-hidden": "^1.2.4",
|
||||||
"react-remove-scroll": "^2.6.3"
|
"react-remove-scroll": "^2.6.3"
|
||||||
},
|
},
|
||||||
@ -2717,17 +2838,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
"integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.1",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
"@radix-ui/react-context": "1.1.2",
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
"@radix-ui/react-use-escape-keydown": "1.1.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2744,15 +2870,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": {
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
"integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==",
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-direction": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
},
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0"
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2769,14 +2942,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": {
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": {
|
||||||
"version": "1.1.4",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
"integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@ -2793,14 +2965,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-escape-keydown": {
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||||
"integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==",
|
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0"
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
@ -2869,21 +3103,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id": {
|
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
@ -3717,21 +3936,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-guards": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-id": {
|
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-id": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
@ -9432,15 +9636,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.23",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
|
|||||||
@ -26,14 +26,14 @@
|
|||||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@melloware/react-logviewer": "^6.1.2",
|
"@melloware/react-logviewer": "^6.1.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.6",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.6",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
@ -68,7 +68,7 @@
|
|||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"konva": "^10.2.3",
|
"konva": "^10.2.3",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.18.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"monaco-yaml": "^5.4.1",
|
"monaco-yaml": "^5.4.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@ -1 +1,8 @@
|
|||||||
{}
|
{
|
||||||
|
"auth": {
|
||||||
|
"label": "Автентикация",
|
||||||
|
"session_length": {
|
||||||
|
"label": "Продължителност на сесията"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -109,7 +109,8 @@
|
|||||||
"classification": "Classificació",
|
"classification": "Classificació",
|
||||||
"chat": "Xat",
|
"chat": "Xat",
|
||||||
"actions": "Accions",
|
"actions": "Accions",
|
||||||
"profiles": "Perfils"
|
"profiles": "Perfils",
|
||||||
|
"features": "Característiques"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": {
|
"previous": {
|
||||||
|
|||||||
@ -60,15 +60,76 @@
|
|||||||
"noVaildTimeSelected": "No s'ha seleccionat un rang de temps vàlid",
|
"noVaildTimeSelected": "No s'ha seleccionat un rang de temps vàlid",
|
||||||
"failed": "No s'ha pogut inciar l'exportació: {{error}}"
|
"failed": "No s'ha pogut inciar l'exportació: {{error}}"
|
||||||
},
|
},
|
||||||
"view": "Vista"
|
"view": "Vista",
|
||||||
|
"queued": "Exporta a la cua. Mostra el progrés a la pàgina d'exportacions.",
|
||||||
|
"batchSuccess_one": "S'ha iniciat l'exportació 1. Obrint el cas ara.",
|
||||||
|
"batchSuccess_many": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||||
|
"batchSuccess_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||||
|
"batchPartial": "S'han iniciat {{successful}} de {{total}} exportacions. Càmeres fallides: {{failedCameras}}",
|
||||||
|
"batchFailed": "No s'han pogut iniciar {{total}} exportacions. Càmeres fallides: {{failedCameras}}",
|
||||||
|
"batchQueuedSuccess_one": "Exporta a la cua 1. Obrint el cas ara.",
|
||||||
|
"batchQueuedSuccess_many": "{{count}} exportacions a la cua. Obrint el cas ara.",
|
||||||
|
"batchQueuedSuccess_other": "{{count}} exportacions a la cua. Obrint el cas ara.",
|
||||||
|
"batchQueuedPartial": "{{successful}} de {{total}} exportacions a la cua. Càmeres fallides: {{failedCameras}}",
|
||||||
|
"batchQueueFailed": "No s'han pogut posar a la cua {{total}} exportacions. Càmeres fallides: {{failedCameras}}"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Guardar exportació",
|
"saveExport": "Guardar exportació",
|
||||||
"previewExport": "Previsualitzar exportació"
|
"previewExport": "Previsualitzar exportació",
|
||||||
|
"queueingExport": "S'està fent la cua de l'exportació...",
|
||||||
|
"useThisRange": "Utilitza aquest interval"
|
||||||
},
|
},
|
||||||
"case": {
|
"case": {
|
||||||
"label": "Cas",
|
"label": "Cas",
|
||||||
"placeholder": "Selecciona un cas"
|
"placeholder": "Selecciona un cas",
|
||||||
|
"newCaseOption": "Crea un cas no",
|
||||||
|
"newCaseNamePlaceholder": "Nom de cas nou",
|
||||||
|
"newCaseDescriptionPlaceholder": "Descripció del cas",
|
||||||
|
"nonAdminHelp": "Es crearà un nou cas per a aquestes exportacions."
|
||||||
|
},
|
||||||
|
"queueing": "S'està fent la cua de l'exportació...",
|
||||||
|
"tabs": {
|
||||||
|
"export": "Càmera única",
|
||||||
|
"multiCamera": "Multicàmera"
|
||||||
|
},
|
||||||
|
"multiCamera": {
|
||||||
|
"timeRange": "Interval de temps",
|
||||||
|
"selectFromTimeline": "Selecciona des de la línia de temps",
|
||||||
|
"cameraSelection": "Càmeres",
|
||||||
|
"cameraSelectionHelp": "Les càmeres amb objectes rastrejats en aquest interval de temps estan preseleccionades",
|
||||||
|
"checkingActivity": "Comprovant l'activitat de la càmera...",
|
||||||
|
"noCameras": "No hi ha càmeres disponibles",
|
||||||
|
"detectionCount_one": "1 objecte rastrejat",
|
||||||
|
"detectionCount_many": "{{count}} objectes rastrejats",
|
||||||
|
"detectionCount_other": "{{count}} objectes rastrejats",
|
||||||
|
"nameLabel": "Nom de l'exportació",
|
||||||
|
"namePlaceholder": "Nom base opcional per a aquestes exportacions",
|
||||||
|
"queueingButton": "S'estan posant a la cua les exportacions...",
|
||||||
|
"exportButton_one": "Exporta 1 càmera",
|
||||||
|
"exportButton_many": "Exporta {{count}} càmeres",
|
||||||
|
"exportButton_other": "Exporta {{count}} càmeres"
|
||||||
|
},
|
||||||
|
"multi": {
|
||||||
|
"title_one": "Exporta {{count}} ressenyes",
|
||||||
|
"title_many": "Exporta {{count}} ressenyes",
|
||||||
|
"title_other": "Exporta {{count}} ressenyes",
|
||||||
|
"description": "Exporta cada revisió seleccionada. Totes les exportacions s'agruparan en un sol cas.",
|
||||||
|
"descriptionNoCase": "Exporta cada revisió seleccionada.",
|
||||||
|
"caseNamePlaceholder": "Exporta la revisió - {{date}}",
|
||||||
|
"exportButton_one": "Exporta {{count}} ressenyes",
|
||||||
|
"exportButton_many": "Exporta {{count}} ressenyes",
|
||||||
|
"exportButton_other": "Exporta {{count}} ressenyes",
|
||||||
|
"exportingButton": "S'està exportant...",
|
||||||
|
"toast": {
|
||||||
|
"started_one": "S'ha iniciat l'exportació 1. Obrint el cas ara.",
|
||||||
|
"started_many": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||||
|
"started_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||||
|
"startedNoCase_one": "S'ha iniciat l'exportació 1.",
|
||||||
|
"startedNoCase_many": "S'han iniciat {{count}} exportacions.",
|
||||||
|
"startedNoCase_other": "S'han iniciat {{count}} exportacions.",
|
||||||
|
"partial": "S'han iniciat {{successful}} de {{total}} exportacions. Ha fallat: {{failedItems}}",
|
||||||
|
"failed": "No s'han pogut iniciar {{total}} exportacions. Ha fallat: {{failedItems}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
@ -116,6 +177,14 @@
|
|||||||
"success": "Els enregistraments de vídeo associats als elements de revisió seleccionats s’han suprimit correctament.",
|
"success": "Els enregistraments de vídeo associats als elements de revisió seleccionats s’han suprimit correctament.",
|
||||||
"error": "No s'ha pogut suprimir: {{error}}"
|
"error": "No s'ha pogut suprimir: {{error}}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shareTimestamp": {
|
||||||
|
"label": "Comparteix la marca horària",
|
||||||
|
"title": "Comparteix la marca horària",
|
||||||
|
"description": "Comparteix un URL amb marca horària de la posició actual del jugador o tria una marca horària personalitzada. Tingueu en compte que aquest no és un URL de compartició pública i només és accessible per als usuaris amb accés a Frigate i aquesta càmera.",
|
||||||
|
"custom": "Marca horària personalitzada",
|
||||||
|
"button": "Comparteix l'URL de la marca horària",
|
||||||
|
"shareTitle": "Marca de temps de revisió de Frigate: {{camera}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"imagePicker": {
|
"imagePicker": {
|
||||||
|
|||||||
@ -32,7 +32,8 @@
|
|||||||
"noPreviewFoundFor": "No s'ha trobat cap previsualització per a {{cameraName}}",
|
"noPreviewFoundFor": "No s'ha trobat cap previsualització per a {{cameraName}}",
|
||||||
"submitFrigatePlus": {
|
"submitFrigatePlus": {
|
||||||
"title": "Enviar aquesta imatge a Frigate+?",
|
"title": "Enviar aquesta imatge a Frigate+?",
|
||||||
"submit": "Enviar"
|
"submit": "Enviar",
|
||||||
|
"previewError": "No s'ha pogut carregar la vista prèvia de la instantània. És possible que l'enregistrament no estigui disponible en aquest moment."
|
||||||
},
|
},
|
||||||
"livePlayerRequiredIOSVersion": "Es requereix iOS 17.1 o superior per a aquest tipus de reproducció en directe.",
|
"livePlayerRequiredIOSVersion": "Es requereix iOS 17.1 o superior per a aquest tipus de reproducció en directe.",
|
||||||
"streamOffline": {
|
"streamOffline": {
|
||||||
|
|||||||
@ -1951,7 +1951,7 @@
|
|||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"label": "Rols",
|
"label": "Rols",
|
||||||
"description": "Funcions genAI (eines, visió, incrustacions); un proveïdor per rol."
|
"description": "Rols de GenAI (xat, descripcions, incrustacions); un proveïdor per rol."
|
||||||
},
|
},
|
||||||
"provider_options": {
|
"provider_options": {
|
||||||
"label": "Opcions del proveïdor",
|
"label": "Opcions del proveïdor",
|
||||||
|
|||||||
@ -27,7 +27,9 @@
|
|||||||
},
|
},
|
||||||
"documentTitle": "Revisió - Frigate",
|
"documentTitle": "Revisió - Frigate",
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"documentTitle": "Enregistraments - Frigate"
|
"documentTitle": "Enregistraments - Frigate",
|
||||||
|
"invalidSharedLink": "No s'ha pogut obrir l'enllaç d'enregistrament amb marques de temps a causa d'un error d'anàlisi.",
|
||||||
|
"invalidSharedCamera": "No s'ha pogut obrir l'enllaç d'enregistrament amb marques de temps a causa d'una càmera desconeguda o no autoritzada."
|
||||||
},
|
},
|
||||||
"calendarFilter": {
|
"calendarFilter": {
|
||||||
"last24Hours": "Últimes 24 hores"
|
"last24Hours": "Últimes 24 hores"
|
||||||
|
|||||||
@ -248,7 +248,7 @@
|
|||||||
"dialog": {
|
"dialog": {
|
||||||
"confirmDelete": {
|
"confirmDelete": {
|
||||||
"title": "Confirmar la supressió",
|
"title": "Confirmar la supressió",
|
||||||
"desc": "Eliminant aquest objecte seguit borrarà l'snapshot, qualsevol embedding gravat, i qualsevol detall de seguiment. Les imatges gravades d'aquest objecte seguit en l'historial <em>NO</em> seràn eliminades.<br /><br />Estas segur que vols continuar?"
|
"desc": "Suprimir aquest objecte rastrejat elimina la instantània, qualsevol incrustació desada, i qualsevol entrada de detalls de seguiment associada. Les imatges gravades d'aquest objecte seguit en l'historial <em>NO</em> seràn eliminades.<br /><br />Estas segur que vols continuar?"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"error": "S'ha produït un error en suprimir aquest objecte rastrejat: {{errorMessage}}"
|
"error": "S'ha produït un error en suprimir aquest objecte rastrejat: {{errorMessage}}"
|
||||||
@ -289,7 +289,10 @@
|
|||||||
"zones": "Zones",
|
"zones": "Zones",
|
||||||
"ratio": "Ràtio",
|
"ratio": "Ràtio",
|
||||||
"area": "Àrea",
|
"area": "Àrea",
|
||||||
"score": "Puntuació"
|
"score": "Puntuació",
|
||||||
|
"computedScore": "Puntuació calculada",
|
||||||
|
"topScore": "Puntuació superior",
|
||||||
|
"toggleAdvancedScores": "Commuta les puntuacions avançades"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
|
|||||||
@ -14,7 +14,9 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"error": {
|
"error": {
|
||||||
"renameExportFailed": "Error al canviar el nom de l’exportació: {{errorMessage}}",
|
"renameExportFailed": "Error al canviar el nom de l’exportació: {{errorMessage}}",
|
||||||
"assignCaseFailed": "No s'ha pogut actualitzar l'assignació de cas:{{errorMessage}}"
|
"assignCaseFailed": "No s'ha pogut actualitzar l'assignació de cas:{{errorMessage}}",
|
||||||
|
"caseSaveFailed": "No s'ha pogut desar el cas: {{errorMessage}}",
|
||||||
|
"caseDeleteFailed": "No s'ha pogut suprimir el cas: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
@ -22,7 +24,8 @@
|
|||||||
"downloadVideo": "Baixa el vídeo",
|
"downloadVideo": "Baixa el vídeo",
|
||||||
"editName": "Edita el nom",
|
"editName": "Edita el nom",
|
||||||
"deleteExport": "Suprimeix l'exportació",
|
"deleteExport": "Suprimeix l'exportació",
|
||||||
"assignToCase": "Afegeix al cas"
|
"assignToCase": "Afegeix al cas",
|
||||||
|
"removeFromCase": "Elimina del cas"
|
||||||
},
|
},
|
||||||
"headings": {
|
"headings": {
|
||||||
"cases": "Casos",
|
"cases": "Casos",
|
||||||
@ -35,5 +38,91 @@
|
|||||||
"newCaseOption": "Crea un cas nou",
|
"newCaseOption": "Crea un cas nou",
|
||||||
"nameLabel": "Nom del cas",
|
"nameLabel": "Nom del cas",
|
||||||
"descriptionLabel": "Descripció"
|
"descriptionLabel": "Descripció"
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"newCase": "Cas nou",
|
||||||
|
"addExport": "Afegeix una exportació",
|
||||||
|
"editCase": "Edita el cas",
|
||||||
|
"deleteCase": "Suprimeix el cas"
|
||||||
|
},
|
||||||
|
"deleteCase": {
|
||||||
|
"label": "Suprimeix el cas",
|
||||||
|
"desc": "Esteu segur que voleu suprimir {{caseName}}?",
|
||||||
|
"descKeepExports": "Les exportacions continuaran estant disponibles com a exportacions sense categoria.",
|
||||||
|
"descDeleteExports": "Totes les exportacions en aquest cas s'eliminaran permanentment.",
|
||||||
|
"deleteExports": "Elimina també les exportacions"
|
||||||
|
},
|
||||||
|
"caseCard": {
|
||||||
|
"emptyCase": "Encara no hi ha exportacions"
|
||||||
|
},
|
||||||
|
"jobCard": {
|
||||||
|
"defaultName": "Exportació de {{camera}}",
|
||||||
|
"queued": "En cua",
|
||||||
|
"running": "En execució",
|
||||||
|
"preparing": "Preparant",
|
||||||
|
"copying": "Copiant",
|
||||||
|
"encoding": "Codificant",
|
||||||
|
"encodingRetry": "Codificant (reintent)",
|
||||||
|
"finalizing": "Finalitzant"
|
||||||
|
},
|
||||||
|
"caseView": {
|
||||||
|
"noDescription": "Sense descripció",
|
||||||
|
"createdAt": "{{value}} creat",
|
||||||
|
"exportCount_one": "1 exportació",
|
||||||
|
"exportCount_other": "{{count}} exportacions",
|
||||||
|
"cameraCount_one": "1 càmera",
|
||||||
|
"cameraCount_other": "{{count}} càmeres",
|
||||||
|
"showMore": "Mostra'n més",
|
||||||
|
"showLess": "Mostra menys",
|
||||||
|
"emptyTitle": "Aquest cas és buit",
|
||||||
|
"emptyDescription": "Afegeix les exportacions no categoritzades existents per mantenir el cas organitzat.",
|
||||||
|
"emptyDescriptionNoExports": "Encara no hi ha exportacions sense categoria per afegir."
|
||||||
|
},
|
||||||
|
"caseEditor": {
|
||||||
|
"createTitle": "Crea un cas",
|
||||||
|
"editTitle": "Edita el cas",
|
||||||
|
"namePlaceholder": "Nom del cas",
|
||||||
|
"descriptionPlaceholder": "Afegeix notes o context per a aquest cas"
|
||||||
|
},
|
||||||
|
"addExportDialog": {
|
||||||
|
"title": "Afegeix l'exportació a {{caseName}}",
|
||||||
|
"searchPlaceholder": "Cerca exportacions sense categoria",
|
||||||
|
"empty": "No hi ha exportacions sense categoria que coincideixin amb aquesta cerca.",
|
||||||
|
"addButton_one": "Afegeix 1 exportació",
|
||||||
|
"addButton_other": "Afegeix {{count}} exportacions",
|
||||||
|
"adding": "S'està afegint..."
|
||||||
|
},
|
||||||
|
"selected_one": "{{count}} seleccionats",
|
||||||
|
"selected_other": "{{count}} seleccionats",
|
||||||
|
"bulkActions": {
|
||||||
|
"addToCase": "Afegeix al cas",
|
||||||
|
"moveToCase": "Mou al cas",
|
||||||
|
"removeFromCase": "Elimina del cas",
|
||||||
|
"delete": "Suprimeix",
|
||||||
|
"deleteNow": "Suprimeix ara"
|
||||||
|
},
|
||||||
|
"bulkDelete": {
|
||||||
|
"title": "Suprimeix les exportacions",
|
||||||
|
"desc_one": "Esteu segur que voleu suprimir {{count}} l'exportació?",
|
||||||
|
"desc_other": "steu segur que voleu suprimir {{count}} exportacions?"
|
||||||
|
},
|
||||||
|
"bulkRemoveFromCase": {
|
||||||
|
"title": "Elimina del cas",
|
||||||
|
"desc_one": "Voleu suprimir {{count}} d'aquest cas?",
|
||||||
|
"desc_other": "Voleu eliminar {{count}} exportacions d'aquest cas?",
|
||||||
|
"descKeepExports": "Les exportacions es mouran a sense categoria.",
|
||||||
|
"descDeleteExports": "Les exportacions s'eliminaran permanentment.",
|
||||||
|
"deleteExports": "Suprimeix les exportacions"
|
||||||
|
},
|
||||||
|
"bulkToast": {
|
||||||
|
"success": {
|
||||||
|
"delete": "Exportacions suprimides amb èxit",
|
||||||
|
"reassign": "Assignació de cas actualitzada amb èxit",
|
||||||
|
"remove": "S'han eliminat les exportacions del cas"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"deleteFailed": "No s'han pogut suprimir les exportacions: {{errorMessage}}",
|
||||||
|
"reassignFailed": "No s'ha pogut actualitzar l'assignació de cas: {{errorMessage}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"uploadFace": "Puja una imatge del rostre",
|
"uploadFace": "Puja una imatge del rostre",
|
||||||
"nextSteps": "Següents passos",
|
"nextSteps": "Següents passos",
|
||||||
"description": {
|
"description": {
|
||||||
"uploadFace": "Puja una imatge de {{name}} que mostri el seu rostre de cares. No cal que la imatge estigui retallada només al rostre."
|
"uploadFace": "Pugeu una imatge de {{name}} que mostra la seva cara des d'un angle frontal. La imatge no necessita ser retallada a la seva cara."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selectFace": "Seleccionar rostre",
|
"selectFace": "Seleccionar rostre",
|
||||||
|
|||||||
@ -1280,7 +1280,8 @@
|
|||||||
},
|
},
|
||||||
"hikvision": {
|
"hikvision": {
|
||||||
"substreamWarning": "El substream 1 està bloquejat a una resolució baixa. Moltes càmeres Hikvision suporten subfluxos addicionals que han d'estar habilitats a la configuració de la càmera. Es recomana comprovar i utilitzar aquests corrents si estan disponibles."
|
"substreamWarning": "El substream 1 està bloquejat a una resolució baixa. Moltes càmeres Hikvision suporten subfluxos addicionals que han d'estar habilitats a la configuració de la càmera. Es recomana comprovar i utilitzar aquests corrents si estan disponibles."
|
||||||
}
|
},
|
||||||
|
"resolutionUnknown": "La resolució d'aquest flux no s'ha pogut investigar. Heu d'establir manualment la resolució de detecció a Configuració o a la configuració."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1297,7 +1298,13 @@
|
|||||||
"enableDesc": "Inhabilita temporalment una càmera habilitada fins que es reiniciï Frigate. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
|
"enableDesc": "Inhabilita temporalment una càmera habilitada fins que es reiniciï Frigate. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
|
||||||
"disableLabel": "Càmeres inhabilitades",
|
"disableLabel": "Càmeres inhabilitades",
|
||||||
"disableDesc": "Habilita una càmera que actualment no és visible a la interfície d'usuari i està desactivada a la configuració. Es requereix un reinici de Frigate després d'activar-la.",
|
"disableDesc": "Habilita una càmera que actualment no és visible a la interfície d'usuari i està desactivada a la configuració. Es requereix un reinici de Frigate després d'activar-la.",
|
||||||
"enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis."
|
"enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis.",
|
||||||
|
"friendlyName": {
|
||||||
|
"edit": "Edita el nom de la pantalla de la càmera",
|
||||||
|
"title": "Edita el nom de la pantalla",
|
||||||
|
"description": "Estableix el nom amigable que es mostra per a aquesta càmera a tota la interfície d'usuari de la Fragata. Deixeu-ho en blanc per utilitzar l'ID de la càmera.",
|
||||||
|
"rename": "Canvia el nom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cameraConfig": {
|
"cameraConfig": {
|
||||||
"add": "Afegeix una càmera",
|
"add": "Afegeix una càmera",
|
||||||
@ -1659,7 +1666,16 @@
|
|||||||
"empty": "No hi ha etiquetes disponibles",
|
"empty": "No hi ha etiquetes disponibles",
|
||||||
"allNonAlertDetections": "Totes les activitats no alertes s'inclouran com a deteccions."
|
"allNonAlertDetections": "Totes les activitats no alertes s'inclouran com a deteccions."
|
||||||
},
|
},
|
||||||
"addCustomLabel": "Afegeix una etiqueta personalitzada..."
|
"addCustomLabel": "Afegeix una etiqueta personalitzada...",
|
||||||
|
"genaiModel": {
|
||||||
|
"placeholder": "Selecciona el model…",
|
||||||
|
"search": "Cerca models…",
|
||||||
|
"noModels": "No hi ha models disponibles"
|
||||||
|
},
|
||||||
|
"knownPlates": {
|
||||||
|
"namePlaceholder": "per exemple. Cotxe de la parella",
|
||||||
|
"platePlaceholder": "Matricula o regex"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"globalConfig": {
|
"globalConfig": {
|
||||||
"title": "Configuració global",
|
"title": "Configuració global",
|
||||||
|
|||||||
@ -64,20 +64,73 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"error": {
|
"error": {
|
||||||
"endTimeMustAfterStartTime": "Die Endzeit darf nicht vor der Startzeit liegen",
|
"endTimeMustAfterStartTime": "Die Endzeit darf nicht vor der Startzeit liegen",
|
||||||
"failed": "Fehler beim Starten des Exports: {{error}}",
|
"failed": "Fehler beim Export in die Warteschlange: {{error}}",
|
||||||
"noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt"
|
"noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt"
|
||||||
},
|
},
|
||||||
"success": "Export erfolgreich gestartet. Die Datei befindet sich auf der Exportseite.",
|
"success": "Export erfolgreich gestartet. Die Datei befindet sich auf der Exportseite.",
|
||||||
"view": "Ansicht"
|
"view": "Ansicht",
|
||||||
|
"queued": "Export in Warteschlange gestellt. Fortschritt auf der Exportseite verfolgen.",
|
||||||
|
"batchSuccess_one": "1 Export gestartet. Öffne den Fall jetzt.",
|
||||||
|
"batchSuccess_other": "{{count}} Exports gestartet. Öffne den Fall jetzt.",
|
||||||
|
"batchPartial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagene Kameras: {{failedCameras}}",
|
||||||
|
"batchFailed": "Fehler beim Starten der {{total}} Exporte. Fehlgeschlagene Kameras: {{failedCameras}}",
|
||||||
|
"batchQueuedSuccess_one": "1 Export in die Warteschlange gestellt. Fall wird jetzt geöffnet.",
|
||||||
|
"batchQueuedSuccess_other": "{{count}} Exporte in der Warteschlange. Fall wird jetzt geöffnet.",
|
||||||
|
"batchQueuedPartial": "{{successful}} von {{total}} Exporten in die Warteschlange gestellt. Fehlerhafte Kameras: {{failedCameras}}",
|
||||||
|
"batchQueueFailed": "Fehler beim Einreihen von {{total}} Exporten in die Warteschlange. Fehlerhafte Kameras: {{failedCameras}}"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Export speichern",
|
"saveExport": "Export speichern",
|
||||||
"previewExport": "Exportvorschau"
|
"previewExport": "Exportvorschau",
|
||||||
|
"queueingExport": "Export wird in die Warteschlange gestellt...",
|
||||||
|
"useThisRange": "Nutzen Sie diesen Bereich"
|
||||||
},
|
},
|
||||||
"export": "Exportieren",
|
"export": "Exportieren",
|
||||||
"case": {
|
"case": {
|
||||||
"label": "Fall",
|
"label": "Fall",
|
||||||
"placeholder": "Einen Fall auswählen"
|
"placeholder": "Einen Fall auswählen",
|
||||||
|
"newCaseOption": "Einen neuen Fall erstellen",
|
||||||
|
"newCaseNamePlaceholder": "Neuer Fallname",
|
||||||
|
"newCaseDescriptionPlaceholder": "Fall Beschreibung",
|
||||||
|
"nonAdminHelp": "Für diese Exporte wird ein neuer Fall angelegt."
|
||||||
|
},
|
||||||
|
"queueing": "Export wird in die Warteschlange gestellt...",
|
||||||
|
"tabs": {
|
||||||
|
"export": "Einzelne Kamera",
|
||||||
|
"multiCamera": "Mehrere-Kameras"
|
||||||
|
},
|
||||||
|
"multiCamera": {
|
||||||
|
"timeRange": "Zeitbereich",
|
||||||
|
"selectFromTimeline": "Wählen Sie aus der Zeitleiste aus",
|
||||||
|
"cameraSelection": "Kameras",
|
||||||
|
"cameraSelectionHelp": "Kameras, die in diesem Zeitbereich Objekte verfolgen, sind vorausgewählt",
|
||||||
|
"checkingActivity": "Kameraaktivität wird überprüft...",
|
||||||
|
"noCameras": "keine kamaeras verfügbar",
|
||||||
|
"detectionCount_one": "1 verfolgtes Objekt",
|
||||||
|
"detectionCount_other": "{{count}} verfolgtesObjekte",
|
||||||
|
"nameLabel": "Export Name",
|
||||||
|
"namePlaceholder": "Optionaler Basisname für diese Exporte",
|
||||||
|
"queueingButton": "Exporte werden in die Warteschlange gestellt...",
|
||||||
|
"exportButton_one": "Export 1 Kamera",
|
||||||
|
"exportButton_other": "xport {{count}} Kameras"
|
||||||
|
},
|
||||||
|
"multi": {
|
||||||
|
"title_one": "1 Bewertung exportieren",
|
||||||
|
"title_other": "{{count}} Bewertung exportieren",
|
||||||
|
"description": "Exportieren Sie jede ausgewählte Rezension. Alle Exporte werden in einem einzigen Fall zusammengefasst.",
|
||||||
|
"descriptionNoCase": "Jede ausgewählte Bewertung exportieren.",
|
||||||
|
"caseNamePlaceholder": "Export prüfen - {{date}}",
|
||||||
|
"exportButton_one": "1 Bewertung exportieren",
|
||||||
|
"exportButton_other": "{{count}} Bewertung exportieren",
|
||||||
|
"exportingButton": "Exportieren...",
|
||||||
|
"toast": {
|
||||||
|
"started_one": "1 Export gestartet. Fall wird jetzt geöffnet.",
|
||||||
|
"started_other": "{{count}} Exporte gestartet. Fall wird jetzt geöffnet.",
|
||||||
|
"startedNoCase_one": "1 Export gestartet.",
|
||||||
|
"startedNoCase_other": "{{count}} Exports gestartet.",
|
||||||
|
"partial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagen: {{failedItems}}",
|
||||||
|
"failed": "Fehler beim Starten der {{total}} Exporte. Fehler: {{failedItems}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
|
|||||||
@ -1710,7 +1710,7 @@
|
|||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"label": "Rollen",
|
"label": "Rollen",
|
||||||
"description": "GenAI-Rollen (Tools, Vision, Einbettungen); ein Anbieter pro Rolle."
|
"description": "GenAI-Rollen (Nachrichten, Beschreibung, Einbettungen); ein Anbieter pro Rolle."
|
||||||
},
|
},
|
||||||
"provider_options": {
|
"provider_options": {
|
||||||
"label": "Anbieter Optionen",
|
"label": "Anbieter Optionen",
|
||||||
|
|||||||
@ -282,7 +282,10 @@
|
|||||||
"zones": "Zonen",
|
"zones": "Zonen",
|
||||||
"ratio": "Verhältnis",
|
"ratio": "Verhältnis",
|
||||||
"area": "Bereich",
|
"area": "Bereich",
|
||||||
"score": "Bewertung"
|
"score": "Bewertung",
|
||||||
|
"computedScore": "Berechnetes Ergebnis",
|
||||||
|
"topScore": "Bester Treffer",
|
||||||
|
"toggleAdvancedScores": "Erweiterte Ergebnisse umschalten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
|
|||||||
@ -14,7 +14,9 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"error": {
|
"error": {
|
||||||
"renameExportFailed": "Umbenennen des Exports fehlgeschlagen: {{errorMessage}}",
|
"renameExportFailed": "Umbenennen des Exports fehlgeschlagen: {{errorMessage}}",
|
||||||
"assignCaseFailed": "Aktualisierung der Fallzuweisung fehlgeschlagen: {{errorMessage}}"
|
"assignCaseFailed": "Aktualisierung der Fallzuweisung fehlgeschlagen: {{errorMessage}}",
|
||||||
|
"caseSaveFailed": "Fehler beim speichern vom Fall: {{errorMessage}}",
|
||||||
|
"caseDeleteFailed": "Fehler beim löschem vom Fall: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
@ -22,7 +24,8 @@
|
|||||||
"downloadVideo": "Video herunterladen",
|
"downloadVideo": "Video herunterladen",
|
||||||
"editName": "Name ändern",
|
"editName": "Name ändern",
|
||||||
"deleteExport": "Export löschen",
|
"deleteExport": "Export löschen",
|
||||||
"assignToCase": "Hinzufügen zum Fall"
|
"assignToCase": "Hinzufügen zum Fall",
|
||||||
|
"removeFromCase": "Vom Gehäuse entfernen"
|
||||||
},
|
},
|
||||||
"headings": {
|
"headings": {
|
||||||
"cases": "Fälle",
|
"cases": "Fälle",
|
||||||
@ -35,5 +38,91 @@
|
|||||||
"newCaseOption": "Neuen Fall erstellen",
|
"newCaseOption": "Neuen Fall erstellen",
|
||||||
"nameLabel": "Fallname",
|
"nameLabel": "Fallname",
|
||||||
"descriptionLabel": "Beschreibung"
|
"descriptionLabel": "Beschreibung"
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"newCase": "Neuer Fall",
|
||||||
|
"addExport": "Zum expotieren hinzufügen",
|
||||||
|
"editCase": "Fall bearbeiten",
|
||||||
|
"deleteCase": "Fall löschen"
|
||||||
|
},
|
||||||
|
"deleteCase": {
|
||||||
|
"label": "Fall löschen",
|
||||||
|
"desc": "Sind sie sich sicher löschen von{{caseName}}?",
|
||||||
|
"descKeepExports": "Exporte bleiben als nicht kategorisierte Exporte verfügbar.",
|
||||||
|
"descDeleteExports": "Alle Exporte werden in diesem Fall endgültig gelöscht.",
|
||||||
|
"deleteExports": "Exporte auch löschen"
|
||||||
|
},
|
||||||
|
"caseCard": {
|
||||||
|
"emptyCase": "Noch keine Exporte"
|
||||||
|
},
|
||||||
|
"jobCard": {
|
||||||
|
"defaultName": "{{camera}} export",
|
||||||
|
"queued": "In der Warteschlange",
|
||||||
|
"running": "läuft",
|
||||||
|
"preparing": "Vorbereitung",
|
||||||
|
"copying": "kopieren",
|
||||||
|
"encoding": "Codierung",
|
||||||
|
"encodingRetry": "Kodierung (Wiederholung)",
|
||||||
|
"finalizing": "Abschließen"
|
||||||
|
},
|
||||||
|
"caseView": {
|
||||||
|
"noDescription": "keine Beschreibung",
|
||||||
|
"createdAt": "Erstellt {{value}}",
|
||||||
|
"exportCount_one": "1 Export",
|
||||||
|
"exportCount_other": "{{count}} Exports",
|
||||||
|
"cameraCount_one": "1 Kamera",
|
||||||
|
"cameraCount_other": "{{count}} Kameras",
|
||||||
|
"showMore": "Mehr anzeigen",
|
||||||
|
"showLess": "Weniger Anzeigen",
|
||||||
|
"emptyTitle": "Der Fall ist leer",
|
||||||
|
"emptyDescription": "Fügen Sie vorhandene, nicht kategorisierte Exporte hinzu, um den Fall übersichtlich zu halten.",
|
||||||
|
"emptyDescriptionNoExports": "Es sind noch keine nicht kategorisierten Exporte zum Hinzufügen verfügbar."
|
||||||
|
},
|
||||||
|
"caseEditor": {
|
||||||
|
"createTitle": "Fall erstellen",
|
||||||
|
"editTitle": "Fall bearbeiten",
|
||||||
|
"namePlaceholder": "Fall Name",
|
||||||
|
"descriptionPlaceholder": "Fügen Sie Anmerkungen oder Kontext zu diesem Fall hinzu"
|
||||||
|
},
|
||||||
|
"addExportDialog": {
|
||||||
|
"title": "Export zum {{caseName}} hinzufügen",
|
||||||
|
"searchPlaceholder": "Suche nach nicht kategorisierten Exporten",
|
||||||
|
"empty": "Es wurden keine nicht kategorisierten Exporte gefunden, die dieser Suche entsprechen.",
|
||||||
|
"addButton_one": "1 Export hinzufügen",
|
||||||
|
"addButton_other": "Fügen Sie {{count}} Exporte hinzu",
|
||||||
|
"adding": "Hinzufügen..."
|
||||||
|
},
|
||||||
|
"selected_one": "{{count}} ausgewählt",
|
||||||
|
"selected_other": "{{count}} ausgewählt",
|
||||||
|
"bulkActions": {
|
||||||
|
"addToCase": "Zum Fall hinzufügen",
|
||||||
|
"moveToCase": "Zum Fall wechseln",
|
||||||
|
"removeFromCase": "Aus dem Fall nehmen",
|
||||||
|
"delete": "löschen",
|
||||||
|
"deleteNow": "jetzt löschen"
|
||||||
|
},
|
||||||
|
"bulkDelete": {
|
||||||
|
"title": "Exporte löschen",
|
||||||
|
"desc_one": "Möchten Sie den Export {{count}} wirklich löschen?",
|
||||||
|
"desc_other": "Möchten Sie wirklich {{count}} Exporte löschen?"
|
||||||
|
},
|
||||||
|
"bulkRemoveFromCase": {
|
||||||
|
"title": "Aus dem Fall nehmen",
|
||||||
|
"desc_one": "{{count}}-Export aus diesem Fall entfernen?",
|
||||||
|
"desc_other": "{{count}} Exporte aus diesem Fall entfernen?",
|
||||||
|
"descKeepExports": "Die Exporte werden in die Kategorie „Nicht kategorisiert“ verschoben.",
|
||||||
|
"descDeleteExports": "Exporte werden endgültig gelöscht.",
|
||||||
|
"deleteExports": "Löschen Sie stattdessen Exporte"
|
||||||
|
},
|
||||||
|
"bulkToast": {
|
||||||
|
"success": {
|
||||||
|
"delete": "Exporte erfolgreich gelöscht",
|
||||||
|
"reassign": "Fallzuweisung erfolgreich aktualisiert",
|
||||||
|
"remove": "Exporte erfolgreich aus dem Fall entfernt"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"deleteFailed": "Fehler beim Löschen der Exporte: {{errorMessage}}",
|
||||||
|
"reassignFailed": "Fehler beim Aktualisieren der Fallzuordnung: {{errorMessage}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -345,6 +345,10 @@
|
|||||||
"zone": "Zone",
|
"zone": "Zone",
|
||||||
"motion_mask": "Bewegungsmaske",
|
"motion_mask": "Bewegungsmaske",
|
||||||
"object_mask": "Objektmaske"
|
"object_mask": "Objektmaske"
|
||||||
|
},
|
||||||
|
"revertOverride": {
|
||||||
|
"title": "Basis Konfiguration wiederherstellen",
|
||||||
|
"desc": "Dadurch wird die Profilüberschreibung für {{type}}<em>{{name}}</em> aufgehoben und die Grundkonfiguration wiederhergestellt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"speed": {
|
"speed": {
|
||||||
@ -507,7 +511,8 @@
|
|||||||
"title": "Aktiviert",
|
"title": "Aktiviert",
|
||||||
"description": "Ob diese Maske in der Konfigurationsdatei aktiviert ist. Ist sie deaktiviert, kann sie nicht über MQTT aktiviert werden. Deaktivierte Masken werden zur Laufzeit ignoriert."
|
"description": "Ob diese Maske in der Konfigurationsdatei aktiviert ist. Ist sie deaktiviert, kann sie nicht über MQTT aktiviert werden. Deaktivierte Masken werden zur Laufzeit ignoriert."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"addDisabledProfile": "Fügen Sie es zuerst der Basiskonfiguration hinzu und überschreiben Sie es dann im Profil"
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"objectShapeFilterDrawing": {
|
"objectShapeFilterDrawing": {
|
||||||
@ -1647,7 +1652,8 @@
|
|||||||
"keyDuplicate": "Der Name des Detektors ist bereits vorhanden.",
|
"keyDuplicate": "Der Name des Detektors ist bereits vorhanden.",
|
||||||
"noSchema": "Es sind keine Detektorschemata verfügbar.",
|
"noSchema": "Es sind keine Detektorschemata verfügbar.",
|
||||||
"none": "Es sind keine Detektorinstanzen konfiguriert.",
|
"none": "Es sind keine Detektorinstanzen konfiguriert.",
|
||||||
"add": "Detektor hinzufügen"
|
"add": "Detektor hinzufügen",
|
||||||
|
"addCustomKey": "Benutzerdefinierten Schlüssel hinzufügen"
|
||||||
},
|
},
|
||||||
"record": {
|
"record": {
|
||||||
"title": "Aufnahmeeinstellungen"
|
"title": "Aufnahmeeinstellungen"
|
||||||
@ -1718,7 +1724,16 @@
|
|||||||
"title": "Einstellungen für Zeitstempel"
|
"title": "Einstellungen für Zeitstempel"
|
||||||
},
|
},
|
||||||
"searchPlaceholder": "Suche...",
|
"searchPlaceholder": "Suche...",
|
||||||
"addCustomLabel": "Benutzerdefiniertes Etikett hinzufügen..."
|
"addCustomLabel": "Benutzerdefiniertes Etikett hinzufügen...",
|
||||||
|
"knownPlates": {
|
||||||
|
"namePlaceholder": "z.B. das Auto der Frau",
|
||||||
|
"platePlaceholder": "Kennzeichen oder regulärer Ausdruck"
|
||||||
|
},
|
||||||
|
"genaiModel": {
|
||||||
|
"placeholder": "Modell auswählen…",
|
||||||
|
"search": "Modell suchen…",
|
||||||
|
"noModels": "Keine Modelle verfügbar"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"globalConfig": {
|
"globalConfig": {
|
||||||
"title": "Globale Konfiguration",
|
"title": "Globale Konfiguration",
|
||||||
@ -1876,6 +1891,10 @@
|
|||||||
},
|
},
|
||||||
"snapshots": {
|
"snapshots": {
|
||||||
"detectDisabled": "Die Objekterkennung ist deaktiviert. Es werden keine Momentaufnahmen von verfolgten Objekten erstellt."
|
"detectDisabled": "Die Objekterkennung ist deaktiviert. Es werden keine Momentaufnahmen von verfolgten Objekten erstellt."
|
||||||
|
},
|
||||||
|
"detectors": {
|
||||||
|
"mixedTypes": "Alle Detektoren müssen von gleichem Typ sein, Entferne bestehende Detektoren um einen anderen Typ zu benutzen.",
|
||||||
|
"mixedTypesSuggestion": "Alle Detektoren müssen vom gleichem Typ sein. Entferne bestehende oder wähle {{type}}."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,14 @@
|
|||||||
{}
|
{
|
||||||
|
"version": {
|
||||||
|
"label": "Τρέχουσα έκδοση διαμόρφωσης"
|
||||||
|
},
|
||||||
|
"safe_mode": {
|
||||||
|
"label": "Ασφαλής λειτουργία"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"reset_admin_password": {
|
||||||
|
"label": "Επανέφερε κωδικού πρόσβασης για τον διαχειριστή admin",
|
||||||
|
"description": "Άμα είναι αλήθεια, επαναφέρει τον κωδικό πρόσβασης του χρήστη διαχειριστή(admin) κατά την εκκίνηση και εκτύπωση του νέου κωδικού πρόσβασης στα αρχείο καταγραφής(logs)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -257,6 +257,7 @@
|
|||||||
"export": "Export",
|
"export": "Export",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"uiPlayground": "UI Playground",
|
"uiPlayground": "UI Playground",
|
||||||
|
"features": "Features",
|
||||||
"faceLibrary": "Face Library",
|
"faceLibrary": "Face Library",
|
||||||
"classification": "Classification",
|
"classification": "Classification",
|
||||||
"chat": "Chat",
|
"chat": "Chat",
|
||||||
|
|||||||
@ -415,6 +415,7 @@
|
|||||||
"audioCodecGood": "Audio codec is {{codec}}.",
|
"audioCodecGood": "Audio codec is {{codec}}.",
|
||||||
"resolutionHigh": "A resolution of {{resolution}} may cause increased resource usage.",
|
"resolutionHigh": "A resolution of {{resolution}} may cause increased resource usage.",
|
||||||
"resolutionLow": "A resolution of {{resolution}} may be too low for reliable detection of small objects.",
|
"resolutionLow": "A resolution of {{resolution}} may be too low for reliable detection of small objects.",
|
||||||
|
"resolutionUnknown": "The resolution of this stream could not be probed. You should manually set the detect resolution in Settings or your config.",
|
||||||
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
|
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
|
||||||
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
||||||
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
||||||
@ -456,7 +457,13 @@
|
|||||||
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
|
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
|
||||||
"disableLabel": "Disabled cameras",
|
"disableLabel": "Disabled cameras",
|
||||||
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
|
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
|
||||||
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes."
|
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
|
||||||
|
"friendlyName": {
|
||||||
|
"edit": "Edit camera display name",
|
||||||
|
"title": "Edit Display Name",
|
||||||
|
"description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
|
||||||
|
"rename": "Rename"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cameraConfig": {
|
"cameraConfig": {
|
||||||
"add": "Add Camera",
|
"add": "Add Camera",
|
||||||
|
|||||||
@ -195,7 +195,8 @@
|
|||||||
"explore": "Explorar",
|
"explore": "Explorar",
|
||||||
"uiPlayground": "Zona de pruebas de la interfaz de usuario",
|
"uiPlayground": "Zona de pruebas de la interfaz de usuario",
|
||||||
"faceLibrary": "Biblioteca de rostros",
|
"faceLibrary": "Biblioteca de rostros",
|
||||||
"classification": "Clasificación"
|
"classification": "Clasificación",
|
||||||
|
"profiles": "Perfiles"
|
||||||
},
|
},
|
||||||
"unit": {
|
"unit": {
|
||||||
"speed": {
|
"speed": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user