Merge branch 'blakeblackshear:dev' into dev

This commit is contained in:
Remon Nashid 2024-04-20 06:55:09 -06:00 committed by GitHub
commit 704217793f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
87 changed files with 5844 additions and 1043 deletions

View File

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

View File

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

View File

@ -2,11 +2,11 @@ click == 8.1.*
Flask == 3.0.*
imutils == 0.5.*
markupsafe == 2.1.*
matplotlib == 3.7.*
matplotlib == 3.8.*
mypy == 1.6.1
numpy == 1.26.*
onvif_zeep == 0.2.12
opencv-python-headless == 4.7.0.*
opencv-python-headless == 4.9.0.*
paho-mqtt == 2.0.*
pandas == 2.2.*
peewee == 3.17.*
@ -16,13 +16,13 @@ pydantic == 2.7.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
PyYAML == 6.0.*
pytz == 2024.1
pyzmq == 25.1.*
pyzmq == 26.0.*
ruamel.yaml == 0.18.*
tzlocal == 5.2
types-PyYAML == 6.0.*
requests == 2.31.*
types-requests == 2.31.*
scipy == 1.11.*
scipy == 1.13.*
norfair == 2.2.*
setproctitle == 1.3.*
ws4py == 0.5.*

View File

@ -607,8 +607,6 @@ ui:
live_mode: mse
# Optional: Set a timezone to use in the UI (default: use browser local time)
# timezone: America/Denver
# Optional: Use an experimental recordings / camera view UI (default: shown below)
use_experimental: False
# Optional: Set the time format used.
# Options are browser, 12hour, or 24hour (default: shown below)
time_format: browser

604
docs/package-lock.json generated
View File

