Merge branch 'dev' into prometheus-metrics

This commit is contained in:
Mitch Ross 2023-04-23 21:55:40 -04:00
commit b1faa4c0ac
67 changed files with 3543 additions and 3008 deletions

View File

@ -1,6 +1,6 @@
name: EdgeTpu Support Request name: Detector Support Request
description: Support for setting up EdgeTPU in Frigate description: Support for setting up object detector in Frigate (Coral, OpenVINO, TensorRT, etc.)
title: "[EdgeTPU Support]: " title: "[Detector Support]: "
labels: ["support", "triage"] labels: ["support", "triage"]
assignees: [] assignees: []
body: body:

View File

@ -19,6 +19,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Image Build name: Image Build
steps: steps:
- name: Remove unnecessary files
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
- id: lowercaseRepo - id: lowercaseRepo
uses: ASzc/change-string-case-action@v5 uses: ASzc/change-string-case-action@v5
with: with:
@ -40,18 +45,18 @@ jobs:
- name: Create short sha - name: Create short sha
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Build and push - name: Build and push
uses: docker/build-push-action@v3 uses: docker/build-push-action@v4
with: with:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
target: frigate target: frigate
tags: | tags: |
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ env.SHORT_SHA }} ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ env.SHORT_SHA }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Build and push TensorRT - name: Build and push TensorRT
uses: docker/build-push-action@v3 uses: docker/build-push-action@v4
with: with:
context: . context: .
push: true push: true

View File

@ -16,7 +16,9 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge for Dependabot PRs - name: Enable auto-merge for Dependabot PRs
if: steps.metadata.outputs.dependency-type == 'direct:development' && (steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch') if: steps.metadata.outputs.dependency-type == 'direct:development' && (steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch')
run: gh pr merge --auto --squash "$PR_URL" run: |
gh pr review --approve "$PR_URL"
gh pr merge --auto --squash "$PR_URL"
env: env:
PR_URL: ${{ github.event.pull_request.html_url }} PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

45
.github/workflows/maintain_cache.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: Maintain Cache
on:
schedule:
- cron: "13 0 * * 0,4"
env:
PYTHON_VERSION: 3.9
jobs:
multi_arch_build:
runs-on: ubuntu-latest
name: Image Build
steps:
- name: Remove unnecessary files
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v5
with:
string: ${{ github.repository }}
- name: Check out code
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to the Container registry
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create version file
run: make version
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: false
platforms: linux/amd64,linux/arm64
target: frigate
cache-from: type=gha

3
.gitignore vendored
View File

