Compare commits

...

53 Commits

Author SHA1 Message Date
Blake Blackshear
bccffe6670 TLS support (#11678)
* implement self signed cert and monitor/reload

* move go2rtc upstream to separate file

* add directory for ACME challenges

* make certsync more resilient

* add TLS docs

* add jwt secret info to docs
2024-06-01 10:29:46 -05:00
Nicolas Mowen
8418b65f34 Fix path containing too many / (#11680) 2024-06-01 08:24:20 -05:00
Soren L. Hansen
6e53c109b6 feat: apply ingress path to app paths (#11677)
When serving Frigate at a subpath, the paths that show in the URL bar
and that wind up in your browser history are anchored at the web root.
I.e. you go to `https://example.com/frigate/`, it changes to
`https://example.com/`, and clicking around works as expected, but the
`frigate/` prefix is gone.

It's confusing if you don't know that the URL's are entirely virtual.
Also, your browser history is useless, since the URL's point to e.g.
`https://example.com/#kitchen`, but visiting that URL will not hit
`/frigate/` at all.

Most of the work is already done. Nginx injects javascript to set
`window.baseURL` based on the X-Ingress-Path header. This change passes
that to BrowserRouter, so that it'll be part of the URL's it shows.

Fixes #4526
2024-06-01 07:08:01 -06:00
Blake Blackshear
7b99bbfd28 Update deps (#11679)
* update web deps

* update actions

* automatic stable tag publishing

* python deps

* typo
2024-06-01 06:39:05 -06:00
Nicolas Mowen
8179278bfa Don't fail if user has bind mounted nginx config (#11671) 2024-06-01 06:19:54 -05:00
Nicolas Mowen
758df09da3 Handle error when live view stalls (#11665)
* Handle error when live view stalls

* Manually calculate buffer timeout

* Formatting
2024-05-31 08:52:42 -05:00
Josh Hawkins
a3d116e70e stay in fullscreen when navigating to a camera (#11666) 2024-05-31 07:58:33 -05:00
Josh Hawkins
8c325801ef fix race where camera change effect sometimes was called after layout building (#11656) 2024-05-30 14:17:00 -06:00
Tom B
35946d332d Fix Statusbar rendering NaN% for unsupported GPUs (#11655) 2024-05-30 13:10:24 -06:00
Nicolas Mowen
142641b387 Adjust nginx proc count based on available CPUs (#11653)
* Restrict nginx to 4 processes if more are available

* Fix bash

* Different sed structure

* Limit ffmpeg thread counts for secondary ffmpeg processes

* Add up / down keyboard shortcut
2024-05-30 12:34:01 -05:00
Josh Hawkins
402c16e7df don't sleep mobile devices when fullscreen (#11652) 2024-05-30 09:37:08 -06:00
Nicolas Mowen
3e6b8c23bc Update dialog sizing for plus dialog (#11650) 2024-05-30 09:26:15 -05:00
Nicolas Mowen
1c5e7ebb48 UI Fixes (#11648)
* Add cursor pointer to preset dropdown

* Catch key index

* Fix iOS mime type
2024-05-30 07:41:37 -06:00
Josh Hawkins
9cb3e11df6 non-modal dropdown menus (#11649) 2024-05-30 07:39:14 -06:00
Nicolas Mowen
1c2e2a7b38 Handle case where user sets detections as empty list (#11646) 2024-05-30 07:45:34 -05:00
Josh Hawkins
a763ae303d static handlebar size to better match figma (#11638) 2024-05-29 21:34:19 -05:00
Nicolas Mowen
ec88752666 Don't show mark reviewed button when all items are in progress (#11636)
* Don't show mark reviewed button when all items are in progress

* Fix unknown preview file
2024-05-29 19:54:56 -05:00
Josh Hawkins
4135cabf58 assume 1 grid column by default on live dashboard (#11630) 2024-05-29 16:03:59 -06:00
Nicolas Mowen
9fc22efa2d Always save previews in UTC offset (#11629) 2024-05-29 14:55:56 -06:00
Josh Hawkins
37dd3fc25b fix birdseye fullscreen (#11625) 2024-05-29 14:18:51 -05:00
Nicolas Mowen
9e8202874e Remove live mode from config (#11618)
* Use preferred mode as default

* Remove live mode from config

* Add deer icon

* remove from config schema
2024-05-29 13:06:48 -05:00
Nicolas Mowen
9245c5cb56 Improve efficiency of log and metrics pages (#11622)
* Rework stats pages

* Handle limited data case

* Handle page and arrow keys

* Adjust sizing
2024-05-29 12:05:39 -06:00
Josh Hawkins
f1c0422d5e Various bugfixes and improvements (#11624)
* various bugfixes and improvements

* add separator

* no separator
2024-05-29 12:05:28 -06:00
Josh Hawkins
3dd401f57a string or list for camera groups (#11616) 2024-05-29 09:19:05 -05:00
Nicolas Mowen
6dd9660ecd Settings rework (#11613)
* refactor settings to be consistent with other page structure

* Implement non auto live

* Adjust missing view

* Quick fix

* Clarify settings options

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update naming and config restarts

* Rename

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2024-05-29 08:01:39 -06:00
Nicolas Mowen
d5f6decd30 Cache camera stream info to speed up future config generations (#11614)
* Cache camera stream info to speed up future config generations

* Formatting

* fix
2024-05-29 07:41:41 -06:00
Nicolas Mowen
cf4517cbdb cache current hour previews (#11606) 2024-05-28 17:35:55 -05:00
Nicolas Mowen
61f79afae9 Always use mse unless webrtc is requrested (#11605) 2024-05-28 17:35:36 -05:00
Nicolas Mowen
5513addab8 UI Fixes (#11602)
* Fix playback rate not showing

* Fix export image

* Formatting

* Formatting
2024-05-28 13:45:08 -06:00
Nicolas Mowen
d064e44571 Fix iOS fullscreen (#11600) 2024-05-28 13:10:42 -06:00
James Tanner-McLeod
c95758580f Draw motion mask first (#11598)
This draws the motion mask before the other overlay elements (such as bounding boxes), so that they are still visible. Fixes #11592
2024-05-28 12:44:20 -06:00
Josh Hawkins
ced5ab203f ensure the correct container is used for canvas calcs (#11599) 2024-05-28 12:41:51 -06:00
Nicolas Mowen
4236580672 UI fixes (#11596)
* Fix using undefined search params

* Fix calendar selection

* Simplify
2024-05-28 13:15:31 -05:00
Josh Hawkins
f7c3ddd380 check if motion dataframe is empty (#11593) 2024-05-28 11:33:28 -06:00
Nicolas Mowen
8546d3d315 Simplify timezone math (#11586)
* Use utc minutes

* Cleanup
2024-05-28 09:09:17 -05:00
Josh Hawkins
2fda383782 clean up unneeded code (#11587) 2024-05-28 08:05:04 -06:00
Josh Hawkins
4165639308 Live view tweaks and jsmpeg bugfix (#11584)
* live view tweaks and jsmpeg bugfix

* use container aspect in check
2024-05-28 08:11:35 -05:00
Nicolas Mowen
6913cc6abc Handle case where preview doesn't automatically changeover (#11583) 2024-05-28 08:11:23 -05:00
Nicolas Mowen
d64633889b Fixes (#11575)
* Fix settings icon

* Handle out of resources
2024-05-27 21:27:01 -05:00
Nicolas Mowen
7bed854ff7 remove libusb build (#11571) 2024-05-27 16:52:19 -06:00
Josh Hawkins
c1330704cf Make jsmpeg players fully responsive (#11567)
* make jsmpeg canvas responsive

* make birdseye responsive too
2024-05-27 16:18:04 -06:00
Nicolas Mowen
5900a2a4ba Add ability to interact with review items in events list (#11562)
* Add ability to interact with review items

* Ignore on iOS

* Don't load metadata

* Bug fixes
2024-05-27 17:12:57 -05:00
Blake Blackshear
bfeb7b8a96 upgrade to latest openvino version (#11563) 2024-05-27 14:49:35 -06:00
Nicolas Mowen
a86e22e0fc Fix live view updating when it shouldn't be (#11561)
* Simplify live image update logic

* Fix case where go2rtc is not setup
2024-05-27 09:50:02 -06:00
Josh Hawkins
c07f6999ca refresh editor value when config is updated (#11559) 2024-05-27 09:31:58 -06:00
Josh Hawkins
eca8c52f15 docs update: how to exclude camera from alerts/detections (#11560) 2024-05-27 09:31:49 -06:00
Nicolas Mowen
be147d218b Fix optimistic state (#11556) 2024-05-27 07:59:26 -05:00
Josh Hawkins
7a9ee63bd3 save video dimensions in onLoadedData instead of onLoadedMetadata (#11545) 2024-05-26 17:48:33 -06:00
Nicolas Mowen
c2eac10925 Tweaks and fixes (#11541)
* Update config version to be stored inside of the config

* Don't remove items from list when navigating back

* Use video api instead of webps for live current hour filmstrip

* Check that the config file is writable

* Show camera name when camera is offline

* Show camera name when offline

* Cleanup
2024-05-26 16:49:12 -05:00
Josh Hawkins
63d81bef45 redirect non existent paths to live view (#11536) 2024-05-26 10:17:52 -06:00
Josh Hawkins
3f171e7670 bugfixes (#11526) 2024-05-25 20:37:53 -06:00
Josh Hawkins
681c7367d7 ensure resizable handles are above jsmpeg canvas elements (#11519) 2024-05-25 15:15:30 -05:00
Marc Altmann
adb043e7ae Update docs for rockchip platform (#11503)
* improve docs for rockchip

* update version info

* fix typo

* fix typo

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>

* fix typo

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2024-05-25 11:27:44 -06:00
115 changed files with 3062 additions and 2055 deletions

View File

@@ -220,7 +220,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -16,7 +16,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -24,14 +24,24 @@ jobs:
- name: Create tag variables
run: |
BRANCH=$([[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "master" || echo "dev")
echo "BRANCH=${BRANCH}" >> $GITHUB_ENV
echo "BASE=ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}" >> $GITHUB_ENV
echo "BUILD_TAG=${BRANCH}-${GITHUB_SHA::7}" >> $GITHUB_ENV
echo "CLEAN_VERSION=$(echo ${GITHUB_REF##*/} | tr '[:upper:]' '[:lower:]' | sed 's/^[v]//')" >> $GITHUB_ENV
- name: Tag and push the main image
run: |
VERSION_TAG=${BASE}:${CLEAN_VERSION}
STABLE_TAG=${BASE}:stable
PULL_TAG=${BASE}:${BUILD_TAG}
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG}
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${VERSION_TAG}-${variant}
done
# stable tag
if [[ "${BRANCH}" == "master" ]]; then
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG}
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant}
done
fi

3
.gitignore vendored
View File

@@ -16,4 +16,5 @@ web/node_modules
web/coverage
core
!/web/**/*.ts
.idea/*
.idea/*
.ipynb_checkpoints

View File

@@ -41,7 +41,6 @@ ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9
# OpenVino Support
#
# 1. Download and convert a model from Intel's Public Open Model Zoo
# 2. Build libUSB without udev to handle NCS2 enumeration
#
####
# Download and Convert OpenVino model
@@ -57,38 +56,11 @@ RUN apt-get -qq update \
&& pip install -r /requirements-ov.txt
# Get OpenVino Model
RUN mkdir /models \
&& cd /models && omz_downloader --name ssdlite_mobilenet_v2 \
&& cd /models && omz_converter --name ssdlite_mobilenet_v2 --precision FP16
# libUSB - No Udev
FROM wget as libusb-build
ARG TARGETARCH
ARG DEBIAN_FRONTEND
ENV CCACHE_DIR /root/.ccache
ENV CCACHE_MAXSIZE 2G
# Build libUSB without udev. Needed for Openvino NCS2 support
WORKDIR /opt
RUN apt-get update && apt-get install -y unzip build-essential automake libtool ccache pkg-config
RUN --mount=type=cache,target=/root/.ccache wget -q https://github.com/libusb/libusb/archive/v1.0.26.zip -O v1.0.26.zip && \
unzip v1.0.26.zip && cd libusb-1.0.26 && \
./bootstrap.sh && \
./configure CC='ccache gcc' CCX='ccache g++' --disable-udev --enable-shared && \
make -j $(nproc --all)
RUN apt-get update && \
apt-get install -y --no-install-recommends libusb-1.0-0-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /opt/libusb-1.0.26/libusb
RUN /bin/mkdir -p '/usr/local/lib' && \
/bin/bash ../libtool --mode=install /usr/bin/install -c libusb-1.0.la '/usr/local/lib' && \
/bin/mkdir -p '/usr/local/include/libusb-1.0' && \
/usr/bin/install -c -m 644 libusb.h '/usr/local/include/libusb-1.0' && \
/bin/mkdir -p '/usr/local/lib/pkgconfig' && \
cd /opt/libusb-1.0.26/ && \
/usr/bin/install -c -m 644 libusb-1.0.pc '/usr/local/lib/pkgconfig' && \
ldconfig
RUN --mount=type=bind,source=docker/main/build_ov_model.py,target=/build_ov_model.py \
mkdir /models && cd /models \
&& wget http://download.tensorflow.org/models/object_detection/ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \
&& tar -xvf ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \
&& python3 /build_ov_model.py
FROM wget AS models
@@ -97,7 +69,8 @@ RUN wget -qO edgetpu_model.tflite https://github.com/google-coral/test_data/raw/
RUN wget -qO cpu_model.tflite https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess.tflite
COPY labelmap.txt .
# Copy OpenVino model
COPY --from=ov-converter /models/public/ssdlite_mobilenet_v2/FP16 openvino-model
COPY --from=ov-converter /models/ssdlite_mobilenet_v2.xml openvino-model/
COPY --from=ov-converter /models/ssdlite_mobilenet_v2.bin openvino-model/
RUN wget -q https://github.com/openvinotoolkit/open_model_zoo/raw/master/data/dataset_classes/coco_91cl_bkgr.txt -O openvino-model/coco_91cl_bkgr.txt && \
sed -i 's/truck/car/g' openvino-model/coco_91cl_bkgr.txt
# Get Audio Model and labels
@@ -158,7 +131,6 @@ RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt
FROM scratch AS deps-rootfs
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
COPY --from=go2rtc /rootfs/ /
COPY --from=libusb-build /usr/local/lib /usr/local/lib
COPY --from=s6-overlay /rootfs/ /
COPY --from=models /rootfs/ /
COPY docker/main/rootfs/ /
@@ -188,8 +160,6 @@ RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
COPY --from=deps-rootfs / /
RUN ldconfig
EXPOSE 5000
EXPOSE 8554
EXPOSE 8555/tcp 8555/udp

View File

@@ -0,0 +1,11 @@
import openvino as ov
from openvino.tools import mo
ov_model = mo.convert_model(
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
compress_to_fp16=True,
transformations_config="/usr/local/lib/python3.9/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json",
tensorflow_object_detection_api_pipeline_config="/models/ssdlite_mobilenet_v2_coco_2018_05_09/pipeline.config",
reverse_input_channels=True,
)
ov.save_model(ov_model, "/models/ssdlite_mobilenet_v2.xml")

View File

@@ -1,5 +1,3 @@
numpy
# Openvino Library - Custom built with MYRIAD support
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64'
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'
openvino-dev[tensorflow2] @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino_dev-2022.3.1-1-py3-none-any.whl
tensorflow
openvino-dev>=2024.0.0

View File

@@ -30,6 +30,4 @@ setproctitle == 1.3.*
ws4py == 0.5.*
unidecode == 1.3.*
onnxruntime == 1.16.*
# Openvino Library - Custom built with MYRIAD support
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64'
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'
openvino == 2024.1.*

View File

@@ -0,0 +1 @@
certsync

View File

@@ -0,0 +1 @@
certsync-pipeline

View File

@@ -0,0 +1,4 @@
#!/command/with-contenv bash
# shellcheck shell=bash
exec logutil-service /dev/shm/logs/certsync

View File

@@ -0,0 +1 @@
longrun

View File

@@ -0,0 +1,30 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# Take down the S6 supervision tree when the service fails
set -o errexit -o nounset -o pipefail
# Logs should be sent to stdout so that s6 can collect them
declare exit_code_container
exit_code_container=$(cat /run/s6-linux-init-container-results/exitcode)
readonly exit_code_container
readonly exit_code_service="${1}"
readonly exit_code_signal="${2}"
readonly service="CERTSYNC"
echo "[INFO] Service ${service} exited with code ${exit_code_service} (by signal ${exit_code_signal})"
if [[ "${exit_code_service}" -eq 256 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo $((128 + exit_code_signal)) >/run/s6-linux-init-container-results/exitcode
fi
if [[ "${exit_code_signal}" -eq 15 ]]; then
exec /run/s6/basedir/bin/halt
fi
elif [[ "${exit_code_service}" -ne 0 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo "${exit_code_service}" >/run/s6-linux-init-container-results/exitcode
fi
exec /run/s6/basedir/bin/halt
fi

View File

@@ -0,0 +1 @@
certsync-log

View File

@@ -0,0 +1,53 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# Start the CERTSYNC service
set -o errexit -o nounset -o pipefail
# Logs should be sent to stdout so that s6 can collect them
echo "[INFO] Starting certsync..."
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
while true
do
if [ ! -e $lefile ]
then
echo "[ERROR] TLS certificate does not exist: $lefile"
fi
leprint=`openssl x509 -in $lefile -fingerprint -noout || echo 'failed'`
case "$leprint" in
*Fingerprint*)
;;
*)
echo "[ERROR] Missing fingerprint from $lefile"
;;
esac
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:443 2>&1 | openssl x509 -fingerprint | grep -i fingerprint || echo 'failed'`
case "$liveprint" in
*Fingerprint*)
;;
*)
echo "[ERROR] Missing fingerprint from current nginx TLS cert"
;;
esac
if [[ "$leprint" != "failed" && "$liveprint" != "failed" && "$leprint" != "$liveprint" ]]
then
echo "[INFO] Reloading nginx to refresh TLS certificate"
echo "$lefile: $leprint"
/usr/local/nginx/sbin/nginx -s reload
fi
sleep 60
done
exit 0

View File

@@ -0,0 +1 @@
30000

View File

@@ -0,0 +1 @@
longrun

View File

@@ -4,7 +4,7 @@
set -o errexit -o nounset -o pipefail
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx)
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync)
mkdir -p "${dirs[@]}"
chown nobody:nogroup "${dirs[@]}"

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
# Wait for PID file to exist.
while ! test -f /run/nginx.pid; do sleep 1; done

View File

@@ -0,0 +1 @@
3

View File

@@ -8,6 +8,36 @@ set -o errexit -o nounset -o pipefail
echo "[INFO] Starting NGINX..."
function set_worker_processes() {
# Capture number of assigned CPUs to calculate worker processes
local proc_count
if proc_count=$(nproc --all) && [[ $proc_count -gt 4 ]]; then
proc_count=4;
fi
# we need to catch any errors because sed will fail if user has bind mounted a custom nginx file
sed -i "s/worker_processes auto;/worker_processes ${proc_count};/" /usr/local/nginx/conf/nginx.conf || true
}
set_worker_processes
# ensure the directory for ACME challenges exists
mkdir -p /etc/letsencrypt/www
# Create self signed certs if needed
letsencrypt_path=/etc/letsencrypt/live/frigate
mkdir -p $letsencrypt_path
if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.pem" \) ]; then
echo "[INFO] No TLS certificate found. Generating a self signed certificate..."
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem"
fi
# Replace the bash process with the NGINX process, redirecting stderr to stdout
exec 2>&1
exec nginx
exec \
s6-notifyoncheck -t 30000 -n 1 \
nginx

View File

@@ -0,0 +1,4 @@
upstream go2rtc {
server 127.0.0.1:1984;
keepalive 1024;
}

View File

@@ -56,9 +56,14 @@ http {
keepalive 1024;
}
upstream go2rtc {
server 127.0.0.1:1984;
keepalive 1024;
include go2rtc_upstream.conf;
server {
listen [::]:80 ipv6only=off default_server;
location / {
return 301 https://$host$request_uri;
}
}
server {
@@ -67,6 +72,8 @@ http {
# intended for internal traffic, not protected by auth
listen [::]:5000 ipv6only=off;
include tls.conf;
# vod settings
vod_base_url '';
vod_segments_base_url '';

View File

@@ -0,0 +1,24 @@
keepalive_timeout 70;
listen [::]:443 ipv6only=off default_server ssl;
ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem;
# generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP
# https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
ssl_session_tickets off;
# modern configuration
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off;
# HSTS (ngx_http_headers_module is required) (63072000 seconds)
add_header Strict-Transport-Security "max-age=63072000" always;
# ACME challenge location
location /.well-known/acme-challenge/ {
default_type "text/plain";
root /etc/letsencrypt/www;
}

View File

@@ -52,6 +52,27 @@ auth:
- 172.18.0.0/16 # <---- this is the subnet for the internal docker compose network
```
#### JWT Token Secret
The JWT token secret needs to be kept secure. Anyone with this secret can generate valid JWT tokens to authenticate with Frigate. This should be a cryptographically random string of at least 64 characters.
You can generate a token using the Python secret library with the following command:
```shell
python3 -c 'import secrets; print(secrets.token_hex(64))'
```
Frigate looks for a JWT token secret in the following order:
1. An environment variable named `FRIGATE_JWT_SECRET`
2. A docker secret named `FRIGATE_JWT_SECRET` in `/run/secrets/`
3. A `jwt_secret` option from the Home Assistant Addon options
4. A `.jwt_secret` file in the config directory
If no secret is found on startup, Frigate generates one and stores it in a `.jwt_secret` file in the config directory.
Changing the secret will invalidate current tokens.
### Proxy mode
Proxy mode is designed to complement common upstream authentication proxies such as Authelia, Authentik, oauth2_proxy, or traefik-forward-auth.

View File

@@ -362,39 +362,11 @@ that NVDEC/NVDEC1 are in use.
## Rockchip platform
Hardware accelerated video de-/encoding is supported on all Rockchip SoCs using [Nyanmisaka's FFmpeg Fork](https://github.com/nyanmisaka/ffmpeg-rockchip) based on [Rockchip's mpp library](https://github.com/rockchip-linux/mpp).
Hardware accelerated video de-/encoding is supported on all Rockchip SoCs using [Nyanmisaka's FFmpeg 6.1 Fork](https://github.com/nyanmisaka/ffmpeg-rockchip) based on [Rockchip's mpp library](https://github.com/rockchip-linux/mpp).
### Prerequisites
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and rkvdec2 driver. To check, enter the following commands:
```
$ uname -r
5.10.xxx-rockchip # or 6.1.xxx; the -rockchip suffix is important
$ ls /dev/dri
by-path card0 card1 renderD128 renderD129 # should list renderD128
```
I recommend [Joshua Riek's Ubuntu for Rockchip](https://github.com/Joshua-Riek/ubuntu-rockchip), if your board is supported.
### Setup
Follow Frigate's default installation instructions, but use a docker image with `-rk` suffix for example `ghcr.io/blakeblackshear/frigate:stable-rk`.
Next, you need to grant docker permissions to access your hardware:
- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command.
- After everything works, you should only grant necessary permissions to increase security. Add the lines below to your `docker-compose.yml` file or the following options to your docker run command: `--security-opt systempaths=unconfined --security-opt apparmor=unconfined --device /dev/dri:/dev/dri --device /dev/dma_heap:/dev/dma_heap --device /dev/rga:/dev/rga --device /dev/mpp_service:/dev/mpp_service`:
```yaml
security_opt:
- apparmor=unconfined
- systempaths=unconfined
devices:
- /dev/dri:/dev/dri
- /dev/dma_heap:/dev/dma_heap
- /dev/rga:/dev/rga
- /dev/mpp_service:/dev/mpp_service
```
Make sure to follow the [Rockchip specific installation instructions](/frigate/installation#rockchip-platform).
### Configuration

View File

@@ -313,39 +313,11 @@ Hardware accelerated object detection is supported on the following SoCs:
- RK3576
- RK3588
This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/) Currently, only [Yolo-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) is supported as object detection model.
This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/), version v2.0.0.beta0. Currently, only [Yolo-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) is supported as object detection model.
### Prerequisites
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and rknpu driver. To check, enter the following commands:
```
$ uname -r
5.10.xxx-rockchip # or 6.1.xxx; the -rockchip suffix is important
$ ls /dev/dri
by-path card0 card1 renderD128 renderD129 # should list renderD129
$ sudo cat /sys/kernel/debug/rknpu/version
RKNPU driver: v0.9.2 # or later version
```
I recommend [Joshua Riek's Ubuntu for Rockchip](https://github.com/Joshua-Riek/ubuntu-rockchip), if your board is supported.
### Setup
Follow Frigate's default installation instructions, but use a docker image with `-rk` suffix for example `ghcr.io/blakeblackshear/frigate:stable-rk`.
Next, you need to grant docker permissions to access your hardware:
- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command.
- After everything works, you should only grant necessary permissions to increase security. Add the lines below to your `docker-compose.yml` file or the following options to your docker run command: `--security-opt systempaths=unconfined --security-opt apparmor=unconfined --device /dev/dri:/dev/dri`:
```yaml
security_opt:
- apparmor=unconfined
- systempaths=unconfined
devices:
- /dev/dri:/dev/dri
```
Make sure to follow the [Rockchip specific installation instrucitions](/frigate/installation#rockchip-platform).
### Configuration
@@ -405,6 +377,5 @@ $ cat /sys/kernel/debug/rknpu/load
:::
- By default the rknn detector uses the yolonas_s model (`model: path: default-fp16-yolonas_s`). This model comes with the image, so no further steps than those mentioned above are necessary and no download happens.
- The other choices are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space.
- Finally, you can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
- All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space.
- You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.

View File

@@ -645,8 +645,6 @@ cameras:
# Optional
ui:
# Optional: Set the default live mode for cameras in the UI (default: shown below)
live_mode: mse
# Optional: Set a timezone to use in the UI (default: use browser local time)
# timezone: America/Denver
# Optional: Set the time format used.

View File

@@ -42,6 +42,20 @@ review:
- dog
```
## Excluding a camera from alerts or detections
To exclude a specific camera from alerts or detections, simply provide an empty list to the alerts or detections field _at the camera level_.
For example, to exclude objects on the camera _gatecamera_ from any detections, include this in your config:
```yaml
cameras:
gatecamera:
review:
detections:
labels: []
```
## Restricting review items to specific zones
By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones)

View File

@@ -0,0 +1,34 @@
---
id: tls
title: TLS
---
# TLS
Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 443. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates.
Frigate is often running behind a reverse proxy that manages TLS certificates for multiple services. However, if you are running on a device that's separate from your proxy or if you expose Frigate directly to the internet, you may want to configure TLS.
## Certificates
TLS certificates can be mounted at `/etc/letsencrypt/live/frigate` using a bind mount or docker volume.
```yaml
frigate:
...
volumes:
- /path/to/your/certificate_folder:/etc/letsencrypt/live/frigate
...
```
Within the folder, the private key is expected to be named `privkey.pem` and the certificate is expected to be named `fullchain.pem`.
Frigate automatically compares the fingerprint of the certificate at `/etc/letsencrypt/live/frigate/fullchain.pem` against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate.
## ACME Challenge
Frigate also supports hosting the acme challenge files for the HTTP challenge method if needed. The challenge files should be mounted at `/etc/letsencrypt/www`.
## Advanced customization
If you would like to customize the TLS configuration, you can do so by using a bind mount to override `/usr/local/nginx/conf/tls.conf`. Check the source code for the default configuration and modify from there.

View File

@@ -95,6 +95,18 @@ Frigate supports all Jetson boards, from the inexpensive Jetson Nano to the powe
Inference speed will vary depending on the YOLO model, jetson platform and jetson nvpmodel (GPU/DLA/EMC clock speed). It is typically 20-40 ms for most models. The DLA is more efficient than the GPU, but not faster, so using the DLA will reduce power consumption but will slightly increase inference time.
#### Rockchip platform
Frigate supports hardware video processing on all Rockchip boards. However, hardware object detection is only supported on these boards:
- RK3562
- RK3566
- RK3568
- RK3576
- RK3588
The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms for yolo-nas s.
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.

View File

@@ -34,7 +34,8 @@ The following ports are used by Frigate and can be mapped via docker as required
| Port | Description |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `8080` | Authenticated UI and API access. Reverse proxies should use this port. |
| `8080` | Authenticated UI and API access without TLS. Reverse proxies should use this port. |
| `443` | Authenticated UI and API access with TLS. See the [TLS configuration](/configuration/tls) for more details. |
| `5000` | Internal unauthenticated UI and API access. Access to this port should be limited. Intended to be used within the docker network for services that integrate with Frigate. |
| `8554` | RTSP restreaming. By default, these streams are unauthenticated. Authentication can be configured in go2rtc section of config. |
| `8555` | WebRTC connections for low latency live views. |
@@ -44,7 +45,6 @@ The following ports are used by Frigate and can be mapped via docker as required
Writing to a local disk or external USB drive:
```yaml
version: "3.9"
services:
frigate:
...
@@ -95,6 +95,56 @@ By default, the Raspberry Pi limits the amount of memory available to the GPU. I
Additionally, the USB Coral draws a considerable amount of power. If using any other USB devices such as an SSD, you will experience instability due to the Pi not providing enough power to USB devices. You will need to purchase an external USB hub with it's own power supply. Some have reported success with <a href="https://amzn.to/3a2mH0P" target="_blank" rel="nofollow noopener sponsored">this</a> (affiliate link).
### Rockchip platform
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and necessary drivers (especially rkvdec2 and rknpu). To check, enter the following commands:
```
$ uname -r
5.10.xxx-rockchip # or 6.1.xxx; the -rockchip suffix is important
$ ls /dev/dri
by-path card0 card1 renderD128 renderD129 # should list renderD128 (VPU) and renderD129 (NPU)
$ sudo cat /sys/kernel/debug/rknpu/version
RKNPU driver: v0.9.2 # or later version
```
I recommend [Joshua Riek's Ubuntu for Rockchip](https://github.com/Joshua-Riek/ubuntu-rockchip), if your board is supported.
#### Setup
Follow Frigate's default installation instructions, but use a docker image with `-rk` suffix for example `ghcr.io/blakeblackshear/frigate:stable-rk`.
Next, you need to grant docker permissions to access your hardware:
- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command.
- After everything works, you should only grant necessary permissions to increase security. Disable the privileged mode and add the lines below to your `docker-compose.yml` file:
```yaml
security_opt:
- apparmor=unconfined
- systempaths=unconfined
devices:
- /dev/dri
- /dev/dma_heap
- /dev/rga
- /dev/mpp_service
```
or add these options to your `docker run` command:
```
--security-opt systempaths=unconfined \
--security-opt apparmor=unconfined \
--device /dev/dri \
--device /dev/dma_heap \
--device /dev/rga \
--device /dev/mpp_service
```
#### Configuration
Next, you should configure [hardware object detection](/configuration/object_detectors#rockchip-platform) and [hardware video processing](/configuration/hardware_acceleration#rockchip-platform).
## Docker
Running in Docker with compose is the recommended install method.

665
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,9 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "^3.3.2",
"@docusaurus/preset-classic": "^3.3.2",
"@docusaurus/theme-mermaid": "^3.3.2",
"@docusaurus/core": "^3.4.0",
"@docusaurus/preset-classic": "^3.4.0",
"@docusaurus/theme-mermaid": "^3.4.0",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.1.0",
@@ -37,8 +37,8 @@
]
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.3.2",
"@docusaurus/types": "^3.3.2",
"@docusaurus/module-type-aliases": "^3.4.0",
"@docusaurus/types": "^3.4.0",
"@types/react": "^18.2.79"
},
"engines": {

View File

@@ -52,6 +52,7 @@ module.exports = {
"configuration/authentication",
"configuration/hardware_acceleration",
"configuration/ffmpeg_presets",
"configuration/tls",
"configuration/advanced",
],
},

View File

@@ -1232,7 +1232,7 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
@MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4")
@MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4")
def preview_mp4(camera_name: str, start_ts, end_ts):
def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4"
if len(file_name) > 1000:
@@ -1380,7 +1380,7 @@ def preview_mp4(camera_name: str, start_ts, end_ts):
response = make_response()
response.headers["Content-Description"] = "File Transfer"
response.headers["Cache-Control"] = "no-cache"
response.headers["Cache-Control"] = f"private, max-age={max_cache_age}"
response.headers["Content-Type"] = "video/mp4"
response.headers["Content-Length"] = os.path.getsize(path)
response.headers["X-Accel-Redirect"] = (

View File

@@ -440,6 +440,11 @@ def motion_activity():
# resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
if df.empty:
logger.warning("No motion data found for the requested time range")
return jsonify([])
df = df.astype(dtype={"motion": "float16"})
# set date as datetime index

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
@@ -46,9 +45,9 @@ from frigate.util.builtin import (
get_ffmpeg_arg_list,
load_config_with_no_duplicates,
)
from frigate.util.config import get_relative_coordinates
from frigate.util.config import StreamInfoRetriever, get_relative_coordinates
from frigate.util.image import create_mask
from frigate.util.services import auto_detect_hwaccel, get_video_properties
from frigate.util.services import auto_detect_hwaccel
logger = logging.getLogger(__name__)
@@ -73,6 +72,9 @@ DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
# stream info handler
stream_info_retriever = StreamInfoRetriever()
class FrigateBaseModel(BaseModel):
model_config = ConfigDict(extra="forbid", protected_namespaces=())
@@ -98,9 +100,6 @@ class DateTimeStyleEnum(str, Enum):
class UIConfig(FrigateBaseModel):
live_mode: LiveModeEnum = Field(
default=LiveModeEnum.mse, title="Default Live Mode."
)
timezone: Optional[str] = Field(default=None, title="Override UI timezone.")
time_format: TimeFormatEnum = Field(
default=TimeFormatEnum.browser, title="Override UI time format."
@@ -1169,12 +1168,20 @@ class LoggerConfig(FrigateBaseModel):
class CameraGroupConfig(FrigateBaseModel):
"""Represents a group of cameras."""
cameras: list[str] = Field(
cameras: Union[str, List[str]] = Field(
default_factory=list, title="List of cameras in this group."
)
icon: str = Field(default="generic", title="Icon that represents camera group.")
order: int = Field(default=0, title="Sort order for group.")
@field_validator("cameras", mode="before")
@classmethod
def validate_cameras(cls, v):
if isinstance(v, str) and "," not in v:
return [v]
return v
def verify_config_roles(camera_config: CameraConfig) -> None:
"""Verify that roles are setup in the config correctly."""
@@ -1355,6 +1362,7 @@ class FrigateConfig(FrigateBaseModel):
default_factory=TimestampStyleConfig,
title="Global timestamp style configuration.",
)
version: Optional[float] = Field(default=None, title="Current config version.")
def runtime_config(self, plus_api: PlusApi = None) -> FrigateConfig:
"""Merge camera config with globals."""
@@ -1415,7 +1423,7 @@ class FrigateConfig(FrigateBaseModel):
if need_detect_dimensions or need_record_fourcc:
stream_info = {"width": 0, "height": 0, "fourcc": None}
try:
stream_info = asyncio.run(get_video_properties(input.path))
stream_info = stream_info_retriever.get_stream_info(input.path)
except Exception:
logger.warn(
f"Error detecting stream parameters automatically for {input.path} Applying default values."

View File

@@ -1,7 +1,7 @@
import logging
import numpy as np
import openvino.runtime as ov
import openvino as ov
from pydantic import Field
from typing_extensions import Literal
@@ -23,28 +23,56 @@ class OvDetector(DetectionApi):
def __init__(self, detector_config: OvDetectorConfig):
self.ov_core = ov.Core()
self.ov_model = self.ov_core.read_model(detector_config.model.path)
self.ov_model_type = detector_config.model.model_type
self.h = detector_config.model.height
self.w = detector_config.model.width
self.interpreter = self.ov_core.compile_model(
model=self.ov_model, device_name=detector_config.device
model=detector_config.model.path, device_name=detector_config.device
)
logger.info(f"Model Input Shape: {self.interpreter.input(0).shape}")
self.output_indexes = 0
self.model_invalid = False
# Ensure the SSD model has the right input and output shapes
if self.ov_model_type == ModelTypeEnum.ssd:
model_inputs = self.interpreter.inputs
model_outputs = self.interpreter.outputs
if len(model_inputs) != 1:
logger.error(
f"SSD models must only have 1 input. Found {len(model_inputs)}."
)
self.model_invalid = True
if len(model_outputs) != 1:
logger.error(
f"SSD models must only have 1 output. Found {len(model_outputs)}."
)
self.model_invalid = True
if model_inputs[0].get_shape() != ov.Shape([1, self.w, self.h, 3]):
logger.error(
f"SSD model input doesn't match. Found {model_inputs[0].get_shape()}."
)
self.model_invalid = True
output_shape = model_outputs[0].get_shape()
if output_shape[0] != 1 or output_shape[1] != 1 or output_shape[3] != 7:
logger.error(f"SSD model output doesn't match. Found {output_shape}.")
self.model_invalid = True
while True:
try:
tensor_shape = self.interpreter.output(self.output_indexes).shape
logger.info(f"Model Output-{self.output_indexes} Shape: {tensor_shape}")
self.output_indexes += 1
except Exception:
logger.info(f"Model has {self.output_indexes} Output Tensors")
break
if self.ov_model_type == ModelTypeEnum.yolox:
self.output_indexes = 0
while True:
try:
tensor_shape = self.interpreter.output(self.output_indexes).shape
logger.info(
f"Model Output-{self.output_indexes} Shape: {tensor_shape}"
)
self.output_indexes += 1
except Exception:
logger.info(f"Model has {self.output_indexes} Output Tensors")
break
self.num_classes = tensor_shape[2] - 5
logger.info(f"YOLOX model has {self.num_classes} classes")
self.set_strides_grids()
@@ -81,29 +109,32 @@ class OvDetector(DetectionApi):
def detect_raw(self, tensor_input):
infer_request = self.interpreter.create_infer_request()
infer_request.infer([tensor_input])
# TODO: see if we can use shared_memory=True
input_tensor = ov.Tensor(array=tensor_input)
infer_request.infer(input_tensor)
if self.ov_model_type == ModelTypeEnum.ssd:
results = infer_request.get_output_tensor()
detections = np.zeros((20, 6), np.float32)
i = 0
for object_detected in results.data[0, 0, :]:
if object_detected[0] != -1:
logger.debug(object_detected)
if object_detected[2] < 0.1 or i == 20:
if self.model_invalid:
return detections
results = infer_request.get_output_tensor(0).data[0][0]
for i, (_, class_id, score, xmin, ymin, xmax, ymax) in enumerate(results):
if i == 20:
break
detections[i] = [
object_detected[1], # Label ID
float(object_detected[2]), # Confidence
object_detected[4], # y_min
object_detected[3], # x_min
object_detected[6], # y_max
object_detected[5], # x_max
class_id,
float(score),
ymin,
xmin,
ymax,
xmax,
]
i += 1
return detections
elif self.ov_model_type == ModelTypeEnum.yolox:
if self.ov_model_type == ModelTypeEnum.yolox:
out_tensor = infer_request.get_output_tensor()
# [x, y, h, w, box_score, class_no_1, ..., class_no_80],
results = out_tensor.data

View File

@@ -50,11 +50,13 @@ def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
or get_ffmpeg_arg_list(ffmpeg.input_args)
)
return (
["ffmpeg", "-vn"]
["ffmpeg", "-vn", "-threads", "1"]
+ input_args
+ ["-i"]
+ [ffmpeg_input.path]
+ [
"-threads",
"1",
"-f",
f"{AUDIO_FORMAT}",
"-ar",

View File

@@ -498,6 +498,10 @@ class CameraState:
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
# draw on the frame
if draw_options.get("mask"):
mask_overlay = np.where(self.camera_config.motion.mask == [0])
frame_copy[mask_overlay] = [0, 0, 0]
if draw_options.get("bounding_boxes"):
# draw the bounding boxes on the frame
for obj in tracked_objects.values():
@@ -622,10 +626,6 @@ class CameraState:
)
cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness)
if draw_options.get("mask"):
mask_overlay = np.where(self.camera_config.motion.mask == [0])
frame_copy[mask_overlay] = [0, 0, 0]
if draw_options.get("motion_boxes"):
for m_box in motion_boxes:
cv2.rectangle(

View File

@@ -134,6 +134,8 @@ class FFMpegConverter(threading.Thread):
ffmpeg_cmd = [
"ffmpeg",
"-threads",
"1",
"-f",
"rawvideo",
"-pix_fmt",
@@ -142,6 +144,8 @@ class FFMpegConverter(threading.Thread):
f"{in_width}x{in_height}",
"-i",
"pipe:",
"-threads",
"1",
"-f",
"mpegts",
"-s",

View File

@@ -31,6 +31,8 @@ class FFMpegConverter(threading.Thread):
ffmpeg_cmd = [
"ffmpeg",
"-threads",
"1",
"-f",
"rawvideo",
"-pix_fmt",
@@ -39,6 +41,8 @@ class FFMpegConverter(threading.Thread):
f"{in_width}x{in_height}",
"-i",
"pipe:",
"-threads",
"1",
"-f",
"mpegts",
"-s",

View File

@@ -5,6 +5,7 @@ import logging
import os
import subprocess as sp
import threading
import time
from pathlib import Path
import cv2
@@ -101,12 +102,24 @@ class FFMpegConverter(threading.Thread):
f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}"
)
p = sp.run(
self.ffmpeg_cmd.split(" "),
input="\n".join(playlist),
encoding="ascii",
capture_output=True,
)
try:
p = sp.run(
self.ffmpeg_cmd.split(" "),
input="\n".join(playlist),
encoding="ascii",
capture_output=True,
)
except BlockingIOError:
logger.warning(
f"Failed to create preview for {self.config.name}, retrying..."
)
time.sleep(2)
p = sp.run(
self.ffmpeg_cmd.split(" "),
input="\n".join(playlist),
encoding="ascii",
capture_output=True,
)
start = self.frame_times[0]
end = self.frame_times[-1]
@@ -167,6 +180,7 @@ class PreviewRecorder:
# end segment at end of hour
self.segment_end = (
(datetime.datetime.now() + datetime.timedelta(hours=1))
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0)
.timestamp()
)
@@ -179,6 +193,7 @@ class PreviewRecorder:
# check for existing items in cache
start_ts = (
datetime.datetime.now()
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0)
.timestamp()
)
@@ -194,7 +209,12 @@ class PreviewRecorder:
os.unlink(os.path.join(PREVIEW_CACHE_DIR, file))
continue
ts = float(file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)])
file_time = file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)]
if not file_time:
continue
ts = float(file_time)
if self.start_time == 0:
self.start_time = ts
@@ -287,6 +307,7 @@ class PreviewRecorder:
# reset frame cache
self.segment_end = (
(datetime.datetime.now() + datetime.timedelta(hours=1))
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0)
.timestamp()
)

View File

@@ -356,7 +356,7 @@ class ReviewSegmentMaintainer(threading.Thread):
if (
not severity
and (
not camera_config.review.detections.labels
camera_config.review.detections.labels is None
or object["label"] in (camera_config.review.detections.labels)
)
and (
@@ -467,7 +467,7 @@ class ReviewSegmentMaintainer(threading.Thread):
current_segment.audio.add(audio)
current_segment.severity = SeverityEnum.alert
elif (
not camera_config.review.detections.labels
camera_config.review.detections.labels is None
or audio in camera_config.review.detections.labels
):
current_segment.audio.add(audio)
@@ -510,7 +510,7 @@ class ReviewSegmentMaintainer(threading.Thread):
detections.add(audio)
severity = SeverityEnum.alert
elif (
not camera_config.review.detections.labels
camera_config.review.detections.labels is None
or audio in camera_config.review.detections.labels
):
detections.add(audio)
@@ -571,7 +571,7 @@ def get_active_objects(
and (
o["label"] in camera_config.review.alerts.labels
or (
not camera_config.review.detections.labels
camera_config.review.detections.labels is None
or o["label"] in camera_config.review.detections.labels
)
) # object must be in the alerts or detections label list

View File

@@ -237,7 +237,7 @@ def update_yaml(data, key_path, new_value):
temp[key[0]] += [{}] * (key[1] - len(temp[key[0]]) + 1)
temp = temp[key[0]][key[1]]
else:
if key not in temp:
if key not in temp or temp[key] is None:
temp[key] = {}
temp = temp[key]

View File

@@ -1,5 +1,6 @@
"""configuration utils."""
import asyncio
import logging
import os
import shutil
@@ -8,6 +9,7 @@ from typing import Optional, Union
from ruamel.yaml import YAML
from frigate.const import CONFIG_DIR, EXPORT_DIR
from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__)
@@ -17,16 +19,17 @@ CURRENT_CONFIG_VERSION = 0.14
def migrate_frigate_config(config_file: str):
"""handle migrating the frigate config."""
logger.info("Checking if frigate config needs migration...")
version_file = os.path.join(CONFIG_DIR, ".version")
if not os.path.isfile(version_file):
previous_version = 0.13
else:
with open(version_file) as f:
try:
previous_version = float(f.readline())
except Exception:
previous_version = 0.13
if not os.access(config_file, mode=os.W_OK):
logger.error("Config file is read-only, unable to migrate config file.")
return
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
with open(config_file, "r") as f:
config: dict[str, dict[str, any]] = yaml.load(f)
previous_version = config.get("version", 0.13)
if previous_version == CURRENT_CONFIG_VERSION:
logger.info("frigate config does not need migration...")
@@ -35,11 +38,6 @@ def migrate_frigate_config(config_file: str):
logger.info("copying config as backup...")
shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml"))
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
with open(config_file, "r") as f:
config: dict[str, dict[str, any]] = yaml.load(f)
if previous_version < 0.14:
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
new_config = migrate_014(config)
@@ -57,9 +55,6 @@ def migrate_frigate_config(config_file: str):
os.path.join(EXPORT_DIR, file), os.path.join(EXPORT_DIR, new_name)
)
with open(version_file, "w") as f:
f.write(str(CURRENT_CONFIG_VERSION))
logger.info("Finished frigate config migration...")
@@ -92,8 +87,12 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
if not new_config["record"]:
del new_config["record"]
if new_config.get("ui", {}).get("use_experimental"):
del new_config["ui"]["experimental"]
if new_config.get("ui"):
if new_config["ui"].get("use_experimental"):
del new_config["ui"]["experimental"]
if new_config["ui"].get("live_mode"):
del new_config["ui"]["live_mode"]
if not new_config["ui"]:
del new_config["ui"]
@@ -141,6 +140,7 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
new_config["cameras"][name] = camera_config
new_config["version"] = 0.14
return new_config
@@ -200,3 +200,16 @@ def get_relative_coordinates(
return mask
return mask
class StreamInfoRetriever:
def __init__(self) -> None:
self.stream_cache: dict[str, tuple[int, int]] = {}
def get_stream_info(self, path: str) -> str:
if path in self.stream_cache:
return self.stream_cache[path]
info = asyncio.run(get_video_properties(path))
self.stream_cache[path] = info
return info

654
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,9 +46,10 @@
"immer": "^10.1.1",
"konva": "^9.3.9",
"lodash": "^4.17.21",
"lucide-react": "^0.379.0",
"lucide-react": "^0.381.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
"nosleep.js": "^0.12.0",
"react": "^18.3.1",
"react-apexcharts": "^1.4.1",
"react-day-picker": "^8.10.1",
@@ -92,7 +93,7 @@
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.55.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.2.0",
"eslint-plugin-prettier": "^5.0.1",
@@ -105,7 +106,7 @@
"msw": "^2.3.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14",
"prettier-plugin-tailwindcss": "^0.6.1",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.11",

View File

@@ -20,12 +20,11 @@ const System = lazy(() => import("@/pages/System"));
const Settings = lazy(() => import("@/pages/Settings"));
const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
const Logs = lazy(() => import("@/pages/Logs"));
const NoMatch = lazy(() => import("@/pages/NoMatch"));
function App() {
return (
<Providers>
<BrowserRouter>
<BrowserRouter basename={window.baseUrl}>
<Wrapper>
<div className="size-full overflow-hidden">
{isDesktop && <Sidebar />}
@@ -52,7 +51,7 @@ function App() {
<Route path="/config" element={<ConfigEditor />} />
<Route path="/logs" element={<Logs />} />
<Route path="/playground" element={<UIPlayground />} />
<Route path="*" element={<NoMatch />} />
<Route path="*" element={<Redirect to="/" />} />
</Routes>
</Suspense>
</div>

View File

@@ -1,33 +1,20 @@
import { useFrigateStats } from "@/api/ws";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-provider";
import useStats from "@/hooks/use-stats";
import { FrigateStats } from "@/types/stats";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import { useContext, useEffect, useMemo } from "react";
import { FaCheck } from "react-icons/fa";
import { IoIosWarning } from "react-icons/io";
import { MdCircle } from "react-icons/md";
import { Link } from "react-router-dom";
import useSWR from "swr";
export default function Statusbar() {
const { data: initialStats } = useSWR<FrigateStats>("stats", {
revalidateOnFocus: false,
});
const { payload: latestStats } = useFrigateStats();
const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext,
)!;
const stats = useMemo(() => {
if (latestStats) {
return latestStats;
}
return initialStats;
}, [initialStats, latestStats]);
const stats = useAutoFrigateStats();
const cpuPercent = useMemo(() => {
const systemCpu = stats?.cpu_usages["frigate.full_system"]?.cpu;
@@ -94,6 +81,10 @@ export default function Statusbar() {
const gpu = parseInt(stats.gpu);
if (isNaN(gpu)) {
return;
}
return (
<Link key={gpuTitle} to="/system#general">
{" "}

View File

@@ -69,7 +69,7 @@ export default function AutoUpdatingCameraImage({
<CameraImage
camera={camera}
onload={handleLoad}
searchParams={`cache=${key}&${searchParams}`}
searchParams={`cache=${key}${searchParams && `&${searchParams}`}`}
className={cameraClasses}
/>
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}

View File

@@ -7,12 +7,10 @@ import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { useNavigate } from "react-router-dom";
import { RecordingStartingPoint } from "@/types/record";
import axios from "axios";
import {
InProgressPreview,
VideoPreview,
} from "../player/PreviewThumbnailPlayer";
import { VideoPreview } from "../player/PreviewThumbnailPlayer";
import { isCurrentHour } from "@/utils/dateUtil";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { baseUrl } from "@/api/baseUrl";
type AnimatedEventCardProps = {
event: ReviewSegment;
@@ -105,19 +103,19 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
windowVisible={windowVisible}
/>
) : (
<InProgressPreview
review={event}
timeRange={{
after: event.start_time,
before: event.end_time ?? event.start_time + 20,
}}
<video
preload="auto"
autoPlay
playsInline
muted
disableRemotePlayback
loop
showProgress={false}
setReviewed={() => {}}
setIgnoreClick={() => {}}
isPlayingBack={() => {}}
windowVisible={windowVisible}
/>
>
<source
src={`${baseUrl}api/review/${event.id}/preview?format=mp4`}
type="video/mp4"
/>
</video>
)}
</div>
<div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent">

View File

@@ -177,7 +177,7 @@ export default function ExportCard({
{exportedRecording.thumb_path.length > 0 ? (
<img
className="absolute inset-0 aspect-video size-full rounded-lg object-contain md:rounded-2xl"
src={exportedRecording.thumb_path.replace("/media/frigate", "")}
src={`${baseUrl}${exportedRecording.thumb_path.replace("/media/frigate/", "")}`}
onLoad={() => setLoading(false)}
/>
) : (

View File

@@ -3,12 +3,24 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
import { getIconForLabel } from "@/utils/iconUtil";
import { isSafari } from "react-device-detect";
import { isDesktop, isIOS, isSafari } from "react-device-detect";
import useSWR from "swr";
import TimeAgo from "../dynamic/TimeAgo";
import { useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import { FaCompactDisc } from "react-icons/fa";
import { FaCircleCheck } from "react-icons/fa6";
import { HiTrash } from "react-icons/hi";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu";
import { Drawer, DrawerContent } from "../ui/drawer";
import axios from "axios";
import { toast } from "sonner";
type ReviewCardProps = {
event: ReviewSegment;
@@ -33,10 +45,61 @@ export default function ReviewCard({
[event, currentTime],
);
return (
const [optionsOpen, setOptionsOpen] = useState(false);
const onMarkAsReviewed = useCallback(async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] });
event.has_been_reviewed = true;
setOptionsOpen(false);
}, [event]);
const onExport = useCallback(async () => {
axios
.post(
`export/${event.camera}/start/${event.start_time}/end/${event.end_time}`,
{ playback: "realtime" },
)
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
);
}
})
.catch((error) => {
if (error.response?.data?.message) {
toast.error(
`Failed to start export: ${error.response.data.message}`,
{ position: "top-center" },
);
} else {
toast.error(`Failed to start export: ${error.message}`, {
position: "top-center",
});
}
});
setOptionsOpen(false);
}, [event]);
const onDelete = useCallback(async () => {
await axios.post(`reviews/delete`, { ids: [event.id] });
event.id = "";
setOptionsOpen(false);
}, [event]);
const content = (
<div
className="relative flex w-full cursor-pointer flex-col gap-1.5"
onClick={onClick}
onContextMenu={
isDesktop
? undefined
: (e) => {
e.preventDefault();
setOptionsOpen(true);
}
}
>
<ImageLoadingIndicator
className="absolute inset-0"
@@ -47,6 +110,15 @@ export default function ReviewCard({
className={`size-full rounded-lg ${isSelected ? "outline outline-[3px] outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`}
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
loading={isSafari ? "eager" : "lazy"}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
onLoad={() => {
onImgLoad();
}}
@@ -69,4 +141,78 @@ export default function ReviewCard({
</div>
</div>
);
if (event.id == "") {
return;
}
if (isDesktop) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
</ContextMenuItem>
{!event.has_been_reviewed && (
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
</div>
</ContextMenuItem>
)}
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
return (
<Drawer open={optionsOpen} onOpenChange={setOptionsOpen}>
{content}
<DrawerContent>
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
{!event.has_been_reviewed && (
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
</div>
)}
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -468,7 +468,7 @@ export function CameraGroupRow({
{isMobile && (
<>
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger>
@@ -555,9 +555,7 @@ export function CameraGroupEdit({
message: "Invalid camera group name.",
}),
cameras: z.array(z.string()).min(2, {
message: "You must select at least two cameras.",
}),
cameras: z.array(z.string()),
icon: z
.string()
.min(1, { message: "You must select an icon." })
@@ -663,6 +661,7 @@ export function CameraGroupEdit({
<FormDescription>
Select cameras for this group.
</FormDescription>
<FormMessage />
{[
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
...Object.keys(config?.cameras ?? {}),
@@ -680,7 +679,6 @@ export function CameraGroupEdit({
/>
</FormControl>
))}
<FormMessage />
</FormItem>
)}
/>

View File

@@ -365,6 +365,7 @@ export function CamerasFilterButton({
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
if (!open) {

View File

@@ -0,0 +1,138 @@
import { useTheme } from "@/context/theme-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo } from "react";
import Chart from "react-apexcharts";
import { isMobileOnly } from "react-device-detect";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
const GRAPH_COLORS = ["#5C7CFA", "#ED5CFA", "#FAD75C"];
type CameraLineGraphProps = {
graphId: string;
unit: string;
dataLabels: string[];
updateTimes: number[];
data: ApexAxisChartSeries;
};
export function CameraLineGraph({
graphId,
unit,
dataLabels,
updateTimes,
data,
}: CameraLineGraphProps) {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const lastValues = useMemo<number[] | undefined>(() => {
if (!dataLabels || !data || data.length == 0) {
return undefined;
}
return dataLabels.map(
(_, labelIdx) =>
// @ts-expect-error y is valid
data[labelIdx].data[data[labelIdx].data.length - 1]?.y ?? 0,
) as number[];
}, [data, dataLabels]);
const { theme, systemTheme } = useTheme();
const formatTime = useCallback(
(val: unknown) => {
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour",
hour: "2-digit",
minute: "2-digit",
});
},
[config, updateTimes],
);
const options = useMemo(() => {
return {
chart: {
id: graphId,
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
colors: GRAPH_COLORS,
grid: {
show: false,
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
stroke: {
width: 1,
},
tooltip: {
theme: systemTheme || theme,
},
markers: {
size: 0,
},
xaxis: {
tickAmount: isMobileOnly ? 3 : 4,
tickPlacement: "on",
labels: {
rotate: 0,
formatter: formatTime,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
show: true,
labels: {
formatter: (val: number) => Math.ceil(val).toString(),
},
min: 0,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme, formatTime]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
return (
<div className="flex w-full flex-col">
{lastValues && (
<div className="flex items-center gap-2.5">
{dataLabels.map((label, labelIdx) => (
<div key={label} className="flex items-center gap-1">
<MdCircle
className="size-2"
style={{ color: GRAPH_COLORS[labelIdx] }}
/>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-xs text-primary">
{lastValues[labelIdx]}
{unit}
</div>
</div>
))}
</div>
)}
<Chart type="line" options={options} series={data} height="120" />
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { useTheme } from "@/context/theme-provider";
import { useEffect, useMemo } from "react";
import Chart from "react-apexcharts";
const getUnitSize = (MB: number) => {
if (MB === null || isNaN(MB) || MB < 0) return "Invalid number";
if (MB < 1024) return `${MB.toFixed(2)} MiB`;
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
return `${(MB / 1048576).toFixed(2)} TiB`;
};
type StorageGraphProps = {
graphId: string;
used: number;
total: number;
};
export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
const { theme, systemTheme } = useTheme();
const options = useMemo(() => {
return {
chart: {
id: graphId,
background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
grid: {
show: false,
padding: {
bottom: -40,
top: -60,
left: -20,
right: 0,
},
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
plotOptions: {
bar: {
horizontal: true,
},
},
states: {
active: {
filter: {
type: "none",
},
},
hover: {
filter: {
type: "none",
},
},
},
tooltip: {
enabled: false,
},
xaxis: {
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
labels: {
show: false,
},
},
yaxis: {
show: false,
min: 0,
max: 100,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
return (
<div className="flex w-full flex-col gap-2.5">
<div className="flex w-full items-center justify-between gap-1">
<div className="flex items-center gap-1">
<div className="text-xs text-primary">{getUnitSize(used)}</div>
<div className="text-xs text-primary">/</div>
<div className="text-xs text-muted-foreground">
{getUnitSize(total)}
</div>
</div>
<div className="text-xs text-primary">
{Math.round((used / total) * 100)}%
</div>
</div>
<div className="h-5 overflow-hidden rounded-md">
<Chart
type="bar"
options={options}
series={[
{ data: [{ x: "storage", y: Math.round((used / total) * 100) }] },
]}
height="100%"
/>
</div>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { Threshold } from "@/types/graph";
import { useCallback, useEffect, useMemo } from "react";
import Chart from "react-apexcharts";
import { isMobileOnly } from "react-device-detect";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
type ThresholdBarGraphProps = {
@@ -37,7 +36,16 @@ export function ThresholdBarGraph({
const formatTime = useCallback(
(val: unknown) => {
const date = new Date(updateTimes[Math.round(val as number) - 1] * 1000);
const dateIndex = Math.round(val as number);
let timeOffset = 0;
if (dateIndex < 0) {
timeOffset = 5000 * Math.abs(dateIndex);
}
const date = new Date(
updateTimes[Math.max(1, dateIndex) - 1] * 1000 - timeOffset,
);
return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour",
hour: "2-digit",
@@ -130,6 +138,22 @@ export function ThresholdBarGraph({
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
const chartData = useMemo(() => {
if (data.length > 0 && data[0].data.length >= 30) {
return data;
}
const copiedData = [...data];
const fakeData = [];
for (let i = data.length; i < 30; i++) {
fakeData.push({ x: i - 30, y: 0 });
}
// @ts-expect-error data types are not obvious
copiedData[0].data = [...fakeData, ...data[0].data];
return copiedData;
}, [data]);
return (
<div className="flex w-full flex-col">
<div className="flex items-center gap-1">
@@ -139,255 +163,7 @@ export function ThresholdBarGraph({
{unit}
</div>
</div>
<Chart type="bar" options={options} series={data} height="120" />
</div>
);
}
const getUnitSize = (MB: number) => {
if (MB === null || isNaN(MB) || MB < 0) return "Invalid number";
if (MB < 1024) return `${MB.toFixed(2)} MiB`;
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
return `${(MB / 1048576).toFixed(2)} TiB`;
};
type StorageGraphProps = {
graphId: string;
used: number;
total: number;
};
export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
const { theme, systemTheme } = useTheme();
const options = useMemo(() => {
return {
chart: {
id: graphId,
background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
grid: {
show: false,
padding: {
bottom: -40,
top: -60,
left: -20,
right: 0,
},
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
plotOptions: {
bar: {
horizontal: true,
},
},
states: {
active: {
filter: {
type: "none",
},
},
hover: {
filter: {
type: "none",
},
},
},
tooltip: {
enabled: false,
},
xaxis: {
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
labels: {
show: false,
},
},
yaxis: {
show: false,
min: 0,
max: 100,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
return (
<div className="flex w-full flex-col gap-2.5">
<div className="flex w-full items-center justify-between gap-1">
<div className="flex items-center gap-1">
<div className="text-xs text-primary">{getUnitSize(used)}</div>
<div className="text-xs text-primary">/</div>
<div className="text-xs text-muted-foreground">
{getUnitSize(total)}
</div>
</div>
<div className="text-xs text-primary">
{Math.round((used / total) * 100)}%
</div>
</div>
<div className="h-5 overflow-hidden rounded-md">
<Chart
type="bar"
options={options}
series={[
{ data: [{ x: "storage", y: Math.round((used / total) * 100) }] },
]}
height="100%"
/>
</div>
</div>
);
}
const GRAPH_COLORS = ["#5C7CFA", "#ED5CFA", "#FAD75C"];
type CameraLineGraphProps = {
graphId: string;
unit: string;
dataLabels: string[];
updateTimes: number[];
data: ApexAxisChartSeries;
};
export function CameraLineGraph({
graphId,
unit,
dataLabels,
updateTimes,
data,
}: CameraLineGraphProps) {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const lastValues = useMemo<number[] | undefined>(() => {
if (!dataLabels || !data || data.length == 0) {
return undefined;
}
return dataLabels.map(
(_, labelIdx) =>
// @ts-expect-error y is valid
data[labelIdx].data[data[labelIdx].data.length - 1]?.y ?? 0,
) as number[];
}, [data, dataLabels]);
const { theme, systemTheme } = useTheme();
const formatTime = useCallback(
(val: unknown) => {
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour",
hour: "2-digit",
minute: "2-digit",
});
},
[config, updateTimes],
);
const options = useMemo(() => {
return {
chart: {
id: graphId,
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
colors: GRAPH_COLORS,
grid: {
show: false,
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
stroke: {
width: 1,
},
tooltip: {
theme: systemTheme || theme,
},
markers: {
size: 0,
},
xaxis: {
tickAmount: isMobileOnly ? 3 : 4,
tickPlacement: "on",
labels: {
rotate: 0,
formatter: formatTime,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
show: true,
labels: {
formatter: (val: number) => Math.ceil(val).toString(),
},
min: 0,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme, formatTime]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
return (
<div className="flex w-full flex-col">
{lastValues && (
<div className="flex items-center gap-2.5">
{dataLabels.map((label, labelIdx) => (
<div key={label} className="flex items-center gap-1">
<MdCircle
className="size-2"
style={{ color: GRAPH_COLORS[labelIdx] }}
/>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-xs text-primary">
{lastValues[labelIdx]}
{unit}
</div>
</div>
))}
</div>
)}
<Chart type="line" options={options} series={data} height="120" />
<Chart type="bar" options={options} series={chartData} height="120" />
</div>
);
}

View File

@@ -34,7 +34,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
const MenuItem = isDesktop ? DropdownMenuItem : DialogClose;
return (
<Container>
<Container modal={!isDesktop}>
<Trigger>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -66,6 +66,7 @@ import {
DialogTrigger,
} from "../ui/dialog";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
type GeneralSettingsProps = {
className?: string;
@@ -113,249 +114,249 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
return (
<>
<div className={className}>
<Container>
<Trigger>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`flex flex-col items-center justify-center ${isDesktop ? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted" : "text-secondary-foreground"}`}
>
<LuSettings className="size-5 md:m-[6px]" />
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
</Trigger>
<Content
className={
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2"
}
>
<div className="w-full flex-col overflow-y-auto overflow-x-hidden">
<DropdownMenuLabel>System</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
<Link to="/system#general">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuActivity className="mr-2 size-4" />
<span>System metrics</span>
</MenuItem>
</Link>
<Link to="/logs">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuList className="mr-2 size-4" />
<span>System logs</span>
</MenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Configuration
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link to="/settings">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuSettings className="mr-2 size-4" />
<span>Settings</span>
</MenuItem>
</Link>
<Link to="/config">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuPenSquare className="mr-2 size-4" />
<span>Configuration editor</span>
</MenuItem>
</Link>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Appearance
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Dark Mode</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("light")}
>
{theme === "light" ? (
<>
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
Light
</>
) : (
<span className="ml-6 mr-2">Light</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("dark")}
>
{theme === "dark" ? (
<>
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
Dark
</>
) : (
<span className="ml-6 mr-2">Dark</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("system")}
>
{theme === "system" ? (
<>
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
System
</>
) : (
<span className="ml-6 mr-2">System</span>
)}
</MenuItem>
</SubItemContent>
</Portal>
</SubItem>
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Theme</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
{colorSchemes.map((scheme) => (
<MenuItem
key={scheme}
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setColorScheme(scheme)}
>
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{friendlyColorSchemeName(scheme)}
</>
) : (
<span className="ml-6 mr-2">
{friendlyColorSchemeName(scheme)}
</span>
)}
</MenuItem>
))}
</SubItemContent>
</Portal>
</SubItem>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Help
</DropdownMenuLabel>
<DropdownMenuSeparator />
<a href="https://docs.frigate.video">
<Container modal={!isDesktop}>
<Trigger>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"flex flex-col items-center justify-center",
isDesktop
? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted"
: "text-secondary-foreground",
className,
)}
>
<LuSettings className="size-5 md:m-[6px]" />
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
</Trigger>
<Content
className={
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2"
}
>
<div className="w-full flex-col overflow-y-auto overflow-x-hidden">
<DropdownMenuLabel>System</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
<Link to="/system#general">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuActivity className="mr-2 size-4" />
<span>System metrics</span>
</MenuItem>
</Link>
<Link to="/logs">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuList className="mr-2 size-4" />
<span>System logs</span>
</MenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Configuration
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link to="/settings">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuSettings className="mr-2 size-4" />
<span>Settings</span>
</MenuItem>
</Link>
<Link to="/config">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuPenSquare className="mr-2 size-4" />
<span>Configuration editor</span>
</MenuItem>
</Link>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Appearance
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuLifeBuoy className="mr-2 size-4" />
<span>Documentation</span>
</MenuItem>
</a>
<a href="https://github.com/blakeblackshear/frigate">
<MenuItem
<LuSunMoon className="mr-2 size-4" />
<span>Dark Mode</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("light")}
>
{theme === "light" ? (
<>
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
Light
</>
) : (
<span className="ml-6 mr-2">Light</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("dark")}
>
{theme === "dark" ? (
<>
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
Dark
</>
) : (
<span className="ml-6 mr-2">Dark</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("system")}
>
{theme === "system" ? (
<>
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
System
</>
) : (
<span className="ml-6 mr-2">System</span>
)}
</MenuItem>
</SubItemContent>
</Portal>
</SubItem>
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuGithub className="mr-2 size-4" />
<span>GitHub</span>
</MenuItem>
</a>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
<LuSunMoon className="mr-2 size-4" />
<span>Theme</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
{colorSchemes.map((scheme) => (
<MenuItem
key={scheme}
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setColorScheme(scheme)}
>
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{friendlyColorSchemeName(scheme)}
</>
) : (
<span className="ml-6 mr-2">
{friendlyColorSchemeName(scheme)}
</span>
)}
</MenuItem>
))}
</SubItemContent>
</Portal>
</SubItem>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Help
</DropdownMenuLabel>
<DropdownMenuSeparator />
<a href="https://docs.frigate.video">
<MenuItem
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
onClick={() => setRestartDialogOpen(true)}
>
<LuRotateCw className="mr-2 size-4" />
<span>Restart Frigate</span>
<LuLifeBuoy className="mr-2 size-4" />
<span>Documentation</span>
</MenuItem>
</div>
</Content>
</Container>
</div>
</a>
<a href="https://github.com/blakeblackshear/frigate">
<MenuItem
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
>
<LuGithub className="mr-2 size-4" />
<span>GitHub</span>
</MenuItem>
</a>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
<MenuItem
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
onClick={() => setRestartDialogOpen(true)}
>
<LuRotateCw className="mr-2 size-4" />
<span>Restart Frigate</span>
</MenuItem>
</div>
</Content>
</Container>
{restartDialogOpen && (
<AlertDialog
open={restartDialogOpen}

View File

@@ -3,6 +3,7 @@ import { Calendar } from "../ui/calendar";
import { useMemo } from "react";
import { FaCircle } from "react-icons/fa";
import { getUTCOffset } from "@/utils/dateUtil";
import { type DayContentProps } from "react-day-picker";
type ReviewActivityCalendarProps = {
reviewSummary?: ReviewSummary;
@@ -22,6 +23,28 @@ export default function ReviewActivityCalendar({
return { from: tomorrow, to: future };
}, []);
const modifiers = useMemo(() => {
if (!reviewSummary) {
return { alerts: [], detections: [] };
}
const unreviewedDetections: Date[] = [];
const unreviewedAlerts: Date[] = [];
Object.entries(reviewSummary).forEach(([date, data]) => {
if (data.total_alert > data.reviewed_alert) {
unreviewedAlerts.push(new Date(date));
} else if (data.total_detection > data.reviewed_detection) {
unreviewedDetections.push(new Date(date));
}
});
return {
alerts: unreviewedAlerts,
detections: unreviewedDetections,
};
}, [reviewSummary]);
return (
<Calendar
mode="single"
@@ -29,46 +52,28 @@ export default function ReviewActivityCalendar({
showOutsideDays={false}
selected={selectedDay}
onSelect={onSelect}
modifiers={modifiers}
components={{
DayContent: (date) => (
<ReviewActivityDay reviewSummary={reviewSummary} day={date.date} />
),
DayContent: ReviewActivityDay,
}}
/>
);
}
type ReviewActivityDayProps = {
reviewSummary?: ReviewSummary;
day: Date;
};
function ReviewActivityDay({ reviewSummary, day }: ReviewActivityDayProps) {
function ReviewActivityDay({ date, activeModifiers }: DayContentProps) {
const dayActivity = useMemo(() => {
if (!reviewSummary) {
return "none";
}
const allActivity =
reviewSummary[
`${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`
];
if (!allActivity) {
return "none";
}
if (allActivity.total_alert > allActivity.reviewed_alert) {
if (activeModifiers["alerts"]) {
return "alert";
} else if (allActivity.total_detection > allActivity.reviewed_detection) {
} else if (activeModifiers["detections"]) {
return "detection";
} else {
return "none";
}
}, [reviewSummary, day]);
}, [activeModifiers]);
return (
<div className="flex flex-col items-center justify-center">
{day.getDate()}
<div className="flex flex-col items-center justify-center gap-0.5">
{date.getDate()}
{dayActivity != "none" && (
<FaCircle
className={`size-2 ${dayActivity == "alert" ? "fill-severity_alert" : "fill-severity_detection"}`}

View File

@@ -5,12 +5,14 @@ import JSMpegPlayer from "./JSMpegPlayer";
import MSEPlayer from "./MsePlayer";
import { LivePlayerMode } from "@/types/live";
import { cn } from "@/lib/utils";
import React from "react";
type LivePlayerProps = {
className?: string;
birdseyeConfig: BirdseyeConfig;
liveMode: LivePlayerMode;
onClick?: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
};
export default function BirdseyeLivePlayer({
@@ -18,6 +20,7 @@ export default function BirdseyeLivePlayer({
birdseyeConfig,
liveMode,
onClick,
containerRef,
}: LivePlayerProps) {
let player;
if (liveMode == "webrtc") {
@@ -50,6 +53,7 @@ export default function BirdseyeLivePlayer({
camera="birdseye"
width={birdseyeConfig.width}
height={birdseyeConfig.height}
containerRef={containerRef}
/>
);
} else {

View File

@@ -39,7 +39,7 @@ type HlsVideoPlayerProps = {
onPlaying?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
setFullscreen?: (full: boolean) => void;
toggleFullscreen?: () => void;
};
export default function HlsVideoPlayer({
videoRef,
@@ -53,7 +53,7 @@ export default function HlsVideoPlayer({
onPlaying,
setFullResolution,
onUploadFrame,
setFullscreen,
toggleFullscreen,
}: HlsVideoPlayerProps) {
const { data: config } = useSWR<FrigateConfig>("config");
@@ -224,7 +224,7 @@ export default function HlsVideoPlayer({
}
}}
fullscreen={fullscreen}
setFullscreen={setFullscreen}
toggleFullscreen={toggleFullscreen}
/>
<TransformComponent
wrapperStyle={{
@@ -272,8 +272,8 @@ export default function HlsVideoPlayer({
? onTimeUpdate(videoRef.current.currentTime)
: undefined
}
onLoadedData={onPlayerLoaded}
onLoadedMetadata={() => {
onLoadedData={() => {
onPlayerLoaded?.();
handleLoadedMetadata();
if (videoRef.current) {

View File

@@ -1,13 +1,15 @@
import { baseUrl } from "@/api/baseUrl";
import { useResizeObserver } from "@/hooks/resize-observer";
// @ts-expect-error we know this doesn't have types
import JSMpeg from "@cycjimmy/jsmpeg-player";
import { useEffect, useRef } from "react";
import React, { useEffect, useMemo, useRef } from "react";
type JSMpegPlayerProps = {
className?: string;
camera: string;
width: number;
height: number;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
};
export default function JSMpegPlayer({
@@ -15,10 +17,65 @@ export default function JSMpegPlayer({
width,
height,
className,
containerRef,
}: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
const playerRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const internalContainerRef = useRef<HTMLDivElement | null>(null);
const selectedContainerRef = useMemo(
() => containerRef ?? internalContainerRef,
[containerRef, internalContainerRef],
);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(selectedContainerRef);
const stretch = true;
const aspectRatio = width / height;
const fitAspect = useMemo(
() => containerWidth / containerHeight,
[containerWidth, containerHeight],
);
const scaledHeight = useMemo(() => {
if (selectedContainerRef?.current && width && height) {
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(
Math.min(
containerHeight,
selectedContainerRef.current?.clientHeight,
),
)
: aspectRatio >= fitAspect
? Math.floor(containerWidth / aspectRatio)
: Math.floor(containerWidth / aspectRatio) / 1.5;
const finalHeight = stretch
? scaledHeight
: Math.min(scaledHeight, height);
if (finalHeight > 0) {
return finalHeight;
}
}
}, [
aspectRatio,
containerWidth,
containerHeight,
fitAspect,
height,
width,
stretch,
selectedContainerRef,
]);
const scaledWidth = useMemo(() => {
if (aspectRatio && scaledHeight) {
return Math.ceil(scaledHeight * aspectRatio);
}
}, [scaledHeight, aspectRatio]);
useEffect(() => {
if (!playerRef.current) {
@@ -28,7 +85,7 @@ export default function JSMpegPlayer({
const video = new JSMpeg.VideoElement(
playerRef.current,
url,
{},
{ canvas: `#${camera}-canvas` },
{ protocols: [], audio: false, videoBufferSize: 1024 * 1024 * 4 },
);
@@ -41,15 +98,21 @@ export default function JSMpegPlayer({
playerRef.current = null;
}
};
}, [url]);
}, [url, camera]);
return (
<div className={className} ref={containerRef}>
<div
ref={playerRef}
className="jsmpeg h-full"
style={{ aspectRatio: width / height }}
/>
<div className={className}>
<div className="size-full" ref={internalContainerRef}>
<div ref={playerRef} className="jsmpeg">
<canvas
id={`${camera}-canvas`}
style={{
width: scaledWidth ?? width,
height: scaledHeight ?? height,
}}
></canvas>
</div>
</div>
</div>
);
}

View File

@@ -8,7 +8,11 @@ import JSMpegPlayer from "./JSMpegPlayer";
import { MdCircle } from "react-icons/md";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import { LivePlayerMode, VideoResolutionType } from "@/types/live";
import {
LivePlayerError,
LivePlayerMode,
VideoResolutionType,
} from "@/types/live";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { getIconForLabel } from "@/utils/iconUtil";
import Chip from "../indicators/Chip";
@@ -17,6 +21,7 @@ import { cn } from "@/lib/utils";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
className?: string;
cameraConfig: CameraConfig;
preferredLiveMode?: LivePlayerMode;
@@ -26,12 +31,15 @@ type LivePlayerProps = {
micEnabled?: boolean; // only webrtc supports mic
iOSCompatFullScreen?: boolean;
pip?: boolean;
autoLive?: boolean;
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
};
export default function LivePlayer({
cameraRef = undefined,
containerRef,
className,
cameraConfig,
preferredLiveMode,
@@ -41,12 +49,14 @@ export default function LivePlayer({
micEnabled = false,
iOSCompatFullScreen = false,
pip,
autoLive = true,
onClick,
setFullResolution,
onError,
}: LivePlayerProps) {
// camera activity
const { activeMotion, activeTracking, objects } =
const { activeMotion, activeTracking, objects, offline } =
useCameraActivity(cameraConfig);
const cameraActive = useMemo(
@@ -62,6 +72,10 @@ export default function LivePlayer({
const [liveReady, setLiveReady] = useState(false);
useEffect(() => {
if (!autoLive) {
return;
}
if (!liveReady) {
if (cameraActive && liveMode == "jsmpeg") {
setLiveReady(true);
@@ -75,12 +89,12 @@ export default function LivePlayer({
}
// live mode won't change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraActive, liveReady]);
}, [autoLive, cameraActive, liveReady]);
// camera still state
const stillReloadInterval = useMemo(() => {
if (!windowVisible) {
if (!windowVisible || offline) {
return -1; // no reason to update the image when the window is not visible
}
@@ -88,19 +102,32 @@ export default function LivePlayer({
return 60000;
}
if (cameraActive) {
return 200;
if (activeMotion || activeTracking) {
if (autoLive) {
return 200;
} else {
return 59000;
}
}
return 30000;
}, [liveReady, cameraActive, windowVisible]);
}, [
autoLive,
liveReady,
activeMotion,
activeTracking,
offline,
windowVisible,
]);
if (!cameraConfig) {
return <ActivityIndicator />;
}
let player;
if (liveMode == "webrtc") {
if (!autoLive) {
player = null;
} else if (liveMode == "webrtc") {
player = (
<WebRtcPlayer
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
@@ -124,6 +151,7 @@ export default function LivePlayer({
onPlaying={() => setLiveReady(true)}
pip={pip}
setFullResolution={setFullResolution}
onError={onError}
/>
);
} else {
@@ -135,14 +163,19 @@ export default function LivePlayer({
);
}
} else if (liveMode == "jsmpeg") {
player = (
<JSMpegPlayer
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl"
camera={cameraConfig.live.stream_name}
width={cameraConfig.detect.width}
height={cameraConfig.detect.height}
/>
);
if (cameraActive || !showStillWithoutActivity) {
player = (
<JSMpegPlayer
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
camera={cameraConfig.live.stream_name}
width={cameraConfig.detect.width}
height={cameraConfig.detect.height}
containerRef={containerRef}
/>
);
} else {
player = null;
}
} else {
player = <ActivityIndicator />;
}
@@ -152,9 +185,7 @@ export default function LivePlayer({
ref={cameraRef}
data-camera={cameraConfig.name}
className={cn(
"relative flex justify-center",
liveMode === "jsmpeg" ? "size-full" : "w-full",
"cursor-pointer outline",
"relative flex w-full cursor-pointer justify-center outline",
activeTracking
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
: "outline-0 outline-background",
@@ -224,9 +255,16 @@ export default function LivePlayer({
/>
</div>
<div className="absolute right-2 top-2 size-4">
{activeMotion && (
<MdCircle className="size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
<div className="absolute right-2 top-2">
{autoLive && !offline && activeMotion && (
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
)}
{offline && (
<Chip
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
>
{cameraConfig.name.replaceAll("_", " ")}
</Chip>
)}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl";
import { VideoResolutionType } from "@/types/live";
import { LivePlayerError, VideoResolutionType } from "@/types/live";
import {
SetStateAction,
useCallback,
@@ -17,6 +17,7 @@ type MSEPlayerProps = {
pip?: boolean;
onPlaying?: () => void;
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
};
function MSEPlayer({
@@ -27,6 +28,7 @@ function MSEPlayer({
pip = false,
onPlaying,
setFullResolution,
onError,
}: MSEPlayerProps) {
const RECONNECT_TIMEOUT: number = 30000;
@@ -45,6 +47,7 @@ function MSEPlayer({
const [wsState, setWsState] = useState<number>(WebSocket.CLOSED);
const [connectTS, setConnectTS] = useState<number>(0);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -303,10 +306,39 @@ function MSEPlayer({
className={className}
playsInline
preload="auto"
onLoadedData={onPlaying}
onLoadedMetadata={handleLoadedMetadata}
onLoadedData={() => {
handleLoadedMetadata?.();
onPlaying?.();
}}
muted={!audioEnabled}
onError={() => {
onProgress={
onError != undefined
? () => {
if (videoRef.current?.paused) {
return;
}
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
setBufferTimeout(
setTimeout(() => {
onError("stalled");
}, 3000),
);
}
: undefined
}
onError={(e) => {
if (
// @ts-expect-error code does exist
e.target.error.code == MediaError.MEDIA_ERR_NETWORK
) {
onError?.("startup");
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;

View File

@@ -10,7 +10,7 @@ import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import { PreviewPlayback } from "@/types/playback";
import { getTimestampOffset, isCurrentHour } from "@/utils/dateUtil";
import { isCurrentHour } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl";
import { isAndroid, isChrome, isMobile } from "react-device-detect";
import { TimeRange } from "@/types/timeline";
@@ -41,13 +41,11 @@ export default function PreviewPlayer({
const [currentHourFrame, setCurrentHourFrame] = useState<string>();
const currentPreview = useMemo(() => {
const timeRangeOffset = getTimestampOffset(timeRange.before);
return cameraPreviews.find(
(preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.after + timeRangeOffset &&
Math.floor(preview.end) <= timeRange.before + timeRangeOffset,
Math.round(preview.start) >= timeRange.after &&
Math.floor(preview.end) <= timeRange.before,
);
}, [cameraPreviews, camera, timeRange]);
@@ -197,6 +195,7 @@ function PreviewVideoPlayer({
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [videoSize, setVideoSize] = useState<number[]>([0, 0]);
const [changeoverTimeout, setChangeoverTimeout] = useState<NodeJS.Timeout>();
const changeSource = useCallback(
(newPreview: Preview | undefined, video: HTMLVideoElement | null) => {
@@ -220,6 +219,15 @@ function PreviewVideoPlayer({
}
setCurrentPreview(newPreview);
const timeout = setTimeout(() => {
if (timeout) {
clearTimeout(timeout);
setChangeoverTimeout(undefined);
}
previewRef.current?.load();
}, 1000);
setChangeoverTimeout(timeout);
// we only want this to change when current preview changes
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -232,17 +240,15 @@ function PreviewVideoPlayer({
return;
}
// account for minutes offset in timezone
const timeRangeOffset = getTimestampOffset(timeRange.before);
const preview = cameraPreviews.find(
(preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.after + timeRangeOffset &&
Math.floor(preview.end) <= timeRange.before + timeRangeOffset,
Math.round(preview.start) >= timeRange.after &&
Math.floor(preview.end) <= timeRange.before,
);
if (preview != currentPreview) {
controller.newPreviewLoaded = false;
changeSource(preview, previewRef.current);
}
@@ -267,7 +273,14 @@ function PreviewVideoPlayer({
<img
className={`absolute size-full object-contain ${currentHourFrame ? "visible" : "invisible"}`}
src={currentHourFrame}
onLoad={() => previewRef.current?.load()}
onLoad={() => {
if (changeoverTimeout) {
clearTimeout(changeoverTimeout);
setChangeoverTimeout(undefined);
}
previewRef.current?.load();
}}
/>
<video
ref={previewRef}
@@ -302,7 +315,10 @@ function PreviewVideoPlayer({
}}
>
{currentPreview != undefined && (
<source src={currentPreview.src} type={currentPreview.type} />
<source
src={`${baseUrl}${currentPreview.src.substring(1)}`}
type={currentPreview.type}
/>
)}
</video>
{cameraPreviews && !currentPreview && (
@@ -324,6 +340,7 @@ class PreviewVideoController extends PreviewController {
private preview: Preview | undefined = undefined;
private timeToSeek: number | undefined = undefined;
public scrubbing = false;
public newPreviewLoaded = true;
private seeking = false;
constructor(
@@ -342,7 +359,12 @@ class PreviewVideoController extends PreviewController {
}
override scrubToTimestamp(time: number): boolean {
if (!this.previewRef.current || !this.preview || !this.timeRange) {
if (
!this.newPreviewLoaded ||
!this.previewRef.current ||
!this.preview ||
!this.timeRange
) {
return false;
}
@@ -393,6 +415,7 @@ class PreviewVideoController extends PreviewController {
}
previewReady() {
this.newPreviewLoaded = true;
this.seeking = false;
this.previewRef.current?.pause();

View File

@@ -25,6 +25,7 @@ import { TimeRange } from "@/types/timeline";
import { NoThumbSlider } from "../ui/slider";
import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { baseUrl } from "@/api/baseUrl";
type PreviewPlayerProps = {
review: ReviewSegment;
@@ -575,7 +576,10 @@ export function VideoPreview({
muted
onTimeUpdate={onProgress}
>
<source src={relevantPreview.src} type={relevantPreview.type} />
<source
src={`${baseUrl}${relevantPreview.src.substring(1)}`}
type={relevantPreview.type}
/>
</video>
{showProgress && (
<NoThumbSlider
@@ -668,7 +672,9 @@ export function InProgressPreview({
setReviewed(review.id);
}
setKey(key + 1);
if (previewFrames[key + 1]) {
setKey(key + 1);
}
}, MIN_LOAD_TIMEOUT_MS);
// we know that these deps are correct

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { isMobileOnly, isSafari } from "react-device-detect";
import { LuPause, LuPlay } from "react-icons/lu";
import {
@@ -68,7 +68,7 @@ type VideoControlsProps = {
onSeek: (diff: number) => void;
onSetPlaybackRate: (rate: number) => void;
onUploadFrame?: () => void;
setFullscreen?: (full: boolean) => void;
toggleFullscreen?: () => void;
};
export default function VideoControls({
className,
@@ -88,8 +88,14 @@ export default function VideoControls({
onSeek,
onSetPlaybackRate,
onUploadFrame,
setFullscreen,
toggleFullscreen,
}: VideoControlsProps) {
// layout
const containerRef = useRef<HTMLDivElement | null>(null);
// controls
const onReplay = useCallback(
(e: React.MouseEvent<SVGElement>) => {
e.stopPropagation();
@@ -133,6 +139,11 @@ export default function VideoControls({
const onKeyboardShortcut = useCallback(
(key: string, down: boolean, repeat: boolean) => {
switch (key) {
case "ArrowDown":
if (down) {
onSeek(-1);
}
break;
case "ArrowLeft":
if (down) {
onSeek(-10);
@@ -143,9 +154,14 @@ export default function VideoControls({
onSeek(10);
}
break;
case "ArrowUp":
if (down) {
onSeek(1);
}
break;
case "f":
if (setFullscreen && down && !repeat) {
setFullscreen(!fullscreen);
if (toggleFullscreen && down && !repeat) {
toggleFullscreen();
}
break;
case "m":
@@ -162,10 +178,12 @@ export default function VideoControls({
},
// only update when preview only changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[video, isPlaying, fullscreen, setFullscreen, onSeek],
[video, isPlaying, fullscreen, toggleFullscreen, onSeek],
);
useKeyboardListener(
hotKeys ? ["ArrowLeft", "ArrowRight", "f", "m", " "] : [],
hotKeys
? ["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "f", "m", " "]
: [],
onKeyboardShortcut,
);
@@ -183,6 +201,7 @@ export default function VideoControls({
MIN_ITEMS_WRAP &&
"min-w-[75%] flex-wrap",
)}
ref={containerRef}
>
{video && features.volume && (
<div className="flex cursor-pointer items-center justify-normal gap-2">
@@ -223,6 +242,7 @@ export default function VideoControls({
)}
{features.playbackRate && (
<DropdownMenu
modal={false}
onOpenChange={(open) => {
if (setControlsOpen) {
setControlsOpen(open);
@@ -230,7 +250,9 @@ export default function VideoControls({
}}
>
<DropdownMenuTrigger>{`${playbackRate}x`}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent
portalProps={{ container: containerRef.current }}
>
<DropdownMenuRadioGroup
onValueChange={(rate) => onSetPlaybackRate(parseFloat(rate))}
>
@@ -265,11 +287,8 @@ export default function VideoControls({
onUploadFrame={onUploadFrame}
/>
)}
{features.fullscreen && setFullscreen && (
<div
className="cursor-pointer"
onClick={() => setFullscreen(!fullscreen)}
>
{features.fullscreen && toggleFullscreen && (
<div className="cursor-pointer" onClick={toggleFullscreen}>
{fullscreen ? <FaCompress /> : <FaExpand />}
</div>
)}
@@ -321,11 +340,11 @@ function FrigatePlusUploadButton({
}}
/>
</AlertDialogTrigger>
<AlertDialogContent className="md:max-w-[80%]">
<AlertDialogContent className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl">
<AlertDialogHeader>
<AlertDialogTitle>Submit this frame to Frigate+?</AlertDialogTitle>
</AlertDialogHeader>
<img className="w-full object-contain" src={videoImg} />
<img className="aspect-video w-full object-contain" src={videoImg} />
<AlertDialogFooter>
<AlertDialogAction className="bg-selected" onClick={onUploadFrame}>
Submit

View File

@@ -1,4 +1,5 @@
import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError } from "@/types/live";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type WebRtcPlayerProps = {
@@ -10,6 +11,7 @@ type WebRtcPlayerProps = {
iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element
pip?: boolean;
onPlaying?: () => void;
onError?: (error: LivePlayerError) => void;
};
export default function WebRtcPlayer({
@@ -21,6 +23,7 @@ export default function WebRtcPlayer({
iOSCompatFullScreen = false,
pip = false,
onPlaying,
onError,
}: WebRtcPlayerProps) {
// metadata
@@ -32,6 +35,7 @@ export default function WebRtcPlayer({
const pcRef = useRef<RTCPeerConnection | undefined>();
const videoRef = useRef<HTMLVideoElement | null>(null);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const PeerConnection = useCallback(
async (media: string) => {
@@ -198,11 +202,39 @@ export default function WebRtcPlayer({
playsInline
muted={!audioEnabled}
onLoadedData={onPlaying}
onProgress={
onError != undefined
? () => {
if (videoRef.current?.paused) {
return;
}
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
setBufferTimeout(
setTimeout(() => {
onError("stalled");
}, 3000),
);
}
: undefined
}
onClick={
iOSCompatFullScreen
? () => setiOSCompatControls(!iOSCompatControls)
: undefined
}
onError={(e) => {
if (
// @ts-expect-error code does exist
e.target.error.code == MediaError.MEDIA_ERR_NETWORK
) {
onError?.("startup");
}
}}
/>
);
}

View File

@@ -29,7 +29,7 @@ type DynamicVideoPlayerProps = {
onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void;
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
setFullscreen: (full: boolean) => void;
toggleFullscreen: () => void;
};
export default function DynamicVideoPlayer({
className,
@@ -44,7 +44,7 @@ export default function DynamicVideoPlayer({
onTimestampUpdate,
onClipEnded,
setFullResolution,
setFullscreen,
toggleFullscreen,
}: DynamicVideoPlayerProps) {
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
@@ -207,7 +207,7 @@ export default function DynamicVideoPlayer({
}}
setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus}
setFullscreen={setFullscreen}
toggleFullscreen={toggleFullscreen}
/>
<PreviewPlayer
className={cn(

View File

@@ -135,9 +135,12 @@ export default function MotionMaskEditPane({
})
.then((res) => {
if (res.status === 200) {
toast.success(`${polygon.name || "Motion Mask"} has been saved.`, {
position: "top-center",
});
toast.success(
`${polygon.name || "Motion Mask"} has been saved. Restart Frigate to apply changes.`,
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {

View File

@@ -193,9 +193,12 @@ export default function ObjectMaskEditPane({
})
.then((res) => {
if (res.status === 200) {
toast.success(`${polygon.name || "Object Mask"} has been saved.`, {
position: "top-center",
});
toast.success(
`${polygon.name || "Object Mask"} has been saved. Restart Frigate to apply changes.`,
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {

View File

@@ -266,7 +266,7 @@ export default function PolygonItem({
{isMobile && (
<>
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger>

View File

@@ -261,9 +261,12 @@ export default function ZoneEditPane({
)
.then((res) => {
if (res.status === 200) {
toast.success(`Zone (${zoneName}) has been saved.`, {
position: "top-center",
});
toast.success(
`Zone (${zoneName}) has been saved. Restart Frigate to apply changes.`,
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {

View File

@@ -7,6 +7,7 @@ import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import { isDesktop, isMobile } from "react-device-detect";
import useTapUtils from "@/hooks/use-tap-utils";
import { cn } from "@/lib/utils";
type MotionSegmentProps = {
events: ReviewSegment[];
@@ -170,7 +171,16 @@ export function MotionSegment({
<div
key={segmentKey}
data-segment-id={segmentKey}
className={`segment ${firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0 ? "has-data" : ""} ${segmentClasses} bg-gradient-to-r ${severityColorsBg[severity[0]]}`}
className={cn(
"segment",
{
"has-data":
firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0,
},
segmentClasses,
severity[0] && "bg-gradient-to-r",
severity[0] && severityColorsBg[severity[0]],
)}
onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
>
@@ -210,7 +220,14 @@ export function MotionSegment({
<div
key={`${segmentKey}_motion_data_1`}
data-motion-value={secondHalfSegmentWidth}
className={`${isDesktop && animationClassesSecondHalf} h-[2px] rounded-full bg-motion_review`}
className={cn(
isDesktop && animationClassesSecondHalf,
"h-[2px]",
"rounded-full",
secondHalfSegmentWidth
? "bg-motion_review"
: "bg-muted-foreground",
)}
style={{
width: secondHalfSegmentWidth || 1,
}}
@@ -223,7 +240,14 @@ export function MotionSegment({
<div
key={`${segmentKey}_motion_data_2`}
data-motion-value={firstHalfSegmentWidth}
className={`${isDesktop && animationClassesFirstHalf} h-[2px] rounded-full bg-motion_review`}
className={cn(
isDesktop && animationClassesFirstHalf,
"h-[2px]",
"rounded-full",
firstHalfSegmentWidth
? "bg-motion_review"
: "bg-muted-foreground",
)}
style={{
width: firstHalfSegmentWidth || 1,
}}

View File

@@ -258,9 +258,9 @@ export function ReviewTimeline({
if (isDragging && isMobile && draggableElementType === draggableElement) {
return "text-lg";
} else if (dense) {
return "text-[8px] md:text-xs";
return "text-[8px] md:text-[11px]";
} else {
return "text-xs";
return "text-[11px]";
}
},
[dense, isDragging, draggableElementType],
@@ -350,7 +350,7 @@ export function ReviewTimeline({
dense
? "w-12 md:w-20"
: segmentDuration < 60
? "w-24"
? "w-[80px]"
: "w-20"
} h-5 ${isDraggingHandlebar && isMobile ? "fixed left-1/2 top-[18px] z-20 h-[30px] w-32 -translate-x-1/2 transform bg-destructive/80" : "static"} flex items-center justify-center`}
>
@@ -388,7 +388,7 @@ export function ReviewTimeline({
dense
? "w-12 md:w-20"
: segmentDuration < 60
? "w-24"
? "w-[80px]"
: "w-20"
} h-5 ${isDraggingExportEnd && isMobile ? "fixed left-1/2 top-[18px] z-20 mt-0 h-[30px] w-32 -translate-x-1/2 transform rounded-full bg-selected/80" : "static rounded-tl-lg rounded-tr-lg"} flex items-center justify-center`}
>
@@ -430,7 +430,7 @@ export function ReviewTimeline({
dense
? "w-12 md:w-20"
: segmentDuration < 60
? "w-24"
? "w-[80px]"
: "w-20"
} h-5 ${isDraggingExportStart && isMobile ? "fixed left-1/2 top-[4px] z-20 mt-0 h-[30px] w-32 -translate-x-1/2 transform rounded-full bg-selected/80" : "static rounded-bl-lg rounded-br-lg"} flex items-center justify-center`}
>

View File

@@ -56,9 +56,11 @@ DropdownMenuSubContent.displayName =
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
portalProps?: DropdownMenuPrimitive.DropdownMenuPortalProps;
}
>(({ className, portalProps, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}

View File

@@ -10,11 +10,13 @@ import { useTimelineUtils } from "./use-timeline-utils";
import { ObjectType } from "@/types/ws";
import useDeepMemo from "./use-deep-memo";
import { isEqual } from "lodash";
import { useAutoFrigateStats } from "./use-stats";
type useCameraActivityReturn = {
activeTracking: boolean;
activeMotion: boolean;
objects: ObjectType[];
offline: boolean;
};
export function useCameraActivity(
@@ -116,12 +118,31 @@ export function useCameraActivity(
handleSetObjects(newObjects);
}, [camera, updatedEvent, objects, handleSetObjects]);
// determine if camera is offline
const stats = useAutoFrigateStats();
const offline = useMemo(() => {
if (!stats) {
return false;
}
const cameras = stats["cameras"];
if (!cameras) {
return false;
}
return cameras[camera.name].camera_fps == 0;
}, [camera, stats]);
return {
activeTracking: hasActiveObjects,
activeMotion: detectingMotion
? detectingMotion === "ON"
: initialCameraState?.motion === true,
objects,
offline,
};
}

View File

@@ -6,7 +6,7 @@ import { LivePlayerMode } from "@/types/live";
export default function useCameraLiveMode(
cameraConfig: CameraConfig,
preferredMode?: string,
preferredMode?: LivePlayerMode,
): LivePlayerMode | undefined {
const { data: config } = useSWR<FrigateConfig>("config");
@@ -23,18 +23,16 @@ export default function useCameraLiveMode(
);
}, [config, cameraConfig]);
const defaultLiveMode = useMemo<LivePlayerMode | undefined>(() => {
if (config && cameraConfig) {
if (config) {
if (restreamEnabled) {
return cameraConfig.ui.live_mode || config.ui.live_mode;
return preferredMode || "mse";
}
return "jsmpeg";
}
return undefined;
// config will be updated if camera config is updated
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraConfig, restreamEnabled]);
}, [config, preferredMode, restreamEnabled]);
const [viewSource] = usePersistence<LivePlayerMode>(
`${cameraConfig.name}-source`,
defaultLiveMode,

View File

@@ -1,4 +1,7 @@
import { RefObject, useCallback, useEffect, useState } from "react";
import nosleep from "nosleep.js";
const NoSleep = new nosleep();
function getFullscreenElement(): HTMLElement | null {
return (
@@ -96,9 +99,11 @@ export function useFullscreen<T extends HTMLElement = HTMLElement>(
const toggleFullscreen = useCallback(async () => {
try {
if (!getFullscreenElement()) {
NoSleep.enable();
await enterFullScreen(elementRef.current!);
} else {
await exitFullscreen();
NoSleep.disable();
}
setError(null);
} catch (err) {

View File

@@ -3,11 +3,11 @@ import { useState, useEffect, useCallback, useRef } from "react";
type OptimisticStateResult<T> = [T, (newValue: T) => void];
const useOptimisticState = <T>(
initialState: T,
currentState: T,
setState: (newValue: T) => void,
delay: number = 20,
): OptimisticStateResult<T> => {
const [optimisticValue, setOptimisticValue] = useState<T>(initialState);
const [optimisticValue, setOptimisticValue] = useState<T>(currentState);
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleValueChange = useCallback(
@@ -37,6 +37,16 @@ const useOptimisticState = <T>(
};
}, []);
useEffect(() => {
if (currentState != optimisticValue) {
setOptimisticValue(currentState);
}
// sometimes an external action will cause the currentState to change
// without handleValueChange being called. In this case
// we need to update the optimistic value so the UI reflects the change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentState]);
return [optimisticValue, handleValueChange];
};

View File

@@ -9,6 +9,7 @@ import { useMemo } from "react";
import useSWR from "swr";
import useDeepMemo from "./use-deep-memo";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { useFrigateStats } from "@/api/ws";
export default function useStats(stats: FrigateStats | undefined) {
const { data: config } = useSWR<FrigateConfig>("config");
@@ -91,3 +92,20 @@ export default function useStats(stats: FrigateStats | undefined) {
return { potentialProblems };
}
export function useAutoFrigateStats() {
const { data: initialStats } = useSWR<FrigateStats>("stats", {
revalidateOnFocus: false,
});
const { payload: latestStats } = useFrigateStats();
const stats = useMemo(() => {
if (latestStats) {
return latestStats;
}
return initialStats;
}, [initialStats, latestStats]);
return stats;
}

View File

@@ -162,3 +162,7 @@
font-display: swap;
src: url("../fonts/Inter-BlackItalic.woff2") format("woff2");
}
.react-resizable-handle {
z-index: 30;
}

View File

@@ -29,7 +29,7 @@ function ConfigEditor() {
const [error, setError] = useState<string | undefined>();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const modelRef = useRef<monaco.editor.IEditorModel | null>(null);
const modelRef = useRef<monaco.editor.ITextModel | null>(null);
const configRef = useRef<HTMLDivElement | null>(null);
const onHandleSaveConfig = useCallback(
@@ -124,6 +124,12 @@ function ConfigEditor() {
};
});
useEffect(() => {
if (config && modelRef.current) {
modelRef.current.setValue(config);
}
}, [config]);
if (!config) {
return <ActivityIndicator />;
}

View File

@@ -10,8 +10,8 @@ import {
ReviewSegment,
ReviewSeverity,
ReviewSummary,
SegmentedReviewData,
} from "@/types/review";
import { getTimestampOffset } from "@/utils/dateUtil";
import EventView from "@/views/events/EventView";
import { RecordingView } from "@/views/events/RecordingView";
import axios from "axios";
@@ -138,6 +138,66 @@ export default function Events() {
},
);
const reviewItems = useMemo<SegmentedReviewData>(() => {
if (!reviews) {
return undefined;
}
const all: ReviewSegment[] = [];
const alerts: ReviewSegment[] = [];
const detections: ReviewSegment[] = [];
const motion: ReviewSegment[] = [];
reviews?.forEach((segment) => {
all.push(segment);
switch (segment.severity) {
case "alert":
alerts.push(segment);
break;
case "detection":
detections.push(segment);
break;
default:
motion.push(segment);
break;
}
});
return {
all: all,
alert: alerts,
detection: detections,
significant_motion: motion,
};
}, [reviews]);
const currentItems = useMemo(() => {
if (!reviewItems || !severity) {
return null;
}
let current;
if (reviewFilter?.showAll) {
current = reviewItems.all;
} else {
current = reviewItems[severity];
}
if (!current || current.length == 0) {
return [];
}
if (reviewFilter?.showReviewed != 1) {
return current.filter((seg) => !seg.has_been_reviewed);
} else {
return current;
}
// only refresh when severity or filter changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [severity, reviewFilter, reviewItems?.all.length]);
// review summary
const { data: reviewSummary, mutate: updateSummary } = useSWR<ReviewSummary>(
@@ -163,18 +223,15 @@ export default function Events() {
// preview videos
const previewTimes = useMemo(() => {
// offset by timezone minutes
const timestampOffset = getTimestampOffset(Date.now() / 1000);
const startDate = new Date(selectedTimeRange.after * 1000);
startDate.setMinutes(0, 0, 0);
startDate.setUTCMinutes(0, 0, 0);
const endDate = new Date(selectedTimeRange.before * 1000);
endDate.setHours(endDate.getHours() + 1, 0, 0, 0);
return {
after: startDate.getTime() / 1000 + timestampOffset,
before: endDate.getTime() / 1000 + timestampOffset,
after: startDate.getTime() / 1000,
before: endDate.getTime() / 1000,
};
}, [selectedTimeRange]);
@@ -353,7 +410,8 @@ export default function Events() {
} else {
return (
<EventView
reviews={reviews}
reviewItems={reviewItems}
currentReviewItems={currentItems}
reviewSummary={reviewSummary}
relevantPreviews={allPreviews}
timeRange={selectedTimeRange}

View File

@@ -1,3 +1,4 @@
import { useFullscreen } from "@/hooks/use-fullscreen";
import {
useHashState,
usePersistedOverlayState,
@@ -6,7 +7,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
import LiveCameraView from "@/views/live/LiveCameraView";
import LiveDashboardView from "@/views/live/LiveDashboardView";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr";
function Live() {
@@ -20,6 +21,12 @@ function Live() {
"default" as string,
);
// fullscreen
const mainRef = useRef<HTMLDivElement | null>(null);
const { fullscreen, toggleFullscreen } = useFullscreen(mainRef);
// document title
useEffect(() => {
@@ -78,21 +85,31 @@ function Live() {
[cameras, selectedCameraName],
);
if (selectedCameraName == "birdseye") {
return <LiveBirdseyeView />;
}
if (selectedCamera) {
return <LiveCameraView config={config} camera={selectedCamera} />;
}
return (
<LiveDashboardView
cameras={cameras}
cameraGroup={cameraGroup}
includeBirdseye={includesBirdseye}
onSelectCamera={setSelectedCameraName}
/>
<div className="size-full" ref={mainRef}>
{selectedCameraName === "birdseye" ? (
<LiveBirdseyeView
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
/>
) : selectedCamera ? (
<LiveCameraView
config={config}
camera={selectedCamera}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
/>
) : (
<LiveDashboardView
cameras={cameras}
cameraGroup={cameraGroup}
includeBirdseye={includesBirdseye}
onSelectCamera={setSelectedCameraName}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LogData, LogLine, LogSeverity } from "@/types/log";
import { LogData, LogLine, LogSeverity, LogType, logTypes } from "@/types/log";
import copy from "copy-to-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
@@ -10,25 +10,20 @@ import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
import { FaCopy } from "react-icons/fa6";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { isDesktop } from "react-device-detect";
import {
isDesktop,
isMobile,
isMobileOnly,
isTablet,
} from "react-device-detect";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { MdVerticalAlignBottom } from "react-icons/md";
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
type LogType = (typeof logTypes)[number];
import { parseLogLines } from "@/utils/logUtil";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
type LogRange = { start: number; end: number };
const frigateDateStamp = /\[[\d\s-:]*]/;
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
const frigateSection = /[\w.]*/;
const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
const goSection = /\[[\w]*]/;
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
function Logs() {
const [logService, setLogService] = useState<LogType>("frigate");
@@ -38,24 +33,38 @@ function Logs() {
// log data handling
const logPageSize = useMemo(() => {
if (isMobileOnly) {
return 15;
}
if (isTablet) {
return 25;
}
return 40;
}, []);
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
const [logs, setLogs] = useState<string[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]);
useEffect(() => {
axios
.get(`logs/${logService}?start=-100`)
.get(`logs/${logService}?start=-${logPageSize}`)
.then((resp) => {
if (resp.status == 200) {
const data = resp.data as LogData;
setLogRange({
start: Math.max(0, data.totalLines - 100),
start: Math.max(0, data.totalLines - logPageSize),
end: data.totalLines,
});
setLogs(data.lines);
setLogLines(parseLogLines(logService, data.lines));
}
})
.catch(() => {});
}, [logService]);
}, [logPageSize, logService]);
useEffect(() => {
if (!logs || logs.length == 0) {
@@ -75,6 +84,10 @@ function Logs() {
end: data.totalLines,
});
setLogs([...logs, ...data.lines]);
setLogLines([
...logLines,
...parseLogLines(logService, data.lines),
]);
}
}
})
@@ -86,137 +99,12 @@ function Logs() {
clearTimeout(id);
}
};
}, [logs, logService, logRange]);
// we need to listen on the current range of visible items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logLines, logService, logRange]);
// convert to log data
const logLines = useMemo<LogLine[]>(() => {
if (!logs) {
return [];
}
if (logService == "frigate") {
return logs
.map((line) => {
const match = frigateDateStamp.exec(line);
if (!match) {
const infoIndex = line.indexOf("[INFO]");
if (infoIndex != -1) {
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: "startup",
content: line.substring(infoIndex + 6).trim(),
};
}
return {
dateStamp: line.substring(0, 19),
severity: "unknown",
section: "unknown",
content: line.substring(30).trim(),
};
}
const sectionMatch = frigateSection.exec(
line.substring(match.index + match[0].length).trim(),
);
if (!sectionMatch) {
return null;
}
return {
dateStamp: match.toString().slice(1, -1),
severity: frigateSeverity
.exec(line)
?.at(0)
?.toString()
?.toLowerCase() as LogSeverity,
section: sectionMatch.toString(),
content: line
.substring(line.indexOf(":", match.index + match[0].length) + 2)
.trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else if (logService == "go2rtc") {
return logs
.map((line) => {
if (line.length == 0) {
return null;
}
const severity = goSeverity.exec(line);
let section =
goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup";
if (frigateSeverity.exec(section)) {
section = "startup";
}
let contentStart;
if (section == "startup") {
if (severity) {
contentStart = severity.index + severity[0].length;
} else {
contentStart = line.lastIndexOf("]") + 1;
}
} else {
contentStart = line.indexOf(section) + section.length + 2;
}
let severityCat: LogSeverity;
switch (severity?.at(0)?.toString().trim()) {
case "INF":
severityCat = "info";
break;
case "WRN":
severityCat = "warning";
break;
case "ERR":
severityCat = "error";
break;
case "DBG":
case "TRC":
severityCat = "debug";
break;
default:
severityCat = "info";
}
return {
dateStamp: line.substring(0, 19),
severity: severityCat,
section: section,
content: line.substring(contentStart).trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else if (logService == "nginx") {
return logs
.map((line) => {
if (line.length == 0) {
return null;
}
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
content: line.substring(line.indexOf(" ", 20)).trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else {
return [];
}
}, [logs, logService]);
const handleCopyLogs = useCallback(() => {
if (logs) {
copy(logs.join("\n"));
@@ -261,31 +149,38 @@ function Logs() {
}
try {
startObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && logRange.start > 0) {
const start = Math.max(0, logRange.start - 100);
startObserver.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && logRange.start > 0) {
const start = Math.max(0, logRange.start - logPageSize);
axios
.get(`logs/${logService}?start=${start}&end=${logRange.start}`)
.then((resp) => {
if (resp.status == 200) {
const data = resp.data as LogData;
axios
.get(`logs/${logService}?start=${start}&end=${logRange.start}`)
.then((resp) => {
if (resp.status == 200) {
const data = resp.data as LogData;
if (data.lines.length > 0) {
setLogRange({
start: start,
end: logRange.end,
});
setLogs([...data.lines, ...logs]);
if (data.lines.length > 0) {
setLogRange({
start: start,
end: logRange.end,
});
setLogs([...data.lines, ...logs]);
setLogLines([
...parseLogLines(logService, data.lines),
...logLines,
]);
}
}
}
})
.catch(() => {});
contentRef.current?.scrollBy({
top: 10,
});
}
});
})
.catch(() => {});
contentRef.current?.scrollBy({
top: 10,
});
}
},
{ rootMargin: `${10 * (isMobile ? 64 : 48)}px 0px 0px 0px` },
);
if (node) startObserver.current.observe(node);
} catch (e) {
// no op
@@ -332,6 +227,40 @@ function Logs() {
const [selectedLog, setSelectedLog] = useState<LogLine>();
// interaction
useKeyboardListener(
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
(key, down, _) => {
if (!down) {
return;
}
switch (key) {
case "PageDown":
contentRef.current?.scrollBy({
top: 480,
});
break;
case "PageUp":
contentRef.current?.scrollBy({
top: -480,
});
break;
case "ArrowDown":
contentRef.current?.scrollBy({
top: 48,
});
break;
case "ArrowUp":
contentRef.current?.scrollBy({
top: -48,
});
break;
}
},
);
return (
<div className="flex size-full flex-col p-2">
<Toaster position="top-center" closeButton={true} />
@@ -346,6 +275,7 @@ function Logs() {
onValueChange={(value: LogType) => {
if (value) {
setLogs([]);
setLogLines([]);
setFilterSeverity(undefined);
setLogService(value);
}

View File

@@ -17,8 +17,6 @@ import {
} from "@/components/ui/alert-dialog";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import MotionTuner from "@/components/settings/MotionTuner";
import MasksAndZones from "@/components/settings/MasksAndZones";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useOptimisticState from "@/hooks/use-optimistic-state";
@@ -26,14 +24,16 @@ import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import General from "@/components/settings/General";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas";
import ObjectSettings from "@/components/settings/ObjectSettings";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed";
import Authentication from "@/components/settings/Authentication";
import GeneralSettingsView from "@/views/settings/GeneralSettingsView";
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import AuthenticationView from "@/views/settings/AuthenticationView";
export default function Settings() {
const settingsViews = [
@@ -156,22 +156,24 @@ export default function Settings() {
)}
</div>
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
{page == "general" && <General />}
{page == "debug" && <ObjectSettings selectedCamera={selectedCamera} />}
{page == "general" && <GeneralSettingsView />}
{page == "debug" && (
<ObjectSettingsView selectedCamera={selectedCamera} />
)}
{page == "masks / zones" && (
<MasksAndZones
<MasksAndZonesView
selectedCamera={selectedCamera}
selectedZoneMask={filterZoneMask}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "motion tuner" && (
<MotionTuner
<MotionTunerView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "users" && <Authentication />}
{page == "users" && <AuthenticationView />}
</div>
{confirmationDialogOpen && (
<AlertDialog
@@ -282,6 +284,7 @@ function CameraSelectButton({
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open: boolean) => {
if (!open) {

View File

@@ -1,5 +1,4 @@
import { IconName } from "@/components/icons/IconPicker";
import { LivePlayerMode } from "./live";
export interface UiConfig {
timezone?: string;
@@ -7,8 +6,6 @@ export interface UiConfig {
date_style?: "full" | "long" | "medium" | "short";
time_style?: "full" | "long" | "medium" | "short";
strftime_fmt?: string;
live_mode?: LivePlayerMode;
use_experimental?: boolean;
dashboard: boolean;
order: number;
}

View File

@@ -30,3 +30,5 @@ export type LiveStreamMetadata = {
producers: LiveProducerMetadata[];
consumers: LiveConsumerMetadata[];
};
export type LivePlayerError = "stalled" | "startup";

View File

@@ -11,3 +11,6 @@ export type LogLine = {
section: string;
content: string;
};
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
export type LogType = (typeof logTypes)[number];

View File

@@ -20,6 +20,15 @@ export type ReviewData = {
zones: string[];
};
export type SegmentedReviewData =
| {
all: ReviewSegment[];
alert: ReviewSegment[];
detection: ReviewSegment[];
significant_motion: ReviewSegment[];
}
| undefined;
export type ReviewFilter = {
cameras?: string[];
labels?: string[];

View File

@@ -269,15 +269,6 @@ export const getUTCOffset = (
);
};
/**
* Gets the minute offset in seconds of the current timezone from UTC.
* Any timezones with an offset in hours will return 0,
* any timezones with an offset of 30 or 45 minutes will return that amount in seconds.
*/
export function getTimestampOffset(timestamp: number) {
return (getUTCOffset(new Date(timestamp * 1000)) % 60) * 60;
}
export function getRangeForTimestamp(timestamp: number) {
const date = new Date(timestamp * 1000);
date.setMinutes(0, 0, 0);
@@ -301,9 +292,7 @@ export function getEndOfDayTimestamp(date: Date) {
export function isCurrentHour(timestamp: number) {
const now = new Date();
now.setMinutes(0, 0, 0);
now.setUTCMinutes(0, 0, 0);
const timeShift = getTimestampOffset(timestamp);
return timestamp + timeShift > now.getTime() / 1000;
return timestamp > now.getTime() / 1000;
}

View File

@@ -12,7 +12,7 @@ import {
FaFire,
FaUps,
} from "react-icons/fa";
import { GiHummingbird } from "react-icons/gi";
import { GiDeer, GiHummingbird } from "react-icons/gi";
import { LuBox, LuLassoSelect } from "react-icons/lu";
import * as LuIcons from "react-icons/lu";
import { MdRecordVoiceOver } from "react-icons/md";
@@ -38,6 +38,8 @@ export function getIconForLabel(label: string, className?: string) {
return <FaCarSide key={label} className={className} />;
case "cat":
return <FaCat key={label} className={className} />;
case "deer":
return <GiDeer key={label} className={className} />;
case "animal":
case "bark":
case "dog":

133
web/src/utils/logUtil.ts Normal file
View File

@@ -0,0 +1,133 @@
import { LogLine, LogSeverity, LogType } from "@/types/log";
const frigateDateStamp = /\[[\d\s-:]*]/;
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
const frigateSection = /[\w.]*/;
const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
const goSection = /\[[\w]*]/;
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
export function parseLogLines(logService: LogType, logs: string[]) {
if (logService == "frigate") {
return logs
.map((line) => {
const match = frigateDateStamp.exec(line);
if (!match) {
const infoIndex = line.indexOf("[INFO]");
if (infoIndex != -1) {
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: "startup",
content: line.substring(infoIndex + 6).trim(),
};
}
return {
dateStamp: line.substring(0, 19),
severity: "unknown",
section: "unknown",
content: line.substring(30).trim(),
};
}
const sectionMatch = frigateSection.exec(
line.substring(match.index + match[0].length).trim(),
);
if (!sectionMatch) {
return null;
}
return {
dateStamp: match.toString().slice(1, -1),
severity: frigateSeverity
.exec(line)
?.at(0)
?.toString()
?.toLowerCase() as LogSeverity,
section: sectionMatch.toString(),
content: line
.substring(line.indexOf(":", match.index + match[0].length) + 2)
.trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else if (logService == "go2rtc") {
return logs
.map((line) => {
if (line.length == 0) {
return null;
}
const severity = goSeverity.exec(line);
let section =
goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup";
if (frigateSeverity.exec(section)) {
section = "startup";
}
let contentStart;
if (section == "startup") {
if (severity) {
contentStart = severity.index + severity[0].length;
} else {
contentStart = line.lastIndexOf("]") + 1;
}
} else {
contentStart = line.indexOf(section) + section.length + 2;
}
let severityCat: LogSeverity;
switch (severity?.at(0)?.toString().trim()) {
case "INF":
severityCat = "info";
break;
case "WRN":
severityCat = "warning";
break;
case "ERR":
severityCat = "error";
break;
case "DBG":
case "TRC":
severityCat = "debug";
break;
default:
severityCat = "info";
}
return {
dateStamp: line.substring(0, 19),
severity: severityCat,
section: section,
content: line.substring(contentStart).trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else if (logService == "nginx") {
return logs
.map((line) => {
if (line.length == 0) {
return null;
}
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
content: line.substring(line.indexOf(" ", 20)).trim(),
};
})
.filter((value) => value != null) as LogLine[];
}
return [];
}

Some files were not shown because too many files have changed in this diff Show More