@ -8,9 +8,9 @@
"name": "docs",
"version": "0.0.0",
"dependencies": {
"@docusaurus/core": "^3.1.1",
"@docusaurus/preset-classic": "^3.1.1",
"@docusaurus/theme-mermaid": "^3.1.1",
"@docusaurus/core": "^3.2.1",
"@docusaurus/preset-classic": "^3.2.1",
"@docusaurus/theme-mermaid": "^3.2.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.1.0",
@ -19,9 +19,9 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.0.0",
"@docusaurus/types": "^3.0.0",
"@types/react": "^18.2.67"
"@docusaurus/module-type-aliases": "^3.2.1",
"@docusaurus/types": "^3.2.1",
"@types/react": "^18.2.79"
},
"engines": {
"node": ">=18.0"
@ -69,74 +69,74 @@
}
},
"node_modules/@algolia/cache-browser-local-storage": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.22.1.tgz",
"integrity": "sha512-Sw6IAmOCvvP6QNgY9j+Hv09mvkvEIDKjYW8ow0UDDAxSXy664RBNQk3i/0nt7gvceOJ6jGmOTimaZoY1THmU7g==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.23.3.tgz",
"integrity": "sha512-vRHXYCpPlTDE7i6UOy2xE03zHF2C8MEFjPN2v7fRbqVpcOvAUQK81x3Kc21xyb5aSIpYCjWCZbYZuz8Glyzyyg==",
"dependencies": {
"@algolia/cache-common": "4.22.1"
"@algolia/cache-common": "4.23.3"
}
},
"node_modules/@algolia/cache-common": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.22.1.tgz",
"integrity": "sha512-TJMBKqZNKYB9TptRRjSUtevJeQVXRmg6rk9qgFKWvOy8jhCPdyNZV1nB3SKGufzvTVbomAukFR8guu/8NRKBTA=="
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.23.3.tgz",
"integrity": "sha512-h9XcNI6lxYStaw32pHpB1TMm0RuxphF+Ik4o7tcQiodEdpKK+wKufY6QXtba7t3k8eseirEMVB83uFFF3Nu54A=="
},
"node_modules/@algolia/cache-in-memory": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.22.1.tgz",
"integrity": "sha512-ve+6Ac2LhwpufuWavM/aHjLoNz/Z/sYSgNIXsinGofWOysPilQZPUetqLj8vbvi+DHZZaYSEP9H5SRVXnpsNNw==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.23.3.tgz",
"integrity": "sha512-yvpbuUXg/+0rbcagxNT7un0eo3czx2Uf0y4eiR4z4SD7SiptwYTpbuS0IHxcLHG3lq22ukx1T6Kjtk/rT+mqNg==",
"dependencies": {
"@algolia/cache-common": "4.22.1"
"@algolia/cache-common": "4.23.3"
}
},
"node_modules/@algolia/client-account": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.22.1.tgz",
"integrity": "sha512-k8m+oegM2zlns/TwZyi4YgCtyToackkOpE+xCaKCYfBfDtdGOaVZCM5YvGPtK+HGaJMIN/DoTL8asbM3NzHonw==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.23.3.tgz",
"integrity": "sha512-hpa6S5d7iQmretHHF40QGq6hz0anWEHGlULcTIT9tbUssWUriN9AUXIFQ8Ei4w9azD0hc1rUok9/DeQQobhQMA==",
"dependencies": {
"@algolia/client-common": "4.22.1",
"@algolia/client-search": "4.22.1",
"@algolia/transporter": "4.22.1"
"@algolia/client-common": "4.23.3",
"@algolia/client-search": "4.23.3",
"@algolia/transporter": "4.23.3"
}
},
"node_modules/@algolia/client-analytics": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.22.1.tgz",
"integrity": "sha512-1ssi9pyxyQNN4a7Ji9R50nSdISIumMFDwKNuwZipB6TkauJ8J7ha/uO60sPJFqQyqvvI+px7RSNRQT3Zrvzieg==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.23.3.tgz",
"integrity": "sha512-LBsEARGS9cj8VkTAVEZphjxTjMVCci+zIIiRhpFun9jGDUlS1XmhCW7CTrnaWeIuCQS/2iPyRqSy1nXPjcBLRA==",
"dependencies": {
"@algolia/client-common": "4.22.1",
"@algolia/client-search": "4.22.1",
"@algolia/requester-common": "4.22.1",
"@algolia/transporter": "4.22.1"
"@algolia/client-common": "4.23.3",
"@algolia/client-search": "4.23.3",
"@algolia/requester-common": "4.23.3",
"@algolia/transporter": "4.23.3"
}
},
"node_modules/@algolia/client-common": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.22.1.tgz",
"integrity": "sha512-IvaL5v9mZtm4k4QHbBGDmU3wa/mKokmqNBqPj0K7lcR8ZDKzUorhcGp/u8PkPC/e0zoHSTvRh7TRkGX3Lm7iOQ==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.23.3.tgz",
"integrity": "sha512-l6EiPxdAlg8CYhroqS5ybfIczsGUIAC47slLPOMDeKSVXYG1n0qGiz4RjAHLw2aD0xzh2EXZ7aRguPfz7UKDKw==",
"dependencies": {
"@algolia/requester-common": "4.22.1",
"@algolia/transporter": "4.22.1"
"@algolia/requester-common": "4.23.3",
"@algolia/transporter": "4.23.3"
}
},
"node_modules/@algolia/client-personalization": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.22.1.tgz",
"integrity": "sha512-sl+/klQJ93+4yaqZ7ezOttMQ/nczly/3GmgZXJ1xmoewP5jmdP/X/nV5U7EHHH3hCUEHeN7X1nsIhGPVt9E1cQ==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.23.3.tgz",
"integrity": "sha512-3E3yF3Ocr1tB/xOZiuC3doHQBQ2zu2MPTYZ0d4lpfWads2WTKG7ZzmGnsHmm63RflvDeLK/UVx7j2b3QuwKQ2g==",
"dependencies": {
"@algolia/client-common": "4.22.1",
"@algolia/requester-common": "4.22.1",
"@algolia/transporter": "4.22.1"
"@algolia/client-common": "4.23.3",
"@algolia/requester-common": "4.23.3",
"@algolia/transporter": "4.23.3"
}
},
"node_modules/@algolia/client-search": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.22.1.tgz",
"integrity": "sha512-yb05NA4tNaOgx3+rOxAmFztgMTtGBi97X7PC3jyNeGiwkAjOZc2QrdZBYyIdcDLoI09N0gjtpClcackoTN0gPA==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.23.3.tgz",
"integrity": "sha512-P4VAKFHqU0wx9O+q29Q8YVuaowaZ5EM77rxfmGnkHUJggh28useXQdopokgwMeYw2XUht49WX5RcTQ40rZIabw==",
"dependencies": {
"@algolia/client-common": "4.22.1",
"@algolia/requester-common": "4.22.1",
"@algolia/transporter": "4.22.1"
"@algolia/client-common": "4.23.3",
"@algolia/requester-common": "4.23.3",
"@algolia/transporter": "4.23.3"
}
},
"node_modules/@algolia/events": {
@ -145,47 +145,65 @@
"integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ=="
},
"node_modules/@algolia/logger-common": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.22.1.tgz",
"integrity": "sha512-OnTFymd2odHSO39r4DSWRFETkBufnY2iGUZNrMXpIhF5cmFE8pGoINNPzwg02QLBlGSaLqdKy0bM8S0GyqPLBg=="
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.23.3.tgz",
"integrity": "sha512-y9kBtmJwiZ9ZZ+1Ek66P0M68mHQzKRxkW5kAAXYN/rdzgDN0d2COsViEFufxJ0pb45K4FRcfC7+33YB4BLrZ+g=="
},
"node_modules/@algolia/logger-console": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.22.1.tgz",
"integrity": "sha512-O99rcqpVPKN1RlpgD6H3khUWylU24OXlzkavUAMy6QZd1776QAcauE3oP8CmD43nbaTjBexZj2nGsBH9Tc0FVA==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.23.3.tgz",
"integrity": "sha512-8xoiseoWDKuCVnWP8jHthgaeobDLolh00KJAdMe9XPrWPuf1by732jSpgy2BlsLTaT9m32pHI8CRfrOqQzHv3A==",
"dependencies": {
"@algolia/logger-common": "4.22.1"
"@algolia/logger-common": "4.23.3"
}
},
"node_modules/@algolia/recommend": {
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.23.3.tgz",
"integrity": "sha512-9fK4nXZF0bFkdcLBRDexsnGzVmu4TSYZqxdpgBW2tEyfuSSY54D4qSRkLmNkrrz4YFvdh2GM1gA8vSsnZPR73w==",
"dependencies": {
"@algolia/cache-browser-local-storage": "4.23.3",
"@algolia/cache-common": "4.23.3",
"@algolia/cache-in-memory": "4.23.3",
"@algolia/client-common": "4.23.3",
"@algolia/client-search": "4.23.3",
"@algolia/logger-common": "4.23.3",
"@algolia/logger-console": "4.23.3",
"@algolia/requester-browser-xhr": "4.23.3",
"@algolia/requester-common": "4.23.3",
"@algolia/requester-node-http": "4.23.3",
"@algolia/transporter": "4.23.3"
}
},
"node_modules/@algolia/requester-browser-xhr": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.22.1.tgz",
"integrity": "sha512-dtQGYIg6MteqT1Uay3J/0NDqD+UciHy3QgRbk7bNddOJu+p3hzjTRYESqEnoX/DpEkaNYdRHUKNylsqMpgwaEw==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.23.3.tgz",
"integrity": "sha512-jDWGIQ96BhXbmONAQsasIpTYWslyjkiGu0Quydjlowe+ciqySpiDUrJHERIRfELE5+wFc7hc1Q5hqjGoV7yghw==",
"dependencies": {
"@algolia/requester-common": "4.22.1"
"@algolia/requester-common": "4.23.3"
}
},
"node_modules/@algolia/requester-common": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.22.1.tgz",
"integrity": "sha512-dgvhSAtg2MJnR+BxrIFqlLtkLlVVhas9HgYKMk2Uxiy5m6/8HZBL40JVAMb2LovoPFs9I/EWIoFVjOrFwzn5Qg=="
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.23.3.tgz",
"integrity": "sha512-xloIdr/bedtYEGcXCiF2muajyvRhwop4cMZo+K2qzNht0CMzlRkm8YsDdj5IaBhshqfgmBb3rTg4sL4/PpvLYw=="
},
"node_modules/@algolia/requester-node-http": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.22.1.tgz",
"integrity": "sha512-JfmZ3MVFQkAU+zug8H3s8rZ6h0ahHZL/SpMaSasTCGYR5EEJsCc8SI5UZ6raPN2tjxa5bxS13BRpGSBUens7EA==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.23.3.tgz",
"integrity": "sha512-zgu++8Uj03IWDEJM3fuNl34s746JnZOWn1Uz5taV1dFyJhVM/kTNw9Ik7YJWiUNHJQXcaD8IXD1eCb0nq/aByA==",
"dependencies": {
"@algolia/requester-common": "4.22.1"
"@algolia/requester-common": "4.23.3"
}
},
"node_modules/@algolia/transporter": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.22.1.tgz",
"integrity": "sha512-kzWgc2c9IdxMa3YqA6TN0NW5VrKYYW/BELIn7vnLyn+U/RFdZ4lxxt9/8yq3DKV5snvoDzzO4ClyejZRdV3lMQ==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.23.3.tgz",
"integrity": "sha512-Wjl5gttqnf/gQKJA+dafnD0Y6Yw97yvfY8R9h0dQltX1GXTgNs1zWgvtWW0tHl1EgMdhAyw189uWiZMnL3QebQ==",
"dependencies": {
"@algolia/cache-common": "4.22.1",
"@algolia/logger-common": "4.22.1",
"@algolia/requester-common": "4.22.1"
"@algolia/cache-common": "4.23.3",
"@algolia/logger-common": "4.23.3",
"@algolia/requester-common": "4.23.3"
}
},
"node_modules/@ampproject/remapping": {
@ -2161,18 +2179,18 @@
}
},
"node_modules/@docsearch/css": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.5.2.tgz",
"integrity": "sha512-SPiDHaWKQZpwR2siD0KQUwlStvIAnEyK6tAE2h2Wuoq8ue9skzhlyVQ1ddzOxX6khULnAALDiR/isSF3bnuciA=="
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.6.0.tgz",
"integrity": "sha512-+sbxb71sWre+PwDK7X2T8+bhS6clcVMLwBPznX45Qu6opJcgRjAp7gYSDzVFp187J+feSj5dNBN1mJoi6ckkUQ=="
},
"node_modules/@docsearch/react": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.5.2.tgz",
"integrity": "sha512-9Ahcrs5z2jq/DcAvYtvlqEBHImbm4YJI8M9y0x6Tqg598P40HTEkX7hsMcIuThI+hTFxRGZ9hll0Wygm2yEjng==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.6.0.tgz",
"integrity": "sha512-HUFut4ztcVNmqy9gp/wxNbC7pTOHhgVVkHVGCACTuLhUKUhKAF9KYHJtMiLUJxEqiFLQiuri1fWF8zqwM/cu1w==",
"dependencies": {
"@algolia/autocomplete-core": "1.9.3",
"@algolia/autocomplete-preset-algolia": "1.9.3",
"@docsearch/css": "3.5.2",
"@docsearch/css": "3.6.0",
"algoliasearch": "^4.19.1"
},
"peerDependencies": {
@ -2197,9 +2215,9 @@
}
},
"node_modules/@docusaurus/core": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.1.1.tgz",
"integrity": "sha512-2nQfKFcf+MLEM7JXsXwQxPOmQAR6ytKMZVSx7tVi9HEm9WtfwBH1fp6bn8Gj4zLUhjWKCLoysQ9/Wm+EZCQ4yQ==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.2.1.tgz",
"integrity": "sha512-ZeMAqNvy0eBv2dThEeMuNzzuu+4thqMQakhxsgT5s02A8LqRcdkg+rbcnuNqUIpekQ4GRx3+M5nj0ODJhBXo9w==",
"dependencies": {
"@babel/core": "^7.23.3",
"@babel/generator": "^7.23.3",
@ -2211,14 +2229,13 @@
"@babel/runtime": "^7.22.6",
"@babel/runtime-corejs3": "^7.22.6",
"@babel/traverse": "^7.22.8",
"@docusaurus/cssnano-preset": "3.1.1",
"@docusaurus/logger": "3.1.1",
"@docusaurus/mdx-loader": "3.1.1",
"@docusaurus/cssnano-preset": "3.2.1",
"@docusaurus/logger": "3.2.1",
"@docusaurus/mdx-loader": "3.2.1",
"@docusaurus/react-loadable": "5.5.2",
"@docusaurus/utils": "3.1.1",
"@docusaurus/utils-common": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@slorber/static-site-generator-webpack-plugin": "^4.0.7",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-common": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"@svgr/webpack": "^6.5.1",
"autoprefixer": "^10.4.14",
"babel-loader": "^9.1.3",
@ -2239,6 +2256,7 @@
"detect-port": "^1.5.1",
"escape-html": "^1.0.3",
"eta": "^2.2.0",
"eval": "^0.1.8",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.1",
"html-minifier-terser": "^7.2.0",
@ -2247,6 +2265,7 @@
"leven": "^3.1.0",
"lodash": "^4.17.21",
"mini-css-extract-plugin": "^2.7.6",
"p-map": "^4.0.0",
"postcss": "^8.4.26",
"postcss-loader": "^7.3.3",
"prompts": "^2.4.2",
@ -2283,9 +2302,9 @@
}
},
"node_modules/@docusaurus/cssnano-preset": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.1.1.tgz",
"integrity": "sha512-LnoIDjJWbirdbVZDMq+4hwmrTl2yHDnBf9MLG9qyExeAE3ac35s4yUhJI8yyTCdixzNfKit4cbXblzzqMu4+8g==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.2.1.tgz",
"integrity": "sha512-wTL9KuSSbMJjKrfu385HZEzAoamUsbKqwscAQByZw4k6Ja/RWpqgVvt/CbAC+aYEH6inLzOt+MjuRwMOrD3VBA==",
"dependencies": {
"cssnano-preset-advanced": "^5.3.10",
"postcss": "^8.4.26",
@ -2297,9 +2316,9 @@
}
},
"node_modules/@docusaurus/logger": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.1.1.tgz",
"integrity": "sha512-BjkNDpQzewcTnST8trx4idSoAla6zZ3w22NqM/UMcFtvYJgmoE4layuTzlfql3VFPNuivvj7BOExa/+21y4X2Q==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.2.1.tgz",
"integrity": "sha512-0voOKJCn9RaM3np6soqEfo7SsVvf2C+CDTWhW+H/1AyBhybASpExtDEz+7ECck9TwPzFQ5tt+I3zVugUJbJWDg==",
"dependencies": {
"chalk": "^4.1.2",
"tslib": "^2.6.0"
@ -2309,15 +2328,13 @@
}
},
"node_modules/@docusaurus/mdx-loader": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.1.1.tgz",
"integrity": "sha512-xN2IccH9+sv7TmxwsDJNS97BHdmlqWwho+kIVY4tcCXkp+k4QuzvWBeunIMzeayY4Fu13A6sAjHGv5qm72KyGA==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.2.1.tgz",
"integrity": "sha512-Fs8tXhXKZjNkdGaOy1xSLXSwfjCMT73J3Zfrju2U16uGedRFRjgK0ojpK5tiC7TnunsL3tOFgp1BSMBRflX9gw==",
"dependencies": {
"@babel/parser": "^7.22.7",
"@babel/traverse": "^7.22.8",
"@docusaurus/logger": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/logger": "3.2.1",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"@mdx-js/mdx": "^3.0.0",
"@slorber/remark-comment": "^1.0.0",
"escape-html": "^1.0.3",
@ -2349,12 +2366,12 @@
}
},
"node_modules/@docusaurus/module-type-aliases": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.1.1.tgz",
"integrity": "sha512-xBJyx0TMfAfVZ9ZeIOb1awdXgR4YJMocIEzTps91rq+hJDFJgJaylDtmoRhUxkwuYmNK1GJpW95b7DLztSBJ3A==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.2.1.tgz",
"integrity": "sha512-FyViV5TqhL1vsM7eh29nJ5NtbRE6Ra6LP1PDcPvhwPSlA7eiWGRKAn3jWwMUcmjkos5SYY+sr0/feCdbM3eQHQ==",
"dependencies": {
"@docusaurus/react-loadable": "5.5.2",
"@docusaurus/types": "3.1.1",
"@docusaurus/types": "3.2.1",
"@types/history": "^4.7.11",
"@types/react": "*",
"@types/react-router-config": "*",
@ -2368,17 +2385,17 @@
}
},
"node_modules/@docusaurus/plugin-content-blog": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.1.1.tgz",
"integrity": "sha512-ew/3VtVoG3emoAKmoZl7oKe1zdFOsI0NbcHS26kIxt2Z8vcXKCUgK9jJJrz0TbOipyETPhqwq4nbitrY3baibg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.2.1.tgz",
"integrity": "sha512-lOx0JfhlGZoZu6pEJfeEpSISZR5dQbJGGvb42IP13G5YThNHhG9R9uoWuo4IOimPqBC7sHThdLA3VLevk61Fsw==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/logger": "3.1.1",
"@docusaurus/mdx-loader": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/utils-common": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/logger": "3.2.1",
"@docusaurus/mdx-loader": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-common": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"cheerio": "^1.0.0-rc.12",
"feed": "^4.2.2",
"fs-extra": "^11.1.1",
@ -2399,17 +2416,18 @@
}
},
"node_modules/@docusaurus/plugin-content-docs": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.1.1.tgz",
"integrity": "sha512-lhFq4E874zw0UOH7ujzxnCayOyAt0f9YPVYSb9ohxrdCM8B4szxitUw9rIX4V9JLLHVoqIJb6k+lJJ1jrcGJ0A==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.2.1.tgz",
"integrity": "sha512-GHe5b/lCskAR8QVbfWAfPAApvRZgqk7FN3sOHgjCtjzQACZxkHmq6QqyqZ8Jp45V7lVck4wt2Xw2IzBJ7Cz3bA==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/logger": "3.1.1",
"@docusaurus/mdx-loader": "3.1.1",
"@docusaurus/module-type-aliases": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/logger": "3.2.1",
"@docusaurus/mdx-loader": "3.2.1",
"@docusaurus/module-type-aliases": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-common": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"@types/react-router-config": "^5.0.7",
"combine-promises": "^1.1.0",
"fs-extra": "^11.1.1",
@ -2428,15 +2446,15 @@
}
},
"node_modules/@docusaurus/plugin-content-pages": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.1.1.tgz",
"integrity": "sha512-NQHncNRAJbyLtgTim9GlEnNYsFhuCxaCNkMwikuxLTiGIPH7r/jpb7O3f3jUMYMebZZZrDq5S7om9a6rvB/YCA==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.2.1.tgz",
"integrity": "sha512-TOqVfMVTAHqWNEGM94Drz+PUpHDbwFy6ucHFgyTx9zJY7wPNSG5EN+rd/mU7OvAi26qpOn2o9xTdUmb28QLjEQ==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/mdx-loader": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/mdx-loader": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"fs-extra": "^11.1.1",
"tslib": "^2.6.0",
"webpack": "^5.88.1"
@ -2450,13 +2468,13 @@
}
},
"node_modules/@docusaurus/plugin-debug": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.1.1.tgz",
"integrity": "sha512-xWeMkueM9wE/8LVvl4+Qf1WqwXmreMjI5Kgr7GYCDoJ8zu4kD+KaMhrh7py7MNM38IFvU1RfrGKacCEe2DRRfQ==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.2.1.tgz",
"integrity": "sha512-AMKq8NuUKf2sRpN1m/sIbqbRbnmk+rSA+8mNU1LNxEl9BW9F/Gng8m9HKlzeyMPrf5XidzS1jqkuTLDJ6KIrFw==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils": "3.2.1",
"fs-extra": "^11.1.1",
"react-json-view-lite": "^1.2.0",
"tslib": "^2.6.0"
@ -2470,13 +2488,13 @@
}
},
"node_modules/@docusaurus/plugin-google-analytics": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.1.1.tgz",
"integrity": "sha512-+q2UpWTqVi8GdlLoSlD5bS/YpxW+QMoBwrPrUH/NpvpuOi0Of7MTotsQf9JWd3hymZxl2uu1o3PIrbpxfeDFDQ==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.2.1.tgz",
"integrity": "sha512-/rJ+9u+Px0eTCiF4TNcNtj3kHf8cp6K1HCwOTdbsSlz6Xn21syZYcy+f1VM9wF6HrvUkXUcbM5TDCvg2IRL6bQ==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"tslib": "^2.6.0"
},
"engines": {
@ -2488,13 +2506,13 @@
}
},
"node_modules/@docusaurus/plugin-google-gtag": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.1.1.tgz",
"integrity": "sha512-0mMPiBBlQ5LFHTtjxuvt/6yzh8v7OxLi3CbeEsxXZpUzcKO/GC7UA1VOWUoBeQzQL508J12HTAlR3IBU9OofSw==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.2.1.tgz",
"integrity": "sha512-XtuJnlMvYfppeVdUyKiDIJAa/gTJKCQU92z8CLZZ9ibJdgVjFOLS10s0hIC0eL5z0U2u2loJz2rZ63HOkNHbBA==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"@types/gtag.js": "^0.0.12",
"tslib": "^2.6.0"
},
@ -2507,13 +2525,13 @@
}
},
"node_modules/@docusaurus/plugin-google-tag-manager": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.1.1.tgz",
"integrity": "sha512-d07bsrMLdDIryDtY17DgqYUbjkswZQr8cLWl4tzXrt5OR/T/zxC1SYKajzB3fd87zTu5W5klV5GmUwcNSMXQXA==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.2.1.tgz",
"integrity": "sha512-wiS/kE0Ny5pnjTxVCs8ljRnkL1RVMj59t6jmSsgEX7piDOoaXSMIUaoIt9ogS/v132uO0xEsxHstkRUZHQyPcQ==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"tslib": "^2.6.0"
},
"engines": {
@ -2525,16 +2543,16 @@
}
},
"node_modules/@docusaurus/plugin-sitemap": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.1.1.tgz",
"integrity": "sha512-iJ4hCaMmDaUqRv131XJdt/C/jJQx8UreDWTRqZKtNydvZVh/o4yXGRRFOplea1D9b/zpwL1Y+ZDwX7xMhIOTmg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.2.1.tgz",
"integrity": "sha512-uWZ7AxzdeaQSTCwD2yZtOiEm9zyKU+wqCmi/Sf25kQQqqFSBZUStXfaQ8OHP9cecnw893ZpZ811rPhB/wfujJw==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/logger": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/utils-common": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/logger": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-common": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"fs-extra": "^11.1.1",
"sitemap": "^7.1.1",
"tslib": "^2.6.0"
@ -2548,23 +2566,23 @@
}
},
"node_modules/@docusaurus/preset-classic": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.1.1.tgz",
"integrity": "sha512-jG4ys/hWYf69iaN/xOmF+3kjs4Nnz1Ay3CjFLDtYa8KdxbmUhArA9HmP26ru5N0wbVWhY+6kmpYhTJpez5wTyg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.2.1.tgz",
"integrity": "sha512-E3OHSmttpEBcSMhfPBq3EJMBxZBM01W1rnaCUTXy9EHvkmB5AwgTfW1PwGAybPAX579ntE03R+2zmXdizWfKnQ==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/plugin-content-blog": "3.1.1",
"@docusaurus/plugin-content-docs": "3.1.1",
"@docusaurus/plugin-content-pages": "3.1.1",
"@docusaurus/plugin-debug": "3.1.1",
"@docusaurus/plugin-google-analytics": "3.1.1",
"@docusaurus/plugin-google-gtag": "3.1.1",
"@docusaurus/plugin-google-tag-manager": "3.1.1",
"@docusaurus/plugin-sitemap": "3.1.1",
"@docusaurus/theme-classic": "3.1.1",
"@docusaurus/theme-common": "3.1.1",
"@docusaurus/theme-search-algolia": "3.1.1",
"@docusaurus/types": "3.1.1"
"@docusaurus/core": "3.2.1",
"@docusaurus/plugin-content-blog": "3.2.1",
"@docusaurus/plugin-content-docs": "3.2.1",
"@docusaurus/plugin-content-pages": "3.2.1",
"@docusaurus/plugin-debug": "3.2.1",
"@docusaurus/plugin-google-analytics": "3.2.1",
"@docusaurus/plugin-google-gtag": "3.2.1",
"@docusaurus/plugin-google-tag-manager": "3.2.1",
"@docusaurus/plugin-sitemap": "3.2.1",
"@docusaurus/theme-classic": "3.2.1",
"@docusaurus/theme-common": "3.2.1",
"@docusaurus/theme-search-algolia": "3.2.1",
"@docusaurus/types": "3.2.1"
},
"engines": {
"node": ">=18.0"
@ -2587,22 +2605,22 @@
}
},
"node_modules/@docusaurus/theme-classic": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.1.1.tgz",
"integrity": "sha512-GiPE/jbWM8Qv1A14lk6s9fhc0LhPEQ00eIczRO4QL2nAQJZXkjPG6zaVx+1cZxPFWbAsqSjKe2lqkwF3fGkQ7Q==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.2.1.tgz",
"integrity": "sha512-+vSbnQyoWjc6vRZi4vJO2dBU02wqzynsai15KK+FANZudrYaBHtkbLZAQhgmxzBGVpxzi87gRohlMm+5D8f4tA==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/mdx-loader": "3.1.1",
"@docusaurus/module-type-aliases": "3.1.1",
"@docusaurus/plugin-content-blog": "3.1.1",
"@docusaurus/plugin-content-docs": "3.1.1",
"@docusaurus/plugin-content-pages": "3.1.1",
"@docusaurus/theme-common": "3.1.1",
"@docusaurus/theme-translations": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/utils-common": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/mdx-loader": "3.2.1",
"@docusaurus/module-type-aliases": "3.2.1",
"@docusaurus/plugin-content-blog": "3.2.1",
"@docusaurus/plugin-content-docs": "3.2.1",
"@docusaurus/plugin-content-pages": "3.2.1",
"@docusaurus/theme-common": "3.2.1",
"@docusaurus/theme-translations": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-common": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"copy-text-to-clipboard": "^3.2.0",
@ -2626,17 +2644,17 @@
}
},
"node_modules/@docusaurus/theme-common": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.1.1.tgz",
"integrity": "sha512-38urZfeMhN70YaXkwIGXmcUcv2CEYK/2l4b05GkJPrbEbgpsIZM3Xc+Js2ehBGGZmfZq8GjjQ5RNQYG+MYzCYg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.2.1.tgz",
"integrity": "sha512-d+adiD7L9xv6EvfaAwUqdKf4orsM3jqgeqAM+HAjgL/Ux0GkVVnfKr+tsoe+4ow4rHe6NUt+nkkW8/K8dKdilA==",
"dependencies": {
"@docusaurus/mdx-loader": "3.1.1",
"@docusaurus/module-type-aliases": "3.1.1",
"@docusaurus/plugin-content-blog": "3.1.1",
"@docusaurus/plugin-content-docs": "3.1.1",
"@docusaurus/plugin-content-pages": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/utils-common": "3.1.1",
"@docusaurus/mdx-loader": "3.2.1",
"@docusaurus/module-type-aliases": "3.2.1",
"@docusaurus/plugin-content-blog": "3.2.1",
"@docusaurus/plugin-content-docs": "3.2.1",
"@docusaurus/plugin-content-pages": "3.2.1",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-common": "3.2.1",
"@types/history": "^4.7.11",
"@types/react": "*",
"@types/react-router-config": "*",
@ -2655,15 +2673,15 @@
}
},
"node_modules/@docusaurus/theme-mermaid": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.1.1.tgz",
"integrity": "sha512-O6u9/7QX/ZapV4HJJSzNs0Jir1KA/LRLORWYeDvbGswqZNusj6q4iLELrKIClysJ3PB3zWUzyKtI/wjIKiV1vA==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.2.1.tgz",
"integrity": "sha512-l1FzUPgDUor/25XeJDeO22dttmzB0QnmAbF2qKjDz3ENa9vlD5rd5r0NrItZIe8y7qoa+OOxkl5lLBKBxBVbLg==",
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/module-type-aliases": "3.1.1",
"@docusaurus/theme-common": "3.1.1",
"@docusaurus/types": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/module-type-aliases": "3.2.1",
"@docusaurus/theme-common": "3.2.1",
"@docusaurus/types": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"mermaid": "^10.4.0",
"tslib": "^2.6.0"
},
@ -2676,18 +2694,18 @@
}
},
"node_modules/@docusaurus/theme-search-algolia": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.1.1.tgz",
"integrity": "sha512-tBH9VY5EpRctVdaAhT+b1BY8y5dyHVZGFXyCHgTrvcXQy5CV4q7serEX7U3SveNT9zksmchPyct6i1sFDC4Z5g==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.2.1.tgz",
"integrity": "sha512-bzhCrpyXBXzeydNUH83II2akvFEGfhsNTPPWsk5N7e+odgQCQwoHhcF+2qILbQXjaoZ6B3c48hrvkyCpeyqGHw==",
"dependencies": {
"@docsearch/react": "^3.5.2",
"@docusaurus/core": "3.1.1",
"@docusaurus/logger": "3.1.1",
"@docusaurus/plugin-content-docs": "3.1.1",
"@docusaurus/theme-common": "3.1.1",
"@docusaurus/theme-translations": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/utils-validation": "3.1.1",
"@docusaurus/core": "3.2.1",
"@docusaurus/logger": "3.2.1",
"@docusaurus/plugin-content-docs": "3.2.1",
"@docusaurus/theme-common": "3.2.1",
"@docusaurus/theme-translations": "3.2.1",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-validation": "3.2.1",
"algoliasearch": "^4.18.0",
"algoliasearch-helper": "^3.13.3",
"clsx": "^2.0.0",
@ -2706,9 +2724,9 @@
}
},
"node_modules/@docusaurus/theme-translations": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.1.1.tgz",
"integrity": "sha512-xvWQFwjxHphpJq5fgk37FXCDdAa2o+r7FX8IpMg+bGZBNXyWBu3MjZ+G4+eUVNpDhVinTc+j6ucL0Ain5KCGrg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.2.1.tgz",
"integrity": "sha512-jAUMkIkFfY+OAhJhv6mV8zlwY6J4AQxJPTgLdR2l+Otof9+QdJjHNh/ifVEu9q0lp3oSPlJj9l05AaP7Ref+cg==",
"dependencies": {
"fs-extra": "^11.1.1",
"tslib": "^2.6.0"
@ -2718,9 +2736,9 @@
}
},
"node_modules/@docusaurus/types": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.1.1.tgz",
"integrity": "sha512-grBqOLnubUecgKFXN9q3uit2HFbCxTWX4Fam3ZFbMN0sWX9wOcDoA7lwdX/8AmeL20Oc4kQvWVgNrsT8bKRvzg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.2.1.tgz",
"integrity": "sha512-n/toxBzL2oxTtRTOFiGKsHypzn/Pm+sXyw+VSk1UbqbXQiHOwHwts55bpKwbcUgA530Is6kix3ELiFOv9GAMfw==",
"dependencies": {
"@mdx-js/mdx": "^3.0.0",
"@types/history": "^4.7.11",
@ -2738,11 +2756,12 @@
}
},
"node_modules/@docusaurus/utils": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.1.1.tgz",
"integrity": "sha512-ZJfJa5cJQtRYtqijsPEnAZoduW6sjAQ7ZCWSZavLcV10Fw0Z3gSaPKA/B4micvj2afRZ4gZxT7KfYqe5H8Cetg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.2.1.tgz",
"integrity": "sha512-DPkIS/EPc+pGAV798PUXgNzJFM3HJouoQXgr0KDZuJVz1EkWbDLOcQwLIz8Qx7liI9ddfkN/TXTRQdsTPZNakw==",
"dependencies": {
"@docusaurus/logger": "3.1.1",
"@docusaurus/logger": "3.2.1",
"@docusaurus/utils-common": "3.2.1",
"@svgr/webpack": "^6.5.1",
"escape-string-regexp": "^4.0.0",
"file-loader": "^6.2.0",
@ -2754,6 +2773,7 @@
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"micromatch": "^4.0.5",
"prompts": "^2.4.2",
"resolve-pathname": "^3.0.0",
"shelljs": "^0.8.5",
"tslib": "^2.6.0",
@ -2773,9 +2793,9 @@
}
},
"node_modules/@docusaurus/utils-common": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.1.1.tgz",
"integrity": "sha512-eGne3olsIoNfPug5ixjepZAIxeYFzHHnor55Wb2P57jNbtVaFvij/T+MS8U0dtZRFi50QU+UPmRrXdVUM8uyMg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.2.1.tgz",
"integrity": "sha512-N5vadULnRLiqX2QfTjVEU3u5vo6RG2EZTdyXvJdzDOdrLCGIZAfnf/VkssinFZ922sVfaFfQ4FnStdhn5TWdVg==",
"dependencies": {
"tslib": "^2.6.0"
},
@ -2792,12 +2812,13 @@
}
},
"node_modules/@docusaurus/utils-validation": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.1.1.tgz",
"integrity": "sha512-KlY4P9YVDnwL+nExvlIpu79abfEv6ZCHuOX4ZQ+gtip+Wxj0daccdReIWWtqxM/Fb5Cz1nQvUCc7VEtT8IBUAA==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.2.1.tgz",
"integrity": "sha512-+x7IR9hNMXi62L1YAglwd0s95fR7+EtirjTxSN4kahYRWGqOi3jlQl1EV0az/yTEvKbxVvOPcdYicGu9dk4LJw==",
"dependencies": {
"@docusaurus/logger": "3.1.1",
"@docusaurus/utils": "3.1.1",
"@docusaurus/logger": "3.2.1",
"@docusaurus/utils": "3.2.1",
"@docusaurus/utils-common": "3.2.1",
"joi": "^17.9.2",
"js-yaml": "^4.1.0",
"tslib": "^2.6.0"
@ -3071,19 +3092,6 @@
"micromark-util-symbol": "^1.0.1"
}
},
"node_modules/@slorber/static-site-generator-webpack-plugin": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@slorber/static-site-generator-webpack-plugin/-/static-site-generator-webpack-plugin-4.0.7.tgz",
"integrity": "sha512-Ug7x6z5lwrz0WqdnNFOMYrDQNTPAprvHLSh6+/fmml3qUiz6l5eq+2MzLKWtn/q5K5NpSiFsZTP/fck/3vjSxA==",
"dependencies": {
"eval": "^0.1.8",
"p-map": "^4.0.0",
"webpack-sources": "^3.2.2"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.5.1.tgz",
@ -3599,12 +3607,11 @@
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
},
"node_modules/@types/react": {
"version": "18.2.67",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.67.tgz",
"integrity": "sha512-vkIE2vTIMHQ/xL0rgmuoECBCkZFZeHr49HeWSc24AptMbNRo7pwSBvj73rlJJs9fGKj0koS+V7kQB1jHS0uCgw==",
"version": "18.2.79",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
"csstype": "^3.0.2"
}
},
@ -3650,11 +3657,6 @@
"@types/node": "*"
}
},
"node_modules/@types/scheduler": {
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.6.tgz",
"integrity": "sha512-Vlktnchmkylvc9SnwwwozTv04L/e1NykF5vgoQ0XTmI8DD+wxfjQuHuvHS3p0r2jz2x2ghPs2h1FVeDirIteWA=="
},
"node_modules/@types/send": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
@ -3991,30 +3993,31 @@
}
},
"node_modules/algoliasearch": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.22.1.tgz",
"integrity": "sha512-jwydKFQJKIx9kIZ8Jm44SdpigFwRGPESaxZBaHSV0XWN2yBJAOT4mT7ppvlrpA4UGzz92pqFnVKr/kaZXrcreg==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.23.3.tgz",
"integrity": "sha512-Le/3YgNvjW9zxIQMRhUHuhiUjAlKY/zsdZpfq4dlLqg6mEm0nL6yk+7f2hDOtLpxsgE4jSzDmvHL7nXdBp5feg==",
"dependencies": {
"@algolia/cache-browser-local-storage": "4.22.1",
"@algolia/cache-common": "4.22.1",
"@algolia/cache-in-memory": "4.22.1",
"@algolia/client-account": "4.22.1",
"@algolia/client-analytics": "4.22.1",
"@algolia/client-common": "4.22.1",
"@algolia/client-personalization": "4.22.1",
"@algolia/client-search": "4.22.1",
"@algolia/logger-common": "4.22.1",
"@algolia/logger-console": "4.22.1",
"@algolia/requester-browser-xhr": "4.22.1",
"@algolia/requester-common": "4.22.1",
"@algolia/requester-node-http": "4.22.1",
"@algolia/transporter": "4.22.1"
"@algolia/cache-browser-local-storage": "4.23.3",
"@algolia/cache-common": "4.23.3",
"@algolia/cache-in-memory": "4.23.3",
"@algolia/client-account": "4.23.3",
"@algolia/client-analytics": "4.23.3",
"@algolia/client-common": "4.23.3",
"@algolia/client-personalization": "4.23.3",
"@algolia/client-search": "4.23.3",
"@algolia/logger-common": "4.23.3",
"@algolia/logger-console": "4.23.3",
"@algolia/recommend": "4.23.3",
"@algolia/requester-browser-xhr": "4.23.3",
"@algolia/requester-common": "4.23.3",
"@algolia/requester-node-http": "4.23.3",
"@algolia/transporter": "4.23.3"
}
},
"node_modules/algoliasearch-helper": {
"version": "3.16.3",
"resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.16.3.tgz",
"integrity": "sha512-1OuJT6sONAa9PxcOmWo5WCAT3jQSpCR9/m5Azujja7nhUQwAUDvaaAYrcmUySsrvHh74usZHbE3jFfGnWtZj8w==",
"version": "3.18.0",
"resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.18.0.tgz",
"integrity": "sha512-ZXvA8r6VG46V343jnIE7Tei8Xr0/9N8YhD27joC0BKxeogQyvNu7O37i510wA7FnrDjoa/tFhK90WUaBlkaqnw==",
"dependencies": {
"@algolia/events": "^4.0.1"
},
@ -4133,9 +4136,9 @@
}
},
"node_modules/autoprefixer": {
"version": "10.4.18",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz",
"integrity": "sha512-1DKbDfsr6KUElM6wg+0zRNkB/Q7WcKYAaK+pzXn+Xqmszm/5Xa9coeNdtP88Vi+dPzZnMjhge8GIV49ZQkDa+g==",
"version": "10.4.19",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
"funding": [
{
"type": "opencollective",
@ -4152,7 +4155,7 @@
],
"dependencies": {
"browserslist": "^4.23.0",
"caniuse-lite": "^1.0.30001591",
"caniuse-lite": "^1.0.30001599",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.0.0",
@ -4535,9 +4538,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001594",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001594.tgz",
"integrity": "sha512-VblSX6nYqyJVs8DKFMldE2IVCJjZ225LW00ydtUWwh5hk9IfkTOffO6r8gJNsH0qqqeAF8KrbMYA2VEwTlGW5g==",
"version": "1.0.30001611",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001611.tgz",
"integrity": "sha512-19NuN1/3PjA3QI8Eki55N8my4LzfkMCRLgCVfrl/slbSAchQfV0+GwjPrK3rq37As4UCLlM/DHajbKkAqbv92Q==",
"funding": [
{
"type": "opencollective",
@ -6520,16 +6523,13 @@
}
},
"node_modules/estree-util-value-to-estree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.0.1.tgz",
"integrity": "sha512-b2tdzTurEIbwRh+mKrEcaWfu1wgb8J1hVsgREg7FFiecWwK/PhO8X0kyc+0bIcKNtD4sqxIdNoRy6/p/TvECEA==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.1.1.tgz",
"integrity": "sha512-5mvUrF2suuv5f5cGDnDphIy4/gW86z82kl5qG6mM9z04SEQI4FB5Apmaw/TGEf3l55nLtMs5s51dmhUzvAHQCA==",
"dependencies": {
"@types/estree": "^1.0.0",
"is-plain-obj": "^4.0.0"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/remcohaszing"
}
@ -13089,9 +13089,9 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-json-view-lite": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-1.2.1.tgz",
"integrity": "sha512-Itc0g86fytOmKZoIoJyGgvNqohWSbh3NXIKNgH6W6FT9PC1ck4xas1tT3Rr/b3UlFXyA9Jjaw9QSXdZy2JwGMQ==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-1.3.0.tgz",
"integrity": "sha512-aN1biKC5v4DQkmQBlZjuMFR09MKZGMPtIg+cut8zEeg2HXd6gl2gRy0n4HMacHf0dznQgo0SVXN7eT8zV3hEuQ==",
"engines": {
"node": ">=14"
},

View File

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

View File

@ -15,6 +15,7 @@ from peewee import operator
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.event import EventBp
from frigate.api.export import ExportBp
from frigate.api.media import MediaBp
from frigate.api.preview import PreviewBp
from frigate.api.review import ReviewBp
@ -39,6 +40,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint("frigate", __name__)
bp.register_blueprint(EventBp)
bp.register_blueprint(ExportBp)
bp.register_blueprint(MediaBp)
bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp)
@ -139,7 +141,7 @@ def stats_history():
def config():
config_obj: FrigateConfig = current_app.frigate_config
config: dict[str, dict[str, any]] = config_obj.model_dump(
mode="json", exclude_none=True
mode="json", warnings="none", exclude_none=True
)
# remove the mqtt password

157
frigate/api/export.py Normal file
View File

@ -0,0 +1,157 @@
"""Export apis."""
import logging
from pathlib import Path
from typing import Optional
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from peewee import DoesNotExist
from werkzeug.utils import secure_filename
from frigate.models import Export, Recordings
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
logger = logging.getLogger(__name__)
ExportBp = Blueprint("exports", __name__)
@ExportBp.route("/exports")
def get_exports():
exports = Export.select().order_by(Export.date.desc()).dicts().iterator()
return jsonify([e for e in exports])
@ExportBp.route(
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
)
@ExportBp.route(
"/export/<camera_name>/start/<float:start_time>/end/<float:end_time>",
methods=["POST"],
)
def export_recording(camera_name: str, start_time, end_time):
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
return make_response(
jsonify(
{"success": False, "message": f"{camera_name} is not a valid camera."}
),
404,
)
json: dict[str, any] = request.get_json(silent=True) or {}
playback_factor = json.get("playback", "realtime")
name: Optional[str] = json.get("name")
if len(name or "") > 256:
return make_response(
jsonify({"success": False, "message": "File name is too long."}),
401,
)
recordings_count = (
Recordings.select()
.where(
Recordings.start_time.between(start_time, end_time)
| Recordings.end_time.between(start_time, end_time)
| ((start_time > Recordings.start_time) & (end_time < Recordings.end_time))
)
.where(Recordings.camera == camera_name)
.count()
)
if recordings_count <= 0:
return make_response(
jsonify(
{"success": False, "message": "No recordings found for time range"}
),
400,
)
exporter = RecordingExporter(
current_app.frigate_config,
camera_name,
secure_filename(name) if name else None,
int(start_time),
int(end_time),
(
PlaybackFactorEnum[playback_factor]
if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime
),
)
exporter.start()
return make_response(
jsonify(
{
"success": True,
"message": "Starting export of recording.",
}
),
200,
)
@ExportBp.route("/export/<id>/<new_name>", methods=["PATCH"])
def export_rename(id, new_name: str):
try:
export: Export = Export.get(Export.id == id)
except DoesNotExist:
return make_response(
jsonify(
{
"success": False,
"message": "Export not found.",
}
),
404,
)
export.name = new_name
export.save()
return make_response(
jsonify(
{
"success": True,
"message": "Successfully renamed export.",
}
),
200,
)
@ExportBp.route("/export/<id>", methods=["DELETE"])
def export_delete(id: str):
try:
export: Export = Export.get(Export.id == id)
except DoesNotExist:
return make_response(
jsonify(
{
"success": False,
"message": "Export not found.",
}
),
404,
)
Path(export.video_path).unlink(missing_ok=True)
if export.thumb_path:
Path(export.thumb_path).unlink(missing_ok=True)
export.delete_instance()
return make_response(
jsonify(
{
"success": True,
"message": "Successfully deleted export.",
}
),
200,
)

View File

@ -4,24 +4,15 @@ import base64
import glob
import logging
import os
import re
import subprocess as sp
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import unquote
import cv2
import numpy as np
import pytz
from flask import (
Blueprint,
Response,
current_app,
jsonify,
make_response,
request,
)
from flask import Blueprint, Response, current_app, jsonify, make_response, request
from peewee import DoesNotExist, fn
from tzlocal import get_localzone_name
from werkzeug.utils import secure_filename
@ -29,16 +20,12 @@ from werkzeug.utils import secure_filename
from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
EXPORT_DIR,
MAX_SEGMENT_DURATION,
PREVIEW_FRAME_TYPE,
RECORD_DIR,
)
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
from frigate.util.builtin import (
get_tz_modifiers,
)
from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__)
@ -601,151 +588,6 @@ def vod_event(id):
)
@MediaBp.route(
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
)
@MediaBp.route(
"/export/<camera_name>/start/<float:start_time>/end/<float:end_time>",
methods=["POST"],
)
def export_recording(camera_name: str, start_time, end_time):
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
return make_response(
jsonify(
{"success": False, "message": f"{camera_name} is not a valid camera."}
),
404,
)
json: dict[str, any] = request.get_json(silent=True) or {}
playback_factor = json.get("playback", "realtime")
name: Optional[str] = json.get("name")
recordings_count = (
Recordings.select()
.where(
Recordings.start_time.between(start_time, end_time)
| Recordings.end_time.between(start_time, end_time)
| ((start_time > Recordings.start_time) & (end_time < Recordings.end_time))
)
.where(Recordings.camera == camera_name)
.count()
)
if recordings_count <= 0:
return make_response(
jsonify(
{"success": False, "message": "No recordings found for time range"}
),
400,
)
exporter = RecordingExporter(
current_app.frigate_config,
camera_name,
secure_filename(name.replace(" ", "_")) if name else None,
int(start_time),
int(end_time),
(
PlaybackFactorEnum[playback_factor]
if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime
),
)
exporter.start()
return make_response(
jsonify(
{
"success": True,
"message": "Starting export of recording.",
}
),
200,
)
def export_filename_check_extension(filename: str):
if filename.endswith(".mp4"):
return filename
else:
return filename + ".mp4"
def export_filename_is_valid(filename: str):
if re.search(r"[^:_A-Za-z0-9]", filename) or filename.startswith("in_progress."):
return False
else:
return True
@MediaBp.route("/export/<file_name_current>/<file_name_new>", methods=["PATCH"])
def export_rename(file_name_current, file_name_new: str):
safe_file_name_current = secure_filename(
export_filename_check_extension(file_name_current)
)
file_current = os.path.join(EXPORT_DIR, safe_file_name_current)
if not os.path.exists(file_current):
return make_response(
jsonify({"success": False, "message": f"{file_name_current} not found."}),
404,
)
if not export_filename_is_valid(file_name_new):
return make_response(
jsonify(
{
"success": False,
"message": f"{file_name_new} contains illegal characters.",
}
),
400,
)
safe_file_name_new = secure_filename(export_filename_check_extension(file_name_new))
file_new = os.path.join(EXPORT_DIR, safe_file_name_new)
if os.path.exists(file_new):
return make_response(
jsonify({"success": False, "message": f"{file_name_new} already exists."}),
400,
)
os.rename(file_current, file_new)
return make_response(
jsonify(
{
"success": True,
"message": "Successfully renamed file.",
}
),
200,
)
@MediaBp.route("/export/<file_name>", methods=["DELETE"])
def export_delete(file_name: str):
safe_file_name = secure_filename(export_filename_check_extension(file_name))
file = os.path.join(EXPORT_DIR, safe_file_name)
if not os.path.exists(file):
return make_response(
jsonify({"success": False, "message": f"{file_name} not found."}),
404,
)
os.unlink(file)
return make_response(
jsonify(
{
"success": True,
"message": "Successfully deleted file.",
}
),
200,
)
@MediaBp.route("/<camera_name>/<label>/snapshot.jpg")
def label_snapshot(camera_name, label):
label = unquote(label)
@ -1322,8 +1164,156 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
return response
@MediaBp.route("/review/<id>/preview.gif")
@MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4")
@MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4")
def preview_mp4(camera_name: str, start_ts, end_ts):
file_name = secure_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4")
path = os.path.join(CACHE_DIR, file_name)
if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0):
# has preview mp4
try:
preview: Previews = (
Previews.select(
Previews.camera,
Previews.path,
Previews.duration,
Previews.start_time,
Previews.end_time,
)
.where(
Previews.start_time.between(start_ts, end_ts)
| Previews.end_time.between(start_ts, end_ts)
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
)
.where(Previews.camera == camera_name)
.limit(1)
.get()
)
except DoesNotExist:
preview = None
if not preview:
return make_response(
jsonify({"success": False, "message": "Preview not found"}), 404
)
diff = start_ts - preview.start_time
minutes = int(diff / 60)
seconds = int(diff % 60)
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-y",
"-ss",
f"00:{minutes}:{seconds}",
"-t",
f"{end_ts - start_ts}",
"-i",
preview.path,
"-r",
"8",
"-vf",
"setpts=0.12*PTS",
"-c:v",
"libx264",
"-movflags",
"+faststart",
path,
]
process = sp.run(
ffmpeg_cmd,
capture_output=True,
)
if process.returncode != 0:
logger.error(process.stderr)
return make_response(
jsonify({"success": False, "message": "Unable to create preview gif"}),
500,
)
else:
# need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{camera_name}"
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
selected_previews = []
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
continue
if file < start_file:
continue
if file > end_file:
break
selected_previews.append(f"file '{os.path.join(preview_dir, file)}'")
selected_previews.append("duration 0.12")
if not selected_previews:
return make_response(
jsonify({"success": False, "message": "Preview not found"}), 404
)
last_file = selected_previews[-2]
selected_previews.append(last_file)
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-f",
"concat",
"-y",
"-protocol_whitelist",
"pipe,file",
"-safe",
"0",
"-i",
"/dev/stdin",
"-c:v",
"libx264",
"-movflags",
"+faststart",
path,
]
process = sp.run(
ffmpeg_cmd,
input=str.encode("\n".join(selected_previews)),
capture_output=True,
)
if process.returncode != 0:
logger.error(process.stderr)
return make_response(
jsonify({"success": False, "message": "Unable to create preview gif"}),
500,
)
response = make_response()
response.headers["Content-Description"] = "File Transfer"
response.headers["Cache-Control"] = "no-cache"
response.headers["Content-Type"] = "video/mp4"
response.headers["Content-Length"] = os.path.getsize(path)
response.headers["X-Accel-Redirect"] = (
f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
)
return response
@MediaBp.route("/review/<id>/preview")
def review_preview(id: str):
format = request.args.get("format", default="gif")
try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
except DoesNotExist:
@ -1336,7 +1326,11 @@ def review_preview(id: str):
end_ts = (
review.end_time + padding if review.end_time else datetime.now().timestamp()
)
if format == "gif":
return preview_gif(review.camera, start_ts, end_ts)
else:
return preview_mp4(review.camera, start_ts, end_ts)
@MediaBp.route("/preview/<file_name>/thumbnail.jpg")