@ -4,7 +4,8 @@
debug debug
.vscode/* .vscode/*
!.vscode/launch.json !.vscode/launch.json
config/config.yml config/*
!config/*.example
models models
*.mp4 *.mp4
*.ts *.ts

View File

@ -133,11 +133,6 @@ RUN apt-get -qq update \
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& python3 get-pip.py "pip" && python3 get-pip.py "pip"
RUN if [ "${TARGETARCH}" = "arm" ]; \
then echo "[global]" > /etc/pip.conf \
&& echo "extra-index-url=https://www.piwheels.org/simple" >> /etc/pip.conf; \
fi
COPY requirements.txt /requirements.txt COPY requirements.txt /requirements.txt
RUN pip3 install -r requirements.txt RUN pip3 install -r requirements.txt

View File

@ -1,7 +1,7 @@
default_target: local default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.12.0 VERSION = 0.13.0
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
CURRENT_UID := $(shell id -u) CURRENT_UID := $(shell id -u)
CURRENT_GID := $(shell id -g) CURRENT_GID := $(shell id -g)
@ -22,18 +22,15 @@ amd64:
arm64: arm64:
docker buildx build --platform linux/arm64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) . docker buildx build --platform linux/arm64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) .
armv7: build: version amd64 arm64
docker buildx build --platform linux/arm/v7 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) . docker buildx build --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) .
build: version amd64 arm64 armv7
docker buildx build --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) .
push: build push: build
docker buildx build --push --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH) . docker buildx build --push --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH) .
docker buildx build --push --platform linux/amd64 --target=frigate-tensorrt --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt . docker buildx build --push --platform linux/amd64 --target=frigate-tensorrt --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt .
run: local run: local
docker run --rm --publish=5000:5000 --volume=${PWD}/config/config.yml:/config/config.yml frigate:latest docker run --rm --publish=5000:5000 --volume=${PWD}/config:/config frigate:latest
run_tests: local run_tests: local
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest python3 -u -m unittest docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest python3 -u -m unittest

View File

@ -27,7 +27,7 @@ services:
- .:/workspace/frigate:cached - .:/workspace/frigate:cached
- ./web/dist:/opt/frigate/web:cached - ./web/dist:/opt/frigate/web:cached
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- ./config/config.yml:/config/config.yml:ro - ./config:/config
- ./debug:/media/frigate - ./debug:/media/frigate
# Create the trt-models folder using the documented method of generating TRT models # Create the trt-models folder using the documented method of generating TRT models
# - ./debug/trt-models:/trt-models # - ./debug/trt-models:/trt-models

View File

@ -37,15 +37,6 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
rm -rf btbn-ffmpeg.tar.xz /usr/lib/btbn-ffmpeg/doc /usr/lib/btbn-ffmpeg/bin/ffplay rm -rf btbn-ffmpeg.tar.xz /usr/lib/btbn-ffmpeg/doc /usr/lib/btbn-ffmpeg/bin/ffplay
fi fi
# ffmpeg -> arm32
if [[ "${TARGETARCH}" == "arm" ]]; then
# add raspberry pi repo
gpg --no-default-keyring --keyring /usr/share/keyrings/raspbian.gpg --keyserver keyserver.ubuntu.com --recv-keys 9165938D90FDDD2E
echo "deb [signed-by=/usr/share/keyrings/raspbian.gpg] http://raspbian.raspberrypi.org/raspbian/ bullseye main contrib non-free rpi" | tee /etc/apt/sources.list.d/raspi.list
apt-get -qq update
apt-get -qq install --no-install-recommends --no-install-suggests -y ffmpeg
fi
# ffmpeg -> arm64 # ffmpeg -> arm64
if [[ "${TARGETARCH}" == "arm64" ]]; then if [[ "${TARGETARCH}" == "arm64" ]]; then
# add raspberry pi repo # add raspberry pi repo
@ -75,17 +66,13 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
libva-drm2 mesa-va-drivers libva-drm2 mesa-va-drivers
fi fi
# not sure why 32bit arm requires all these
if [[ "${TARGETARCH}" == "arm" ]]; then
apt-get -qq install --no-install-recommends --no-install-suggests -y \
libgtk-3-dev \
libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \
libxvidcore-dev libx264-dev libjpeg-dev libpng-dev libtiff-dev \
gfortran openexr libatlas-base-dev libtbb-dev libdc1394-22-dev libopenexr-dev \
libgstreamer-plugins-base1.0-dev libgstreamer1.0-dev
fi
apt-get purge gnupg apt-transport-https wget xz-utils -y apt-get purge gnupg apt-transport-https wget xz-utils -y
apt-get clean autoclean -y apt-get clean autoclean -y
apt-get autoremove --purge -y apt-get autoremove --purge -y
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Install yq, for frigate-prepare and go2rtc echo source
curl -fsSL \
"https://github.com/mikefarah/yq/releases/download/v4.33.3/yq_linux_$(dpkg --print-architecture)" \
--output /usr/local/bin/yq
chmod +x /usr/local/bin/yq

View File

@ -6,8 +6,6 @@ s6_version="3.1.4.1"
if [[ "${TARGETARCH}" == "amd64" ]]; then if [[ "${TARGETARCH}" == "amd64" ]]; then
s6_arch="x86_64" s6_arch="x86_64"
elif [[ "${TARGETARCH}" == "arm" ]]; then
s6_arch="armhf"
elif [[ "${TARGETARCH}" == "arm64" ]]; then elif [[ "${TARGETARCH}" == "arm64" ]]; then
s6_arch="aarch64" s6_arch="aarch64"
fi fi

View File

@ -9,6 +9,42 @@ set -o errexit -o nounset -o pipefail
# Tell S6-Overlay not to restart this service # Tell S6-Overlay not to restart this service
s6-svc -O . s6-svc -O .
function migrate_db_path() {
# Find config file in yaml or yml, but prefer yaml
local config_file="${CONFIG_FILE:-"/config/config.yml"}"
local config_file_yaml="${config_file//.yml/.yaml}"
if [[ -f "${config_file_yaml}" ]]; then
config_file="${config_file_yaml}"
elif [[ ! -f "${config_file}" ]]; then
echo "[ERROR] Frigate config file not found"
return 1
fi
unset config_file_yaml
# Use yq to check if database.path is set
local user_db_path
user_db_path=$(yq eval '.database.path' "${config_file}")
if [[ "${user_db_path}" == "null" ]]; then
local previous_db_path="/media/frigate/frigate.db"
local new_db_dir="/config"
if [[ -f "${previous_db_path}" ]]; then
if mountpoint --quiet "${new_db_dir}"; then
# /config is a mount point, move the db
echo "[INFO] Moving db from '${previous_db_path}' to the '${new_db_dir}' dir..."
# Move all files that starts with frigate.db to the new directory
mv -vf "${previous_db_path}"* "${new_db_dir}"
else
echo "[ERROR] Trying to migrate the db path from '${previous_db_path}' to the '${new_db_dir}' dir, but '${new_db_dir}' is not a mountpoint, please mount the '${new_db_dir}' dir"
return 1
fi
fi
fi
}
echo "[INFO] Preparing Frigate..."
migrate_db_path
echo "[INFO] Starting Frigate..." echo "[INFO] Starting Frigate..."
cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate" cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate"

View File

@ -54,8 +54,17 @@ if [[ ! -f "/dev/shm/go2rtc.yaml" ]]; then
python3 /usr/local/go2rtc/create_config.py python3 /usr/local/go2rtc/create_config.py
fi fi
readonly config_path="/config"
if [[ -x "${config_path}/go2rtc" ]]; then
readonly binary_path="${config_path}/go2rtc"
echo "[WARN] Using go2rtc binary from '${binary_path}' instead of the embedded one"
else
readonly binary_path="/usr/local/go2rtc/bin/go2rtc"
fi
echo "[INFO] Starting go2rtc..." echo "[INFO] Starting go2rtc..."
# Replace the bash process with the go2rtc process, redirecting stderr to stdout # Replace the bash process with the go2rtc process, redirecting stderr to stdout
exec 2>&1 exec 2>&1
exec go2rtc -config=/dev/shm/go2rtc.yaml exec "${binary_path}" -config=/dev/shm/go2rtc.yaml

View File

@ -30,6 +30,12 @@ elif config_file.endswith(".json"):
go2rtc_config: dict[str, any] = config.get("go2rtc", {}) go2rtc_config: dict[str, any] = config.get("go2rtc", {})
# Need to enable CORS for go2rtc so the frigate integration / card work automatically
if go2rtc_config.get("api") is None:
go2rtc_config["api"] = {"origin": "*"}
elif go2rtc_config["api"].get("origin") is None:
go2rtc_config["api"]["origin"] = "*"
# we want to ensure that logs are easy to read # we want to ensure that logs are easy to read
if go2rtc_config.get("log") is None: if go2rtc_config.get("log") is None:
go2rtc_config["log"] = {"format": "text"} go2rtc_config["log"] = {"format": "text"}

View File

@ -42,7 +42,7 @@ environment_vars:
### `database` ### `database`
Event and recording information is managed in a sqlite database at `/media/frigate/frigate.db`. If that database is deleted, recordings will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant. Event and recording information is managed in a sqlite database at `/config/frigate.db`. If that database is deleted, recordings will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant.
If you are storing your database on a network share (SMB, NFS, etc), you may get a `database is locked` error message on startup. You can customize the location of the database in the config if necessary. If you are storing your database on a network share (SMB, NFS, etc), you may get a `database is locked` error message on startup. You can customize the location of the database in the config if necessary.

View File

@ -16,7 +16,7 @@ Note that mjpeg cameras require encoding the video into h264 for recording, and
```yaml ```yaml
go2rtc: go2rtc:
streams: streams:
mjpeg_cam: ffmpeg:{your_mjpeg_stream_url}#video=h264#hardware # <- use hardware acceleration to create an h264 stream usable for other components. mjpeg_cam: "ffmpeg:{your_mjpeg_stream_url}#video=h264#hardware" # <- use hardware acceleration to create an h264 stream usable for other components.
cameras: cameras:
... ...
@ -110,7 +110,7 @@ go2rtc:
streams: streams:
reolink: reolink:
- http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password - http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password
- ffmpeg:reolink#audio=opus - "ffmpeg:reolink#audio=opus"
reolink_sub: reolink_sub:
- http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password - http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password
@ -130,7 +130,18 @@ cameras:
### Unifi Protect Cameras ### Unifi Protect Cameras
Unifi protect cameras require the rtspx stream to be used with go2rtc https://github.com/AlexxIT/go2rtc#source-rtsp Unifi protect cameras require the rtspx stream to be used with go2rtc.
To utilize a Unifi protect camera, modify the rtsps link to begin with rtspx.
Additionally, remove the "?enableSrtp" from the end of the Unifi link.
```yaml
go2rtc:
streams:
front:
- rtspx://192.168.1.1:7441/abcdefghijk
```
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#source-rtsp)
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record and rtmp if used directly with unifi protect. In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record and rtmp if used directly with unifi protect.

View File

@ -24,7 +24,7 @@ ffmpeg:
hwaccel_args: preset-vaapi hwaccel_args: preset-vaapi
``` ```
**NOTICE**: With some of the processors, like the J4125, the default driver `iHD` doesn't seem to work correctly for hardware acceleration. You may need to change the driver to `i965` by adding the following environment variable `LIBVA_DRIVER_NAME=i965` to your docker-compose file or [in the frigate.yml for HA OS users](advanced.md#environment_vars). **NOTICE**: With some of the processors, like the J4125, the default driver `iHD` doesn't seem to work correctly for hardware acceleration. You may need to change the driver to `i965` by adding the following environment variable `LIBVA_DRIVER_NAME=i965` to your docker-compose file or [in the `frigate.yaml` for HA OS users](advanced.md#environment_vars).
### Intel-based CPUs (>=10th Generation) via Quicksync ### Intel-based CPUs (>=10th Generation) via Quicksync

View File

@ -3,7 +3,7 @@ id: index
title: Configuration File title: Configuration File
--- ---
For Home Assistant Addon installations, the config file needs to be in the root of your Home Assistant config directory (same location as `configuration.yaml`) and named `frigate.yml`. For Home Assistant Addon installations, the config file needs to be in the root of your Home Assistant config directory (same location as `configuration.yaml`). It can be named `frigate.yaml` or `frigate.yml`, but if both files exist `frigate.yaml` will be preferred and `frigate.yml` will be ignored.
For all other installation types, the config file should be mapped to `/config/config.yml` inside the container. For all other installation types, the config file should be mapped to `/config/config.yml` inside the container.
@ -36,6 +36,25 @@ It is not recommended to copy this full configuration file. Only specify values
::: :::
**Note:** The following values will be replaced at runtime by using environment variables
- `{FRIGATE_MQTT_USER}`
- `{FRIGATE_MQTT_PASSWORD}`
- `{FRIGATE_RTSP_USER}`
- `{FRIGATE_RTSP_PASSWORD}`
for example:
```yaml
mqtt:
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
```
```yaml
- path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast
```
```yaml ```yaml
mqtt: mqtt:
# Optional: Enable mqtt server (default: shown below) # Optional: Enable mqtt server (default: shown below)
@ -86,7 +105,7 @@ detectors:
# Optional: Database configuration # Optional: Database configuration
database: database:
# The path to store the SQLite DB (default: shown below) # The path to store the SQLite DB (default: shown below)
path: /media/frigate/frigate.db path: /config/frigate.db
# Optional: model modifications # Optional: model modifications
model: model:
@ -148,7 +167,7 @@ birdseye:
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets # More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets
ffmpeg: ffmpeg:
# Optional: global ffmpeg args (default: shown below) # Optional: global ffmpeg args (default: shown below)
global_args: -hide_banner -loglevel warning -threads 1 global_args: -hide_banner -loglevel warning -threads 2
# Optional: global hwaccel args (default: shown below) # Optional: global hwaccel args (default: shown below)
# NOTE: See hardware acceleration docs for your specific device # NOTE: See hardware acceleration docs for your specific device
hwaccel_args: [] hwaccel_args: []
@ -157,7 +176,7 @@ ffmpeg:
# Optional: global output args # Optional: global output args
output_args: output_args:
# Optional: output args for detect streams (default: shown below) # Optional: output args for detect streams (default: shown below)
detect: -threads 1 -f rawvideo -pix_fmt yuv420p detect: -threads 2 -f rawvideo -pix_fmt yuv420p
# Optional: output args for record streams (default: shown below) # Optional: output args for record streams (default: shown below)
record: preset-record-generic record: preset-record-generic
# Optional: output args for rtmp streams (default: shown below) # Optional: output args for rtmp streams (default: shown below)
@ -483,9 +502,32 @@ ui:
# Optional: Set the default live mode for cameras in the UI (default: shown below) # Optional: Set the default live mode for cameras in the UI (default: shown below)
live_mode: mse live_mode: mse
# Optional: Set a timezone to use in the UI (default: use browser local time) # Optional: Set a timezone to use in the UI (default: use browser local time)
timezone: None # timezone: America/Denver
# Optional: Use an experimental recordings / camera view UI (default: shown below) # Optional: Use an experimental recordings / camera view UI (default: shown below)
experimental_ui: False use_experimental: False
# Optional: Set the time format used.
# Options are browser, 12hour, or 24hour (default: shown below)
time_format: browser
# Optional: Set the date style for a specified length.
# Options are: full, long, medium, short
# Examples:
# short: 2/11/23
# medium: Feb 11, 2023
# full: Saturday, February 11, 2023
# (default: shown below).
date_style: short
# Optional: Set the time style for a specified length.
# Options are: full, long, medium, short
# Examples:
# short: 8:14 PM
# medium: 8:15:22 PM
# full: 8:15:22 PM Mountain Standard Time
# (default: shown below).
time_style: medium
# Optional: Ability to manually override the date / time styling to use strftime format
# https://www.gnu.org/software/libc/manual/html_node/Formatting-Calendar-Time.html
# possible values are shown above (default: not set)
strftime_fmt: "%Y/%m/%d %H:%M"
# Optional: Telemetry configuration # Optional: Telemetry configuration
telemetry: telemetry:

View File

@ -59,7 +59,7 @@ cameras:
roles: roles:
- detect - detect
live: live:
stream_name: test_cam_sub stream_name: rtsp_cam_sub
``` ```
### WebRTC extra configuration: ### WebRTC extra configuration:
@ -101,4 +101,4 @@ If you are having difficulties getting WebRTC to work and you are running Frigat
::: :::
See https://github.com/AlexxIT/go2rtc#module-webrtc for more information about this. See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#module-webrtc) for more information about this.

View File

@ -7,7 +7,7 @@ title: Restream
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate. Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc#configuration) for more advanced configurations and features. Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.2.0) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#configuration) for more advanced configurations and features.
:::note :::note
@ -130,7 +130,7 @@ cameras:
## Advanced Restream Configurations ## Advanced Restream Configurations
The [exec](https://github.com/AlexxIT/go2rtc#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below: The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
NOTE: The output will need to be passed with two curly braces `{{output}}` NOTE: The output will need to be passed with two curly braces `{{output}}`

View File

@ -24,14 +24,10 @@ I may earn a small commission for my endorsement, recommendation, testimonial, o
My current favorite is the Minisforum GK41 because of the dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral. I may earn a small commission for my endorsement, recommendation, testimonial, or link to any products or services from this website. My current favorite is the Minisforum GK41 because of the dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral. I may earn a small commission for my endorsement, recommendation, testimonial, or link to any products or services from this website.
| Name | Coral Inference Speed | Coral Compatibility | Notes | | Name | Coral Inference Speed | Coral Compatibility | Notes |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Odyssey X86 Blue J4125 (<a href="https://amzn.to/3oH4BKi" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) (<a href="https://www.seeedstudio.com/Frigate-NVR-with-Odyssey-Blue-and-Coral-USB-Accelerator.html?utm_source=Frigate" target="_blank" rel="nofollow noopener sponsored">SeeedStudio</a>) | 9-10ms | M.2 B+M, USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. | | Odyssey X86 Blue J4125 (<a href="https://amzn.to/3oH4BKi" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) (<a href="https://www.seeedstudio.com/Frigate-NVR-with-Odyssey-Blue-and-Coral-USB-Accelerator.html?utm_source=Frigate" target="_blank" rel="nofollow noopener sponsored">SeeedStudio</a>) | 9-10ms | M.2 B+M, USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. |
| Minisforum GK41 (<a href="https://amzn.to/3ptnb8D" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 9-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. | | Minisforum GK41 (<a href="https://amzn.to/3ptnb8D" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 9-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. |
| Beelink GK55 (<a href="https://amzn.to/35E79BC" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 9-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. |
| Intel NUC (<a href="https://amzn.to/3psFlHi" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 8-10ms | USB | Overkill for most, but great performance. Can handle many cameras at 5fps depending on typical amounts of motion. Requires extra parts. | | Intel NUC (<a href="https://amzn.to/3psFlHi" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 8-10ms | USB | Overkill for most, but great performance. Can handle many cameras at 5fps depending on typical amounts of motion. Requires extra parts. |
| BMAX B2 Plus (<a href="https://amzn.to/3a6TBh8" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 10-12ms | USB | Good balance of performance and cost. Also capable of running many other services at the same time as Frigate. |
| Atomic Pi (<a href="https://amzn.to/2YjpY9m" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 16ms | USB | Good option for a dedicated low power board with a small number of cameras. Can leverage Intel QuickSync for stream decoding. |
| Raspberry Pi 4 (64bit) (<a href="https://amzn.to/2YhSGHH" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 10-15ms | USB | Can handle a small number of cameras. |
## Detectors ## Detectors
@ -50,8 +46,9 @@ A single Coral can handle many cameras and will be sufficient for the majority o
### OpenVino ### OpenVino
The OpenVINO detector type is able to run on: The OpenVINO detector type is able to run on:
- 6th Gen Intel Platforms and newer that have an iGPU - 6th Gen Intel Platforms and newer that have an iGPU
- x86 & Arm32/64 hosts with VPU Hardware (ex: Intel NCS2) - x86 & Arm64 hosts with VPU Hardware (ex: Intel NCS2)
More information is available [in the detector docs](/configuration/detectors#openvino-detector) More information is available [in the detector docs](/configuration/detectors#openvino-detector)
@ -81,7 +78,7 @@ Inference speeds will vary greatly depending on the GPU and the model used.
`tiny` variants are faster than the equivalent non-tiny model, some known examples are below: `tiny` variants are faster than the equivalent non-tiny model, some known examples are below:
| Name | Inference Speed | | Name | Inference Speed |
| --------------- | ----------------- | | --------------- | --------------- |
| GTX 1060 6GB | ~ 7 ms | | GTX 1060 6GB | ~ 7 ms |
| GTX 1070 | ~ 6 ms | | GTX 1070 | ~ 6 ms |
| GTX 1660 SUPER | ~ 4 ms | | GTX 1660 SUPER | ~ 4 ms |

View File

@ -21,12 +21,11 @@ Windows is not officially supported, but some users have had success getting it
Frigate uses the following locations for read/write operations in the container. Docker volume mappings can be used to map these to any location on your host machine. Frigate uses the following locations for read/write operations in the container. Docker volume mappings can be used to map these to any location on your host machine.
- `/config`: Used to store the Frigate config file and sqlite database. You will also see a few files alongside the database file while Frigate is running.
- `/media/frigate/clips`: Used for snapshot storage. In the future, it will likely be renamed from `clips` to `snapshots`. The file structure here cannot be modified and isn't intended to be browsed or managed manually. - `/media/frigate/clips`: Used for snapshot storage. In the future, it will likely be renamed from `clips` to `snapshots`. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
- `/media/frigate/recordings`: Internal system storage for recording segments. The file structure here cannot be modified and isn't intended to be browsed or managed manually. - `/media/frigate/recordings`: Internal system storage for recording segments. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
- `/media/frigate/frigate.db`: Default location for the sqlite database. You will also see several files alongside this file while Frigate is running. If moving the database location (often needed when using a network drive at `/media/frigate`), it is recommended to mount a volume with docker at `/db` and change the storage location of the database to `/db/frigate.db` in the config file.
- `/tmp/cache`: Cache location for recording segments. Initial recordings are written here before being checked and converted to mp4 and moved to the recordings folder. - `/tmp/cache`: Cache location for recording segments. Initial recordings are written here before being checked and converted to mp4 and moved to the recordings folder.
- `/dev/shm`: It is not recommended to modify this directory or map it with docker. This is the location for raw decoded frames in shared memory and it's size is impacted by the `shm-size` calculations below. - `/dev/shm`: It is not recommended to modify this directory or map it with docker. This is the location for raw decoded frames in shared memory and it's size is impacted by the `shm-size` calculations below.
- `/config/config.yml`: Default location of the config file.
#### Common docker compose storage configurations #### Common docker compose storage configurations
@ -38,7 +37,7 @@ services:
frigate: frigate:
... ...
volumes: volumes:
- /path/to/your/config.yml:/config/config.yml - /path/to/your/config:/config
- /path/to/your/storage:/media/frigate - /path/to/your/storage:/media/frigate
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear - type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
target: /tmp/cache target: /tmp/cache
@ -47,31 +46,6 @@ services:
... ...
``` ```
Writing to a network drive with database on a local drive:
```yaml
version: "3.9"
services:
frigate:
...
volumes:
- /path/to/your/config.yml:/config/config.yml
- /path/to/network/storage:/media/frigate
- /path/to/local/disk:/db
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
target: /tmp/cache
tmpfs:
size: 1000000000
...
```
frigate.yml
```yaml
database:
path: /db/frigate.db
```
### Calculating required shm-size ### Calculating required shm-size
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**. Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
@ -123,7 +97,7 @@ services:
- /dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware - /dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /path/to/your/config.yml:/config/config.yml - /path/to/your/config:/config
- /path/to/your/storage:/media/frigate - /path/to/your/storage:/media/frigate
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear - type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
target: /tmp/cache target: /tmp/cache
@ -149,7 +123,7 @@ docker run -d \
--device /dev/dri/renderD128 \ --device /dev/dri/renderD128 \
--shm-size=64m \ --shm-size=64m \
-v /path/to/your/storage:/media/frigate \ -v /path/to/your/storage:/media/frigate \
-v /path/to/your/config.yml:/config/config.yml \ -v /path/to/your/config:/config \
-v /etc/localtime:/etc/localtime:ro \ -v /etc/localtime:/etc/localtime:ro \
-e FRIGATE_RTSP_PASSWORD='password' \ -e FRIGATE_RTSP_PASSWORD='password' \
-p 5000:5000 \ -p 5000:5000 \
@ -163,7 +137,10 @@ docker run -d \
:::caution :::caution
Due to limitations in Home Assistant Operating System, utilizing external storage for recordings or snapshots requires [modifying udev rules manually](https://community.home-assistant.io/t/solved-mount-usb-drive-in-hassio-to-be-used-on-the-media-folder-with-udev-customization/258406/46). There are important limitations in Home Assistant Operating System to be aware of:
- Utilizing external storage for recordings or snapshots requires [modifying udev rules manually](https://community.home-assistant.io/t/solved-mount-usb-drive-in-hassio-to-be-used-on-the-media-folder-with-udev-customization/258406/46).
- AMD GPUs are not supported because HA OS does not include the mesa driver.
- Nvidia GPUs are not supported because addons do not support the nvidia runtime.
::: :::
@ -179,7 +156,7 @@ HassOS users can install via the addon repository.
2. Add https://github.com/blakeblackshear/frigate-hass-addons 2. Add https://github.com/blakeblackshear/frigate-hass-addons
3. Install your desired Frigate NVR Addon and navigate to it's page 3. Install your desired Frigate NVR Addon and navigate to it's page
4. Setup your network configuration in the `Configuration` tab 4. Setup your network configuration in the `Configuration` tab
5. (not for proxy addon) Create the file `frigate.yml` in your `config` directory with your detailed Frigate configuration 5. (not for proxy addon) Create the file `frigate.yaml` in your `config` directory with your detailed Frigate configuration
6. Start the addon container 6. Start the addon container
7. (not for proxy addon) If you are using hardware acceleration for ffmpeg, you may need to disable "Protection mode" 7. (not for proxy addon) If you are using hardware acceleration for ffmpeg, you may need to disable "Protection mode"
@ -194,6 +171,13 @@ There are several versions of the addon available:
## Home Assistant Supervised ## Home Assistant Supervised
:::caution
There are important limitations in Home Assistant Supervised to be aware of:
- Nvidia GPUs are not supported because addons do not support the nvidia runtime.
:::
:::tip :::tip
If possible, it is recommended to run Frigate standalone in Docker and use [Frigate's Proxy Addon](https://github.com/blakeblackshear/frigate-hass-addons/blob/main/frigate_proxy/README.md). If possible, it is recommended to run Frigate standalone in Docker and use [Frigate's Proxy Addon](https://github.com/blakeblackshear/frigate-hass-addons/blob/main/frigate_proxy/README.md).

View File

@ -10,7 +10,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
# Setup a go2rtc stream # Setup a go2rtc stream
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc#module-streams), not just rtsp. First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#module-streams), not just rtsp.
```yaml ```yaml
go2rtc: go2rtc:
@ -23,7 +23,7 @@ The easiest live view to get working is MSE. After adding this to the config, re
### What if my video doesn't play? ### What if my video doesn't play?
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration: If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.2.0#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
```yaml ```yaml
go2rtc: go2rtc:

View File

@ -168,6 +168,16 @@ Events from the database. Accepts the following query string parameters:
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) | | `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
| `in_progress` | int | Limit to events in progress (0 or 1) | | `in_progress` | int | Limit to events in progress (0 or 1) |
### `GET /api/timeline`
Timeline of key moments of an event(s) from the database. Accepts the following query string parameters:
| param | Type | Description |
| -------------------- | ---- | --------------------------------------------- |
| `camera` | int | Name of camera |
| `source_id` | str | ID of tracked object |
| `limit` | int | Limit the number of events returned |
### `GET /api/events/summary` ### `GET /api/events/summary`
Returns summary data for events in the database. Used by the Home Assistant integration. Returns summary data for events in the database. Used by the Home Assistant integration.
@ -233,6 +243,10 @@ Accepts the following query string parameters, but they are only applied when an
Returns the snapshot image from the latest event for the given camera and label combo. Using `any` as the label will return the latest thumbnail regardless of type. Returns the snapshot image from the latest event for the given camera and label combo. Using `any` as the label will return the latest thumbnail regardless of type.
### `GET /api/<camera_name>/recording/<frame_time>/snapshot.png`
Returns the snapshot image from the specific point in that cameras recordings.
### `GET /clips/<camera>-<id>.jpg` ### `GET /clips/<camera>-<id>.jpg`
JPG snapshot for the given camera and event id. JPG snapshot for the given camera and event id.

View File

@ -16,6 +16,8 @@ See the [MQTT integration
documentation](https://www.home-assistant.io/integrations/mqtt/) for more documentation](https://www.home-assistant.io/integrations/mqtt/) for more
details. details.
In addition, MQTT must be enabled in your Frigate configuration file and Frigate must be connected to the same MQTT server as Home Assistant for many of the entities created by the integration to function.
### Integration installation ### Integration installation
Available via HACS as a default repository. To install: Available via HACS as a default repository. To install:
@ -30,7 +32,7 @@ Home Assistant > HACS > Integrations > "Explore & Add Integrations" > Frigate
- Then add/configure the integration: - Then add/configure the integration:
``` ```
Home Assistant > Configuration > Integrations > Add Integration > Frigate Home Assistant > Settings > Devices & Services > Add Integration > Frigate
``` ```
Note: You will also need Note: You will also need
@ -64,13 +66,13 @@ Home Assistant > Configuration > Integrations > Frigate > Options
| Option | Description | | Option | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| RTSP URL Template | A [jinja2](https://jinja.palletsprojects.com/) template that is used to override the standard RTMP stream URL (e.g. for use with reverse proxies). This option is only shown to users who have [advanced mode](https://www.home-assistant.io/blog/2019/07/17/release-96/#advanced-mode) enabled. See [RTSP streams](#streams) below. | | RTSP URL Template | A [jinja2](https://jinja.palletsprojects.com/) template that is used to override the standard RTSP stream URL (e.g. for use with reverse proxies). This option is only shown to users who have [advanced mode](https://www.home-assistant.io/blog/2019/07/17/release-96/#advanced-mode) enabled. See [RTSP streams](#streams) below. |
## Entities Provided ## Entities Provided
| Platform | Description | | Platform | Description |
| --------------- | --------------------------------------------------------------------------------- | | --------------- | --------------------------------------------------------------------------------- |
| `camera` | Live camera stream (requires RTMP), camera for image of the last detected object. | | `camera` | Live camera stream (requires RTSP), camera for image of the last detected object. |
| `sensor` | States to monitor Frigate performance, object counts for all zones and cameras. | | `sensor` | States to monitor Frigate performance, object counts for all zones and cameras. |
| `switch` | Switch entities to toggle detection, recordings and snapshots. | | `switch` | Switch entities to toggle detection, recordings and snapshots. |
| `binary_sensor` | A "motion" binary sensor entity per camera/zone/object. | | `binary_sensor` | A "motion" binary sensor entity per camera/zone/object. |

View File

@ -39,6 +39,12 @@ You cannot use the `environment_vars` section of your configuration file to set
Once your API key is configured, you can submit examples directly from the events page in Frigate using the `SEND TO FRIGATE+` button. Once your API key is configured, you can submit examples directly from the events page in Frigate using the `SEND TO FRIGATE+` button.
:::note
Snapshots must be enabled to be able to submit examples to Frigate+
:::
![Send To Plus](/img/send-to-plus.png) ![Send To Plus](/img/send-to-plus.png)
### Annotate and verify ### Annotate and verify

View File

@ -11,7 +11,7 @@ This error message is due to a shm-size that is too small. Try updating your shm
By default, Frigate removes audio from recordings to reduce the likelihood of failing for invalid data. If you would like to include audio, you need to set a [FFmpeg preset](/configuration/ffmpeg_presets) that supports audio: By default, Frigate removes audio from recordings to reduce the likelihood of failing for invalid data. If you would like to include audio, you need to set a [FFmpeg preset](/configuration/ffmpeg_presets) that supports audio:
```yaml title="frigate.yml" ```yaml
ffmpeg: ffmpeg:
output_args: output_args:
record: preset-record-generic-audio-aac record: preset-record-generic-audio-aac

1365
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,12 +14,12 @@
"write-heading-ids": "docusaurus write-heading-ids" "write-heading-ids": "docusaurus write-heading-ids"
}, },
"dependencies": { "dependencies": {
"@docusaurus/core": "^2.2.0", "@docusaurus/core": "^2.4.0",
"@docusaurus/preset-classic": "^2.2.0", "@docusaurus/preset-classic": "^2.4.0",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"raw-loader": "^4.0.2",
"prism-react-renderer": "^1.3.5", "prism-react-renderer": "^1.3.5",
"raw-loader": "^4.0.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2" "react-dom": "^17.0.2"
}, },
@ -36,8 +36,8 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^17.0.0", "@docusaurus/module-type-aliases": "^2.4.0",
"@docusaurus/module-type-aliases": "2.2.0" "@types/react": "^17.0.0"
}, },
"engines": { "engines": {
"node": ">=16.14" "node": ">=16.14"

View File

@ -18,18 +18,19 @@ from frigate.comms.dispatcher import Communicator, Dispatcher
from frigate.comms.mqtt import MqttClient from frigate.comms.mqtt import MqttClient
from frigate.comms.ws import WebSocketClient from frigate.comms.ws import WebSocketClient
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR from frigate.const import CACHE_DIR, CLIPS_DIR, CONFIG_DIR, DEFAULT_DB_PATH, RECORD_DIR
from frigate.object_detection import ObjectDetectProcess from frigate.object_detection import ObjectDetectProcess
from frigate.events import EventCleanup, EventProcessor from frigate.events import EventCleanup, EventProcessor
from frigate.http import create_app from frigate.http import create_app
from frigate.log import log_process, root_configurer from frigate.log import log_process, root_configurer
from frigate.models import Event, Recordings from frigate.models import Event, Recordings, Timeline
from frigate.object_processing import TrackedObjectProcessor from frigate.object_processing import TrackedObjectProcessor
from frigate.output import output_frames from frigate.output import output_frames
from frigate.plus import PlusApi from frigate.plus import PlusApi
from frigate.record import RecordingCleanup, RecordingMaintainer from frigate.record import RecordingCleanup, RecordingMaintainer
from frigate.monitoring.stats import StatsEmitter, stats_init from frigate.monitoring.stats import StatsEmitter, stats_init
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
from frigate.timeline import TimelineProcessor
from frigate.version import VERSION from frigate.version import VERSION
from frigate.video import capture_camera, track_camera from frigate.video import capture_camera, track_camera
from frigate.watchdog import FrigateWatchdog from frigate.watchdog import FrigateWatchdog
@ -54,7 +55,7 @@ class FrigateApp:
os.environ[key] = value os.environ[key] = value
def ensure_dirs(self) -> None: def ensure_dirs(self) -> None:
for d in [RECORD_DIR, CLIPS_DIR, CACHE_DIR]: for d in [CONFIG_DIR, RECORD_DIR, CLIPS_DIR, CACHE_DIR]:
if not os.path.exists(d) and not os.path.islink(d): if not os.path.exists(d) and not os.path.islink(d):
logger.info(f"Creating directory: {d}") logger.info(f"Creating directory: {d}")
os.makedirs(d) os.makedirs(d)
@ -135,9 +136,12 @@ class FrigateApp:
# Queue for recordings info # Queue for recordings info
self.recordings_info_queue: Queue = mp.Queue() self.recordings_info_queue: Queue = mp.Queue()
# Queue for timeline events
self.timeline_queue: Queue = mp.Queue()
def init_database(self) -> None: def init_database(self) -> None:
# Migrate DB location # Migrate DB location
old_db_path = os.path.join(CLIPS_DIR, "frigate.db") old_db_path = DEFAULT_DB_PATH
if not os.path.isfile(self.config.database.path) and os.path.isfile( if not os.path.isfile(self.config.database.path) and os.path.isfile(
old_db_path old_db_path
): ):
@ -154,7 +158,7 @@ class FrigateApp:
migrate_db.close() migrate_db.close()
self.db = SqliteQueueDatabase(self.config.database.path) self.db = SqliteQueueDatabase(self.config.database.path)
models = [Event, Recordings] models = [Event, Recordings, Timeline]
self.db.bind(models) self.db.bind(models)
def init_stats(self) -> None: def init_stats(self) -> None:
@ -286,12 +290,19 @@ class FrigateApp:
capture_process.start() capture_process.start()
logger.info(f"Capture process started for {name}: {capture_process.pid}") logger.info(f"Capture process started for {name}: {capture_process.pid}")
def start_timeline_processor(self) -> None:
self.timeline_processor = TimelineProcessor(
self.config, self.timeline_queue, self.stop_event
)
self.timeline_processor.start()
def start_event_processor(self) -> None: def start_event_processor(self) -> None:
self.event_processor = EventProcessor( self.event_processor = EventProcessor(
self.config, self.config,
self.camera_metrics, self.camera_metrics,
self.event_queue, self.event_queue,
self.event_processed_queue, self.event_processed_queue,
self.timeline_queue,
self.stop_event, self.stop_event,
) )
self.event_processor.start() self.event_processor.start()
@ -384,6 +395,7 @@ class FrigateApp:
self.start_storage_maintainer() self.start_storage_maintainer()
self.init_stats() self.init_stats()
self.init_web_server() self.init_web_server()
self.start_timeline_processor()
self.start_event_processor() self.start_event_processor()
self.start_event_cleanup() self.start_event_cleanup()
self.start_recording_maintainer() self.start_recording_maintainer()
@ -391,7 +403,6 @@ class FrigateApp:
self.start_stats_emitter() self.start_stats_emitter()
self.start_watchdog() self.start_watchdog()
self.check_shm() self.check_shm()
# self.zeroconf = broadcast_zeroconf(self.config.mqtt.client_id)
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None: def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
self.stop() self.stop()

View File

@ -13,8 +13,8 @@ from pydantic import BaseModel, Extra, Field, validator, parse_obj_as
from pydantic.fields import PrivateAttr from pydantic.fields import PrivateAttr
from frigate.const import ( from frigate.const import (
BASE_DIR,
CACHE_DIR, CACHE_DIR,
DEFAULT_DB_PATH,
REGEX_CAMERA_NAME, REGEX_CAMERA_NAME,
YAML_EXT, YAML_EXT,
) )
@ -66,6 +66,12 @@ class LiveModeEnum(str, Enum):
webrtc = "webrtc" webrtc = "webrtc"
class TimeFormatEnum(str, Enum):
browser = "browser"
hours12 = "12hour"
hours24 = "24hour"
class DateTimeStyleEnum(str, Enum): class DateTimeStyleEnum(str, Enum):
full = "full" full = "full"
long = "long" long = "long"
@ -79,7 +85,9 @@ class UIConfig(FrigateBaseModel):
) )
timezone: Optional[str] = Field(title="Override UI timezone.") timezone: Optional[str] = Field(title="Override UI timezone.")
use_experimental: bool = Field(default=False, title="Experimental UI") use_experimental: bool = Field(default=False, title="Experimental UI")
use12hour: Optional[bool] = Field(title="Override UI time format.") time_format: TimeFormatEnum = Field(
default=TimeFormatEnum.browser, title="Override UI time format."
)
date_style: DateTimeStyleEnum = Field( date_style: DateTimeStyleEnum = Field(
default=DateTimeStyleEnum.short, title="Override UI dateStyle." default=DateTimeStyleEnum.short, title="Override UI dateStyle."
) )
@ -156,8 +164,6 @@ class RecordConfig(FrigateBaseModel):
default=60, default=60,
title="Number of minutes to wait between cleanup runs.", title="Number of minutes to wait between cleanup runs.",
) )
# deprecated - to be removed in a future version
retain_days: Optional[float] = Field(title="Recording retention period in days.")
retain: RecordRetainConfig = Field( retain: RecordRetainConfig = Field(
default_factory=RecordRetainConfig, title="Record retention settings." default_factory=RecordRetainConfig, title="Record retention settings."
) )
@ -387,11 +393,13 @@ class BirdseyeCameraConfig(BaseModel):
) )
FFMPEG_GLOBAL_ARGS_DEFAULT = ["-hide_banner", "-loglevel", "warning", "-threads", "1"] # Note: Setting threads to less than 2 caused several issues with recording segments
# https://github.com/blakeblackshear/frigate/issues/5659
FFMPEG_GLOBAL_ARGS_DEFAULT = ["-hide_banner", "-loglevel", "warning", "-threads", "2"]
FFMPEG_INPUT_ARGS_DEFAULT = "preset-rtsp-generic" FFMPEG_INPUT_ARGS_DEFAULT = "preset-rtsp-generic"
DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = [ DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = [
"-threads", "-threads",
"1", "2",
"-f", "-f",
"rawvideo", "rawvideo",
"-pix_fmt", "-pix_fmt",
@ -723,9 +731,7 @@ class CameraConfig(FrigateBaseModel):
class DatabaseConfig(FrigateBaseModel): class DatabaseConfig(FrigateBaseModel):
path: str = Field( path: str = Field(default=DEFAULT_DB_PATH, title="Database path.")
default=os.path.join(BASE_DIR, "frigate.db"), title="Database path."
)
class LogLevelEnum(str, Enum): class LogLevelEnum(str, Enum):
@ -775,16 +781,6 @@ def verify_valid_live_stream_name(
) )
def verify_old_retain_config(camera_config: CameraConfig) -> None:
"""Leave log if old retain_days is used."""
if not camera_config.record.retain_days is None:
logger.warning(
"The 'retain_days' config option has been DEPRECATED and will be removed in a future version. Please use the 'days' setting under 'retain'"
)
if camera_config.record.retain.days == 0:
camera_config.record.retain.days = camera_config.record.retain_days
def verify_recording_retention(camera_config: CameraConfig) -> None: def verify_recording_retention(camera_config: CameraConfig) -> None:
"""Verify that recording retention modes are ranked correctly.""" """Verify that recording retention modes are ranked correctly."""
rank_map = { rank_map = {
@ -991,7 +987,6 @@ class FrigateConfig(FrigateBaseModel):
verify_config_roles(camera_config) verify_config_roles(camera_config)
verify_valid_live_stream_name(config, camera_config) verify_valid_live_stream_name(config, camera_config)
verify_old_retain_config(camera_config)
verify_recording_retention(camera_config) verify_recording_retention(camera_config)
verify_recording_segments_setup_with_reasonable_time(camera_config) verify_recording_segments_setup_with_reasonable_time(camera_config)
verify_zone_objects_are_tracked(camera_config) verify_zone_objects_are_tracked(camera_config)

View File

@ -1,3 +1,5 @@
CONFIG_DIR = "/config"
DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db"
BASE_DIR = "/media/frigate" BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips" CLIPS_DIR = f"{BASE_DIR}/clips"
RECORD_DIR = f"{BASE_DIR}/recordings" RECORD_DIR = f"{BASE_DIR}/recordings"

View File

@ -5,7 +5,11 @@ from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig from frigate.detectors.detector_config import BaseDetectorConfig
from typing import Literal from typing import Literal
from pydantic import Extra, Field from pydantic import Extra, Field
import tflite_runtime.interpreter as tflite
try:
from tflite_runtime.interpreter import Interpreter
except ModuleNotFoundError:
from tensorflow.lite.python.interpreter import Interpreter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -22,7 +26,7 @@ class CpuTfl(DetectionApi):
type_key = DETECTOR_KEY type_key = DETECTOR_KEY
def __init__(self, detector_config: CpuDetectorConfig): def __init__(self, detector_config: CpuDetectorConfig):
self.interpreter = tflite.Interpreter( self.interpreter = Interpreter(
model_path=detector_config.model.path or "/cpu_model.tflite", model_path=detector_config.model.path or "/cpu_model.tflite",
num_threads=detector_config.num_threads or 3, num_threads=detector_config.num_threads or 3,
) )

View File

@ -5,8 +5,11 @@ from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig from frigate.detectors.detector_config import BaseDetectorConfig
from typing import Literal from typing import Literal
from pydantic import Extra, Field from pydantic import Extra, Field
import tflite_runtime.interpreter as tflite
from tflite_runtime.interpreter import load_delegate try:
from tflite_runtime.interpreter import Interpreter, load_delegate
except ModuleNotFoundError:
from tensorflow.lite.python.interpreter import Interpreter, load_delegate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -33,7 +36,7 @@ class EdgeTpuTfl(DetectionApi):
logger.info(f"Attempting to load TPU as {device_config['device']}") logger.info(f"Attempting to load TPU as {device_config['device']}")
edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config) edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config)
logger.info("TPU found") logger.info("TPU found")
self.interpreter = tflite.Interpreter( self.interpreter = Interpreter(
model_path=detector_config.model.path or "/edgetpu_model.tflite", model_path=detector_config.model.path or "/edgetpu_model.tflite",
experimental_delegates=[edge_tpu_delegate], experimental_delegates=[edge_tpu_delegate],
) )

View File

@ -3,14 +3,14 @@ import logging
import os import os
import queue import queue
import threading import threading
import time
from pathlib import Path from pathlib import Path
from peewee import fn from peewee import fn
from frigate.config import EventsConfig, FrigateConfig, RecordConfig from frigate.config import EventsConfig, FrigateConfig
from frigate.const import CLIPS_DIR from frigate.const import CLIPS_DIR
from frigate.models import Event from frigate.models import Event
from frigate.timeline import TimelineSourceEnum
from frigate.types import CameraMetricsTypes from frigate.types import CameraMetricsTypes
from multiprocessing.queues import Queue from multiprocessing.queues import Queue
@ -48,6 +48,7 @@ class EventProcessor(threading.Thread):
camera_processes: dict[str, CameraMetricsTypes], camera_processes: dict[str, CameraMetricsTypes],
event_queue: Queue, event_queue: Queue,
event_processed_queue: Queue, event_processed_queue: Queue,
timeline_queue: Queue,
stop_event: MpEvent, stop_event: MpEvent,
): ):
threading.Thread.__init__(self) threading.Thread.__init__(self)
@ -56,6 +57,7 @@ class EventProcessor(threading.Thread):
self.camera_processes = camera_processes self.camera_processes = camera_processes
self.event_queue = event_queue self.event_queue = event_queue
self.event_processed_queue = event_processed_queue self.event_processed_queue = event_processed_queue
self.timeline_queue = timeline_queue
self.events_in_process: Dict[str, Event] = {} self.events_in_process: Dict[str, Event] = {}
self.stop_event = stop_event self.stop_event = stop_event
@ -73,6 +75,16 @@ class EventProcessor(threading.Thread):
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}") logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
self.timeline_queue.put(
(
camera,
TimelineSourceEnum.tracked_object,
event_type,
self.events_in_process.get(event_data["id"]),
event_data,
)
)
event_config: EventsConfig = self.config.cameras[camera].record.events event_config: EventsConfig = self.config.cameras[camera].record.events
if event_type == "start": if event_type == "start":

View File

@ -35,7 +35,7 @@ from playhouse.shortcuts import model_to_dict
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
from frigate.models import Event, Recordings from frigate.models import Event, Recordings, Timeline
from frigate.object_processing import TrackedObject from frigate.object_processing import TrackedObject
from frigate.monitoring.prometheus import setupRegistry from frigate.monitoring.prometheus import setupRegistry
@ -119,6 +119,7 @@ def events_summary():
Event.select( Event.select(
Event.camera, Event.camera,
Event.label, Event.label,
Event.sub_label,
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
@ -132,6 +133,7 @@ def events_summary():
.group_by( .group_by(
Event.camera, Event.camera,
Event.label, Event.label,
Event.sub_label,
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
@ -330,7 +332,9 @@ def get_sub_labels():
sub_labels.remove(None) sub_labels.remove(None)
if split_joined: if split_joined:
for label in sub_labels: original_labels = sub_labels.copy()
for label in original_labels:
if "," in label: if "," in label:
sub_labels.remove(label) sub_labels.remove(label)
parts = label.split(",") parts = label.split(",")
@ -339,6 +343,7 @@ def get_sub_labels():
if not (part.strip()) in sub_labels: if not (part.strip()) in sub_labels:
sub_labels.append(part.strip()) sub_labels.append(part.strip())
sub_labels.sort()
return jsonify(sub_labels) return jsonify(sub_labels)
@ -417,6 +422,42 @@ def event_thumbnail(id, max_cache_age=2592000):
return response return response
@bp.route("/timeline")
def timeline():
camera = request.args.get("camera", "all")
source_id = request.args.get("source_id", type=str)
limit = request.args.get("limit", 100)
clauses = []
selected_columns = [
Timeline.timestamp,
Timeline.camera,
Timeline.source,
Timeline.source_id,
Timeline.class_type,
Timeline.data,
]
if camera != "all":
clauses.append((Timeline.camera == camera))
if source_id:
clauses.append((Timeline.source_id == source_id))
if len(clauses) == 0:
clauses.append((True))
timeline = (
Timeline.select(*selected_columns)
.where(reduce(operator.and_, clauses))
.order_by(Timeline.timestamp.asc())
.limit(limit)
)
return jsonify([model_to_dict(t) for t in timeline])
@bp.route("/<camera_name>/<label>/best.jpg") @bp.route("/<camera_name>/<label>/best.jpg")
@bp.route("/<camera_name>/<label>/thumbnail.jpg") @bp.route("/<camera_name>/<label>/thumbnail.jpg")
def label_thumbnail(camera_name, label): def label_thumbnail(camera_name, label):
@ -646,7 +687,13 @@ def events():
sub_label_clauses.append((Event.sub_label.is_null())) sub_label_clauses.append((Event.sub_label.is_null()))
for label in filtered_sub_labels: for label in filtered_sub_labels:
sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label}*")) sub_label_clauses.append(
(Event.sub_label.cast("text") == label)
) # include exact matches
# include this label when part of a list
sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*"))
sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*"))
sub_label_clause = reduce(operator.or_, sub_label_clauses) sub_label_clause = reduce(operator.or_, sub_label_clauses)
clauses.append((sub_label_clause)) clauses.append((sub_label_clause))
@ -921,6 +968,53 @@ def latest_frame(camera_name):
return "Camera named {} not found".format(camera_name), 404 return "Camera named {} not found".format(camera_name), 404
@bp.route("/<camera_name>/recordings/<frame_time>/snapshot.png")
def get_snapshot_from_recording(camera_name: str, frame_time: str):
if camera_name not in current_app.frigate_config.cameras:
return "Camera named {} not found".format(camera_name), 404
frame_time = float(frame_time)
recording_query = (
Recordings.select()
.where(
((frame_time > Recordings.start_time) & (frame_time < Recordings.end_time))
)
.where(Recordings.camera == camera_name)
)
try:
recording: Recordings = recording_query.get()
time_in_segment = frame_time - recording.start_time
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-ss",
f"00:00:{time_in_segment}",
"-i",
recording.path,
"-frames:v",
"1",
"-c:v",
"png",
"-f",
"image2pipe",
"-",
]
process = sp.run(
ffmpeg_cmd,
capture_output=True,
)
response = make_response(process.stdout)
response.headers["Content-Type"] = "image/png"
return response
except DoesNotExist:
return "Recording not found for {} at {}".format(camera_name, frame_time), 404
@bp.route("/recordings/storage", methods=["GET"]) @bp.route("/recordings/storage", methods=["GET"])
def get_recordings_storage_usage(): def get_recordings_storage_usage():
recording_stats = stats_snapshot( recording_stats = stats_snapshot(
@ -1304,12 +1398,12 @@ def ffprobe():
output.append( output.append(
{ {
"return_code": ffprobe.returncode, "return_code": ffprobe.returncode,
"stderr": json.loads(ffprobe.stderr.decode("unicode_escape").strip()) "stderr": ffprobe.stderr.decode("unicode_escape").strip()
if ffprobe.stderr.decode() if ffprobe.returncode != 0
else {}, else "",
"stdout": json.loads(ffprobe.stdout.decode("unicode_escape").strip()) "stdout": json.loads(ffprobe.stdout.decode("unicode_escape").strip())
if ffprobe.stdout.decode() if ffprobe.returncode == 0
else {}, else "",
} }
) )

View File

@ -32,6 +32,15 @@ class Event(Model): # type: ignore[misc]
plus_id = CharField(max_length=30) plus_id = CharField(max_length=30)
class Timeline(Model): # type: ignore[misc]
timestamp = DateTimeField()
camera = CharField(index=True, max_length=20)
source = CharField(index=True, max_length=20) # ex: tracked object, audio, external
source_id = CharField(index=True, max_length=30)
class_type = CharField(max_length=50) # ex: entered_zone, audio_heard
data = JSONField() # ex: tracked object id, region, box, etc.
class Recordings(Model): # type: ignore[misc] class Recordings(Model): # type: ignore[misc]
id = CharField(null=False, primary_key=True, max_length=30) id = CharField(null=False, primary_key=True, max_length=30)
camera = CharField(index=True, max_length=20) camera = CharField(index=True, max_length=20)

View File

@ -23,7 +23,6 @@ logger = logging.getLogger(__name__)
def get_latest_version(config: FrigateConfig) -> str: def get_latest_version(config: FrigateConfig) -> str:
if not config.telemetry.version_check: if not config.telemetry.version_check:
return "disabled" return "disabled"

View File

@ -20,8 +20,8 @@ class MotionDetector:
config.frame_height, config.frame_height,
config.frame_height * frame_shape[1] // frame_shape[0], config.frame_height * frame_shape[1] // frame_shape[0],
) )
self.avg_frame = np.zeros(self.motion_frame_size, np.float) self.avg_frame = np.zeros(self.motion_frame_size, np.float32)
self.avg_delta = np.zeros(self.motion_frame_size, np.float) self.avg_delta = np.zeros(self.motion_frame_size, np.float32)
self.motion_frame_count = 0 self.motion_frame_count = 0
self.frame_counter = 0 self.frame_counter = 0
resized_mask = cv2.resize( resized_mask = cv2.resize(

View File

@ -59,6 +59,3 @@ ignore_errors = false
[mypy-frigate.watchdog] [mypy-frigate.watchdog]
ignore_errors = false ignore_errors = false
disallow_untyped_calls = false disallow_untyped_calls = false
[mypy-frigate.zeroconf]
ignore_errors = false

View File

@ -329,7 +329,6 @@ class BirdsEyeFrameManager:
# update each position in the layout # update each position in the layout
for position, camera in enumerate(self.camera_layout, start=0): for position, camera in enumerate(self.camera_layout, start=0):
# if this camera was removed, replace it or clear it # if this camera was removed, replace it or clear it
if camera in removed_cameras: if camera in removed_cameras:
# if replacing this camera with a newly added one # if replacing this camera with a newly added one

View File

@ -111,7 +111,6 @@ class RecordingMaintainer(threading.Thread):
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:] grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
for camera, recordings in grouped_recordings.items(): for camera, recordings in grouped_recordings.items():
# clear out all the recording info for old frames # clear out all the recording info for old frames
while ( while (
len(self.recordings_info[camera]) > 0 len(self.recordings_info[camera]) > 0

View File

@ -179,7 +179,6 @@ class StorageMaintainer(threading.Thread):
def run(self): def run(self):
"""Check every 5 minutes if storage needs to be cleaned up.""" """Check every 5 minutes if storage needs to be cleaned up."""
while not self.stop_event.wait(300): while not self.stop_event.wait(300):
if not self.camera_storage_stats or True in [ if not self.camera_storage_stats or True in [
r["needs_refresh"] for r in self.camera_storage_stats.values() r["needs_refresh"] for r in self.camera_storage_stats.values()
]: ]:

View File

@ -36,3 +36,14 @@ class TestUserPassCleanup(unittest.TestCase):
"""Test that no change is made to path with no special characters.""" """Test that no change is made to path with no special characters."""
escaped = escape_special_characters(self.rtsp_with_pass) escaped = escape_special_characters(self.rtsp_with_pass)
assert escaped == self.rtsp_with_pass assert escaped == self.rtsp_with_pass
class TestUserPassMasking(unittest.TestCase):
def setUp(self) -> None:
self.rtsp_log_message = "Did you mean file:rtsp://user:password@192.168.1.3:554"
def test_rtsp_in_log_message(self):
"""Test that the rtsp url in a log message is espaced."""
escaped = clean_camera_user_pass(self.rtsp_log_message)
print(f"The escaped is {escaped}")
assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554"

View File

@ -673,7 +673,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5 assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5
def test_motion_frame_height_wont_go_below_120(self): def test_motion_frame_height_wont_go_below_120(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -702,7 +701,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].motion.frame_height == 50 assert runtime_config.cameras["back"].motion.frame_height == 50
def test_motion_contour_area_dynamic(self): def test_motion_contour_area_dynamic(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -731,7 +729,6 @@ class TestConfig(unittest.TestCase):
assert round(runtime_config.cameras["back"].motion.contour_area) == 30 assert round(runtime_config.cameras["back"].motion.contour_area) == 30
def test_merge_labelmap(self): def test_merge_labelmap(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"model": {"labelmap": {7: "truck"}}, "model": {"labelmap": {7: "truck"}},
@ -761,7 +758,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.model.merged_labelmap[7] == "truck" assert runtime_config.model.merged_labelmap[7] == "truck"
def test_default_labelmap_empty(self): def test_default_labelmap_empty(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -790,7 +786,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.model.merged_labelmap[0] == "person" assert runtime_config.model.merged_labelmap[0] == "person"
def test_default_labelmap(self): def test_default_labelmap(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"model": {"width": 320, "height": 320}, "model": {"width": 320, "height": 320},
@ -820,7 +815,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.model.merged_labelmap[0] == "person" assert runtime_config.model.merged_labelmap[0] == "person"
def test_fails_on_invalid_role(self): def test_fails_on_invalid_role(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -849,7 +843,6 @@ class TestConfig(unittest.TestCase):
self.assertRaises(ValidationError, lambda: FrigateConfig(**config)) self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
def test_fails_on_missing_role(self): def test_fails_on_missing_role(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -880,7 +873,6 @@ class TestConfig(unittest.TestCase):
self.assertRaises(ValueError, lambda: frigate_config.runtime_config) self.assertRaises(ValueError, lambda: frigate_config.runtime_config)
def test_works_on_missing_role_multiple_cams(self): def test_works_on_missing_role_multiple_cams(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -929,7 +921,6 @@ class TestConfig(unittest.TestCase):
runtime_config = frigate_config.runtime_config runtime_config = frigate_config.runtime_config
def test_global_detect(self): def test_global_detect(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"detect": {"max_disappeared": 1}, "detect": {"max_disappeared": 1},
@ -959,7 +950,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].detect.height == 1080 assert runtime_config.cameras["back"].detect.height == 1080
def test_default_detect(self): def test_default_detect(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -983,7 +973,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].detect.height == 720 assert runtime_config.cameras["back"].detect.height == 720
def test_global_detect_merge(self): def test_global_detect_merge(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"detect": {"max_disappeared": 1, "height": 720}, "detect": {"max_disappeared": 1, "height": 720},
@ -1014,7 +1003,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].detect.width == 1920 assert runtime_config.cameras["back"].detect.width == 1920
def test_global_snapshots(self): def test_global_snapshots(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"snapshots": {"enabled": True}, "snapshots": {"enabled": True},
@ -1042,7 +1030,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].snapshots.height == 100 assert runtime_config.cameras["back"].snapshots.height == 100
def test_default_snapshots(self): def test_default_snapshots(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -1066,7 +1053,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].snapshots.quality == 70 assert runtime_config.cameras["back"].snapshots.quality == 70
def test_global_snapshots_merge(self): def test_global_snapshots_merge(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"snapshots": {"bounding_box": False, "height": 300}, "snapshots": {"bounding_box": False, "height": 300},
@ -1096,7 +1082,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].snapshots.enabled assert runtime_config.cameras["back"].snapshots.enabled
def test_global_rtmp_disabled(self): def test_global_rtmp_disabled(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -1119,7 +1104,6 @@ class TestConfig(unittest.TestCase):
assert not runtime_config.cameras["back"].rtmp.enabled assert not runtime_config.cameras["back"].rtmp.enabled
def test_default_not_rtmp(self): def test_default_not_rtmp(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -1142,7 +1126,6 @@ class TestConfig(unittest.TestCase):
assert not runtime_config.cameras["back"].rtmp.enabled assert not runtime_config.cameras["back"].rtmp.enabled
def test_global_rtmp_merge(self): def test_global_rtmp_merge(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"rtmp": {"enabled": False}, "rtmp": {"enabled": False},
@ -1169,7 +1152,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].rtmp.enabled assert runtime_config.cameras["back"].rtmp.enabled
def test_global_rtmp_default(self): def test_global_rtmp_default(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -1196,7 +1178,6 @@ class TestConfig(unittest.TestCase):
assert not runtime_config.cameras["back"].rtmp.enabled assert not runtime_config.cameras["back"].rtmp.enabled
def test_global_jsmpeg(self): def test_global_jsmpeg(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"live": {"quality": 4}, "live": {"quality": 4},
@ -1220,7 +1201,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].live.quality == 4 assert runtime_config.cameras["back"].live.quality == 4
def test_default_live(self): def test_default_live(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -1243,7 +1223,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].live.quality == 8 assert runtime_config.cameras["back"].live.quality == 8
def test_global_live_merge(self): def test_global_live_merge(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"live": {"quality": 4, "height": 480}, "live": {"quality": 4, "height": 480},
@ -1271,7 +1250,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].live.height == 480 assert runtime_config.cameras["back"].live.height == 480
def test_global_timestamp_style(self): def test_global_timestamp_style(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"timestamp_style": {"position": "bl"}, "timestamp_style": {"position": "bl"},
@ -1295,7 +1273,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].timestamp_style.position == "bl" assert runtime_config.cameras["back"].timestamp_style.position == "bl"
def test_default_timestamp_style(self): def test_default_timestamp_style(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -1318,7 +1295,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].timestamp_style.position == "tl" assert runtime_config.cameras["back"].timestamp_style.position == "tl"
def test_global_timestamp_style_merge(self): def test_global_timestamp_style_merge(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"rtmp": {"enabled": False}, "rtmp": {"enabled": False},
@ -1345,7 +1321,6 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].timestamp_style.thickness == 4 assert runtime_config.cameras["back"].timestamp_style.thickness == 4
def test_allow_retain_to_be_a_decimal(self): def test_allow_retain_to_be_a_decimal(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"snapshots": {"retain": {"default": 1.5}}, "snapshots": {"retain": {"default": 1.5}},

140
frigate/timeline.py Normal file
View File

@ -0,0 +1,140 @@
"""Record events for object, audio, etc. detections."""
import logging
import threading
import queue
from enum import Enum
from frigate.config import FrigateConfig
from frigate.models import Timeline
from multiprocessing.queues import Queue
from multiprocessing.synchronize import Event as MpEvent
logger = logging.getLogger(__name__)
class TimelineSourceEnum(str, Enum):
# api = "api"
# audio = "audio"
tracked_object = "tracked_object"
class TimelineProcessor(threading.Thread):
"""Handle timeline queue and update DB."""
def __init__(
self,
config: FrigateConfig,
queue: Queue,
stop_event: MpEvent,
) -> None:
threading.Thread.__init__(self)
self.name = "timeline_processor"
self.config = config
self.queue = queue
self.stop_event = stop_event
def run(self) -> None:
while not self.stop_event.is_set():
try:
(
camera,
input_type,
event_type,
prev_event_data,
event_data,
) = self.queue.get(timeout=1)
except queue.Empty:
continue
if input_type == TimelineSourceEnum.tracked_object:
self.handle_object_detection(
camera, event_type, prev_event_data, event_data
)
def handle_object_detection(
self,
camera: str,
event_type: str,
prev_event_data: dict[any, any],
event_data: dict[any, any],
) -> None:
"""Handle object detection."""
camera_config = self.config.cameras[camera]
if event_type == "start":
Timeline.insert(
timestamp=event_data["frame_time"],
camera=camera,
source="tracked_object",
source_id=event_data["id"],
class_type="visible",
data={
"box": [
event_data["box"][0] / camera_config.detect.width,
event_data["box"][1] / camera_config.detect.height,
event_data["box"][2] / camera_config.detect.width,
event_data["box"][3] / camera_config.detect.height,
],
"label": event_data["label"],
"region": [
event_data["region"][0] / camera_config.detect.width,
event_data["region"][1] / camera_config.detect.height,
event_data["region"][2] / camera_config.detect.width,
event_data["region"][3] / camera_config.detect.height,
],
},
).execute()
elif (
event_type == "update"
and prev_event_data["current_zones"] != event_data["current_zones"]
and len(event_data["current_zones"]) > 0
):
Timeline.insert(
timestamp=event_data["frame_time"],
camera=camera,
source="tracked_object",
source_id=event_data["id"],
class_type="entered_zone",
data={
"box": [
event_data["box"][0] / camera_config.detect.width,
event_data["box"][1] / camera_config.detect.height,
event_data["box"][2] / camera_config.detect.width,
event_data["box"][3] / camera_config.detect.height,
],
"label": event_data["label"],
"region": [
event_data["region"][0] / camera_config.detect.width,
event_data["region"][1] / camera_config.detect.height,
event_data["region"][2] / camera_config.detect.width,
event_data["region"][3] / camera_config.detect.height,
],
"zones": event_data["current_zones"],
},
).execute()
elif event_type == "end":
Timeline.insert(
timestamp=event_data["frame_time"],
camera=camera,
source="tracked_object",
source_id=event_data["id"],
class_type="gone",
data={
"box": [
event_data["box"][0] / camera_config.detect.width,
event_data["box"][1] / camera_config.detect.height,
event_data["box"][2] / camera_config.detect.width,
event_data["box"][3] / camera_config.detect.height,
],
"label": event_data["label"],
"region": [
event_data["region"][0] / camera_config.detect.width,
event_data["region"][1] / camera_config.detect.height,
event_data["region"][2] / camera_config.detect.width,
event_data["region"][3] / camera_config.detect.height,
],
},
).execute()

View File

@ -722,7 +722,7 @@ def load_labels(path, encoding="utf-8"):
def clean_camera_user_pass(line: str) -> str: def clean_camera_user_pass(line: str) -> str:
"""Removes user and password from line.""" """Removes user and password from line."""
if line.startswith("rtsp://"): if "rtsp://" in line:
return re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line) return re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
else: else:
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", line) return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", line)
@ -772,7 +772,6 @@ def get_docker_memlimit_bytes() -> int:
# check running a supported cgroups version # check running a supported cgroups version
if get_cgroups_version() == "cgroup2": if get_cgroups_version() == "cgroup2":
memlimit_command = ["cat", "/sys/fs/cgroup/memory.max"] memlimit_command = ["cat", "/sys/fs/cgroup/memory.max"]
p = sp.run( p = sp.run(
@ -817,7 +816,6 @@ def get_cpu_stats() -> dict[str, dict]:
for line in lines: for line in lines:
stats = list(filter(lambda a: a != "", line.strip().split(" "))) stats = list(filter(lambda a: a != "", line.strip().split(" ")))
try: try:
if docker_memlimit > 0: if docker_memlimit > 0:
mem_res = int(stats[5]) mem_res = int(stats[5])
mem_pct = str( mem_pct = str(

View File

@ -104,7 +104,7 @@ def create_tensor_input(frame, model_config, region):
if cropped_frame.shape != (model_config.height, model_config.width, 3): if cropped_frame.shape != (model_config.height, model_config.width, 3):
cropped_frame = cv2.resize( cropped_frame = cv2.resize(
cropped_frame, cropped_frame,
dsize=(model_config.height, model_config.width), dsize=(model_config.width, model_config.height),
interpolation=cv2.INTER_LINEAR, interpolation=cv2.INTER_LINEAR,
) )
@ -162,7 +162,6 @@ def capture_frames(
current_frame: mp.Value, current_frame: mp.Value,
stop_event: mp.Event, stop_event: mp.Event,
): ):
frame_size = frame_shape[0] * frame_shape[1] frame_size = frame_shape[0] * frame_shape[1]
frame_rate = EventsPerSecond() frame_rate = EventsPerSecond()
frame_rate.start() frame_rate.start()
@ -594,7 +593,6 @@ def process_frames(
stop_event, stop_event,
exit_on_empty: bool = False, exit_on_empty: bool = False,
): ):
fps = process_info["process_fps"] fps = process_info["process_fps"]
detection_fps = process_info["detection_fps"] detection_fps = process_info["detection_fps"]
current_frame_time = process_info["detection_frame"] current_frame_time = process_info["detection_frame"]
@ -748,7 +746,6 @@ def process_frames(
selected_objects = [] selected_objects = []
for group in detected_object_groups.values(): for group in detected_object_groups.values():
# apply non-maxima suppression to suppress weak, overlapping bounding boxes # apply non-maxima suppression to suppress weak, overlapping bounding boxes
# o[2] is the box of the object: xmin, ymin, xmax, ymax # o[2] is the box of the object: xmin, ymin, xmax, ymax
# apply max/min to ensure values do not exceed the known frame size # apply max/min to ensure values do not exceed the known frame size

View File

@ -1,62 +0,0 @@
import logging
import socket
from zeroconf import (
ServiceInfo,
NonUniqueNameException,
InterfaceChoice,
IPVersion,
Zeroconf,
)
logger = logging.getLogger(__name__)
ZEROCONF_TYPE = "_frigate._tcp.local."
# Taken from: http://stackoverflow.com/a/11735897
def get_local_ip() -> bytes:
"""Try to determine the local IP address of the machine."""
host_ip_str = ""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Use Google Public DNS server to determine own IP
sock.connect(("8.8.8.8", 80))
host_ip_str = sock.getsockname()[0]
except OSError:
try:
host_ip_str = socket.gethostbyname(socket.gethostname())
except socket.gaierror:
host_ip_str = "127.0.0.1"
finally:
sock.close()
try:
host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip_str)
except OSError:
host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip_str)
return host_ip_pton
def broadcast_zeroconf(frigate_id: str) -> Zeroconf:
zeroconf = Zeroconf(interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only)
host_ip = get_local_ip()
info = ServiceInfo(
ZEROCONF_TYPE,
name=f"{frigate_id}.{ZEROCONF_TYPE}",
addresses=[host_ip],
port=5000,
)
logger.info("Starting Zeroconf broadcast")
try:
zeroconf.register_service(info)
except NonUniqueNameException:
logger.error(
"Frigate instance with identical name present in the local network"
)
return zeroconf

View File

@ -0,0 +1,48 @@
"""Peewee migrations -- 013_create_timeline_table.py.
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import datetime as dt
import peewee as pw
from playhouse.sqlite_ext import *
from decimal import ROUND_HALF_EVEN
from frigate.models import Recordings
try:
import playhouse.postgres_ext as pw_pext
except ImportError:
pass
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE TABLE IF NOT EXISTS "timeline" ("timestamp" DATETIME NOT NULL, "camera" VARCHAR(20) NOT NULL, "source" VARCHAR(20) NOT NULL, "source_id" VARCHAR(30), "class_type" VARCHAR(50) NOT NULL, "data" JSON)'
)
migrator.sql('CREATE INDEX IF NOT EXISTS "timeline_camera" ON "timeline" ("camera")')
migrator.sql('CREATE INDEX IF NOT EXISTS "timeline_source" ON "timeline" ("source")')
migrator.sql('CREATE INDEX IF NOT EXISTS "timeline_source_id" ON "timeline" ("source_id")')
def rollback(migrator, database, fake=False, **kwargs):
pass

View File

@ -1,2 +1,2 @@
pylint == 2.15.* pylint == 2.17.*
black == 22.12.* black == 23.3.*

View File

@ -1,3 +1,3 @@
numpy == 1.19.* numpy == 1.23.*
openvino == 2022.* openvino == 2022.*
openvino-dev[tensorflow2] == 2022.* openvino-dev[tensorflow2] == 2022.*

View File

@ -3,7 +3,7 @@ Flask == 2.2.*
imutils == 0.5.* imutils == 0.5.*
matplotlib == 3.6.* matplotlib == 3.6.*
mypy == 0.942 mypy == 0.942
numpy == 1.22.* numpy == 1.23.*
opencv-python-headless == 4.5.5.* opencv-python-headless == 4.5.5.*
paho-mqtt == 1.6.* paho-mqtt == 1.6.*
peewee == 3.15.* peewee == 3.15.*
@ -11,12 +11,12 @@ peewee_migrate == 1.6.*
psutil == 5.9.* psutil == 5.9.*
pydantic == 1.10.* pydantic == 1.10.*
PyYAML == 6.0 PyYAML == 6.0
pytz == 2022.7 pytz == 2023.3
tzlocal == 4.2 tzlocal == 4.2
types-PyYAML == 6.0.* types-PyYAML == 6.0.*
requests == 2.28.* requests == 2.28.*
types-requests == 2.28.* types-requests == 2.28.*
scipy == 1.8.* scipy == 1.10.*
setproctitle == 1.3.* setproctitle == 1.3.*
ws4py == 0.5.* ws4py == 0.5.*
zeroconf == 0.47.* zeroconf == 0.47.*
@ -24,4 +24,3 @@ prometheus-client == 0.16.*
# Openvino Library - Custom built with MYRIAD support # Openvino Library - Custom built with MYRIAD support
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64' openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64'
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64' openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.2.0/openvino-2022.2.0-000-cp39-cp39-linux_armv7l.whl; platform_machine == 'armv7l'

View File

@ -1,2 +1,2 @@
scikit-build == 0.16.4 scikit-build == 0.17.1
nvidia-pyindex nvidia-pyindex

3860
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,48 +13,46 @@
}, },
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5", "@cycjimmy/jsmpeg-player": "^6.0.5",
"axios": "^1.2.2", "axios": "^1.3.5",
"copy-to-clipboard": "3.3.3", "copy-to-clipboard": "3.3.3",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"idb-keyval": "^6.2.0", "idb-keyval": "^6.2.0",
"immer": "^9.0.16", "immer": "^9.0.21",
"monaco-yaml": "^4.0.2", "monaco-yaml": "^4.0.4",
"preact": "^10.11.3", "preact": "^10.13.2",
"preact-async-route": "^2.2.1", "preact-async-route": "^2.2.1",
"preact-router": "^4.1.0", "preact-router": "^4.1.0",
"react": "npm:@preact/compat@^17.1.2", "react": "npm:@preact/compat@^17.1.2",
"react-dom": "npm:@preact/compat@^17.1.2", "react-dom": "npm:@preact/compat@^17.1.2",
"strftime": "^0.10.1", "strftime": "^0.10.1",
"swr": "^1.3.0", "swr": "^1.3.0",
"video.js": "^7.20.3", "video.js": "^8.3.0",
"videojs-playlist": "^5.0.0", "videojs-playlist": "^5.1.0",
"videojs-seek-buttons": "^3.0.1",
"vite-plugin-monaco-editor": "^1.1.0" "vite-plugin-monaco-editor": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "^2.5.0", "@preact/preset-vite": "^2.5.0",
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/preact": "^3.2.2", "@testing-library/preact": "^3.2.3",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"@types/video.js": "^7.3.50", "@typescript-eslint/eslint-plugin": "^5.58.0",
"@typescript-eslint/eslint-plugin": "^5.47.1", "@typescript-eslint/parser": "^5.58.0",
"@typescript-eslint/parser": "^5.47.1", "@vitest/coverage-c8": "^0.30.1",
"@vitest/coverage-c8": "^0.26.2", "@vitest/ui": "^0.30.1",
"@vitest/ui": "^0.26.2", "autoprefixer": "^10.4.14",
"autoprefixer": "^10.4.13", "eslint": "^8.38.0",
"eslint": "^8.30.0",
"eslint-config-preact": "^1.3.0", "eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-vitest-globals": "^1.2.0", "eslint-plugin-vitest-globals": "^1.3.1",
"fake-indexeddb": "^4.0.1", "fake-indexeddb": "^4.0.1",
"jsdom": "^20.0.3", "jsdom": "^21.1.1",
"msw": "^0.49.2", "msw": "^1.2.1",
"postcss": "^8.4.19", "postcss": "^8.4.19",
"prettier": "^2.8.0", "prettier": "^2.8.7",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.3.1",
"typescript": "^4.8.4", "typescript": "^5.0.4",
"vite": "^4.0.3", "vite": "^4.2.1",
"vitest": "^0.25.3" "vitest": "^0.30.1"
} }
} }

View File

@ -21,7 +21,7 @@ export default function Dialog({ children, portalRootID = 'dialogs' }) {
> >
<div <div
role="modal" role="modal"
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 max-w-sm text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${ className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 sm:max-w-sm md:max-w-md lg:max-w-lg text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
show ? 'scale-100 opacity-100' : '' show ? 'scale-100 opacity-100' : ''
}`} }`}
> >

View File

@ -3,11 +3,10 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useApiHost } from '../../api'; import { useApiHost } from '../../api';
import { isNullOrUndefined } from '../../utils/objectUtils'; import { isNullOrUndefined } from '../../utils/objectUtils';
import 'videojs-seek-buttons';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
import 'videojs-seek-buttons/dist/videojs-seek-buttons.css';
import videojs, { VideoJsPlayer } from 'video.js'; import videojs from 'video.js';
import type Player from 'video.js/dist/types/player';
interface OnTimeUpdateEvent { interface OnTimeUpdateEvent {
timestamp: number; timestamp: number;
@ -34,10 +33,10 @@ export const HistoryVideo = ({
const apiHost = useApiHost(); const apiHost = useApiHost();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [video, setVideo] = useState<VideoJsPlayer>(); const [video, setVideo] = useState<Player>();
useEffect(() => { useEffect(() => {
let video: VideoJsPlayer let video: Player
if (videoRef.current) { if (videoRef.current) {
video = videojs(videoRef.current, {}) video = videojs(videoRef.current, {})
setVideo(video) setVideo(video)
@ -88,7 +87,8 @@ export const HistoryVideo = ({
); );
useEffect(() => { useEffect(() => {
if (video && video.readyState() >= 1) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (video && (video as any).readyState() >= 1) {
if (videoIsPlaying) { if (videoIsPlaying) {
video.play() video.play()
} else { } else {
@ -98,7 +98,8 @@ export const HistoryVideo = ({
}, [video, videoIsPlaying]) }, [video, videoIsPlaying])
const onLoad = useCallback(() => { const onLoad = useCallback(() => {
if (video && video.readyState() >= 1 && videoIsPlaying) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (video && (video as any).readyState() >= 1 && videoIsPlaying) {
video.play() video.play()
} }
}, [video, videoIsPlaying]) }, [video, videoIsPlaying])

View File

@ -16,6 +16,8 @@ export default function MultiSelect({ className, title, options, selection, onTo
const isOptionSelected = (item) => { return selection == "all" || selection.split(',').indexOf(item) > -1; } const isOptionSelected = (item) => { return selection == "all" || selection.split(',').indexOf(item) > -1; }
const menuHeight = Math.round(window.innerHeight * 0.55);
return ( return (
<div className={`${className} p-2`} ref={popupRef}> <div className={`${className} p-2`} ref={popupRef}>
<div <div
@ -26,7 +28,7 @@ export default function MultiSelect({ className, title, options, selection, onTo
<ArrowDropdown className="w-6" /> <ArrowDropdown className="w-6" />
</div> </div>
{state.showMenu ? ( {state.showMenu ? (
<Menu relativeTo={popupRef} onDismiss={() => setState({ showMenu: false })}> <Menu className={`max-h-[${menuHeight}px] overflow-scroll`} relativeTo={popupRef} onDismiss={() => setState({ showMenu: false })}>
<div className="flex flex-wrap justify-between items-center"> <div className="flex flex-wrap justify-between items-center">
<Heading className="p-4 justify-center" size="md">{title}</Heading> <Heading className="p-4 justify-center" size="md">{title}</Heading>
<Button tabindex="false" className="mx-4" onClick={() => onShowAll() }> <Button tabindex="false" className="mx-4" onClick={() => onShowAll() }>

View File

@ -0,0 +1,131 @@
import { h } from 'preact';
import useSWR from 'swr';
import ActivityIndicator from './ActivityIndicator';
import { formatUnixTimestampToDateTime } from '../utils/dateUtil';
import PlayIcon from '../icons/Play';
import ExitIcon from '../icons/Exit';
import { Zone } from '../icons/Zone';
import { useState } from 'preact/hooks';
import Button from './Button';
import { getUnixTime } from 'date-fns';
export default function TimelineSummary({ event, onFrameSelected }) {
const { data: eventTimeline } = useSWR([
'timeline',
{
source_id: event.id,
},
]);
const { data: config } = useSWR('config');
const [timeIndex, setTimeIndex] = useState(-1);
const recordingParams = {
before: event.end_time || getUnixTime(),
after: event.start_time,
};
const { data: recordings } = useSWR([`${event.camera}/recordings`, recordingParams], { revalidateOnFocus: false });
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
const getSeekSeconds = (seekUnix) => {
if (!recordings) {
return 0;
}
let seekSeconds = 0;
recordings.every((segment) => {
// if the next segment is past the desired time, stop calculating
if (segment.start_time > seekUnix) {
return false;
}
if (segment.end_time < seekUnix) {
seekSeconds += segment.end_time - segment.start_time;
return true;
}
seekSeconds += segment.end_time - segment.start_time - (segment.end_time - seekUnix);
return true;
});
return seekSeconds;
};
const onSelectMoment = async (index) => {
setTimeIndex(index);
onFrameSelected(eventTimeline[index], getSeekSeconds(eventTimeline[index].timestamp));
};
if (!eventTimeline || !config) {
return <ActivityIndicator />;
}
if (eventTimeline.length == 0) {
return <div />;
}
return (
<div className="flex flex-col">
<div className="h-14 flex justify-center">
<div className="sm:w-1 md:w-1/4 flex flex-row flex-nowrap justify-between overflow-auto">
{eventTimeline.map((item, index) =>
item.class_type == 'visible' || item.class_type == 'gone' ? (
<Button
key={index}
className="rounded-full"
type="text"
color={index == timeIndex ? 'blue' : 'gray'}
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
onClick={() => onSelectMoment(index)}
>
{item.class_type == 'visible' ? <PlayIcon className="w-8" /> : <ExitIcon className="w-8" />}
</Button>
) : (
<Button
key={index}
className="rounded-full"
type="text"
color={index == timeIndex ? 'blue' : 'gray'}
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
onClick={() => onSelectMoment(index)}
>
<Zone className="w-8" />
</Button>
)
)}
</div>
</div>
{timeIndex >= 0 ? (
<div className="bg-gray-500 p-4 m-2 max-w-md self-center">
Disclaimer: This data comes from the detect feed but is shown on the recordings, it is unlikely that the
streams are perfectly in sync so the bounding box and the footage will not line up perfectly.
</div>
) : null}
</div>
);
}
function getTimelineItemDescription(config, timelineItem, event) {
if (timelineItem.class_type == 'visible') {
return `${event.label} detected at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
} else if (timelineItem.class_type == 'entered_zone') {
return `${event.label.replaceAll('_', ' ')} entered ${timelineItem.data.zones
.join(' and ')
.replaceAll('_', ' ')} at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
}
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
}

View File

@ -49,7 +49,7 @@ export default function Tooltip({ relativeTo, text }) {
const tooltip = ( const tooltip = (
<div <div
role="tooltip" role="tooltip"
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${ className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm capitalize ${
position.top >= 0 ? 'opacity-100 scale-100' : '' position.top >= 0 ? 'opacity-100 scale-100' : ''
}`} }`}
ref={ref} ref={ref}

View File

@ -2,24 +2,21 @@ import { h } from 'preact';
import { useRef, useEffect } from 'preact/hooks'; import { useRef, useEffect } from 'preact/hooks';
import videojs from 'video.js'; import videojs from 'video.js';
import 'videojs-playlist'; import 'videojs-playlist';
import 'videojs-seek-buttons';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
import 'videojs-seek-buttons/dist/videojs-seek-buttons.css';
export default function VideoPlayer({ children, options, seekOptions = {}, onReady = () => {}, onDispose = () => {} }) { export default function VideoPlayer({ children, options, seekOptions = {forward:30, backward: 10}, onReady = () => {}, onDispose = () => {} }) {
const playerRef = useRef(); const playerRef = useRef();
useEffect(() => { useEffect(() => {
const defaultOptions = { const defaultOptions = {
controls: true, controls: true,
controlBar: {
skipButtons: seekOptions,
},
playbackRates: [0.5, 1, 2, 4, 8], playbackRates: [0.5, 1, 2, 4, 8],
fluid: true, fluid: true,
}; };
const defaultSeekOptions = {
forward: 30,
back: 10,
};
if (!videojs.browser.IS_FIREFOX) { if (!videojs.browser.IS_FIREFOX) {
defaultOptions.playbackRates.push(16); defaultOptions.playbackRates.push(16);
@ -28,10 +25,6 @@ export default function VideoPlayer({ children, options, seekOptions = {}, onRea
const player = videojs(playerRef.current, { ...defaultOptions, ...options }, () => { const player = videojs(playerRef.current, { ...defaultOptions, ...options }, () => {
onReady(player); onReady(player);
}); });
player.seekButtons({
...defaultSeekOptions,
...seekOptions,
});
// Allows player to continue on error // Allows player to continue on error
player.reloadSourceOnError(); player.reloadSourceOnError();

12
web/src/icons/Exit.jsx Normal file
View File

@ -0,0 +1,12 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
function Exit({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M22 12l-4-4v3h-8v2h8v3m2 2a10 10 0 110-12h-2.73a8 8 0 100 12z" />
</svg>
);
}
export default memo(Exit);

View File

@ -1,9 +1,9 @@
import { h } from 'preact'; import { h } from 'preact';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
export function Play() { export function Play({ className = '' }) {
return ( return (
<svg style="width:24px;height:24px" viewBox="0 0 24 24"> <svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" /> <path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
</svg> </svg>
); );

View File

@ -26,6 +26,7 @@ import Dialog from '../components/Dialog';
import MultiSelect from '../components/MultiSelect'; import MultiSelect from '../components/MultiSelect';
import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil'; import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil';
import TimeAgo from '../components/TimeAgo'; import TimeAgo from '../components/TimeAgo';
import TimelineSummary from '../components/TimelineSummary';
const API_LIMIT = 25; const API_LIMIT = 25;
@ -60,12 +61,14 @@ export default function Events({ path, ...props }) {
}); });
const [uploading, setUploading] = useState([]); const [uploading, setUploading] = useState([]);
const [viewEvent, setViewEvent] = useState(); const [viewEvent, setViewEvent] = useState();
const [eventOverlay, setEventOverlay] = useState();
const [eventDetailType, setEventDetailType] = useState('clip'); const [eventDetailType, setEventDetailType] = useState('clip');
const [downloadEvent, setDownloadEvent] = useState({ const [downloadEvent, setDownloadEvent] = useState({
id: null, id: null,
has_clip: false, has_clip: false,
has_snapshot: false, has_snapshot: false,
plus_id: undefined, plus_id: undefined,
end_time: null,
}); });
const [deleteFavoriteState, setDeleteFavoriteState] = useState({ const [deleteFavoriteState, setDeleteFavoriteState] = useState({
deletingFavoriteEventId: null, deletingFavoriteEventId: null,
@ -179,6 +182,14 @@ export default function Events({ path, ...props }) {
onFilter(name, items); onFilter(name, items);
}; };
const onEventFrameSelected = (event, frame, seekSeconds) => {
if (this.player) {
this.player.pause();
this.player.currentTime(seekSeconds);
setEventOverlay(frame);
}
};
const datePicker = useRef(); const datePicker = useRef();
const downloadButton = useRef(); const downloadButton = useRef();
@ -190,6 +201,7 @@ export default function Events({ path, ...props }) {
has_clip: event.has_clip, has_clip: event.has_clip,
has_snapshot: event.has_snapshot, has_snapshot: event.has_snapshot,
plus_id: event.plus_id, plus_id: event.plus_id,
end_time: event.end_time,
})); }));
downloadButton.current = e.target; downloadButton.current = e.target;
setState({ ...state, showDownloadMenu: true }); setState({ ...state, showDownloadMenu: true });
@ -524,7 +536,7 @@ export default function Events({ path, ...props }) {
</div> </div>
</div> </div>
<div class="hidden sm:flex flex-col justify-end mr-2"> <div class="hidden sm:flex flex-col justify-end mr-2">
{(event.end_time && event.has_snapshot) && ( {event.end_time && event.has_snapshot && (
<Fragment> <Fragment>
{event.plus_id ? ( {event.plus_id ? (
<div className="uppercase text-xs">Sent to Frigate+</div> <div className="uppercase text-xs">Sent to Frigate+</div>
@ -571,6 +583,12 @@ export default function Events({ path, ...props }) {
<div> <div>
{eventDetailType == 'clip' && event.has_clip ? ( {eventDetailType == 'clip' && event.has_clip ? (
<div>
<TimelineSummary
event={event}
onFrameSelected={(frame, seekSeconds) => onEventFrameSelected(event, frame, seekSeconds)}
/>
<div>
<VideoPlayer <VideoPlayer
options={{ options={{
preload: 'auto', preload: 'auto',
@ -582,9 +600,35 @@ export default function Events({ path, ...props }) {
}, },
], ],
}} }}
seekOptions={{ forward: 10, back: 5 }} seekOptions={{ forward: 10, backward: 5 }}
onReady={() => {}} onReady={(player) => {
/> this.player = player;
this.player.on('playing', () => {
setEventOverlay(undefined);
});
}}
onDispose={() => {
this.player = null;
}}
>
{eventOverlay ? (
<div
className="absolute border-4 border-red-600"
style={{
left: `${Math.round(eventOverlay.data.box[0] * 100)}%`,
top: `${Math.round(eventOverlay.data.box[1] * 100)}%`,
right: `${Math.round((1 - eventOverlay.data.box[2]) * 100)}%`,
bottom: `${Math.round((1 - eventOverlay.data.box[3]) * 100)}%`,
}}
>
{eventOverlay.class_type == 'entered_zone' ? (
<div className="absolute w-2 h-2 bg-yellow-500 left-[50%] bottom-0" />
) : null}
</div>
) : null}
</VideoPlayer>
</div>
</div>
) : null} ) : null}
{eventDetailType == 'image' || !event.has_clip ? ( {eventDetailType == 'image' || !event.has_clip ? (

View File

@ -128,7 +128,7 @@ export default function Storage() {
<Tbody> <Tbody>
<Tr> <Tr>
<Td>{Math.round(camera['usage_percent'] ?? 0)}%</Td> <Td>{Math.round(camera['usage_percent'] ?? 0)}%</Td>
<Td>{camera['bandwidth'] ? getUnitSize(camera['bandwidth']) : 'Calculating...'}/hr</Td> <Td>{camera['bandwidth'] ? `${getUnitSize(camera['bandwidth'])}/hr` : 'Calculating...'}</Td>
</Tr> </Tr>
</Tbody> </Tbody>
</Table> </Table>

View File

@ -49,14 +49,14 @@ export default function System() {
}); });
if (response.status === 200) { if (response.status === 200) {
setState({ ...state, showFfprobe: true, ffprobe: JSON.stringify(response.data, null, 2) }); setState({ ...state, showFfprobe: true, ffprobe: response.data });
} else { } else {
setState({ ...state, showFfprobe: true, ffprobe: 'There was an error getting the ffprobe output.' }); setState({ ...state, showFfprobe: true, ffprobe: 'There was an error getting the ffprobe output.' });
} }
}; };
const onCopyFfprobe = async () => { const onCopyFfprobe = async () => {
copy(JSON.stringify(state.ffprobe, null, 2)); copy(JSON.stringify(state.ffprobe).replace(/[\\\s]+/gi, ''));
setState({ ...state, ffprobe: '', showFfprobe: false }); setState({ ...state, ffprobe: '', showFfprobe: false });
}; };
@ -68,14 +68,18 @@ export default function System() {
const response = await axios.get('vainfo'); const response = await axios.get('vainfo');
if (response.status === 200) { if (response.status === 200) {
setState({ ...state, showVainfo: true, vainfo: JSON.stringify(response.data, null, 2) }); setState({
...state,
showVainfo: true,
vainfo: response.data,
});
} else { } else {
setState({ ...state, showVainfo: true, vainfo: 'There was an error getting the vainfo output.' }); setState({ ...state, showVainfo: true, vainfo: 'There was an error getting the vainfo output.' });
} }
}; };
const onCopyVainfo = async () => { const onCopyVainfo = async () => {
copy(JSON.stringify(state.vainfo, null, 2)); copy(JSON.stringify(state.vainfo).replace(/[\\\s]+/gi, ''));
setState({ ...state, vainfo: '', showVainfo: false }); setState({ ...state, vainfo: '', showVainfo: false });
}; };
@ -107,9 +111,52 @@ export default function System() {
{state.showFfprobe && ( {state.showFfprobe && (
<Dialog> <Dialog>
<div className="p-4"> <div className="p-4 mb-2 max-h-96 whitespace-pre-line overflow-scroll">
<Heading size="lg">Ffprobe Output</Heading> <Heading size="lg">Ffprobe Output</Heading>
{state.ffprobe != '' ? <p className="mb-2">{state.ffprobe}</p> : <ActivityIndicator />} {state.ffprobe != '' ? (
<div>
{state.ffprobe.map((stream, idx) => (
<div key={idx} className="mb-2 max-h-96 whitespace-pre-line">
<div>Stream {idx}:</div>
<div className="px-2">Return Code: {stream.return_code}</div>
<br />
{stream.return_code == 0 ? (
<div>
{stream.stdout.streams.map((codec, idx) => (
<div className="px-2" key={idx}>
{codec.width ? (
<div>
<div>Video:</div>
<br />
<div>Codec: {codec.codec_long_name}</div>
<div>
Resolution: {codec.width}x{codec.height}
</div>
<div>FPS: {codec.avg_frame_rate == '0/0' ? 'Unknown' : codec.avg_frame_rate}</div>
<br />
</div>
) : (
<div>
<div>Audio:</div>
<br />
<div>Codec: {codec.codec_long_name}</div>
<br />
</div>
)}
</div>
))}
</div>
) : (
<div className="px-2">
<div>Error: {stream.stderr}</div>
</div>
)}
</div>
))}
</div>
) : (
<ActivityIndicator />
)}
</div> </div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2"> <div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => onCopyFfprobe()} type="text"> <Button className="ml-2" onClick={() => onCopyFfprobe()} type="text">
@ -128,10 +175,16 @@ export default function System() {
{state.showVainfo && ( {state.showVainfo && (
<Dialog> <Dialog>
<div className="p-4"> <div className="p-4 overflow-scroll whitespace-pre-line">
<Heading size="lg">Vainfo Output</Heading> <Heading size="lg">Vainfo Output</Heading>
{state.vainfo != '' ? ( {state.vainfo != '' ? (
<p className="mb-2 max-h-96 overflow-scroll">{state.vainfo}</p> <div className="mb-2 max-h-96 whitespace-pre-line">
<div className="">Return Code: {state.vainfo.return_code}</div>
<br />
<div className="">Process {state.vainfo.return_code == 0 ? 'Output' : 'Error'}:</div>
<br />
<div>{state.vainfo.return_code == 0 ? state.vainfo.stdout : state.vainfo.stderr}</div>
</div>
) : ( ) : (
<ActivityIndicator /> <ActivityIndicator />
)} )}

View File

@ -37,25 +37,26 @@ export const getNowYesterdayInLong = (): number => {
*/ */
interface DateTimeStyle { interface DateTimeStyle {
timezone: string; timezone: string;
use12hour: boolean | undefined; time_format: 'browser' | '12hour' | '24hour';
date_style: 'full' | 'long' | 'medium' | 'short'; date_style: 'full' | 'long' | 'medium' | 'short';
time_style: 'full' | 'long' | 'medium' | 'short'; time_style: 'full' | 'long' | 'medium' | 'short';
strftime_fmt: string; strftime_fmt: string;
} }
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: DateTimeStyle): string => { export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: DateTimeStyle): string => {
const { timezone, use12hour, date_style, time_style, strftime_fmt } = config; const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
const locale = window.navigator?.language || 'en-US'; const locale = window.navigator?.language || 'en-us';
if (isNaN(unixTimestamp)) { if (isNaN(unixTimestamp)) {
return 'Invalid time'; return 'Invalid time';
} }
try { try {
const date = new Date(unixTimestamp * 1000); const date = new Date(unixTimestamp * 1000);
// use strftime_fmt if defined in config file // use strftime_fmt if defined in config file
if (strftime_fmt) { if (strftime_fmt) {
const strftime_locale = strftime.localizeByIdentifier(locale); const strftime_locale = strftime.timezone(getUTCOffset(date, timezone || Intl.DateTimeFormat().resolvedOptions().timeZone)).localizeByIdentifier(locale);
return strftime_locale(strftime_fmt, date); return strftime_locale(strftime_fmt, date);
} }
@ -64,7 +65,7 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: Dat
dateStyle: date_style, dateStyle: date_style,
timeStyle: time_style, timeStyle: time_style,
timeZone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, timeZone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
hour12: use12hour !== null ? use12hour : undefined, hour12: time_format !== 'browser' ? time_format == '12hour' : undefined,
}); });
return formatter.format(date); return formatter.format(date);
} catch (error) { } catch (error) {
@ -113,3 +114,18 @@ export const getDurationFromTimestamps = (start_time: number, end_time: number |
} }
return duration; return duration;
}; };
/**
* Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and
* returns the offset of that timezone from UTC in minutes.
* @param timezone string representation of the timezone the user is requesting
* @returns number of minutes offset from UTC
*/
const getUTCOffset = (date: Date, timezone: string): number => {
const utcDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60 * 1000));
// locale of en-CA is required for proper locale format
let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T');
iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`;
const target = new Date(`${iso}Z`);
return (target.getTime() - utcDate.getTime()) / 60 / 1000;
}