View File

@ -8,6 +8,7 @@ from pathlib import Path
import pandas as pd
from flask import Blueprint, jsonify, make_response, request
from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.models import Recordings, ReviewSegment
from frigate.util.builtin import get_tz_modifiers
@ -78,6 +79,14 @@ def review():
return jsonify([r for r in review])
@ReviewBp.route("/review/<id>")
def get_review(id: str):
try:
return model_to_dict(ReviewSegment.get(ReviewSegment.id == id))
except DoesNotExist:
return "Review item not found", 404
@ReviewBp.route("/review/summary")
def review_summary():
tz_name = request.args.get("timezone", default="utc", type=str)

View File

@ -41,6 +41,7 @@ from frigate.events.maintainer import EventProcessor
from frigate.log import log_process, root_configurer
from frigate.models import (
Event,
Export,
Previews,
Recordings,
RecordingsToDelete,
@ -55,6 +56,7 @@ from frigate.plus import PlusApi
from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.ptz.onvif import OnvifController
from frigate.record.cleanup import RecordingCleanup
from frigate.record.export import migrate_exports
from frigate.record.record import manage_recordings
from frigate.review.review import manage_review_segments
from frigate.stats.emitter import StatsEmitter
@ -320,6 +322,7 @@ class FrigateApp:
)
models = [
Event,
Export,
Previews,
Recordings,
RecordingsToDelete,
@ -329,6 +332,17 @@ class FrigateApp:
]
self.db.bind(models)
def check_db_data_migrations(self) -> None:
# check if vacuum needs to be run
if not os.path.exists(f"{CONFIG_DIR}/.exports"):
try:
with open(f"{CONFIG_DIR}/.exports", "w") as f:
f.write(str(datetime.datetime.now().timestamp()))
except PermissionError:
logger.error("Unable to write to /config to save export state")
migrate_exports(self.config.cameras.keys())
def init_external_event_processor(self) -> None:
self.external_event_processor = ExternalEventProcessor(self.config)
@ -629,6 +643,7 @@ class FrigateApp:
self.init_review_segment_manager()
self.init_go2rtc()
self.bind_database()
self.check_db_data_migrations()
self.init_inter_process_communicator()
self.init_dispatcher()
except Exception as e:

View File

@ -46,6 +46,7 @@ from frigate.util.builtin import (
get_ffmpeg_arg_list,
load_config_with_no_duplicates,
)
from frigate.util.config import get_relative_coordinates
from frigate.util.image import create_mask
from frigate.util.services import auto_detect_hwaccel, get_video_properties
@ -101,7 +102,6 @@ class UIConfig(FrigateBaseModel):
default=LiveModeEnum.mse, title="Default Live Mode."
)
timezone: Optional[str] = Field(default=None, title="Override UI timezone.")
use_experimental: bool = Field(default=False, title="Experimental UI")
time_format: TimeFormatEnum = Field(
default=TimeFormatEnum.browser, title="Override UI time format."
)
@ -349,35 +349,7 @@ class RuntimeMotionConfig(MotionConfig):
def __init__(self, **config):
frame_shape = config.get("frame_shape", (1, 1))
mask = config.get("mask", "")
# masks and zones are saved as relative coordinates
# we know if any points are > 1 then it is using the
# old native resolution coordinates
if mask:
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
relative_masks = []
for m in mask:
points = m.split(",")
relative_masks.append(
",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
)
mask = relative_masks
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
points = mask.split(",")
mask = ",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
mask = get_relative_coordinates(config.get("mask", ""), frame_shape)
config["raw_mask"] = mask
if mask:
@ -509,34 +481,7 @@ class RuntimeFilterConfig(FilterConfig):
def __init__(self, **config):
frame_shape = config.get("frame_shape", (1, 1))
mask = config.get("mask")
# masks and zones are saved as relative coordinates
# we know if any points are > 1 then it is using the
# old native resolution coordinates
if mask:
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
relative_masks = []
for m in mask:
points = m.split(",")
relative_masks.append(
",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
)
mask = relative_masks
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
points = mask.split(",")
mask = ",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
mask = get_relative_coordinates(config.get("mask"), frame_shape)
config["raw_mask"] = mask
@ -588,6 +533,14 @@ class ZoneConfig(BaseModel):
def contour(self) -> np.ndarray:
return self._contour
@field_validator("objects", mode="before")
@classmethod
def validate_objects(cls, v):
if isinstance(v, str) and "," not in v:
return [v]
return v
def __init__(self, **config):
super().__init__(**config)
@ -668,6 +621,14 @@ class AlertsConfig(FrigateBaseModel):
title="List of required zones to be entered in order to save the event as an alert.",
)
@field_validator("required_zones", mode="before")
@classmethod
def validate_required_zones(cls, v):
if isinstance(v, str) and "," not in v:
return [v]
return v
class DetectionsConfig(FrigateBaseModel):
"""Configure detections"""
@ -680,6 +641,14 @@ class DetectionsConfig(FrigateBaseModel):
title="List of required zones to be entered in order to save the event as a detection.",
)
@field_validator("required_zones", mode="before")
@classmethod
def validate_required_zones(cls, v):
if isinstance(v, str) and "," not in v:
return [v]
return v
class ReviewConfig(FrigateBaseModel):
"""Configure reviews"""
@ -1232,6 +1201,20 @@ def verify_zone_objects_are_tracked(camera_config: CameraConfig) -> None:
)
def verify_required_zones_exist(camera_config: CameraConfig) -> None:
for det_zone in camera_config.review.detections.required_zones:
if det_zone not in camera_config.zones.keys():
raise ValueError(
f"Camera {camera_config.name} has a required zone for detections {det_zone} that is not defined."
)
for det_zone in camera_config.review.alerts.required_zones:
if det_zone not in camera_config.zones.keys():
raise ValueError(
f"Camera {camera_config.name} has a required zone for alerts {det_zone} that is not defined."
)
def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None:
"""Verify that required_zones are specified when autotracking is enabled."""
if (
@ -1457,9 +1440,15 @@ class FrigateConfig(FrigateBaseModel):
else [filter.mask]
)
object_mask = (
get_relative_coordinates(
(
camera_config.objects.mask
if isinstance(camera_config.objects.mask, list)
else [camera_config.objects.mask]
),
camera_config.frame_shape,
)
or []
)
filter.mask = filter_mask + object_mask
@ -1496,6 +1485,7 @@ class FrigateConfig(FrigateBaseModel):
verify_recording_retention(camera_config)
verify_recording_segments_setup_with_reasonable_time(camera_config)
verify_zone_objects_are_tracked(camera_config)
verify_required_zones_exist(camera_config)
verify_autotrack_zones(camera_config)
verify_motion_and_detect(camera_config)

View File

@ -83,7 +83,7 @@ class EventCleanup(threading.Thread):
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# grab all events after specific time
expired_events = (
expired_events: list[Event] = (
Event.select(
Event.id,
Event.camera,
@ -103,12 +103,16 @@ class EventCleanup(threading.Thread):
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
)
try:
media_path.unlink(missing_ok=True)
if file_extension == "jpg":
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
)
media_path.unlink(missing_ok=True)
except OSError as e:
logger.warning(f"Unable to delete event images: {e}")
# update the clips attribute for the db entry
update_query = Event.update(update_params).where(
@ -163,6 +167,7 @@ class EventCleanup(threading.Thread):
events_to_update.append(event.id)
if media_type == EventCleanupType.snapshots:
try:
media_name = f"{event.camera}-{event.id}"
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
@ -172,6 +177,8 @@ class EventCleanup(threading.Thread):
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
)
media_path.unlink(missing_ok=True)
except OSError as e:
logger.warning(f"Unable to delete event images: {e}")
# update the clips attribute for the db entry
Event.update(update_params).where(Event.id << events_to_update).execute()
@ -195,14 +202,18 @@ class EventCleanup(threading.Thread):
select distinct id, camera, has_snapshot, has_clip from grouped_events
where copy_number > 1 and end_time not null;"""
duplicate_events = Event.raw(duplicate_query)
duplicate_events: list[Event] = Event.raw(duplicate_query)
for event in duplicate_events:
logger.debug(f"Removing duplicate: {event.id}")
try:
media_name = f"{event.camera}-{event.id}"
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
media_path.unlink(missing_ok=True)
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
media_path.unlink(missing_ok=True)
except OSError as e:
logger.warning(f"Unable to delete event images: {e}")
(
Event.delete()

View File

@ -77,6 +77,16 @@ class Recordings(Model): # type: ignore[misc]
regions = IntegerField(null=True)
class Export(Model): # type: ignore[misc]
id = CharField(null=False, primary_key=True, max_length=30)
camera = CharField(index=True, max_length=20)
name = CharField(index=True, max_length=100)
date = DateTimeField()
video_path = CharField(unique=True)
thumb_path = CharField(unique=True)
in_progress = BooleanField()
class ReviewSegment(Model): # type: ignore[misc]
id = CharField(null=False, primary_key=True, max_length=30)
camera = CharField(index=True, max_length=20)

View File

@ -158,7 +158,7 @@ class ObjectDetectProcess:
logging.info("Waiting for detection process to exit gracefully...")
self.detect_process.join(timeout=30)
if self.detect_process.exitcode is None:
logging.info("Detection process didnt exit. Force killing...")
logging.info("Detection process didn't exit. Force killing...")
self.detect_process.kill()
self.detect_process.join()
logging.info("Detection process has exited...")

View File

@ -51,7 +51,9 @@ class OnvifController:
cam.onvif.port,
cam.onvif.user,
cam.onvif.password,
wsdl_dir=Path(find_spec("onvif").origin).parent / "../wsdl",
wsdl_dir=str(
Path(find_spec("onvif").origin).parent / "wsdl"
).replace("dist-packages/onvif", "site-packages"),
),
"init": False,
"active": False,

View File

@ -3,18 +3,27 @@
import datetime
import logging
import os
import random
import shutil
import string
import subprocess as sp
import threading
from enum import Enum
from pathlib import Path
from frigate.config import FrigateConfig
from frigate.const import EXPORT_DIR, MAX_PLAYLIST_SECONDS
from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
EXPORT_DIR,
MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE,
)
from frigate.ffmpeg_presets import (
EncodeTypeEnum,
parse_preset_hardware_acceleration_encode,
)
from frigate.models import Recordings
from frigate.models import Export, Previews, Recordings
logger = logging.getLogger(__name__)
@ -51,20 +60,122 @@ class RecordingExporter(threading.Thread):
self.end_time = end_time
self.playback_factor = playback_factor
# ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
def get_datetime_from_timestamp(self, timestamp: int) -> str:
"""Convenience fun to get a simple date time from timestamp."""
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M")
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y/%m/%d %H:%M")
def save_thumbnail(self, id: str) -> str:
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
if datetime.datetime.fromtimestamp(
self.start_time
) < datetime.datetime.now().replace(minute=0, second=0):
# has preview mp4
preview: Previews = (
Previews.select(
Previews.camera,
Previews.path,
Previews.duration,
Previews.start_time,
Previews.end_time,
)
.where(
Previews.start_time.between(self.start_time, self.end_time)
| Previews.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Previews.start_time)
& (self.end_time < Previews.end_time)
)
)
.where(Previews.camera == self.camera)
.limit(1)
.get()
)
if not preview:
return ""
diff = self.start_time - preview.start_time
minutes = int(diff / 60)
seconds = int(diff % 60)
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-ss",
f"00:{minutes}:{seconds}",
"-i",
preview.path,
"-c:v",
"libwebp",
thumb_path,
]
process = sp.run(
ffmpeg_cmd,
capture_output=True,
)
if process.returncode != 0:
logger.error(process.stderr)
return ""
else:
# need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{self.camera}"
start_file = f"{file_start}-{self.start_time}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{self.end_time}.{PREVIEW_FRAME_TYPE}"
selected_preview = None
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
continue
if file < start_file:
continue
if file > end_file:
break
selected_preview = os.path.join(preview_dir, file)
break
if not selected_preview:
return ""
shutil.copyfile(selected_preview, thumb_path)
return thumb_path
def run(self) -> None:
logger.debug(
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}"
)
file_name = (
export_id = f"{self.camera}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
export_name = (
self.user_provided_name
or f"{self.camera}_{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}"
or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}"
)
file_path = f"{EXPORT_DIR}/in_progress.{file_name}.mp4"
final_file_path = f"{EXPORT_DIR}/{file_name}.mp4"
video_path = f"{EXPORT_DIR}/{export_id}.mp4"
thumb_path = self.save_thumbnail(export_id)
Export.insert(
{
Export.id: export_id,
Export.camera: self.camera,
Export.name: export_name,
Export.date: self.start_time,
Export.video_path: video_path,
Export.thumb_path: thumb_path,
Export.in_progress: True,
}
).execute()
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
@ -103,14 +214,14 @@ class RecordingExporter(threading.Thread):
if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = (
f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {file_path}"
f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}"
).split(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.hwaccel_args,
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {file_path}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
EncodeTypeEnum.timelapse,
)
).split(" ")
@ -128,9 +239,71 @@ class RecordingExporter(threading.Thread):
f"Failed to export recording for command {' '.join(ffmpeg_cmd)}"
)
logger.error(p.stderr)
Path(file_path).unlink(missing_ok=True)
Path(video_path).unlink(missing_ok=True)
Export.delete().where(Export.id == export_id).execute()
Path(thumb_path).unlink(missing_ok=True)
return
else:
Export.update({Export.in_progress: False}).where(
Export.id == export_id
).execute()
logger.debug(f"Updating finalized export {file_path}")
os.rename(file_path, final_file_path)
logger.debug(f"Finished exporting {file_path}")
logger.debug(f"Finished exporting {video_path}")
def migrate_exports(camera_names: list[str]):
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
exports = []
for export_file in os.listdir(EXPORT_DIR):
camera = "unknown"
for cam_name in camera_names:
if cam_name in export_file:
camera = cam_name
break
id = f"{camera}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
video_path = os.path.join(EXPORT_DIR, export_file)
thumb_path = os.path.join(
CLIPS_DIR, f"export/{id}.jpg"
) # use jpg because webp encoder can't get quality low enough
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-i",
video_path,
"-vf",
"scale=-1:180",
"-frames",
"1",
"-q:v",
"8",
thumb_path,
]
process = sp.run(
ffmpeg_cmd,
capture_output=True,
)
if process.returncode != 0:
logger.error(process.stderr)
continue
exports.append(
{
Export.id: id,
Export.camera: camera,
Export.name: export_file.replace(".mp4", ""),
Export.date: os.path.getctime(video_path),
Export.video_path: video_path,
Export.thumb_path: thumb_path,
Export.in_progress: False,
}
)
Export.insert_many(exports).execute()

View File

@ -9,6 +9,7 @@ import sys
import threading
from enum import Enum
from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
from typing import Optional
import cv2
@ -46,8 +47,9 @@ class PendingReviewSegment:
frame_time: float,
severity: SeverityEnum,
detections: dict[str, str],
zones: set[str] = set(),
audio: set[str] = set(),
sub_labels: set[str],
zones: set[str],
audio: set[str],
):
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
self.id = f"{frame_time}-{rand_id}"
@ -55,6 +57,7 @@ class PendingReviewSegment:
self.start_time = frame_time
self.severity = severity
self.detections = detections
self.sub_labels = sub_labels
self.zones = zones
self.audio = audio
self.last_update = frame_time
@ -62,7 +65,9 @@ class PendingReviewSegment:
# thumbnail
self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8)
self.frame_active_count = 0
self.frame_path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg")
self.frame_path = os.path.join(
CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp"
)
def update_frame(
self, camera_config: CameraConfig, frame, objects: list[TrackedObject]
@ -111,6 +116,7 @@ class PendingReviewSegment:
ReviewSegment.data: {
"detections": list(set(self.detections.keys())),
"objects": list(set(self.detections.values())),
"sub_labels": list(self.sub_labels),
"zones": list(self.zones),
"audio": list(self.audio),
},
@ -135,6 +141,9 @@ class ReviewSegmentMaintainer(threading.Thread):
# manual events
self.indefinite_events: dict[str, dict[str, any]] = {}
# ensure dirs
Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True)
self.stop_event = stop_event
def update_segment(self, segment: PendingReviewSegment) -> None:
@ -181,6 +190,7 @@ class ReviewSegmentMaintainer(threading.Thread):
segment.detections[object["id"]] = object["sub_label"][0]
else:
segment.detections[object["id"]] = f'{object["label"]}-verified'
segment.sub_labels.add(object["sub_label"][0])
# if object is alert label
# and has entered required zones or required zones is not set
@ -233,8 +243,8 @@ class ReviewSegmentMaintainer(threading.Thread):
active_objects = get_active_objects(frame_time, camera_config, objects)
if len(active_objects) > 0:
has_sig_object = False
detections: dict[str, str] = {}
sub_labels = set()
zones: set = set()
severity = None
@ -245,6 +255,7 @@ class ReviewSegmentMaintainer(threading.Thread):
detections[object["id"]] = object["sub_label"][0]
else:
detections[object["id"]] = f'{object["label"]}-verified'
sub_labels.add(object["sub_label"][0])
# if object is alert label
# and has entered required zones or required zones is not set
@ -290,8 +301,9 @@ class ReviewSegmentMaintainer(threading.Thread):
self.active_review_segments[camera] = PendingReviewSegment(
camera,
frame_time,
SeverityEnum.alert if has_sig_object else SeverityEnum.detection,
severity,
detections,
sub_labels=sub_labels,
audio=set(),
zones=zones,
)
@ -435,6 +447,7 @@ class ReviewSegmentMaintainer(threading.Thread):
severity,
{},
set(),
set(),
detections,
)
elif topic == DetectionTypeEnum.api:
@ -445,6 +458,7 @@ class ReviewSegmentMaintainer(threading.Thread):
{manual_info["event_id"]: manual_info["label"]},
set(),
set(),
set(),
)
if manual_info["state"] == ManualEventState.start:

View File

@ -223,8 +223,6 @@ def update_yaml_file(file_path, key_path, new_value):
data = yaml.load(f)
data = update_yaml(data, key_path, new_value)
with open("/config/test.yaml", "w") as f:
yaml.dump(data, f)
with open(file_path, "w") as f:
yaml.dump(data, f)

View File

@ -3,10 +3,11 @@
import logging
import os
import shutil
from typing import Optional, Union
from ruamel.yaml import YAML
from frigate.const import CONFIG_DIR
from frigate.const import CONFIG_DIR, EXPORT_DIR
logger = logging.getLogger(__name__)
@ -46,6 +47,16 @@ def migrate_frigate_config(config_file: str):
yaml.dump(new_config, f)
previous_version = 0.14
logger.info("Migrating export file names...")
for file in os.listdir(EXPORT_DIR):
if "@" not in file:
continue
new_name = file.replace("@", "_")
os.rename(
os.path.join(EXPORT_DIR, file), os.path.join(EXPORT_DIR, new_name)
)
with open(version_file, "w") as f:
f.write(str(CURRENT_CONFIG_VERSION))
@ -81,6 +92,12 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
if not new_config["record"]:
del new_config["record"]
if new_config.get("ui", {}).get("use_experimental"):
del new_config["ui"]["experimental"]
if not new_config["ui"]:
del new_config["ui"]
# remove rtmp
if new_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"):
del new_config["ffmpeg"]["output_args"]["rtmp"]
@ -125,3 +142,38 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
new_config["cameras"][name] = camera_config
return new_config
def get_relative_coordinates(
mask: Optional[Union[str, list]], frame_shape: tuple[int, int]
) -> Union[str, list]:
# masks and zones are saved as relative coordinates
# we know if any points are > 1 then it is using the
# old native resolution coordinates
if mask:
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
relative_masks = []
for m in mask:
points = m.split(",")
relative_masks.append(
",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
)
mask = relative_masks
elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")):
points = mask.split(",")
mask = ",".join(
[
f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}"
for i in range(0, len(points), 2)
]
)
return mask
return mask

View File

@ -60,7 +60,7 @@ def stop_ffmpeg(ffmpeg_process, logger):
logger.info("Waiting for ffmpeg to exit gracefully...")
ffmpeg_process.communicate(timeout=30)
except sp.TimeoutExpired:
logger.info("FFmpeg didnt exit. Force killing...")
logger.info("FFmpeg didn't exit. Force killing...")
ffmpeg_process.kill()
ffmpeg_process.communicate()
ffmpeg_process = None

View File

@ -0,0 +1,37 @@
"""Peewee migrations -- 024_create_export_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 peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE TABLE IF NOT EXISTS "export" ("id" VARCHAR(30) NOT NULL PRIMARY KEY, "camera" VARCHAR(20) NOT NULL, "name" VARCHAR(100) NOT NULL, "date" DATETIME NOT NULL, "video_path" VARCHAR(255) NOT NULL, "thumb_path" VARCHAR(255) NOT NULL, "in_progress" INTEGER NOT NULL)'
)
migrator.sql('CREATE INDEX IF NOT EXISTS "export_camera" ON "export" ("camera")')
def rollback(migrator, database, fake=False, **kwargs):
pass

View File

@ -53,7 +53,7 @@ def get_frame_shape(source):
if video_info["height"] != 0 and video_info["width"] != 0:
return (video_info["height"], video_info["width"], 3)
# fallback to using opencv if ffprobe didnt succeed
# fallback to using opencv if ffprobe didn't succeed
video = cv2.VideoCapture(source)
ret, frame = video.read()
frame_shape = frame.shape

187
web/package-lock.json generated
View File

@ -9,7 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5",
"@hookform/resolvers": "^3.3.2",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-context-menu": "^2.1.5",
@ -21,6 +21,7 @@
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
@ -37,16 +38,18 @@
"hls.js": "^1.5.8",
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
"lucide-react": "^0.368.0",
"konva": "^9.3.6",
"lucide-react": "^0.372.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
"react": "^18.2.0",
"react-apexcharts": "^1.4.1",
"react-day-picker": "^8.9.1",
"react-day-picker": "^8.10.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.3",
"react-icons": "^5.0.1",
"react-icons": "^5.1.0",
"react-konva": "^18.2.10",
"react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14",
@ -59,17 +62,17 @@
"sort-by": "^1.2.0",
"strftime": "^0.10.2",
"swr": "^2.2.5",
"tailwind-merge": "^2.2.2",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.0",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.22.4"
"zod": "^3.22.5"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5",
"@types/node": "^20.12.7",
"@types/react": "^18.2.78",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
@ -89,12 +92,12 @@
"fake-indexeddb": "^5.0.2",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^24.0.0",
"msw": "^2.2.13",
"msw": "^2.2.14",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vite": "^5.2.9",
"vitest": "^1.4.0"
}
},
@ -168,9 +171,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz",
"integrity": "sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==",
"version": "7.24.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz",
"integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -1648,6 +1651,29 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz",
"integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz",
@ -2518,14 +2544,12 @@
"node_modules/@types/prop-types": {
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==",
"devOptional": true
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
},
"node_modules/@types/react": {
"version": "18.2.78",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.78.tgz",
"integrity": "sha512-qOwdPnnitQY4xKlKayt42q5W5UQrSHjgoXNVEtxeqdITJ99k4VXJOP3vt8Rkm9HmgJpH50UNU+rlqfkfWOqp0A==",
"devOptional": true,
"version": "18.2.79",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@ -2550,6 +2574,14 @@
"react-icons": "*"
}
},
"node_modules/@types/react-reconciler": {
"version": "0.28.8",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.8.tgz",
"integrity": "sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
@ -5046,6 +5078,17 @@
"node": ">=8"
}
},
"node_modules/its-fine": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.1.3.tgz",
"integrity": "sha512-mncCA+yb6tuh5zK26cHqKlsSyxm4zdm4YgJpxycyx6p9fgxgK5PLu3iDVpKhzTn57Yrv3jk/r0aK0RFTT1OjFw==",
"dependencies": {
"@types/react-reconciler": "^0.28.0"
},
"peerDependencies": {
"react": ">=18.0"
}
},
"node_modules/jest-diff": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
@ -5177,6 +5220,25 @@
"json-buffer": "3.0.1"
}
},
"node_modules/konva": {
"version": "9.3.6",
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.6.tgz",
"integrity": "sha512-dqR8EbcM0hjuilZCBP6xauQ5V3kH3m9kBcsDkqPypQuRgsXbcXUrxqYxhNbdvKZpYNW8Amq94jAD/C0NY3qfBQ==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
]
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -5279,9 +5341,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.368.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.368.0.tgz",
"integrity": "sha512-soryVrCjheZs8rbXKdINw9B8iPi5OajBJZMJ1HORig89ljcOcEokKKAgGbg3QWxSXel7JwHOfDFUdDHAKyUAMw==",
"version": "0.372.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.372.0.tgz",
"integrity": "sha512-0cKdqmilHXWUwWAWnf6CrrjHD8YaqPMtLrmEHXolZusNTr9epULCsiJwIOHk2q1yFxdEwd96D4zShlAj67UJdA==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
@ -5523,9 +5585,9 @@
"dev": true
},
"node_modules/msw": {
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/msw/-/msw-2.2.13.tgz",
"integrity": "sha512-ljFf1xZsU0b4zv1l7xzEmC6OZA6yD06hcx0H+dc8V0VypaP3HGYJa1rMLjQbBWl32ptGhcfwcPCWDB1wjmsftw==",
"version": "2.2.14",
"resolved": "https://registry.npmjs.org/msw/-/msw-2.2.14.tgz",
"integrity": "sha512-64i8rNCa1xzDK8ZYsTrVMli05D687jty8+Th+PU5VTbJ2/4P7fkQFVyDQ6ZFT5FrNR8z2BHhbY47fKNvfHrumA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@ -6224,9 +6286,9 @@
}
},
"node_modules/react-day-picker": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.0.tgz",
"integrity": "sha512-mz+qeyrOM7++1NCb1ARXmkjMkzWVh2GL9YiPbRjKe0zHccvekk4HE+0MPOZOrosn8r8zTHIIeOUXTmXRqmkRmg==",
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
@ -6276,9 +6338,9 @@
}
},
"node_modules/react-icons": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.0.1.tgz",
"integrity": "sha512-WqLZJ4bLzlhmsvme6iFdgO8gfZP17rfjYEJ2m9RsZjZ+cc4k1hTzknEz63YS1MeT50kVzoa1Nz36f4BEx+Wigw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.1.0.tgz",
"integrity": "sha512-D3zug1270S4hbSlIRJ0CUS97QE1yNNKDjzQe3HqY0aefp2CBn9VgzgES27sRR2gOvFK+0CNx/BW0ggOESp6fqQ==",
"peerDependencies": {
"react": "*"
}
@ -6289,6 +6351,51 @@
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true
},
"node_modules/react-konva": {
"version": "18.2.10",
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz",
"integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"dependencies": {
"@types/react-reconciler": "^0.28.2",
"its-fine": "^1.1.1",
"react-reconciler": "~0.29.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/react-reconciler": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz",
"integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",
@ -7183,11 +7290,11 @@
}
},
"node_modules/tailwind-merge": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.2.tgz",
"integrity": "sha512-tWANXsnmJzgw6mQ07nE3aCDkCK4QdT3ThPMCzawoYA2Pws7vSTCvz3Vrjg61jVUGfFZPJzxEP+NimbcW+EdaDw==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.3.0.tgz",
"integrity": "sha512-vkYrLpIP+lgR0tQCG6AP7zZXCTLc1Lnv/CCRT3BqJ9CZ3ui2++GPaGb1x/ILsINIMSYqqvrpqjUFsMNLlW99EA==",
"dependencies": {
"@babel/runtime": "^7.24.0"
"@babel/runtime": "^7.24.1"
},
"funding": {
"type": "github",
@ -7634,9 +7741,9 @@
}
},
"node_modules/vite": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz",
"integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.9.tgz",
"integrity": "sha512-uOQWfuZBlc6Y3W/DTuQ1Sr+oIXWvqljLvS881SVmAj00d5RdgShLcuXWxseWPd4HXwiYBFW/vXHfKFeqj9uQnw==",
"dev": true,
"dependencies": {
"esbuild": "^0.20.1",
@ -8031,9 +8138,9 @@
}
},
"node_modules/zod": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.5.tgz",
"integrity": "sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -14,7 +14,7 @@
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5",
"@hookform/resolvers": "^3.3.2",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-context-menu": "^2.1.5",
@ -26,6 +26,7 @@
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
@ -42,16 +43,18 @@
"hls.js": "^1.5.8",
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
"lucide-react": "^0.368.0",
"konva": "^9.3.6",
"lucide-react": "^0.372.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
"react": "^18.2.0",
"react-apexcharts": "^1.4.1",
"react-day-picker": "^8.9.1",
"react-day-picker": "^8.10.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.3",
"react-icons": "^5.0.1",
"react-icons": "^5.1.0",
"react-konva": "^18.2.10",
"react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14",
@ -64,17 +67,17 @@
"sort-by": "^1.2.0",
"strftime": "^0.10.2",
"swr": "^2.2.5",
"tailwind-merge": "^2.2.2",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.0",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.22.4"
"zod": "^3.22.5"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5",
"@types/node": "^20.12.7",
"@types/react": "^18.2.78",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
@ -94,12 +97,12 @@
"fake-indexeddb": "^5.0.2",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^24.0.0",
"msw": "^2.2.13",
"msw": "^2.2.14",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vite": "^5.2.9",
"vitest": "^1.4.0"
}
}

View File

@ -11,7 +11,7 @@ import { Redirect } from "./components/navigation/Redirect";
const Live = lazy(() => import("@/pages/Live"));
const Events = lazy(() => import("@/pages/Events"));
const Export = lazy(() => import("@/pages/Export"));
const Exports = lazy(() => import("@/pages/Exports"));
const SubmitPlus = lazy(() => import("@/pages/SubmitPlus"));
const ConfigEditor = lazy(() => import("@/pages/ConfigEditor"));
const System = lazy(() => import("@/pages/System"));
@ -38,7 +38,7 @@ function App() {
<Route path="/" element={<Live />} />
<Route path="/events" element={<Redirect to="/review" />} />
<Route path="/review" element={<Events />} />
<Route path="/export" element={<Export />} />
<Route path="/export" element={<Exports />} />
<Route path="/plus" element={<SubmitPlus />} />
<Route path="/system" element={<System />} />
<Route path="/settings" element={<Settings />} />

View File

@ -206,3 +206,45 @@ export function useAudioActivity(camera: string): { payload: number } {
} = useWs(`${camera}/audio/rms`, "");
return { payload: payload as number };
}
export function useMotionThreshold(camera: string): {
payload: string;
send: (payload: number, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/motion_threshold/state`,
`${camera}/motion_threshold/set`,
);
return { payload: payload as string, send };
}
export function useMotionContourArea(camera: string): {
payload: string;
send: (payload: number, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/motion_contour_area/state`,
`${camera}/motion_contour_area/set`,
);
return { payload: payload as string, send };
}
export function useImproveContrast(camera: string): {
payload: ToggleableSetting;
send: (payload: string, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/improve_contrast/state`,
`${camera}/improve_contrast/set`,
);
return { payload: payload as ToggleableSetting, send };
}

View File

@ -6,6 +6,7 @@ type AutoUpdatingCameraImageProps = {
searchParams?: URLSearchParams;
showFps?: boolean;
className?: string;
cameraClasses?: string;
reloadInterval?: number;
};
@ -16,6 +17,7 @@ export default function AutoUpdatingCameraImage({
searchParams = undefined,
showFps = true,
className,
cameraClasses,
reloadInterval = MIN_LOAD_TIMEOUT_MS,
}: AutoUpdatingCameraImageProps) {
const [key, setKey] = useState(Date.now());
@ -68,6 +70,7 @@ export default function AutoUpdatingCameraImage({
camera={camera}
onload={handleLoad}
searchParams={`cache=${key}&${searchParams}`}
className={cameraClasses}
/>
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
</div>

View File

@ -36,12 +36,7 @@ export default function CameraImage({
}, [apiHost, name, imgRef, searchParams, config]);
return (
<div
className={`relative w-full h-full flex justify-center ${
className || ""
}`}
ref={containerRef}
>
<div className={className} ref={containerRef}>
{enabled ? (
<img
ref={imgRef}

View File

@ -53,6 +53,7 @@ export default function DebugCameraImage({
<AutoUpdatingCameraImage
camera={cameraConfig.name}
searchParams={searchParams}
cameraClasses="relative w-full h-full flex justify-center"
/>
<Button onClick={handleToggleSettings} variant="link" size="sm">
<span className="w-5 h-5">

View File

@ -1,14 +1,17 @@
import { baseUrl } from "@/api/baseUrl";
import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
import { useNavigate } from "react-router-dom";
import { Skeleton } from "../ui/skeleton";
import { RecordingStartingPoint } from "@/types/record";
import axios from "axios";
import { Preview } from "@/types/preview";
import {
InProgressPreview,
VideoPreview,
} from "../player/PreviewThumbnailPlayer";
type AnimatedEventCardProps = {
event: ReviewSegment;
@ -16,6 +19,12 @@ type AnimatedEventCardProps = {
export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
const { data: config } = useSWR<FrigateConfig>("config");
// preview
const { data: previews } = useSWR<Preview[]>(
`/preview/${event.camera}/start/${Math.round(event.start_time)}/end/${Math.round(event.end_time || event.start_time + 20)}`,
);
// interaction
const navigate = useNavigate();
@ -35,16 +44,6 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
// image behavior
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(0);
const imageUrl = useMemo(() => {
if (error > 0) {
return `${baseUrl}api/review/${event.id}/preview.gif?key=${error}`;
}
return `${baseUrl}api/review/${event.id}/preview.gif`;
}, [error, event]);
const aspectRatio = useMemo(() => {
if (!config) {
return 1;
@ -63,18 +62,36 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
aspectRatio: aspectRatio,
}}
>
<img
className="size-full rounded object-cover object-center cursor-pointer"
src={imageUrl}
<div
className="size-full rounded cursor-pointer overflow-hidden"
onClick={onOpenReview}
onLoad={() => setLoaded(true)}
onError={() => {
if (error < 2) {
setError(error + 1);
}
}}
>
{previews ? (
<VideoPreview
relevantPreview={previews[previews.length - 1]}
startTime={event.start_time}
endTime={event.end_time}
loop
showProgress={false}
setReviewed={() => {}}
setIgnoreClick={() => {}}
isPlayingBack={() => {}}
/>
{!loaded && <Skeleton className="absolute inset-0" />}
) : (
<InProgressPreview
review={event}
timeRange={{
after: event.start_time,
before: event.end_time ?? event.start_time + 20,
}}
loop
showProgress={false}
setReviewed={() => {}}
setIgnoreClick={() => {}}
isPlayingBack={() => {}}
/>
)}
</div>
<div className="absolute bottom-0 inset-x-0 h-6 bg-gradient-to-t from-slate-900/50 to-transparent rounded">
<div className="w-full absolute left-1 bottom-0 text-xs text-white">
<TimeAgo time={event.start_time * 1000} dense />
@ -83,7 +100,18 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
</div>
</TooltipTrigger>
<TooltipContent>
{`${[...event.data.objects, ...event.data.audio].join(", ").replaceAll("-verified", "")} detected`}
{`${[
...new Set([
...(event.data.objects || []),
...(event.data.sub_labels || []),
...(event.data.audio || []),
]),
]
.filter((item) => item !== undefined && !item.includes("-verified"))
.map((text) => text.charAt(0).toUpperCase() + text.substring(1))
.sort()
.join(", ")
.replaceAll("-verified", "")} detected`}
</TooltipContent>
</Tooltip>
);

View File

@ -1,38 +1,36 @@
import { baseUrl } from "@/api/baseUrl";
import ActivityIndicator from "../indicators/activity-indicator";
import { LuPencil, LuTrash } from "react-icons/lu";
import { LuTrash } from "react-icons/lu";
import { Button } from "../ui/button";
import { useMemo, useRef, useState } from "react";
import { useState } from "react";
import { isDesktop } from "react-device-detect";
import { FaPlay } from "react-icons/fa";
import { FaDownload, FaPlay } from "react-icons/fa";
import Chip from "../indicators/Chip";
import { Skeleton } from "../ui/skeleton";
import { Dialog, DialogContent, DialogFooter, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Export } from "@/types/export";
import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl";
type ExportProps = {
className: string;
file: {
name: string;
};
exportedRecording: Export;
onSelect: (selected: Export) => void;
onRename: (original: string, update: string) => void;
onDelete: (file: string) => void;
};
export default function ExportCard({
className,
file,
exportedRecording,
onSelect,
onRename,
onDelete,
}: ExportProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [hovered, setHovered] = useState(false);
const [playing, setPlaying] = useState(false);
const [loading, setLoading] = useState(true);
const inProgress = useMemo(
() => file.name.startsWith("in_progress"),
[file.name],
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
);
// editing name
@ -46,7 +44,7 @@ export default function ExportCard({
editName != undefined ? ["Enter"] : [],
(_, down, repeat) => {
if (down && !repeat && editName && editName.update.length > 0) {
onRename(editName.original, editName.update.replaceAll(" ", "_"));
onRename(exportedRecording.id, editName.update);
setEditName(undefined);
}
},
@ -84,10 +82,7 @@ export default function ExportCard({
variant="select"
disabled={(editName?.update?.length ?? 0) == 0}
onClick={() => {
onRename(
editName.original,
editName.update.replaceAll(" ", "_"),
);
onRename(exportedRecording.id, editName.update);
setEditName(undefined);
}}
>
@ -102,75 +97,84 @@ export default function ExportCard({
<div
className={`relative aspect-video bg-black rounded-2xl flex justify-center items-center ${className}`}
onMouseEnter={
isDesktop && !inProgress ? () => setHovered(true) : undefined
isDesktop && !exportedRecording.in_progress
? () => setHovered(true)
: undefined
}
onMouseLeave={
isDesktop && !inProgress ? () => setHovered(false) : undefined
isDesktop && !exportedRecording.in_progress
? () => setHovered(false)
: undefined
}
onClick={
isDesktop || inProgress ? undefined : () => setHovered(!hovered)
isDesktop || exportedRecording.in_progress
? undefined
: () => setHovered(!hovered)
}
>
{hovered && (
<>
{!playing && (
<div className="absolute inset-0 z-10 bg-black bg-opacity-60 rounded-2xl" />
)}
<div className="absolute top-1 right-1 flex items-center gap-2">
<a
className="z-20"
download
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
>
<Chip className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
<Chip
className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer"
onClick={() => setEditName({ original: file.name, update: "" })}
onClick={() =>
setEditName({ original: exportedRecording.name, update: "" })
}
>
<LuPencil className="size-4 text-white" />
<MdEditSquare className="size-4 text-white" />
</Chip>
<Chip
className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer"
onClick={() => onDelete(file.name)}
onClick={() => onDelete(exportedRecording.id)}
>
<LuTrash className="size-4 text-destructive fill-destructive" />
</Chip>
</div>
{!playing && (
<Button
className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 w-20 h-20 z-20 text-white hover:text-white hover:bg-transparent"
className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 w-20 h-20 z-20 text-white hover:text-white hover:bg-transparent cursor-pointer"
variant="ghost"
onClick={() => {
setPlaying(true);
videoRef.current?.play();
onSelect(exportedRecording);
}}
>
<FaPlay />
</Button>
)}
</>
)}
{inProgress ? (
{exportedRecording.in_progress ? (
<ActivityIndicator />
) : (
<video
ref={videoRef}
className="absolute inset-0 aspect-video rounded-2xl"
playsInline
preload="auto"
muted
controls={playing}
onLoadedData={() => setLoading(false)}
>
<source src={`${baseUrl}exports/${file.name}`} type="video/mp4" />
</video>
<>
{exportedRecording.thumb_path.length > 0 ? (
<img
className="size-full absolute inset-0 object-contain aspect-video rounded-2xl"
src={exportedRecording.thumb_path.replace("/media/frigate", "")}
onLoad={() => setLoading(false)}
/>
) : (
<div className="absolute inset-0 bg-secondary rounded-2xl" />
)}
</>
)}
{loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-2xl" />
)}
{!playing && (
<div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none rounded-2xl">
<div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm capitalize">
{file.name
.substring(0, file.name.length - 4)
.replaceAll("_", " ")}
{exportedRecording.name.replaceAll("_", " ")}
</div>
</div>
)}
</div>
</>
);

View File

@ -14,7 +14,7 @@ const variants = {
overlay: {
active: "font-bold text-white bg-selected rounded-full",
inactive:
"text-primary-white rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500",
"text-primary rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500",
},
};

View File

@ -22,8 +22,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import FilterCheckBox from "./FilterCheckBox";
import axios from "axios";
import FilterSwitch from "./FilterSwitch";
type CameraGroupSelectorProps = {
className?: string;
@ -305,7 +305,7 @@ function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) {
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
...Object.keys(config?.cameras ?? {}),
].map((camera) => (
<FilterCheckBox
<FilterSwitch
key={camera}
isChecked={cameras.includes(camera)}
label={camera.replaceAll("_", " ")}

View File

@ -1,34 +0,0 @@
import { LuCheck } from "react-icons/lu";
import { Button } from "../ui/button";
import { IconType } from "react-icons";
type FilterCheckBoxProps = {
label: string;
CheckIcon?: IconType;
iconClassName?: string;
isChecked: boolean;
onCheckedChange: (isChecked: boolean) => void;
};
export default function FilterCheckBox({
label,
CheckIcon = LuCheck,
iconClassName = "size-6",
isChecked,
onCheckedChange,
}: FilterCheckBoxProps) {
return (
<Button
className="capitalize flex justify-between items-center cursor-pointer w-full text-primary"
variant="ghost"
onClick={() => onCheckedChange(!isChecked)}
>
{isChecked ? (
<CheckIcon className={iconClassName} />
) : (
<div className={iconClassName} />
)}
<div className="ml-1 w-full flex justify-start">{label}</div>
</Button>
);
}

View File

@ -0,0 +1,29 @@
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
type FilterSwitchProps = {
label: string;
isChecked: boolean;
onCheckedChange: (checked: boolean) => void;
};
export default function FilterSwitch({
label,
isChecked,
onCheckedChange,
}: FilterSwitchProps) {
return (
<div className="flex justify-between items-center gap-1">
<Label
className="w-full mx-2 text-primary capitalize cursor-pointer"
htmlFor={label}
>
{label}
</Label>
<Switch
id={label}
checked={isChecked}
onCheckedChange={onCheckedChange}
/>
</div>
);
}

View File

@ -120,7 +120,6 @@ export function GeneralFilterContent({
))}
</div>
</div>
<DropdownMenuSeparator />
</>
);
}

View File

@ -56,7 +56,7 @@ export default function ReviewActionGroup({
onClearSelected();
}}
>
<FaCompactDisc />
<FaCompactDisc className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Export</div>}
</Button>
)}
@ -65,15 +65,15 @@ export default function ReviewActionGroup({
size="sm"
onClick={onMarkAsReviewed}
>
<FaCircleCheck />
<FaCircleCheck className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Mark as reviewed</div>}
</Button>
<Button
className="p-2 flex items-center gap-1"
className="p-2 flex items-center gap-2"
size="sm"
onClick={onDelete}
>
<HiTrash />
<HiTrash className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Delete</div>}
</Button>
</div>

View File

@ -24,12 +24,12 @@ import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import FilterCheckBox from "./FilterCheckBox";
import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar";
import MobileReviewSettingsDrawer, {
DrawerFeatures,
} from "../overlay/MobileReviewSettingsDrawer";
import useOptimisticState from "@/hooks/use-optimistic-state";
import FilterSwitch from "./FilterSwitch";
const REVIEW_FILTERS = [
"cameras",
@ -248,8 +248,8 @@ export function CamerasFilterButton({
<DropdownMenuSeparator />
</>
)}
<div className="h-auto overflow-y-auto overflow-x-hidden">
<FilterCheckBox
<div className="h-auto p-4 overflow-y-auto overflow-x-hidden">
<FilterSwitch
isChecked={currentCameras == undefined}
label="All Cameras"
onCheckedChange={(isChecked) => {
@ -260,24 +260,24 @@ export function CamerasFilterButton({
/>
{groups.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuSeparator className="mt-2" />
{groups.map(([name, conf]) => {
return (
<FilterCheckBox
<div
key={name}
label={name}
isChecked={false}
onCheckedChange={() => {
setCurrentCameras([...conf.cameras]);
}}
/>
className="w-full px-2 py-1.5 text-sm text-primary capitalize cursor-pointer rounded-lg hover:bg-muted"
onClick={() => setCurrentCameras([...conf.cameras])}
>
{name}
</div>
);
})}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuSeparator className="my-2" />
<div className="flex flex-col gap-2.5">
{allCameras.map((item) => (
<FilterCheckBox
<FilterSwitch
key={item}
isChecked={currentCameras?.includes(item) ?? false}
label={item.replaceAll("_", " ")}
@ -304,7 +304,8 @@ export function CamerasFilterButton({
/>
))}
</div>
<DropdownMenuSeparator />
</div>
<DropdownMenuSeparator className="my-2" />
<div className="p-2 flex justify-evenly items-center">
<Button
variant="select"
@ -592,30 +593,17 @@ export function GeneralFilterContent({
<DropdownMenuSeparator />
<div className="my-2.5 flex flex-col gap-2.5">
{allLabels.map((item) => (
<div className="flex justify-between items-center">
<Label
className="w-full mx-2 text-primary capitalize cursor-pointer"
htmlFor={item}
>
{item.replaceAll("_", " ")}
</Label>
<Switch
key={item}
className="ml-1"
id={item}
checked={currentLabels?.includes(item) ?? false}
<FilterSwitch
label={item.replaceAll("_", " ")}
isChecked={currentLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedLabels = currentLabels
? [...currentLabels]
: [];
const updatedLabels = currentLabels ? [...currentLabels] : [];
updatedLabels.push(item);
setCurrentLabels(updatedLabels);
} else {
const updatedLabels = currentLabels
? [...currentLabels]
: [];
const updatedLabels = currentLabels ? [...currentLabels] : [];
// can not deselect the last item
if (updatedLabels.length > 1) {
@ -625,7 +613,6 @@ export function GeneralFilterContent({
}
}}
/>
</div>
))}
</div>
</div>

View File

@ -0,0 +1,139 @@
import { Button } from "../ui/button";
import { FaFilter } from "react-icons/fa";
import { isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { PolygonType } from "@/types/canvas";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
type ZoneMaskFilterButtonProps = {
selectedZoneMask?: PolygonType[];
updateZoneMaskFilter: (labels: PolygonType[] | undefined) => void;
};
export function ZoneMaskFilterButton({
selectedZoneMask,
updateZoneMaskFilter,
}: ZoneMaskFilterButtonProps) {
const trigger = (
<Button
size="sm"
variant={selectedZoneMask?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<FaFilter
className={`${selectedZoneMask?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`hidden md:block ${selectedZoneMask?.length ? "text-selected-foreground" : "text-primary"}`}
>
Filter
</div>
</Button>
);
const content = (
<GeneralFilterContent
selectedZoneMask={selectedZoneMask}
updateZoneMaskFilter={updateZoneMaskFilter}
/>
);
if (isMobile) {
return (
<Drawer>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] p-3 mx-1 overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<Popover>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
);
}
type GeneralFilterContentProps = {
selectedZoneMask: PolygonType[] | undefined;
updateZoneMaskFilter: (labels: PolygonType[] | undefined) => void;
};
export function GeneralFilterContent({
selectedZoneMask,
updateZoneMaskFilter,
}: GeneralFilterContentProps) {
return (
<>
<div className="h-auto overflow-y-auto overflow-x-hidden">
<div className="flex justify-between items-center my-2.5">
<Label
className="mx-2 text-primary cursor-pointer"
htmlFor="allLabels"
>
All Masks and Zones
</Label>
<Switch
className="ml-1"
id="allLabels"
checked={selectedZoneMask == undefined}
onCheckedChange={(isChecked) => {
if (isChecked) {
updateZoneMaskFilter(undefined);
}
}}
/>
</div>
<DropdownMenuSeparator />
<div className="my-2.5 flex flex-col gap-2.5">
{["zone", "motion_mask", "object_mask"].map((item) => (
<div key={item} className="flex justify-between items-center">
<Label
className="w-full mx-2 text-primary capitalize cursor-pointer"
htmlFor={item}
>
{item
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase()) + "s"}
</Label>
<Switch
key={item}
className="ml-1"
id={item}
checked={
selectedZoneMask?.includes(item as PolygonType) ?? false
}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedLabels = selectedZoneMask
? [...selectedZoneMask]
: [];
updatedLabels.push(item as PolygonType);
updateZoneMaskFilter(updatedLabels);
} else {
const updatedLabels = selectedZoneMask
? [...selectedZoneMask]
: [];
// can not deselect the last item
if (updatedLabels.length > 1) {
updatedLabels.splice(
updatedLabels.indexOf(item as PolygonType),
1,
);
updateZoneMaskFilter(updatedLabels);
}
}
}}
/>
</div>
))}
</div>
</div>
</>
);
}

View File

@ -72,7 +72,7 @@ export function ThresholdBarGraph({
} else if (value >= threshold.warning) {
return "#FF9966";
} else {
return (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5";
return "#217930";
}
},
],
@ -90,6 +90,13 @@ export function ThresholdBarGraph({
distributed: true,
},
},
states: {
active: {
filter: {
type: "none",
},
},
},
tooltip: {
theme: systemTheme || theme,
y: {
@ -192,6 +199,18 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
horizontal: true,
},
},
states: {
active: {
filter: {
type: "none",
},
},
hover: {
filter: {
type: "none",
},
},
},
tooltip: {
enabled: false,
},

View File

@ -3,16 +3,18 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { isDesktop } from "react-device-detect";
import { VscAccount } from "react-icons/vsc";
import { Button } from "../ui/button";
export default function AccountSettings() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost">
<VscAccount />
</Button>
<div
className={`flex flex-col justify-center items-center ${isDesktop ? "rounded-lg text-secondary-foreground bg-secondary hover:bg-muted cursor-pointer" : "text-secondary-foreground"}`}
>
<VscAccount className="size-5 md:m-[6px]" />
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>Account</p>

View File

@ -118,9 +118,11 @@ export default function GeneralSettings({ className }: GeneralSettings) {
<a href="#">
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost">
<LuSettings />
</Button>
<div
className={`flex flex-col justify-center items-center ${isDesktop ? "rounded-lg text-secondary-foreground bg-secondary hover:bg-muted cursor-pointer" : "text-secondary-foreground"}`}
>
<LuSettings className="size-5 md:m-[6px]" />
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>Settings</p>

View File

@ -6,8 +6,8 @@ import { FrigateStats } from "@/types/stats";
import { useFrigateStats } from "@/api/ws";
import { useMemo } from "react";
import useStats from "@/hooks/use-stats";
import GeneralSettings from "../settings/GeneralSettings";
import AccountSettings from "../settings/AccountSettings";
import GeneralSettings from "../menu/GeneralSettings";
import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
function Bottombar() {

View File

@ -11,8 +11,8 @@ import { IconType } from "react-icons";
const variants = {
primary: {
active: "font-bold text-white bg-selected",
inactive: "text-secondary-foreground bg-secondary",
active: "font-bold text-white bg-selected hover:bg-selected/80",
inactive: "text-secondary-foreground bg-secondary hover:bg-muted",
},
secondary: {
active: "font-bold text-selected",

View File

@ -2,8 +2,8 @@ import Logo from "../Logo";
import NavItem from "./NavItem";
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
import { useLocation } from "react-router-dom";
import GeneralSettings from "../settings/GeneralSettings";
import AccountSettings from "../settings/AccountSettings";
import GeneralSettings from "../menu/GeneralSettings";
import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
function Sidebar() {
@ -32,7 +32,7 @@ function Sidebar() {
);
})}
</div>
<div className="flex flex-col items-center mb-8">
<div className="flex flex-col items-center gap-4 mb-8">
<GeneralSettings />
<AccountSettings />
</div>

View File

@ -28,7 +28,9 @@ export default function LogInfoDialog({
}
}}
>
<Content className={isDesktop ? "" : "max-h-[75dvh] p-2 overflow-hidden"}>
<Content
className={isDesktop ? "" : "max-h-[75dvh] p-2 pb-4 overflow-hidden"}
>
{logLine && (
<div className="size-full flex flex-col gap-5">
<div className="w-min flex flex-col gap-1.5">

View File

@ -163,6 +163,7 @@ export default function LivePlayer({
camera={cameraConfig.name}
showFps={false}
reloadInterval={stillReloadInterval}
cameraClasses="relative w-full h-full flex justify-center"
/>
</div>

View File

@ -239,7 +239,7 @@ export default function PreviewThumbnailPlayer({
<Chip
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "from-green-600 to-green-700 bg-green-600" : "from-gray-400 to-gray-500 bg-gray-500"} z-0`}
>
{review.data.objects.map((object) => {
{review.data.objects.sort().map((object) => {
return getIconForLabel(object, "size-3 text-white");
})}
{review.data.audio.map((audio) => {
@ -252,8 +252,18 @@ export default function PreviewThumbnailPlayer({
</TooltipTrigger>
</div>
<TooltipContent className="capitalize">
{[...(review.data.objects || []), ...(review.data.audio || [])]
.filter((item) => item !== undefined)
{[
...new Set([
...(review.data.objects || []),
...(review.data.sub_labels || []),
...(review.data.audio || []),
]),
]
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
.map((text) => text.charAt(0).toUpperCase() + text.substring(1))
.sort()
.join(", ")
.replaceAll("-verified", "")}
</TooltipContent>
@ -332,15 +342,19 @@ type VideoPreviewProps = {
relevantPreview: Preview;
startTime: number;
endTime?: number;
showProgress?: boolean;
loop?: boolean;
setReviewed: () => void;
setIgnoreClick: (ignore: boolean) => void;
isPlayingBack: (ended: boolean) => void;
onTimeUpdate?: (time: number | undefined) => void;
};
function VideoPreview({
export function VideoPreview({
relevantPreview,
startTime,
endTime,
showProgress = true,
loop = false,
setReviewed,
setIgnoreClick,
isPlayingBack,
@ -415,6 +429,11 @@ function VideoPreview({
if (playerPercent > 100) {
setReviewed();
if (loop && playerRef.current) {
playerRef.current.currentTime = playerStartTime;
return;
}
if (isMobile) {
isPlayingBack(false);
@ -501,6 +520,7 @@ function VideoPreview({
const onStopManualSeek = useCallback(() => {
setTimeout(() => {
setIgnoreClick(false);
setHoverTimeout(undefined);
if (isSafari || (isFirefox && isMobile)) {
setManualPlayback(true);
@ -543,9 +563,10 @@ function VideoPreview({
>
<source src={relevantPreview.src} type={relevantPreview.type} />
</video>
{showProgress && (
<NoThumbSlider
ref={sliderRef}
className="absolute inset-x-0 bottom-0 z-30"
className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${hoverTimeout != undefined ? "h-4" : "h-2"}`}
value={[progress]}
onValueChange={onManualSeek}
onValueCommit={onStopManualSeek}
@ -554,6 +575,7 @@ function VideoPreview({
max={100}
onMouseMove={isMobile ? undefined : onProgressHover}
/>
)}
</div>
);
}
@ -562,14 +584,18 @@ const MIN_LOAD_TIMEOUT_MS = 200;
type InProgressPreviewProps = {
review: ReviewSegment;
timeRange: TimeRange;
showProgress?: boolean;
loop?: boolean;
setReviewed: (reviewId: string) => void;
setIgnoreClick: (ignore: boolean) => void;
isPlayingBack: (ended: boolean) => void;
onTimeUpdate?: (time: number | undefined) => void;
};
function InProgressPreview({
export function InProgressPreview({
review,
timeRange,
showProgress = true,
loop = false,
setReviewed,
setIgnoreClick,
isPlayingBack,
@ -605,6 +631,11 @@ function InProgressPreview({
setReviewed(review.id);
}
if (loop) {
setKey(0);
return;
}
if (isMobile) {
isPlayingBack(false);
@ -707,9 +738,10 @@ function InProgressPreview({
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.webp`}
onLoad={handleLoad}
/>
{showProgress && (
<NoThumbSlider
ref={sliderRef}
className="absolute inset-x-0 bottom-0 z-30"
className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${manualFrame ? "h-4" : "h-2"}`}
value={[key]}
onValueChange={onManualSeek}
onValueCommit={onStopManualSeek}
@ -718,6 +750,7 @@ function InProgressPreview({
max={previewFrames.length - 1}
onMouseMove={isMobile ? undefined : onProgressHover}
/>
)}
</div>
);
}

View File

@ -1,7 +1,7 @@
import { Recording } from "@/types/record";
import { DynamicPlayback } from "@/types/playback";
import { PreviewController } from "../PreviewPlayer";
import { Timeline } from "@/types/timeline";
import { TimeRange, Timeline } from "@/types/timeline";
type PlayerMode = "playback" | "scrubbing";
@ -10,11 +10,13 @@ export class DynamicVideoController {
public camera = "";
private playerController: HTMLVideoElement;
private previewController: PreviewController;
private setNoRecording: (noRecs: boolean) => void;
private setFocusedItem: (timeline: Timeline) => void;
private playerMode: PlayerMode = "playback";
// playback
private recordings: Recording[] = [];
private timeRange: TimeRange = { after: 0, before: 0 };
private annotationOffset: number;
private timeToStart: number | undefined = undefined;
@ -24,6 +26,7 @@ export class DynamicVideoController {
previewController: PreviewController,
annotationOffset: number,
defaultMode: PlayerMode,
setNoRecording: (noRecs: boolean) => void,
setFocusedItem: (timeline: Timeline) => void,
) {
this.camera = camera;
@ -31,11 +34,13 @@ export class DynamicVideoController {
this.previewController = previewController;
this.annotationOffset = annotationOffset;
this.playerMode = defaultMode;
this.setNoRecording = setNoRecording;
this.setFocusedItem = setFocusedItem;
}
newPlayback(newPlayback: DynamicPlayback) {
this.recordings = newPlayback.recordings;
this.timeRange = newPlayback.timeRange;
if (this.timeToStart) {
this.seekToTimestamp(this.timeToStart);
@ -52,12 +57,17 @@ export class DynamicVideoController {
}
seekToTimestamp(time: number, play: boolean = false) {
if (time < this.timeRange.after || time > this.timeRange.before) {
this.timeToStart = time;
return;
}
if (
this.recordings.length == 0 ||
time < this.recordings[0].start_time ||
time > this.recordings[this.recordings.length - 1].end_time
) {
this.timeToStart = time;
this.setNoRecording(true);
return;
}
@ -90,6 +100,8 @@ export class DynamicVideoController {
} else {
this.playerController.pause();
}
} else {
console.log(`seek time is 0`);
}
}

View File

@ -45,6 +45,7 @@ export default function DynamicVideoPlayer({
const playerRef = useRef<HTMLVideoElement | null>(null);
const [previewController, setPreviewController] =
useState<PreviewController | null>(null);
const [noRecording, setNoRecording] = useState(false);
const controller = useMemo(() => {
if (!config || !playerRef.current || !previewController) {
return undefined;
@ -56,6 +57,7 @@ export default function DynamicVideoPlayer({
previewController,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
isScrubbing ? "scrubbing" : "playback",
setNoRecording,
() => {},
);
// we only want to fire once when players are ready
@ -89,7 +91,15 @@ export default function DynamicVideoPlayer({
if (!isScrubbing) {
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
}
}, [isScrubbing]);
return () => {
if (loadingTimeout) {
clearTimeout(loadingTimeout);
}
};
// we only want trigger when scrubbing state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera, isScrubbing]);
const onPlayerLoaded = useCallback(() => {
if (!controller || !startTimestamp) {
@ -105,9 +115,13 @@ export default function DynamicVideoPlayer({
return;
}
if (isLoading) {
setIsLoading(false);
}
onTimestampUpdate(controller.getProgress(time));
},
[controller, onTimestampUpdate, isScrubbing],
[controller, onTimestampUpdate, isScrubbing, isLoading],
);
// state of playback player
@ -139,6 +153,7 @@ export default function DynamicVideoPlayer({
controller.newPlayback({
recordings: recordings ?? [],
timeRange,
});
// we only want this to change when recordings update
@ -165,6 +180,7 @@ export default function DynamicVideoPlayer({
}
setIsLoading(false);
setNoRecording(false);
}}
/>
<PreviewPlayer
@ -178,8 +194,13 @@ export default function DynamicVideoPlayer({
setPreviewController(previewController);
}}
/>
{isLoading && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x1/2 -translate-y-1/2" />
{isLoading && !noRecording && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
{!isScrubbing && noRecording && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No recordings found for this time
</div>
)}
</>
);

View File

@ -0,0 +1,40 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
export default function General() {
return (
<>
<Heading as="h2">Settings</Heading>
<div className="flex items-center space-x-2 mt-5">
<Switch id="lowdata" checked={false} onCheckedChange={() => {}} />
<Label htmlFor="lowdata">Low Data Mode (this device only)</Label>
</div>
<div className="flex items-center space-x-2 mt-5">
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Another General Option" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Live Mode</SelectLabel>
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
<SelectItem value="mse">MSE</SelectItem>
<SelectItem value="webrtc">WebRTC</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</>
);
}

View File

@ -0,0 +1,634 @@
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PolygonCanvas } from "./PolygonCanvas";
import { Polygon, PolygonType } from "@/types/canvas";
import { interpolatePoints, parseCoordinates } from "@/utils/canvasUtil";
import { Skeleton } from "../ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer";
import { LuExternalLink, LuPlus } from "react-icons/lu";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import Heading from "../ui/heading";
import ZoneEditPane from "./ZoneEditPane";
import MotionMaskEditPane from "./MotionMaskEditPane";
import ObjectMaskEditPane from "./ObjectMaskEditPane";
import PolygonItem from "./PolygonItem";
import { Link } from "react-router-dom";
import { isDesktop } from "react-device-detect";
type MasksAndZoneProps = {
selectedCamera: string;
selectedZoneMask?: PolygonType[];
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function MasksAndZones({
selectedCamera,
selectedZoneMask,
setUnsavedChanges,
}: MasksAndZoneProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [activePolygonIndex, setActivePolygonIndex] = useState<
number | undefined
>(undefined);
const [hoveredPolygonIndex, setHoveredPolygonIndex] = useState<number | null>(
null,
);
const containerRef = useRef<HTMLDivElement | null>(null);
const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
const aspectRatio = useMemo(() => {
if (!config) {
return undefined;
}
const camera = config.cameras[selectedCamera];
if (!camera) {
return undefined;
}
return camera.detect.width / camera.detect.height;
}, [config, selectedCamera]);
const detectHeight = useMemo(() => {
if (!config) {
return undefined;
}
const camera = config.cameras[selectedCamera];
if (!camera) {
return undefined;
}
return camera.detect.height;
}, [config, selectedCamera]);
const stretch = true;
// may need tweaking for mobile
const fitAspect = isDesktop ? 16 / 9 : 3 / 4;
const scaledHeight = useMemo(() => {
if (containerRef.current && aspectRatio && detectHeight) {
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(
Math.min(containerHeight, containerRef.current?.clientHeight),
)
: isDesktop || aspectRatio > fitAspect
? Math.floor(containerWidth / aspectRatio)
: Math.floor(containerWidth / aspectRatio) / 1.5;
const finalHeight = stretch
? scaledHeight
: Math.min(scaledHeight, detectHeight);
if (finalHeight > 0) {
return finalHeight;
}
}
}, [
aspectRatio,
containerWidth,
containerHeight,
fitAspect,
detectHeight,
stretch,
]);
const scaledWidth = useMemo(() => {
if (aspectRatio && scaledHeight) {
return Math.ceil(scaledHeight * aspectRatio);
}
}, [scaledHeight, aspectRatio]);
const handleNewPolygon = (type: PolygonType) => {
if (!cameraConfig) {
return;
}
setActivePolygonIndex(allPolygons.length);
let polygonColor = [128, 128, 0];
if (type == "motion_mask") {
polygonColor = [0, 0, 220];
}
if (type == "object_mask") {
polygonColor = [128, 128, 128];
}
setEditingPolygons([
...(allPolygons || []),
{
points: [],
isFinished: false,
type,
typeIndex: 9999,
name: "",
objects: [],
camera: selectedCamera,
color: polygonColor,
},
]);
};
const handleCancel = useCallback(() => {
setEditPane(undefined);
setEditingPolygons([...allPolygons]);
setActivePolygonIndex(undefined);
setHoveredPolygonIndex(null);
setUnsavedChanges(false);
}, [allPolygons, setUnsavedChanges]);
const handleSave = useCallback(() => {
setAllPolygons([...(editingPolygons ?? [])]);
setHoveredPolygonIndex(null);
setUnsavedChanges(false);
}, [editingPolygons, setUnsavedChanges]);
useEffect(() => {
if (isLoading) {
return;
}
if (!isLoading && editPane !== undefined) {
setActivePolygonIndex(undefined);
setEditPane(undefined);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);
const handleCopyCoordinates = useCallback(
(index: number) => {
if (allPolygons && scaledWidth && scaledHeight) {
const poly = allPolygons[index];
copy(
interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1)
.map((point) => `${point[0]},${point[1]}`)
.join(","),
);
toast.success(`Copied coordinates for ${poly.name} to clipboard.`);
} else {
toast.error("Could not copy coordinates to clipboard.");
}
},
[allPolygons, scaledHeight, scaledWidth],
);
useEffect(() => {
if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) {
const zones = Object.entries(cameraConfig.zones).map(
([name, zoneData], index) => ({
type: "zone" as PolygonType,
typeIndex: index,
camera: cameraConfig.name,
name,
objects: zoneData.objects,
points: interpolatePoints(
parseCoordinates(zoneData.coordinates),
1,
1,
scaledWidth,
scaledHeight,
),
isFinished: true,
color: zoneData.color,
}),
);
let motionMasks: Polygon[] = [];
let globalObjectMasks: Polygon[] = [];
let objectMasks: Polygon[] = [];
// this can be an array or a string
motionMasks = (
Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: cameraConfig.motion.mask
? [cameraConfig.motion.mask]
: []
).map((maskData, index) => ({
type: "motion_mask" as PolygonType,
typeIndex: index,
camera: cameraConfig.name,
name: `Motion Mask ${index + 1}`,
objects: [],
points: interpolatePoints(
parseCoordinates(maskData),
1,
1,
scaledWidth,
scaledHeight,
),
isFinished: true,
color: [0, 0, 255],
}));
const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask)
? cameraConfig.objects.mask
: cameraConfig.objects.mask
? [cameraConfig.objects.mask]
: [];
globalObjectMasks = globalObjectMasksArray.map((maskData, index) => ({
type: "object_mask" as PolygonType,
typeIndex: index,
camera: cameraConfig.name,
name: `Object Mask ${index + 1} (all objects)`,
objects: [],
points: interpolatePoints(
parseCoordinates(maskData),
1,
1,
scaledWidth,
scaledHeight,
),
isFinished: true,
color: [128, 128, 128],
}));
const globalObjectMasksCount = globalObjectMasks.length;
let index = 0;
objectMasks = Object.entries(cameraConfig.objects.filters)
.filter(([, { mask }]) => mask || Array.isArray(mask))
.flatMap(([objectName, { mask }]): Polygon[] => {
const maskArray = Array.isArray(mask) ? mask : mask ? [mask] : [];
return maskArray.flatMap((maskItem, subIndex) => {
const maskItemString = maskItem;
const newMask = {
type: "object_mask" as PolygonType,
typeIndex: subIndex,
camera: cameraConfig.name,
name: `Object Mask ${globalObjectMasksCount + index + 1} (${objectName})`,
objects: [objectName],
points: interpolatePoints(
parseCoordinates(maskItem),
1,
1,
scaledWidth,
scaledHeight,
),
isFinished: true,
color: [128, 128, 128],
};
index++;
if (
globalObjectMasksArray.some(
(globalMask) => globalMask === maskItemString,
)
) {
index--;
return [];
} else {
return [newMask];
}
});
});
setAllPolygons([
...zones,
...motionMasks,
...globalObjectMasks,
...objectMasks,
]);
setEditingPolygons([
...zones,
...motionMasks,
...globalObjectMasks,
...objectMasks,
]);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraConfig, containerRef, scaledHeight, scaledWidth]);
useEffect(() => {
if (editPane === undefined) {
setEditingPolygons([...allPolygons]);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditingPolygons, allPolygons]);
useEffect(() => {
if (selectedCamera) {
setActivePolygonIndex(undefined);
setEditPane(undefined);
}
}, [selectedCamera]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
}
return (
<>
{cameraConfig && editingPolygons && (
<div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" />
<div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 mb-10 md:mb-0 md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
{editPane == "zone" && (
<ZoneEditPane
polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex}
scaledWidth={scaledWidth}
scaledHeight={scaledHeight}
isLoading={isLoading}
setIsLoading={setIsLoading}
onCancel={handleCancel}
onSave={handleSave}
/>
)}
{editPane == "motion_mask" && (
<MotionMaskEditPane
polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex}
scaledWidth={scaledWidth}
scaledHeight={scaledHeight}
isLoading={isLoading}
setIsLoading={setIsLoading}
onCancel={handleCancel}
onSave={handleSave}
/>
)}
{editPane == "object_mask" && (
<ObjectMaskEditPane
polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex}
scaledWidth={scaledWidth}
scaledHeight={scaledHeight}
isLoading={isLoading}
setIsLoading={setIsLoading}
onCancel={handleCancel}
onSave={handleSave}
/>
)}
{editPane === undefined && (
<>
<Heading as="h3" className="my-2">
Masks / Zones
</Heading>
<div className="flex flex-col w-full">
{(selectedZoneMask === undefined ||
selectedZoneMask.includes("zone" as PolygonType)) && (
<div className="mt-0 pt-0 last:pb-3 last:border-b-[1px] last:border-secondary">
<div className="flex flex-row justify-between items-center my-3">
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">Zones</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex flex-col gap-2 text-sm text-primary-variant my-2">
<p>
Zones allow you to define a specific area of the
frame so you can determine whether or not an
object is within a particular area.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/zones"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
<LuExternalLink className="size-3 ml-2 inline-flex" />
</Link>
</div>
</div>
</HoverCardContent>
</HoverCard>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
className="size-6 p-1 rounded-md text-background bg-secondary-foreground"
onClick={() => {
setEditPane("zone");
handleNewPolygon("zone");
}}
>
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Zone</TooltipContent>
</Tooltip>
</div>
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "zone" ? [{ polygon, index }] : [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
handleCopyCoordinates={handleCopyCoordinates}
/>
))}
</div>
)}
{(selectedZoneMask === undefined ||
selectedZoneMask.includes(
"motion_mask" as PolygonType,
)) && (
<div className="first:mt-0 mt-3 first:pt-0 pt-3 last:pb-3 border-t-[1px] last:border-b-[1px] first:border-transparent border-secondary">
<div className="flex flex-row justify-between items-center my-3">
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
Motion Masks
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex flex-col gap-2 text-sm text-primary-variant my-2">
<p>
Motion masks are used to prevent unwanted types
of motion from triggering detection. Over
masking will make it more difficult for objects
to be tracked.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#motion-masks"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
<LuExternalLink className="size-3 ml-2 inline-flex" />
</Link>
</div>
</div>
</HoverCardContent>
</HoverCard>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
className="size-6 p-1 rounded-md text-background bg-secondary-foreground"
onClick={() => {
setEditPane("motion_mask");
handleNewPolygon("motion_mask");
}}
>
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Motion Mask</TooltipContent>
</Tooltip>
</div>
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "motion_mask"
? [{ polygon, index }]
: [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
handleCopyCoordinates={handleCopyCoordinates}
/>
))}
</div>
)}
{(selectedZoneMask === undefined ||
selectedZoneMask.includes(
"object_mask" as PolygonType,
)) && (
<div className="first:mt-0 mt-3 first:pt-0 pt-3 last:pb-3 border-t-[1px] last:border-b-[1px] first:border-transparent border-secondary">
<div className="flex flex-row justify-between items-center my-3">
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
Object Masks
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex flex-col gap-2 text-sm text-primary-variant my-2">
<p>
Object filter masks are used to filter out false
positives for a given object type based on
location.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#object-filter-masks"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
<LuExternalLink className="size-3 ml-2 inline-flex" />
</Link>
</div>
</div>
</HoverCardContent>
</HoverCard>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
className="size-6 p-1 rounded-md text-background bg-secondary-foreground"
onClick={() => {
setEditPane("object_mask");
handleNewPolygon("object_mask");
}}
>
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Object Mask</TooltipContent>
</Tooltip>
</div>
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "object_mask"
? [{ polygon, index }]
: [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
handleCopyCoordinates={handleCopyCoordinates}
/>
))}
</div>
)}
</div>
</>
)}
</div>
<div
ref={containerRef}
className="flex md:w-7/12 md:grow md:h-dvh max-h-[50%] md:max-h-full"
>
<div className="flex flex-row justify-center mx-auto size-full">
{cameraConfig &&
scaledWidth &&
scaledHeight &&
editingPolygons ? (
<PolygonCanvas
camera={cameraConfig.name}
width={scaledWidth}
height={scaledHeight}
polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
selectedZoneMask={selectedZoneMask}
/>
) : (
<Skeleton className="size-full" />
)}
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,268 @@
import Heading from "../ui/heading";
import { Separator } from "../ui/separator";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { useCallback, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
import { Polygon } from "@/types/canvas";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import {
flattenPoints,
interpolatePoints,
parseCoordinates,
} from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
type MotionMaskEditPaneProps = {
polygons?: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex?: number;
scaledWidth?: number;
scaledHeight?: number;
isLoading: boolean;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void;
onCancel?: () => void;
};
export default function MotionMaskEditPane({
polygons,
setPolygons,
activePolygonIndex,
scaledWidth,
scaledHeight,
isLoading,
setIsLoading,
onSave,
onCancel,
}: MotionMaskEditPaneProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) {
return polygons[activePolygonIndex];
} else {
return null;
}
}, [polygons, activePolygonIndex]);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
}
}, [polygon, config]);
const defaultName = useMemo(() => {
if (!polygons) {
return;
}
const count = polygons.filter((poly) => poly.type == "motion_mask").length;
return `Motion Mask ${count + 1}`;
}, [polygons]);
const formSchema = z
.object({
polygon: z.object({ name: z.string(), isFinished: z.boolean() }),
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
path: ["polygon.isFinished"],
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName },
},
});
const saveToConfig = useCallback(async () => {
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
return;
}
const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
let index = Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask.length
: cameraConfig.motion.mask
? 1
: 0;
const editingMask = polygon.name.length > 0;
// editing existing mask, not creating a new one
if (editingMask) {
index = polygon.typeIndex;
}
const filteredMask = (
Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: [cameraConfig.motion.mask]
).filter((_, currentIndex) => currentIndex !== index);
filteredMask.splice(index, 0, coordinates);
const queryString = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(parseCoordinates(pointsArray)).join(
",",
);
return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
})
.join("");
axios
.put(`config/set?${queryString}`, {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(`${polygon.name || "Motion Mask"} has been saved.`, {
position: "top-center",
});
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
}, [
updateConfig,
polygon,
scaledWidth,
scaledHeight,
setIsLoading,
cameraConfig,
]);
function onSubmit(values: z.infer<typeof formSchema>) {
if (activePolygonIndex === undefined || !values || !polygons) {
return;
}
setIsLoading(true);
saveToConfig();
if (onSave) {
onSave();
}
}
if (!polygon) {
return;
}
return (
<>
<Toaster position="top-center" />
<Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Motion Mask
</Heading>
<div className="text-sm text-muted-foreground my-2">
<p>
Motion masks are used to prevent unwanted types of motion from
triggering detection. Over masking will make it more difficult for
objects to be tracked.
</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
</div>
<PolygonEditControls
polygons={polygons}
setPolygons={setPolygons}
activePolygonIndex={activePolygonIndex}
/>
</div>
)}
<div className="mb-3 text-sm text-muted-foreground">
Click to draw a polygon on the image.
</div>
<Separator className="my-3 bg-secondary" />
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 flex flex-col flex-1"
>
<FormField
control={form.control}
name="polygon.name"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="polygon.isFinished"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col flex-1 justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
</form>
</Form>
</>
);
}

View File

@ -0,0 +1,303 @@
import Heading from "@/components/ui/heading";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import {
useImproveContrast,
useMotionContourArea,
useMotionThreshold,
} from "@/api/ws";
import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button";
import { Switch } from "../ui/switch";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Separator } from "../ui/separator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
type MotionTunerProps = {
selectedCamera: string;
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
type MotionSettings = {
threshold?: number;
contour_area?: number;
improve_contrast?: boolean;
};
export default function MotionTuner({
selectedCamera,
setUnsavedChanges,
}: MotionTunerProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { send: sendMotionThreshold } = useMotionThreshold(selectedCamera);
const { send: sendMotionContourArea } = useMotionContourArea(selectedCamera);
const { send: sendImproveContrast } = useImproveContrast(selectedCamera);
const [motionSettings, setMotionSettings] = useState<MotionSettings>({
threshold: undefined,
contour_area: undefined,
improve_contrast: undefined,
});
const [origMotionSettings, setOrigMotionSettings] = useState<MotionSettings>({
threshold: undefined,
contour_area: undefined,
improve_contrast: undefined,
});
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
useEffect(() => {
if (cameraConfig) {
setMotionSettings({
threshold: cameraConfig.motion.threshold,
contour_area: cameraConfig.motion.contour_area,
improve_contrast: cameraConfig.motion.improve_contrast,
});
setOrigMotionSettings({
threshold: cameraConfig.motion.threshold,
contour_area: cameraConfig.motion.contour_area,
improve_contrast: cameraConfig.motion.improve_contrast,
});
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCamera]);
useEffect(() => {
if (!motionSettings.threshold) return;
sendMotionThreshold(motionSettings.threshold);
}, [motionSettings.threshold, sendMotionThreshold]);
useEffect(() => {
if (!motionSettings.contour_area) return;
sendMotionContourArea(motionSettings.contour_area);
}, [motionSettings.contour_area, sendMotionContourArea]);
useEffect(() => {
if (motionSettings.improve_contrast === undefined) return;
sendImproveContrast(motionSettings.improve_contrast ? "ON" : "OFF");
}, [motionSettings.improve_contrast, sendImproveContrast]);
const handleMotionConfigChange = (newConfig: Partial<MotionSettings>) => {
setMotionSettings((prevConfig) => ({ ...prevConfig, ...newConfig }));
setUnsavedChanges(true);
setChangedValue(true);
};
const saveToConfig = useCallback(async () => {
setIsLoading(true);
axios
.put(
`config/set?cameras.${selectedCamera}.motion.threshold=${motionSettings.threshold}&cameras.${selectedCamera}.motion.contour_area=${motionSettings.contour_area}&cameras.${selectedCamera}.motion.improve_contrast=${motionSettings.improve_contrast}`,
{ requires_restart: 0 },
)
.then((res) => {
if (res.status === 200) {
toast.success("Motion settings have been saved.", {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
}, [
updateConfig,
motionSettings.threshold,
motionSettings.contour_area,
motionSettings.improve_contrast,
selectedCamera,
]);
const onCancel = useCallback(() => {
setMotionSettings(origMotionSettings);
setChangedValue(false);
}, [origMotionSettings]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
}
return (
<div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" />
<div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 mb-10 md:mb-0 md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
<Heading as="h3" className="my-2">
Motion Detection Tuner
</Heading>
<div className="text-sm text-muted-foreground my-3 space-y-3">
<p>
Frigate uses motion detection as a first line check to see if there
is anything happening in the frame worth checking with object
detection.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/motion_detection"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the Motion Tuning Guide{" "}
<LuExternalLink className="size-3 ml-2 inline-flex" />
</Link>
</div>
</div>
<Separator className="flex my-2 bg-secondary" />
<div className="flex flex-col w-full space-y-6">
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
Threshold
</Label>
<div className="text-sm text-muted-foreground my-2">
<p>
The threshold value dictates how much of a change in a pixel's
luminance is required to be considered motion.{" "}
<em>Default: 30</em>
</p>
</div>
</div>
<div className="flex flex-row justify-between">
<Slider
id="motion-threshold"
className="w-full"
disabled={motionSettings.threshold === undefined}
value={[motionSettings.threshold ?? 0]}
min={5}
max={80}
step={1}
onValueChange={(value) => {
handleMotionConfigChange({ threshold: value[0] });
}}
/>
<div className="text-lg ml-6 mr-2 flex align-center">
{motionSettings.threshold}
</div>
</div>
</div>
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
Contour Area
</Label>
<div className="text-sm text-muted-foreground my-2">
<p>
The contour area value is used to decide which groups of
changed pixels qualify as motion. <em>Default: 10</em>
</p>
</div>
</div>
<div className="flex flex-row justify-between">
<Slider
id="motion-contour-area"
className="w-full"
disabled={motionSettings.contour_area === undefined}
value={[motionSettings.contour_area ?? 0]}
min={5}
max={100}
step={1}
onValueChange={(value) => {
handleMotionConfigChange({ contour_area: value[0] });
}}
/>
<div className="text-lg ml-6 mr-2 flex align-center">
{motionSettings.contour_area}
</div>
</div>
</div>
<Separator className="flex my-2 bg-secondary" />
<div className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="improve-contrast">Improve Contrast</Label>
<div className="text-sm text-muted-foreground">
Improve contrast for darker scenes. <em>Default: ON</em>
</div>
</div>
<Switch
id="improve-contrast"
className="ml-3"
disabled={motionSettings.improve_contrast === undefined}
checked={motionSettings.improve_contrast === true}
onCheckedChange={(isChecked) => {
handleMotionConfigChange({ improve_contrast: isChecked });
}}
/>
</div>
</div>
<div className="flex flex-col flex-1 justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}>
Reset
</Button>
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex flex-1"
onClick={saveToConfig}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
</div>
{cameraConfig ? (
<div className="flex md:w-7/12 md:grow md:h-dvh md:max-h-full">
<div className="size-full min-h-10">
<AutoUpdatingCameraImage
camera={cameraConfig.name}
searchParams={new URLSearchParams([["motion", "1"]])}
showFps={false}
className="size-full"
cameraClasses="relative w-full h-full flex flex-col justify-start"
/>
</div>
</div>
) : (
<Skeleton className="size-full rounded-2xl" />
)}
</div>
);
}

View File

@ -0,0 +1,409 @@
import Heading from "../ui/heading";
import { Separator } from "../ui/separator";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useCallback, useMemo } from "react";
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas";
import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
import {
flattenPoints,
interpolatePoints,
parseCoordinates,
} from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
type ObjectMaskEditPaneProps = {
polygons?: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex?: number;
scaledWidth?: number;
scaledHeight?: number;
isLoading: boolean;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void;
onCancel?: () => void;
};
export default function ObjectMaskEditPane({
polygons,
setPolygons,
activePolygonIndex,
scaledWidth,
scaledHeight,
isLoading,
setIsLoading,
onSave,
onCancel,
}: ObjectMaskEditPaneProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) {
return polygons[activePolygonIndex];
} else {
return null;
}
}, [polygons, activePolygonIndex]);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
}
}, [polygon, config]);
const defaultName = useMemo(() => {
if (!polygons) {
return;
}
const count = polygons.filter((poly) => poly.type == "object_mask").length;
let objectType = "";
const objects = polygon?.objects[0];
if (objects === undefined) {
objectType = "all objects";
} else {
objectType = objects;
}
return `Object Mask ${count + 1} (${objectType})`;
}, [polygons, polygon]);
const formSchema = z
.object({
objects: z.string(),
polygon: z.object({ isFinished: z.boolean(), name: z.string() }),
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
path: ["polygon.isFinished"],
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
objects: polygon?.objects[0] ?? "all_labels",
polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName },
},
});
const saveToConfig = useCallback(
async (
{ objects: form_objects }: ObjectMaskFormValuesType, // values submitted via the form
) => {
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
return;
}
const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
let queryString = "";
let configObject;
let createFilter = false;
let globalMask = false;
let filteredMask = [coordinates];
const editingMask = polygon.name.length > 0;
// global mask on camera for all objects
if (form_objects == "all_labels") {
configObject = cameraConfig.objects.mask;
globalMask = true;
} else {
if (
cameraConfig.objects.filters[form_objects] &&
cameraConfig.objects.filters[form_objects].mask !== null
) {
configObject = cameraConfig.objects.filters[form_objects].mask;
} else {
createFilter = true;
}
}
if (!createFilter) {
let index = Array.isArray(configObject)
? configObject.length
: configObject
? 1
: 0;
if (editingMask) {
index = polygon.typeIndex;
}
// editing existing mask, not creating a new one
if (editingMask) {
index = polygon.typeIndex;
}
filteredMask = (
Array.isArray(configObject) ? configObject : [configObject as string]
).filter((_, currentIndex) => currentIndex !== index);
filteredMask.splice(index, 0, coordinates);
}
queryString = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(parseCoordinates(pointsArray)).join(
",",
);
return globalMask
? `cameras.${polygon?.camera}.objects.mask=${coordinates}&`
: `cameras.${polygon?.camera}.objects.filters.${form_objects}.mask=${coordinates}&`;
})
.join("");
if (!queryString) {
return;
}
axios
.put(`config/set?${queryString}`, {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(`${polygon.name || "Object Mask"} has been saved.`, {
position: "top-center",
});
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
},
[
updateConfig,
polygon,
scaledWidth,
scaledHeight,
setIsLoading,
cameraConfig,
],
);
function onSubmit(values: z.infer<typeof formSchema>) {
if (activePolygonIndex === undefined || !values || !polygons) {
return;
}
setIsLoading(true);
saveToConfig(values as ObjectMaskFormValuesType);
if (onSave) {
onSave();
}
}
if (!polygon) {
return;
}
return (
<>
<Toaster position="top-center" />
<Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Object Mask
</Heading>
<div className="text-sm text-muted-foreground my-2">
<p>
Object filter masks are used to filter out false positives for a given
object type based on location.
</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
</div>
<PolygonEditControls
polygons={polygons}
setPolygons={setPolygons}
activePolygonIndex={activePolygonIndex}
/>
</div>
)}
<div className="mb-3 text-sm text-muted-foreground">
Click to draw a polygon on the image.
</div>
<Separator className="my-3 bg-secondary" />
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 flex flex-col flex-1"
>
<div>
<FormField
control={form.control}
name="polygon.name"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="objects"
render={({ field }) => (
<FormItem>
<FormLabel>Objects</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an object type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<ZoneObjectSelector camera={polygon.camera} />
</SelectContent>
</Select>
<FormDescription>
The object type that that applies to this object mask.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="polygon.isFinished"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-col flex-1 justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
</form>
</Form>
</>
);
}
type ZoneObjectSelectorProps = {
camera: string;
};
export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const cameraConfig = useMemo(() => {
if (config && camera) {
return config.cameras[camera];
}
}, [config, camera]);
const allLabels = useMemo<string[]>(() => {
if (!config || !cameraConfig) {
return [];
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
camera.objects.track.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label);
}
});
});
cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label);
}
});
return [...labels].sort();
}, [config, cameraConfig]);
return (
<>
<SelectGroup>
<SelectItem value="all_labels">All object types</SelectItem>
<SelectSeparator className="bg-secondary" />
{allLabels.map((item) => (
<SelectItem key={item} value={item}>
{item.replaceAll("_", " ").charAt(0).toUpperCase() + item.slice(1)}
</SelectItem>
))}
</SelectGroup>
</>
);
}

View File

@ -0,0 +1,31 @@
import { useMemo } from "react";
import DebugCameraImage from "../camera/DebugCameraImage";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import ActivityIndicator from "../indicators/activity-indicator";
type ObjectSettingsProps = {
selectedCamera?: string;
};
export default function ObjectSettings({
selectedCamera,
}: ObjectSettingsProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
if (!cameraConfig) {
return <ActivityIndicator />;
}
return (
<div className="flex flex-col h-50">
<DebugCameraImage cameraConfig={cameraConfig} className="size-full" />
</div>
);
}

View File

@ -0,0 +1,378 @@
import React, { useMemo, useRef, useState, useEffect } from "react";
import PolygonDrawer from "./PolygonDrawer";
import { Stage, Layer, Image } from "react-konva";
import Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node";
import { Polygon, PolygonType } from "@/types/canvas";
import { useApiHost } from "@/api";
type PolygonCanvasProps = {
camera: string;
width: number;
height: number;
polygons: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex: number | undefined;
hoveredPolygonIndex: number | null;
selectedZoneMask: PolygonType[] | undefined;
};
export function PolygonCanvas({
camera,
width,
height,
polygons,
setPolygons,
activePolygonIndex,
hoveredPolygonIndex,
selectedZoneMask,
}: PolygonCanvasProps) {
const [image, setImage] = useState<HTMLImageElement | undefined>();
const imageRef = useRef<Konva.Image | null>(null);
const stageRef = useRef<Konva.Stage>(null);
const apiHost = useApiHost();
const videoElement = useMemo(() => {
if (camera && width && height) {
const element = new window.Image();
element.width = width;
element.height = height;
element.src = `${apiHost}api/${camera}/latest.jpg`;
return element;
}
}, [camera, width, height, apiHost]);
useEffect(() => {
if (!videoElement) {
return;
}
const onload = function () {
setImage(videoElement);
};
videoElement.addEventListener("load", onload);
return () => {
videoElement.removeEventListener("load", onload);
};
}, [videoElement]);
const getMousePos = (stage: Konva.Stage) => {
return [stage.getPointerPosition()!.x, stage.getPointerPosition()!.y];
};
const addPointToPolygon = (polygon: Polygon, newPoint: number[]) => {
const points = polygon.points;
const pointsOrder = polygon.pointsOrder;
const [newPointX, newPointY] = newPoint;
const updatedPoints = [...points];
let updatedPointsOrder: number[];
if (!pointsOrder) {
updatedPointsOrder = [];
} else {
updatedPointsOrder = [...pointsOrder];
}
let insertIndex = points.length;
for (let i = 0; i < points.length; i++) {
const [x1, y1] = points[i];
const [x2, y2] = i === points.length - 1 ? points[0] : points[i + 1];
if (
(x1 <= newPointX && newPointX <= x2) ||
(x2 <= newPointX && newPointX <= x1)
) {
if (
(y1 <= newPointY && newPointY <= y2) ||
(y2 <= newPointY && newPointY <= y1)
) {
insertIndex = i + 1;
break;
}
}
}
updatedPoints.splice(insertIndex, 0, [newPointX, newPointY]);
updatedPointsOrder.splice(insertIndex, 0, updatedPoints.length);
return { updatedPoints, updatedPointsOrder };
};
const isMouseOverFirstPoint = (polygon: Polygon, mousePos: number[]) => {
if (!polygon || !polygon.points || polygon.points.length < 1) {
return false;
}
const [firstPoint] = polygon.points;
const distance = Math.hypot(
mousePos[0] - firstPoint[0],
mousePos[1] - firstPoint[1],
);
return distance < 10;
};
const isMouseOverAnyPoint = (polygon: Polygon, mousePos: number[]) => {
if (!polygon || !polygon.points || polygon.points.length === 0) {
return false;
}
for (let i = 1; i < polygon.points.length; i++) {
const point = polygon.points[i];
const distance = Math.hypot(
mousePos[0] - point[0],
mousePos[1] - point[1],
);
if (distance < 10) {
return true;
}
}
return false;
};
const handleMouseDown = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
const stage = e.target.getStage()!;
const mousePos = getMousePos(stage);
if (
activePolygon.points.length >= 3 &&
isMouseOverFirstPoint(activePolygon, mousePos)
) {
// Close the polygon
updatedPolygons[activePolygonIndex] = {
...activePolygon,
isFinished: true,
};
setPolygons(updatedPolygons);
} else {
if (
!activePolygon.isFinished &&
!isMouseOverAnyPoint(activePolygon, mousePos)
) {
const { updatedPoints, updatedPointsOrder } = addPointToPolygon(
activePolygon,
mousePos,
);
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: updatedPoints,
pointsOrder: updatedPointsOrder,
};
setPolygons(updatedPolygons);
}
}
// }
};
const handleMouseOverStartPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const activePolygon = polygons[activePolygonIndex];
if (!activePolygon.isFinished && activePolygon.points.length >= 3) {
e.target.getStage()!.container().style.cursor = "default";
e.currentTarget.scale({ x: 2, y: 2 });
}
};
const handleMouseOutStartPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
e.currentTarget.scale({ x: 1, y: 1 });
if (activePolygonIndex === undefined || !polygons) {
return;
}
const activePolygon = polygons[activePolygonIndex];
if (
(!activePolygon.isFinished && activePolygon.points.length >= 3) ||
activePolygon.isFinished
) {
e.currentTarget.scale({ x: 1, y: 1 });
}
};
const handleMouseOverAnyPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (!polygons) {
return;
}
e.target.getStage()!.container().style.cursor = "move";
};
const handleMouseOutAnyPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const activePolygon = polygons[activePolygonIndex];
if (activePolygon.isFinished) {
e.target.getStage()!.container().style.cursor = "default";
} else {
e.target.getStage()!.container().style.cursor = "crosshair";
}
};
const handlePointDragMove = (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
const stage = e.target.getStage();
if (stage) {
const index = e.target.index - 1;
const pos = [e.target._lastPos!.x, e.target._lastPos!.y];
if (pos[0] < 0) pos[0] = 0;
if (pos[1] < 0) pos[1] = 0;
if (pos[0] > stage.width()) pos[0] = stage.width();
if (pos[1] > stage.height()) pos[1] = stage.height();
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: [
...activePolygon.points.slice(0, index),
pos,
...activePolygon.points.slice(index + 1),
],
};
setPolygons(updatedPolygons);
}
};
const handleGroupDragEnd = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (activePolygonIndex !== undefined && e.target.name() === "polygon") {
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
const result: number[][] = [];
activePolygon.points.map((point: number[]) =>
result.push([point[0] + e.target.x(), point[1] + e.target.y()]),
);
e.target.position({ x: 0, y: 0 });
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: result,
};
setPolygons(updatedPolygons);
}
};
const handleStageMouseOver = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
const stage = e.target.getStage()!;
const mousePos = getMousePos(stage);
if (
activePolygon.isFinished ||
isMouseOverAnyPoint(activePolygon, mousePos) ||
isMouseOverFirstPoint(activePolygon, mousePos)
)
return;
e.target.getStage()!.container().style.cursor = "crosshair";
};
useEffect(() => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
// add default points order for already completed polygons
if (!activePolygon.pointsOrder && activePolygon.isFinished) {
updatedPolygons[activePolygonIndex] = {
...activePolygon,
pointsOrder: activePolygon.points.map((_, index) => index),
};
setPolygons(updatedPolygons);
}
}, [activePolygonIndex, polygons, setPolygons]);
return (
<Stage
ref={stageRef}
width={width}
height={height}
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
onMouseOver={handleStageMouseOver}
>
<Layer>
<Image
ref={imageRef}
image={image}
x={0}
y={0}
width={width}
height={height}
/>
{polygons?.map(
(polygon, index) =>
(selectedZoneMask === undefined ||
selectedZoneMask.includes(polygon.type)) &&
index !== activePolygonIndex && (
<PolygonDrawer
key={index}
points={polygon.points}
isActive={index === activePolygonIndex}
isHovered={index === hoveredPolygonIndex}
isFinished={polygon.isFinished}
color={polygon.color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}
handleMouseOverStartPoint={handleMouseOverStartPoint}
handleMouseOutStartPoint={handleMouseOutStartPoint}
handleMouseOverAnyPoint={handleMouseOverAnyPoint}
handleMouseOutAnyPoint={handleMouseOutAnyPoint}
/>
),
)}
{activePolygonIndex !== undefined &&
polygons?.[activePolygonIndex] &&
(selectedZoneMask === undefined ||
selectedZoneMask.includes(polygons[activePolygonIndex].type)) && (
<PolygonDrawer
key={activePolygonIndex}
points={polygons[activePolygonIndex].points}
isActive={true}
isHovered={activePolygonIndex === hoveredPolygonIndex}
isFinished={polygons[activePolygonIndex].isFinished}
color={polygons[activePolygonIndex].color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}
handleMouseOverStartPoint={handleMouseOverStartPoint}
handleMouseOutStartPoint={handleMouseOutStartPoint}
handleMouseOverAnyPoint={handleMouseOverAnyPoint}
handleMouseOutAnyPoint={handleMouseOutAnyPoint}
/>
)}
</Layer>
</Stage>
);
}
export default PolygonCanvas;

View File

@ -0,0 +1,172 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Line, Circle, Group } from "react-konva";
import {
minMax,
toRGBColorString,
dragBoundFunc,
flattenPoints,
} from "@/utils/canvasUtil";
import type { KonvaEventObject } from "konva/lib/Node";
import Konva from "konva";
import { Vector2d } from "konva/lib/types";
type PolygonDrawerProps = {
points: number[][];
isActive: boolean;
isHovered: boolean;
isFinished: boolean;
color: number[];
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleMouseOverStartPoint: (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
handleMouseOutStartPoint: (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
handleMouseOverAnyPoint: (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
handleMouseOutAnyPoint: (
e: KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
};
export default function PolygonDrawer({
points,
isActive,
isHovered,
isFinished,
color,
handlePointDragMove,
handleGroupDragEnd,
handleMouseOverStartPoint,
handleMouseOutStartPoint,
handleMouseOverAnyPoint,
handleMouseOutAnyPoint,
}: PolygonDrawerProps) {
const vertexRadius = 6;
const flattenedPoints = useMemo(() => flattenPoints(points), [points]);
const [stage, setStage] = useState<Konva.Stage>();
const [minMaxX, setMinMaxX] = useState([0, 0]);
const [minMaxY, setMinMaxY] = useState([0, 0]);
const groupRef = useRef<Konva.Group>(null);
const handleGroupMouseOver = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (!isFinished) return;
e.target.getStage()!.container().style.cursor = "move";
setStage(e.target.getStage()!);
};
const handleGroupMouseOut = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (!e.target || !isFinished) return;
e.target.getStage()!.container().style.cursor = "default";
};
const handleGroupDragStart = () => {
const arrX = points.map((p) => p[0]);
const arrY = points.map((p) => p[1]);
setMinMaxX(minMax(arrX));
setMinMaxY(minMax(arrY));
};
const groupDragBound = (pos: Vector2d) => {
if (!stage) {
return pos;
}
let { x, y } = pos;
const sw = stage.width();
const sh = stage.height();
if (minMaxY[0] + y < 0) y = -1 * minMaxY[0];
if (minMaxX[0] + x < 0) x = -1 * minMaxX[0];
if (minMaxY[1] + y > sh) y = sh - minMaxY[1];
if (minMaxX[1] + x > sw) x = sw - minMaxX[1];
return { x, y };
};
const colorString = useCallback(
(darkened: boolean) => {
return toRGBColorString(color, darkened);
},
[color],
);
return (
<Group
name="polygon"
ref={groupRef}
draggable={isActive && isFinished}
onDragStart={isActive ? handleGroupDragStart : undefined}
onDragEnd={isActive ? handleGroupDragEnd : undefined}
dragBoundFunc={isActive ? groupDragBound : undefined}
onMouseOver={isActive ? handleGroupMouseOver : undefined}
onTouchStart={isActive ? handleGroupMouseOver : undefined}
onMouseOut={isActive ? handleGroupMouseOut : undefined}
>
<Line
points={flattenedPoints}
stroke={colorString(true)}
strokeWidth={3}
closed={isFinished}
fill={colorString(isActive || isHovered ? true : false)}
/>
{points.map((point, index) => {
if (!isActive) {
return;
}
const x = point[0];
const y = point[1];
const startPointAttr =
index === 0
? {
hitStrokeWidth: 12,
onMouseOver: handleMouseOverStartPoint,
onMouseOut: handleMouseOutStartPoint,
}
: null;
const otherPointsAttr =
index !== 0
? {
onMouseOver: handleMouseOverAnyPoint,
onMouseOut: handleMouseOutAnyPoint,
}
: null;
return (
<Circle
key={index}
x={x}
y={y}
radius={vertexRadius}
stroke={colorString(true)}
fill="#ffffff"
strokeWidth={3}
draggable={isActive}
onDragMove={isActive ? handlePointDragMove : undefined}
dragBoundFunc={(pos) => {
if (stage) {
return dragBoundFunc(
stage.width(),
stage.height(),
vertexRadius,
pos,
);
} else {
return pos;
}
}}
{...startPointAttr}
{...otherPointsAttr}
/>
);
})}
</Group>
);
}

View File

@ -0,0 +1,100 @@
import { Polygon } from "@/types/canvas";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
import { Button } from "../ui/button";
type PolygonEditControlsProps = {
polygons: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex: number | undefined;
};
export default function PolygonEditControls({
polygons,
setPolygons,
activePolygonIndex,
}: PolygonEditControlsProps) {
const undo = () => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
if (
activePolygon.points.length > 0 &&
activePolygon.pointsOrder &&
activePolygon.pointsOrder.length > 0
) {
const lastPointOrderIndex = activePolygon.pointsOrder.indexOf(
Math.max(...activePolygon.pointsOrder),
);
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: [
...activePolygon.points.slice(0, lastPointOrderIndex),
...activePolygon.points.slice(lastPointOrderIndex + 1),
],
pointsOrder: [
...activePolygon.pointsOrder.slice(0, lastPointOrderIndex),
...activePolygon.pointsOrder.slice(lastPointOrderIndex + 1),
],
isFinished: false,
};
setPolygons(updatedPolygons);
}
};
const reset = () => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: [],
isFinished: false,
};
setPolygons(updatedPolygons);
};
if (activePolygonIndex === undefined || !polygons) {
return;
}
return (
<div className="flex flex-row justify-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className="size-6 p-1 rounded-md"
disabled={!polygons[activePolygonIndex].points.length}
onClick={undo}
>
<MdUndo className="text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Undo</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className="size-6 p-1 rounded-md"
disabled={!polygons[activePolygonIndex].points.length}
onClick={reset}
>
<MdOutlineRestartAlt className="text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Reset</TooltipContent>
</Tooltip>
</div>
);
}

View File

@ -0,0 +1,334 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuCopy, LuPencil } from "react-icons/lu";
import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
import { BsPersonBoundingBox } from "react-icons/bs";
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import { isMobile } from "react-device-detect";
import {
flattenPoints,
parseCoordinates,
toRGBColorString,
} from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useMemo, useState } from "react";
import axios from "axios";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
type PolygonItemProps = {
polygon: Polygon;
index: number;
hoveredPolygonIndex: number | null;
setHoveredPolygonIndex: (index: number | null) => void;
setActivePolygonIndex: (index: number | undefined) => void;
setEditPane: (type: PolygonType) => void;
handleCopyCoordinates: (index: number) => void;
};
export default function PolygonItem({
polygon,
index,
hoveredPolygonIndex,
setHoveredPolygonIndex,
setActivePolygonIndex,
setEditPane,
handleCopyCoordinates,
}: PolygonItemProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
}
}, [polygon, config]);
const polygonTypeIcons = {
zone: FaDrawPolygon,
motion_mask: FaObjectGroup,
object_mask: BsPersonBoundingBox,
};
const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined;
const saveToConfig = useCallback(
async (polygon: Polygon) => {
if (!polygon || !cameraConfig) {
return;
}
let url = "";
if (polygon.type == "zone") {
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
}
if (polygon.type == "motion_mask") {
const filteredMask = (
Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: [cameraConfig.motion.mask]
).filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
url = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(
parseCoordinates(pointsArray),
).join(",");
return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
})
.join("");
if (!url) {
// deleting last mask
url = `cameras.${polygon?.camera}.motion.mask&`;
}
}
if (polygon.type == "object_mask") {
let configObject;
let globalMask = false;
// global mask on camera for all objects
if (!polygon.objects.length) {
configObject = cameraConfig.objects.mask;
globalMask = true;
} else {
configObject = cameraConfig.objects.filters[polygon.objects[0]].mask;
}
if (!configObject) {
return;
}
const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask)
? cameraConfig.objects.mask
: cameraConfig.objects.mask
? [cameraConfig.objects.mask]
: [];
let filteredMask;
if (globalMask) {
filteredMask = (
Array.isArray(configObject) ? configObject : [configObject]
).filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
} else {
filteredMask = (
Array.isArray(configObject) ? configObject : [configObject]
)
.filter((mask) => !globalObjectMasksArray.includes(mask))
.filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
}
url = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(
parseCoordinates(pointsArray),
).join(",");
return globalMask
? `cameras.${polygon?.camera}.objects.mask=${coordinates}&`
: `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask=${coordinates}&`;
})
.join("");
if (!url) {
// deleting last mask
url = globalMask
? `cameras.${polygon?.camera}.objects.mask&`
: `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask`;
}
}
setIsLoading(true);
await axios
.put(`config/set?${url}`, { requires_restart: 0 })
.then((res) => {
if (res.status === 200) {
toast.success(`${polygon?.name} has been deleted.`, {
position: "top-center",
});
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig, cameraConfig],
);
const handleDelete = () => {
setActivePolygonIndex(undefined);
saveToConfig(polygon);
};
return (
<>
<Toaster position="top-center" />
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 my-1.5 transition-background duration-100"
data-index={index}
onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)}
style={{
backgroundColor:
hoveredPolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
<div
className={`flex items-center ${
hoveredPolygonIndex === index
? "text-primary"
: "text-primary-variant"
}`}
>
{PolygonItemIcon && (
<PolygonItemIcon
className="size-5 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
)}
<p className="cursor-default">{polygon.name}</p>
</div>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete the{" "}
{polygon.type.replace("_", " ")} <em>{polygon.name}</em>?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isMobile && (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyCoordinates(index)}>
Copy
</DropdownMenuItem>
<DropdownMenuItem
disabled={isLoading}
onClick={() => setDeleteDialogOpen(true)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!isMobile && hoveredPolygonIndex === index && (
<div className="flex flex-row gap-2 items-center">
<Tooltip>
<TooltipTrigger asChild>
<IconWrapper
icon={LuPencil}
className={`size-[15px] cursor-pointer ${hoveredPolygonIndex === index && "text-primary-variant"}`}
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconWrapper
icon={LuCopy}
className={`size-[15px] cursor-pointer ${
hoveredPolygonIndex === index && "text-primary-variant"
}`}
onClick={() => handleCopyCoordinates(index)}
/>
</TooltipTrigger>
<TooltipContent>Copy coordinates</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconWrapper
icon={HiTrash}
className={`size-[15px] cursor-pointer ${
hoveredPolygonIndex === index &&
"text-primary-variant fill-primary-variant"
}`}
onClick={() => !isLoading && setDeleteDialogOpen(true)}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,649 @@
import Heading from "../ui/heading";
import { Separator } from "../ui/separator";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ZoneFormValuesType, Polygon } from "@/types/canvas";
import { reviewQueries } from "@/utils/zoneEdutUtil";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
import axios from "axios";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import ActivityIndicator from "../indicators/activity-indicator";
type ZoneEditPaneProps = {
polygons?: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex?: number;
scaledWidth?: number;
scaledHeight?: number;
isLoading: boolean;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void;
onCancel?: () => void;
};
export default function ZoneEditPane({
polygons,
setPolygons,
activePolygonIndex,
scaledWidth,
scaledHeight,
isLoading,
setIsLoading,
onSave,
onCancel,
}: ZoneEditPaneProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const cameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) {
return polygons[activePolygonIndex];
} else {
return null;
}
}, [polygons, activePolygonIndex]);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
}
}, [polygon, config]);
const formSchema = z.object({
name: z
.string()
.min(2, {
message: "Zone name must be at least 2 characters.",
})
.transform((val: string) => val.trim().replace(/\s+/g, "_"))
.refine(
(value: string) => {
return !cameras.map((cam) => cam.name).includes(value);
},
{
message: "Zone name must not be the name of a camera.",
},
)
.refine(
(value: string) => {
const otherPolygonNames =
polygons
?.filter((_, index) => index !== activePolygonIndex)
.map((polygon) => polygon.name) || [];
return !otherPolygonNames.includes(value);
},
{
message: "Zone name already exists on this camera.",
},
),
inertia: z.coerce
.number()
.min(1, {
message: "Inertia must be above 0.",
})
.or(z.literal("")),
loitering_time: z.coerce
.number()
.min(0, {
message: "Loitering time must be greater than or equal to 0.",
})
.optional()
.or(z.literal("")),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
}),
objects: z.array(z.string()).optional(),
review_alerts: z.boolean().default(false).optional(),
review_detections: z.boolean().default(false).optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
name: polygon?.name ?? "",
inertia:
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.inertia,
loitering_time:
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time,
isFinished: polygon?.isFinished ?? false,
objects: polygon?.objects ?? [],
review_alerts:
(polygon?.camera &&
polygon?.name &&
config?.cameras[
polygon.camera
]?.review.alerts.required_zones.includes(polygon.name)) ||
false,
review_detections:
(polygon?.camera &&
polygon?.name &&
config?.cameras[
polygon.camera
]?.review.detections.required_zones.includes(polygon.name)) ||
false,
},
});
const saveToConfig = useCallback(
async (
{
name: zoneName,
inertia,
loitering_time,
objects: form_objects,
review_alerts,
review_detections,
}: ZoneFormValuesType, // values submitted via the form
objects: string[],
) => {
if (!scaledWidth || !scaledHeight || !polygon) {
return;
}
let mutatedConfig = config;
const renamingZone = zoneName != polygon.name && polygon.name != "";
if (renamingZone) {
// rename - delete old zone and replace with new
const {
alertQueries: renameAlertQueries,
detectionQueries: renameDetectionQueries,
} = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
try {
await axios.put(
`config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`,
{
requires_restart: 0,
},
);
// Wait for the config to be updated
mutatedConfig = await updateConfig();
} catch (error) {
toast.error(`Failed to save config changes.`, {
position: "top-center",
});
return;
}
}
const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
let objectQueries = objects
.map(
(object) =>
`&cameras.${polygon?.camera}.zones.${zoneName}.objects=${object}`,
)
.join("");
const same_objects =
form_objects.length == objects.length &&
form_objects.every(function (element, index) {
return element === objects[index];
});
// deleting objects
if (!objectQueries && !same_objects && !renamingZone) {
objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`;
}
const { alertQueries, detectionQueries } = reviewQueries(
zoneName,
review_alerts,
review_detections,
polygon.camera,
mutatedConfig?.cameras[polygon.camera]?.review.alerts.required_zones ||
[],
mutatedConfig?.cameras[polygon.camera]?.review.detections
.required_zones || [],
);
let inertiaQuery = "";
if (inertia) {
inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`;
}
let loiteringTimeQuery = "";
if (loitering_time) {
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
}
axios
.put(
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${objectQueries}${alertQueries}${detectionQueries}`,
{ requires_restart: 0 },
)
.then((res) => {
if (res.status === 200) {
toast.success(`Zone (${zoneName}) has been saved.`, {
position: "top-center",
});
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
},
[
config,
updateConfig,
polygon,
scaledWidth,
scaledHeight,
setIsLoading,
cameraConfig,
],
);
function onSubmit(values: z.infer<typeof formSchema>) {
if (activePolygonIndex === undefined || !values || !polygons) {
return;
}
setIsLoading(true);
saveToConfig(
values as ZoneFormValuesType,
polygons[activePolygonIndex].objects,
);
if (onSave) {
onSave();
}
}
if (!polygon) {
return;
}
return (
<>
<Toaster position="top-center" />
<Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Zone
</Heading>
<div className="text-sm text-muted-foreground my-2">
<p>
Zones allow you to define a specific area of the frame so you can
determine whether or not an object is within a particular area.
</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
</div>
<PolygonEditControls
polygons={polygons}
setPolygons={setPolygons}
activePolygonIndex={activePolygonIndex}
/>
</div>
)}
<div className="mb-3 text-sm text-muted-foreground">
Click to draw a polygon on the image.
</div>
<Separator className="my-3 bg-secondary" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
className="w-full p-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="Enter a name..."
{...field}
/>
</FormControl>
<FormDescription>
Name must be at least 2 characters and must not be the name of
a camera or another zone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Separator className="flex my-2 bg-secondary" />
<FormField
control={form.control}
name="inertia"
render={({ field }) => (
<FormItem>
<FormLabel>Inertia</FormLabel>
<FormControl>
<Input
className="w-full p-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="3"
{...field}
/>
</FormControl>
<FormDescription>
Specifies how many frames that an object must be in a zone
before they are considered in the zone. <em>Default: 3</em>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Separator className="flex my-2 bg-secondary" />
<FormField
control={form.control}
name="loitering_time"
render={({ field }) => (
<FormItem>
<FormLabel>Loitering Time</FormLabel>
<FormControl>
<Input
className="w-full p-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0"
{...field}
/>
</FormControl>
<FormDescription>
Sets a minimum amount of time in seconds that the object must
be in the zone for it to activate. <em>Default: 0</em>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Separator className="flex my-2 bg-secondary" />
<FormItem>
<FormLabel>Objects</FormLabel>
<FormDescription>
List of objects that apply to this zone.
</FormDescription>
<ZoneObjectSelector
camera={polygon.camera}
zoneName={polygon.name}
selectedLabels={polygon.objects}
updateLabelFilter={(objects) => {
if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
updatedPolygons[activePolygonIndex] = {
...activePolygon,
objects: objects ?? [],
};
setPolygons(updatedPolygons);
}}
/>
</FormItem>
<Separator className="flex my-2 bg-secondary" />
<FormField
control={form.control}
name="review_alerts"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel>Alerts</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as an
alert.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className="ml-3"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="review_detections"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-base">Detections</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as a
detection.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className="ml-3"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isFinished"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</form>
</Form>
</>
);
}
type ZoneObjectSelectorProps = {
camera: string;
zoneName: string;
selectedLabels: string[];
updateLabelFilter: (labels: string[] | undefined) => void;
};
export function ZoneObjectSelector({
camera,
zoneName,
selectedLabels,
updateLabelFilter,
}: ZoneObjectSelectorProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const cameraConfig = useMemo(() => {
if (config && camera) {
return config.cameras[camera];
}
}, [config, camera]);
const allLabels = useMemo<string[]>(() => {
if (!cameraConfig || !config) {
return [];
}
const labels = new Set<string>();
// Object.values(config.cameras).forEach((camera) => {
// camera.objects.track.forEach((label) => {
// if (!ATTRIBUTE_LABELS.includes(label)) {
// labels.add(label);
// }
// });
// });
cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label);
}
});
if (zoneName) {
if (cameraConfig.zones[zoneName]) {
cameraConfig.zones[zoneName].objects.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label);
}
});
}
}
return [...labels].sort() || [];
}, [config, cameraConfig, zoneName]);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
selectedLabels,
);
useEffect(() => {
updateLabelFilter(currentLabels);
}, [currentLabels, updateLabelFilter]);
return (
<>
<div className="h-auto overflow-y-auto overflow-x-hidden">
<div className="flex justify-between items-center my-2.5">
<Label className="text-primary cursor-pointer" htmlFor="allLabels">
All Objects
</Label>
<Switch
className="ml-1"
id="allLabels"
checked={!currentLabels?.length}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentLabels([]);
}
}}
/>
</div>
<Separator />
<div className="my-2.5 flex flex-col gap-2.5">
{allLabels.map((item) => (
<div key={item} className="flex justify-between items-center">
<Label
className="w-full text-primary capitalize cursor-pointer"
htmlFor={item}
>
{item.replaceAll("_", " ")}
</Label>
<Switch
key={item}
className="ml-1"
id={item}
checked={currentLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedLabels = currentLabels
? [...currentLabels]
: [];
updatedLabels.push(item);
setCurrentLabels(updatedLabels);
} else {
const updatedLabels = currentLabels
? [...currentLabels]
: [];
// can not deselect the last item
if (updatedLabels.length > 1) {
updatedLabels.splice(updatedLabels.indexOf(item), 1);
setCurrentLabels(updatedLabels);
}
}
}}
/>
</div>
))}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,21 @@
import { ForwardedRef, forwardRef } from "react";
import { IconType } from "react-icons";
interface IconWrapperProps {
icon: IconType;
className?: string;
[key: string]: any;
}
const IconWrapper = forwardRef(
(
{ icon: Icon, className, ...props }: IconWrapperProps,
ref: ForwardedRef<HTMLDivElement>,
) => (
<div {...props} ref={ref}>
<Icon className={className} />
</div>
),
);
export default IconWrapper;

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -18,7 +18,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full cursor-pointer border-2 border-primary bg-primary ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
@ -55,7 +55,7 @@ const NoThumbSlider = React.forwardRef<
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Track className="relative h-full w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="absolute h-full bg-selected" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-16 rounded-full bg-transparent -translate-y-[50%] ring-offset-transparent focus-visible:outline-none focus-visible:ring-transparent disabled:pointer-events-none disabled:opacity-50 cursor-col-resize" />

View File

@ -18,6 +18,7 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-muted-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
"data-[state=checked]:bg-background dark:data-[state=checked]:bg-primary",
)}
/>
</SwitchPrimitives.Root>

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { usePersistence } from "./use-persistence";
@ -91,3 +91,30 @@ export function useHashState<S extends string>(): [
return [hash, setHash];
}
export function useSearchEffect(
key: string,
callback: (value: string) => void,
) {
const location = useLocation();
const param = useMemo(() => {
if (!location || !location.search || location.search.length == 0) {
return undefined;
}
const params = location.search.substring(1).split("&");
return params
.find((p) => p.includes("=") && p.split("=")[0] == key)
?.split("=");
}, [location, key]);
useEffect(() => {
if (!param) {
return;
}
callback(param[1]);
}, [param, callback]);
}

View File

@ -1,7 +1,7 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import useApiFilter from "@/hooks/use-api-filter";
import { useTimezone } from "@/hooks/use-date-utils";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import { RecordingStartingPoint } from "@/types/record";
@ -33,6 +33,24 @@ export default function Events() {
const [recording, setRecording] =
useOverlayState<RecordingStartingPoint>("recording");
useSearchEffect("id", (reviewId: string) => {
axios
.get(`review/${reviewId}`)
.then((resp) => {
if (resp.status == 200 && resp.data) {
setRecording(
{
camera: resp.data.camera,
startTime: resp.data.start_time,
severity: resp.data.severity,
},
true,
);
}
})
.catch(() => {});
});
const [startTime, setStartTime] = useState<number>();
useEffect(() => {

View File

@ -10,20 +10,15 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Export } from "@/types/export";
import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
type ExportItem = {
name: string;
};
function Export() {
const { data: allExports, mutate } = useSWR<ExportItem[]>(
"exports/",
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
);
function Exports() {
const { data: exports, mutate } = useSWR<Export[]>("exports");
useEffect(() => {
document.title = "Export - Frigate";
@ -33,17 +28,18 @@ function Export() {
const [search, setSearch] = useState("");
const exports = useMemo(() => {
if (!search || !allExports) {
return allExports;
const filteredExports = useMemo(() => {
if (!search || !exports) {
return exports;
}
return allExports.filter((exp) =>
return exports.filter((exp) =>
exp.name
.toLowerCase()
.includes(search.toLowerCase().replaceAll(" ", "_")),
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [allExports, search]);
}, [exports, search]);
// Deleting
@ -65,8 +61,8 @@ function Export() {
// Renaming
const onHandleRename = useCallback(
(original: string, update: string) => {
axios.patch(`export/${original}/${update}`).then((response) => {
(id: string, update: string) => {
axios.patch(`export/${id}/${update}`).then((response) => {
if (response.status == 200) {
setDeleteClip(undefined);
mutate();
@ -76,6 +72,10 @@ function Export() {
[mutate],
);
// Viewing
const [selected, setSelected] = useState<Export>();
return (
<div className="size-full p-2 overflow-hidden flex flex-col gap-2">
<AlertDialog
@ -91,13 +91,43 @@ function Export() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button variant="destructive" onClick={() => onHandleDelete()}>
<Button
className="text-white"
variant="destructive"
onClick={() => onHandleDelete()}
>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog
open={selected != undefined}
onOpenChange={(open) => {
if (!open) {
setSelected(undefined);
}
}}
>
<DialogContent className="max-w-7xl">
<DialogTitle>{selected?.name}</DialogTitle>
<video
className="size-full rounded-2xl"
playsInline
preload="auto"
autoPlay
controls
muted
>
<source
src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`}
type="video/mp4"
/>
</video>
</DialogContent>
</Dialog>
<div className="w-full p-2 flex items-center justify-center">
<Input
className="w-full md:w-1/3 bg-muted"
@ -108,17 +138,18 @@ function Export() {
</div>
<div className="w-full overflow-hidden">
{allExports && exports && (
{exports && filteredExports && (
<div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto">
{Object.values(allExports).map((item) => (
{Object.values(exports).map((item) => (
<ExportCard
key={item.name}
className={
search == "" || exports.includes(item) ? "" : "hidden"
search == "" || filteredExports.includes(item) ? "" : "hidden"
}
file={item}
exportedRecording={item}
onSelect={setSelected}
onRename={onHandleRename}
onDelete={(file) => setDeleteClip(file)}
onDelete={(id) => setDeleteClip(id)}
/>
))}
</div>
@ -128,4 +159,4 @@ function Export() {
);
}
export default Export;
export default Exports;

View File

@ -31,7 +31,7 @@ function Logs() {
const [logService, setLogService] = useState<LogType>("frigate");
useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Stats - Frigate`;
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
}, [logService]);
// log data handling
@ -352,7 +352,7 @@ function Logs() {
{Object.values(logTypes).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 ${logService == item ? "" : "text-gray-500"}`}
className={`flex items-center justify-between gap-2 ${logService == item ? "" : "text-muted-foreground"}`}
value={item}
aria-label={`Select ${item}`}
>
@ -366,7 +366,7 @@ function Logs() {
size="sm"
onClick={handleCopyLogs}
>
<FaCopy />
<FaCopy className="text-secondary-foreground" />
<div className="hidden md:block text-primary">
Copy to Clipboard
</div>
@ -405,7 +405,7 @@ function Logs() {
</div>
<div
ref={contentRef}
className="w-full flex flex-col overflow-y-auto no-scrollbar"
className="w-full flex flex-col overflow-y-auto no-scrollbar overscroll-contain"
>
{logLines.length > 0 &&
[...Array(logRange.end).keys()].map((idx) => {

View File

@ -1,44 +1,293 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import MotionTuner from "@/components/settings/MotionTuner";
import MasksAndZones from "@/components/settings/MasksAndZones";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useOptimisticState from "@/hooks/use-optimistic-state";
import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import General from "@/components/settings/General";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas";
import ObjectSettings from "@/components/settings/ObjectSettings";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed";
export default function Settings() {
const settingsViews = [
"general",
"objects",
"masks / zones",
"motion tuner",
] as const;
type SettingsType = (typeof settingsViews)[number];
const [page, setPage] = useState<SettingsType>("general");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config");
// TODO: confirm leave page
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const cameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
const handleDialog = useCallback(
(save: boolean) => {
if (unsavedChanges && save) {
// TODO
}
setConfirmationDialogOpen(false);
setUnsavedChanges(false);
},
[unsavedChanges],
);
useEffect(() => {
if (cameras.length) {
setSelectedCamera(cameras[0].name);
}
// only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (tabsRef.current) {
const element = tabsRef.current.querySelector(
`[data-nav-item="${pageToggle}"]`,
);
if (element instanceof HTMLElement) {
scrollIntoView(element, {
behavior: "smooth",
inline: "start",
});
}
}
}, [tabsRef, pageToggle]);
function Settings() {
return (
<>
<Heading as="h2">Settings</Heading>
<div className="flex items-center space-x-2 mt-5">
<Switch id="detect" checked={false} onCheckedChange={() => {}} />
<Label htmlFor="detect">
Always show PTZ controls for ONVIF cameras
</Label>
<div className="size-full p-2 flex flex-col">
<div className="w-full h-11 relative flex justify-between items-center">
<ScrollArea className="w-full whitespace-nowrap">
<div ref={tabsRef} className="flex flex-row">
<ToggleGroup
className="*:px-3 *:py-4 *:rounded-md"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SettingsType) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(settingsViews).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 scroll-mx-10 ${page == "general" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
<div className="capitalize">{item}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
<div className="flex items-center space-x-2 mt-5">
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Default Live Mode" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Live Mode</SelectLabel>
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
<SelectItem value="mse">MSE</SelectItem>
<SelectItem value="webrtc">WebRTC</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</ScrollArea>
{(page == "objects" ||
page == "masks / zones" ||
page == "motion tuner") && (
<div className="flex items-center gap-2 ml-2 flex-shrink-0">
{page == "masks / zones" && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
/>
)}
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
/>
</div>
)}
</div>
<div className="mt-2 flex flex-col items-start w-full h-full md:h-dvh md:pb-24">
{page == "general" && <General />}
{page == "objects" && (
<ObjectSettings selectedCamera={selectedCamera} />
)}
{page == "masks / zones" && (
<MasksAndZones
selectedCamera={selectedCamera}
selectedZoneMask={filterZoneMask}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "motion tuner" && (
<MotionTuner
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
/>
)}
</div>
{confirmationDialogOpen && (
<AlertDialog
open={confirmationDialogOpen}
onOpenChange={() => setConfirmationDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>You have unsaved changes.</AlertDialogTitle>
<AlertDialogDescription>
Do you want to save your changes before continuing?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => handleDialog(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDialog(true)}>
Save
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</>
);
}
export default Settings;
type CameraSelectButtonProps = {
allCameras: CameraConfig[];
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
};
function CameraSelectButton({
allCameras,
selectedCamera,
setSelectedCamera,
}: CameraSelectButtonProps) {
const [open, setOpen] = useState(false);
if (!allCameras.length) {
return;
}
const trigger = (
<Button
className="flex items-center gap-2 capitalize bg-selected hover:bg-selected"
size="sm"
>
<FaVideo className="text-background dark:text-primary" />
<div className="hidden md:block text-background dark:text-primary">
{selectedCamera == undefined
? "No Camera"
: selectedCamera.replaceAll("_", " ")}
</div>
</Button>
);
const content = (
<>
{isMobile && (
<>
<DropdownMenuLabel className="flex justify-center">
Camera
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<div className="h-auto p-4 mb-5 md:mb-1 overflow-y-auto overflow-x-hidden">
<div className="flex flex-col gap-2.5">
{allCameras.map((item) => (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
/>
))}
</div>
</div>
</>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -24,7 +24,7 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { DualThumbSlider } from "@/components/ui/slider";
import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
@ -199,8 +199,6 @@ export default function SubmitPlus() {
);
}
const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
type PlusFilterGroupProps = {
selectedCameras: string[] | undefined;
selectedLabels: string[] | undefined;
@ -237,7 +235,7 @@ function PlusFilterGroup({
cameras.forEach((camera) => {
const cameraConfig = config.cameras[camera];
cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTES.includes(label)) {
if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label);
}
});

View File

@ -52,7 +52,7 @@ function System() {
{Object.values(metrics).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-gray-500"}`}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
aria-label={`Select ${item}`}
>

31
web/src/types/canvas.ts Normal file
View File

@ -0,0 +1,31 @@
export type PolygonType = "zone" | "motion_mask" | "object_mask";
export type Polygon = {
typeIndex: number;
camera: string;
name: string;
type: PolygonType;
objects: string[];
points: number[][];
pointsOrder?: number[];
isFinished: boolean;
color: number[];
};
export type ZoneFormValuesType = {
name: string;
inertia: number;
loitering_time: number;
isFinished: boolean;
objects: string[];
review_alerts: boolean;
review_detections: boolean;
};
export type ObjectMaskFormValuesType = {
objects: string;
polygon: {
isFinished: boolean;
name: string;
};
};

9
web/src/types/export.ts Normal file
View File

@ -0,0 +1,9 @@
export type Export = {
id: string;
camera: string;
name: string;
date: number;
video_path: string;
thumb_path: string;
in_progress: boolean;
};

View File

@ -21,6 +21,14 @@ export interface BirdseyeConfig {
width: number;
}
export const ATTRIBUTE_LABELS = [
"amazon",
"face",
"fedex",
"license_plate",
"ups",
];
export interface CameraConfig {
audio: {
enabled: boolean;
@ -106,7 +114,7 @@ export interface CameraConfig {
objects: {
filters: {
[objectName: string]: {
mask: string | null;
mask: string[] | null;
max_area: number;
max_ratio: number;
min_area: number;
@ -163,6 +171,14 @@ export interface CameraConfig {
};
sync_recordings: boolean;
};
review: {
alerts: {
required_zones: string[];
};
detections: {
required_zones: string[];
};
};
rtmp: {
enabled: boolean;
};
@ -199,7 +215,9 @@ export interface CameraConfig {
coordinates: string;
filters: Record<string, unknown>;
inertia: number;
loitering_time: number;
objects: string[];
color: number[];
};
};
}
@ -327,7 +345,7 @@ export interface FrigateConfig {
objects: {
filters: {
[objectName: string]: {
mask: string | null;
mask: string[] | null;
max_area: number;
max_ratio: number;
min_area: number;
@ -336,7 +354,7 @@ export interface FrigateConfig {
threshold: number;
};
};
mask: string;
mask: string[];
track: string[];
};

View File

@ -18,6 +18,11 @@ export const InferenceThreshold = {
error: 100,
} as Threshold;
export const DetectorTempThreshold = {
warning: 72,
error: 80,
} as Threshold;
export const DetectorCpuThreshold = {
warning: 25,
error: 50,

View File

@ -4,6 +4,7 @@ import { TimeRange } from "./timeline";
export type DynamicPlayback = {
recordings: Recording[];
timeRange: TimeRange;
};
export type PreviewPlayback = {

View File

@ -15,6 +15,7 @@ export type ReviewData = {
audio: string[];
detections: string[];
objects: string[];
sub_labels?: string[];
significant_motion_areas: number[];
zones: string[];
};

102
web/src/utils/canvasUtil.ts Normal file
View File

@ -0,0 +1,102 @@
import { Vector2d } from "konva/lib/types";
export const getAveragePoint = (points: number[]): Vector2d => {
let totalX = 0;
let totalY = 0;
for (let i = 0; i < points.length; i += 2) {
totalX += points[i];
totalY += points[i + 1];
}
return {
x: totalX / (points.length / 2),
y: totalY / (points.length / 2),
};
};
export const getDistance = (node1: number[], node2: number[]): string => {
const diffX = Math.abs(node1[0] - node2[0]);
const diffY = Math.abs(node1[1] - node2[1]);
const distanceInPixel = Math.sqrt(diffX * diffX + diffY * diffY);
return distanceInPixel.toFixed(2);
};
export const dragBoundFunc = (
stageWidth: number,
stageHeight: number,
vertexRadius: number,
pos: Vector2d,
): Vector2d => {
let x = pos.x;
let y = pos.y;
if (pos.x + vertexRadius > stageWidth) x = stageWidth;
if (pos.x - vertexRadius < 0) x = 0;
if (pos.y + vertexRadius > stageHeight) y = stageHeight;
if (pos.y - vertexRadius < 0) y = 0;
return { x, y };
};
export const minMax = (points: number[]): [number, number] => {
return points.reduce(
(acc: [number | undefined, number | undefined], val) => {
acc[0] = acc[0] === undefined || val < acc[0] ? val : acc[0];
acc[1] = acc[1] === undefined || val > acc[1] ? val : acc[1];
return acc;
},
[undefined, undefined],
) as [number, number];
};
export const interpolatePoints = (
points: number[][],
width: number,
height: number,
newWidth: number,
newHeight: number,
): number[][] => {
const newPoints: number[][] = [];
for (const [x, y] of points) {
const newX = Math.min(+((x * newWidth) / width).toFixed(3), newWidth);
const newY = Math.min(+((y * newHeight) / height).toFixed(3), newHeight);
newPoints.push([newX, newY]);
}
return newPoints;
};
export const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(",");
const points = [];
for (let i = 0; i < coordinates.length; i += 2) {
const x = parseFloat(coordinates[i]);
const y = parseFloat(coordinates[i + 1]);
points.push([x, y]);
}
return points;
};
export const flattenPoints = (points: number[][]): number[] => {
return points.reduce((acc, point) => [...acc, ...point], []);
};
export const toRGBColorString = (color: number[], darkened: boolean) => {
if (color.length !== 3) {
return "rgb(220,0,0,0.5)";
}
return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.7" : "0.3"})`;
};
export const masksAreIdentical = (arr1: string[], arr2: string[]): boolean => {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) {
return false;
}
}
return true;
};

View File

@ -0,0 +1,50 @@
export const reviewQueries = (
name: string,
review_alerts: boolean,
review_detections: boolean,
camera: string,
alertsZones: string[],
detectionsZones: string[],
) => {
let alertQueries = "";
let detectionQueries = "";
let same_alerts = false;
let same_detections = false;
const alerts = new Set<string>(alertsZones || []);
if (review_alerts) {
alerts.add(name);
} else {
same_alerts = !alerts.has(name);
alerts.delete(name);
}
alertQueries = [...alerts]
.map((zone) => `&cameras.${camera}.review.alerts.required_zones=${zone}`)
.join("");
const detections = new Set<string>(detectionsZones || []);
if (review_detections) {
detections.add(name);
} else {
same_detections = !detections.has(name);
detections.delete(name);
}
detectionQueries = [...detections]
.map(
(zone) => `&cameras.${camera}.review.detections.required_zones=${zone}`,
)
.join("");
if (!alertQueries && !same_alerts) {
alertQueries = `&cameras.${camera}.review.alerts`;
}
if (!detectionQueries && !same_detections) {
detectionQueries = `&cameras.${camera}.review.detections`;
}
return { alertQueries, detectionQueries };
};

View File

@ -228,7 +228,7 @@ export default function EventView({
} // don't allow the severity to be unselected
>
<ToggleGroupItem
className={`${severityToggle == "alert" ? "" : "text-gray-500"}`}
className={`${severityToggle == "alert" ? "" : "text-muted-foreground"}`}
value="alert"
aria-label="Select alerts"
>
@ -238,7 +238,7 @@ export default function EventView({
</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`${severityToggle == "detection" ? "" : "text-gray-500"}`}
className={`${severityToggle == "detection" ? "" : "text-muted-foreground"}`}
value="detection"
aria-label="Select detections"
>
@ -250,7 +250,9 @@ export default function EventView({
</ToggleGroupItem>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
severityToggle == "significant_motion" ? "" : "text-gray-500"
severityToggle == "significant_motion"
? ""
: "text-muted-foreground"
}`}
value="significant_motion"
aria-label="Select motion"
@ -589,7 +591,9 @@ function DetectionReview({
})
: Array(itemsToReview)
.fill(0)
.map(() => <Skeleton className="size-full aspect-video" />)}
.map((_, idx) => (
<Skeleton key={idx} className="size-full aspect-video" />
))}
{!loading &&
(currentItems?.length ?? 0) > 0 &&
(itemsToReview ?? 0) > 0 && (
@ -951,7 +955,7 @@ function MotionReview({
{!scrubbing && (
<VideoControls
className="absolute bottom-16 left-1/2 -translate-x-1/2"
className="absolute bottom-16 left-1/2 -translate-x-1/2 bg-secondary"
features={{
volume: false,
seek: true,

View File

@ -321,14 +321,14 @@ export function RecordingView({
} // don't allow the severity to be unselected
>
<ToggleGroupItem
className={`${timelineType == "timeline" ? "" : "text-gray-500"}`}
className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`}
value="timeline"
aria-label="Select timeline"
>
<div className="">Timeline</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`${timelineType == "events" ? "" : "text-gray-500"}`}
className={`${timelineType == "events" ? "" : "text-muted-foreground"}`}
value="events"
aria-label="Select events"
>

View File

@ -6,6 +6,7 @@ import {
useSnapshotsState,
} from "@/api/ws";
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
import FilterSwitch from "@/components/filter/FilterSwitch";
import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
@ -15,8 +16,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useResizeObserver } from "@/hooks/resize-observer";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
@ -127,22 +126,22 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
if (mainRef.current == null) {
return;
}
const fsListener = () => {
setFullscreen(document.fullscreenElement != null);
};
const pipListener = () => {
setPip(document.pictureInPictureElement != null);
};
document.addEventListener("fullscreenchange", fsListener);
document.addEventListener("focusin", pipListener);
return () => {
document.removeEventListener("fullscreenchange", fsListener);
document.removeEventListener("focusin", pipListener);
};
}, [mainRef]);
useEffect(() => {
setPip(document.pictureInPictureElement != null);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [document.pictureInPictureElement]);
// playback state
const [audio, setAudio] = useState(false);
@ -623,67 +622,29 @@ function FrigateCameraFeatures({
/>
</DrawerTrigger>
<DrawerContent className="px-2 py-4 flex flex-col gap-3 rounded-2xl">
<div className="flex justify-between items-center gap-1">
<Label
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
htmlFor={"camera-detect"}
>
Object Detection
</Label>
<Switch
id={"camera-detect"}
checked={detectState == "ON"}
onCheckedChange={() =>
sendDetect(detectState == "ON" ? "OFF" : "ON")
}
<FilterSwitch
label="Object Detection"
isChecked={detectState == "ON"}
onCheckedChange={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
/>
</div>
<div className="flex justify-between items-center gap-1">
<Label
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
htmlFor={"camera-record"}
>
Recording
</Label>
<Switch
id={"camera-record"}
checked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
<FilterSwitch
label="Recording"
isChecked={recordState == "ON"}
onCheckedChange={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
/>
</div>
<div className="flex justify-between items-center gap-1">
<Label
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
htmlFor={"camera-snapshot"}
>
Snapshots
</Label>
<Switch
id={"camera-snapshot"}
checked={snapshotState == "ON"}
<FilterSwitch
label="Snapshots"
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
</div>
{audioDetectEnabled && (
<div className="flex justify-between items-center gap-1">
<Label
className="w-full mx-2 text-secondary-foreground capitalize cursor-pointer"
htmlFor={"camera-audio-detect"}
>
Audio Detection
</Label>
<Switch
id={"camera-audio-detect"}
checked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
}
<FilterSwitch
label="Audio Detection"
isChecked={audioState == "ON"}
onCheckedChange={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
/>
</div>
)}
</DrawerContent>
</Drawer>

View File

@ -5,6 +5,7 @@ import { useFrigateStats } from "@/api/ws";
import {
DetectorCpuThreshold,
DetectorMemThreshold,
DetectorTempThreshold,
GPUMemThreshold,
GPUUsageThreshold,
InferenceThreshold,
@ -105,6 +106,44 @@ export default function GeneralMetrics({
return Object.values(series);
}, [statsHistory]);
const detTempSeries = useMemo(() => {
if (!statsHistory) {
return [];
}
if (
statsHistory.length > 0 &&
Object.keys(statsHistory[0].service.temperatures).length == 0
) {
return undefined;
}
const series: {
[key: string]: { name: string; data: { x: number; y: number }[] };
} = {};
statsHistory.forEach((stats, statsIdx) => {
if (!stats) {
return;
}
Object.entries(stats.detectors).forEach(([key], cIdx) => {
if (cIdx <= Object.keys(stats.service.temperatures).length) {
if (!(key in series)) {
series[key] = {
name: key,
data: [],
};
}
const temp = Object.values(stats.service.temperatures)[cIdx];
series[key].data.push({ x: statsIdx + 1, y: Math.round(temp) });
}
});
});
return Object.values(series);
}, [statsHistory]);
const detCpuSeries = useMemo(() => {
if (!statsHistory) {
return [];
@ -291,7 +330,9 @@ export default function GeneralMetrics({
<div className="text-muted-foreground text-sm font-medium">
Detectors
</div>
<div className="w-full mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div
className={`w-full mt-4 grid grid-cols-1 gap-2 ${detTempSeries == undefined ? "sm:grid-cols-3" : "sm:grid-cols-4"}`}
>
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">Detector Inference Speed</div>
@ -310,6 +351,28 @@ export default function GeneralMetrics({
) : (
<Skeleton className="w-full aspect-video" />
)}
{statsHistory.length != 0 ? (
<>
{detTempSeries && (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">Detector Temperature</div>
{detTempSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-temp`}
name={series.name}
unit="°C"
threshold={DetectorTempThreshold}
updateTimes={updateTimes}
data={[series]}
/>
))}
</div>
)}
</>
) : (
<Skeleton className="w-full aspect-video" />
)}
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">Detector CPU Usage</div>

View File

@ -53,6 +53,7 @@ module.exports = {
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
variant: "hsl(var(--primary-variant))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",

View File

@ -24,6 +24,9 @@
--primary: hsl(222.2, 37.4%, 11.2%);
--primary: 222.2 47.4% 11.2%;
--primary-variant: hsl(222.2, 37.4%, 24.2%);
--primary-variant: 222.2 47.4% 24.2%;
--primary-foreground: hsl(210, 40%, 98%);
--primary-foreground: 210 40% 98%;
@ -115,12 +118,15 @@
--popover: hsl(0, 0%, 15%);
--popover: 0, 0%, 15%;
--popover-foreground: hsl(0, 0%, 100%);
--popover-foreground: 210 40% 98%;
--popover-foreground: hsl(0, 0%, 98%);
--popover-foreground: 0 0% 98%;
--primary: hsl(0, 0%, 91%);
--primary: 0 0% 91%;
--primary-variant: hsl(0, 0%, 64%);
--primary-variant: 0 0% 64%;
--primary-foreground: hsl(0, 0%, 9%);
--primary-foreground: 0 0% 9%;
@ -133,8 +139,8 @@
--secondary-highlight: hsl(0, 0%, 25%);
--secondary-highlight: 0 0% 25%;
--muted: hsl(0, 0%, 8%);
--muted: 0 0% 8%;
--muted: hsl(0, 0%, 12%);
--muted: 0 0% 12%;
--muted-foreground: hsl(0, 0%, 32%);
--muted-foreground: 0 0% 32%;