mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
feat!: web user interface
This commit is contained in:
parent
5ad4017510
commit
c618867941
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,8 +1,11 @@
|
|||||||
*.pyc
|
.DS_Store
|
||||||
|
*.pyc
|
||||||
debug
|
debug
|
||||||
.vscode
|
.vscode
|
||||||
config/config.yml
|
config/config.yml
|
||||||
models
|
models
|
||||||
*.mp4
|
*.mp4
|
||||||
*.db
|
*.db
|
||||||
frigate/version.py
|
frigate/version.py
|
||||||
|
web/build
|
||||||
|
web/node_modules
|
||||||
|
13
Makefile
13
Makefile
@ -5,13 +5,16 @@ COMMIT_HASH := $(shell git log -1 --pretty=format:"%h")
|
|||||||
version:
|
version:
|
||||||
echo "VERSION='0.8.0-$(COMMIT_HASH)'" > frigate/version.py
|
echo "VERSION='0.8.0-$(COMMIT_HASH)'" > frigate/version.py
|
||||||
|
|
||||||
|
web:
|
||||||
|
cd web && npm install && npm run build
|
||||||
|
|
||||||
amd64_wheels:
|
amd64_wheels:
|
||||||
docker build --tag blakeblackshear/frigate-wheels:amd64 --file docker/Dockerfile.wheels .
|
docker build --tag blakeblackshear/frigate-wheels:amd64 --file docker/Dockerfile.wheels .
|
||||||
|
|
||||||
amd64_ffmpeg:
|
amd64_ffmpeg:
|
||||||
docker build --tag blakeblackshear/frigate-ffmpeg:1.1.0-amd64 --file docker/Dockerfile.ffmpeg.amd64 .
|
docker build --tag blakeblackshear/frigate-ffmpeg:1.1.0-amd64 --file docker/Dockerfile.ffmpeg.amd64 .
|
||||||
|
|
||||||
amd64_frigate: version
|
amd64_frigate: version web
|
||||||
docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --file docker/Dockerfile.base .
|
docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --file docker/Dockerfile.base .
|
||||||
docker build --tag frigate --file docker/Dockerfile.amd64 .
|
docker build --tag frigate --file docker/Dockerfile.amd64 .
|
||||||
|
|
||||||
@ -23,7 +26,7 @@ amd64nvidia_wheels:
|
|||||||
amd64nvidia_ffmpeg:
|
amd64nvidia_ffmpeg:
|
||||||
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-amd64nvidia --file docker/Dockerfile.ffmpeg.amd64nvidia .
|
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-amd64nvidia --file docker/Dockerfile.ffmpeg.amd64nvidia .
|
||||||
|
|
||||||
amd64nvidia_frigate: version
|
amd64nvidia_frigate: version web
|
||||||
docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
|
docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||||
docker build --tag frigate --file docker/Dockerfile.amd64nvidia .
|
docker build --tag frigate --file docker/Dockerfile.amd64nvidia .
|
||||||
|
|
||||||
@ -35,7 +38,7 @@ aarch64_wheels:
|
|||||||
aarch64_ffmpeg:
|
aarch64_ffmpeg:
|
||||||
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-aarch64 --file docker/Dockerfile.ffmpeg.aarch64 .
|
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-aarch64 --file docker/Dockerfile.ffmpeg.aarch64 .
|
||||||
|
|
||||||
aarch64_frigate: version
|
aarch64_frigate: version web
|
||||||
docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
|
docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||||
docker build --tag frigate --file docker/Dockerfile.aarch64 .
|
docker build --tag frigate --file docker/Dockerfile.aarch64 .
|
||||||
|
|
||||||
@ -47,8 +50,10 @@ armv7_wheels:
|
|||||||
armv7_ffmpeg:
|
armv7_ffmpeg:
|
||||||
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-armv7 --file docker/Dockerfile.ffmpeg.armv7 .
|
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-armv7 --file docker/Dockerfile.ffmpeg.armv7 .
|
||||||
|
|
||||||
armv7_frigate: version
|
armv7_frigate: version web
|
||||||
docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
|
docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||||
docker build --tag frigate --file docker/Dockerfile.armv7 .
|
docker build --tag frigate --file docker/Dockerfile.armv7 .
|
||||||
|
|
||||||
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
||||||
|
|
||||||
|
.PHONY: web
|
||||||
|
@ -44,6 +44,7 @@ RUN wget -q https://github.com/google-coral/test_data/raw/master/ssdlite_mobiled
|
|||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
ADD frigate frigate/
|
ADD frigate frigate/
|
||||||
|
ADD web/build web/
|
||||||
|
|
||||||
COPY run.sh /run.sh
|
COPY run.sh /run.sh
|
||||||
RUN chmod +x /run.sh
|
RUN chmod +x /run.sh
|
||||||
|
@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
|
|||||||
DETECTORS_SCHEMA = vol.Schema(
|
DETECTORS_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(str): {
|
vol.Required(str): {
|
||||||
vol.Required('type', default='edgetpu'): vol.In(['cpu', 'edgetpu']),
|
vol.Required('type', default='edgetpu'): vol.In(['cpu', 'edgetpu']),
|
||||||
vol.Optional('device', default='usb'): str,
|
vol.Optional('device', default='usb'): str,
|
||||||
vol.Optional('num_threads', default=3): int
|
vol.Optional('num_threads', default=3): int
|
||||||
}
|
}
|
||||||
@ -77,7 +77,7 @@ RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT = ["-f", "segment", "-segment_time",
|
|||||||
"1", "-c", "copy", "-an"]
|
"1", "-c", "copy", "-an"]
|
||||||
|
|
||||||
GLOBAL_FFMPEG_SCHEMA = vol.Schema(
|
GLOBAL_FFMPEG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional('global_args', default=FFMPEG_GLOBAL_ARGS_DEFAULT): vol.Any(str, [str]),
|
vol.Optional('global_args', default=FFMPEG_GLOBAL_ARGS_DEFAULT): vol.Any(str, [str]),
|
||||||
vol.Optional('hwaccel_args', default=[]): vol.Any(str, [str]),
|
vol.Optional('hwaccel_args', default=[]): vol.Any(str, [str]),
|
||||||
vol.Optional('input_args', default=FFMPEG_INPUT_ARGS_DEFAULT): vol.Any(str, [str]),
|
vol.Optional('input_args', default=FFMPEG_INPUT_ARGS_DEFAULT): vol.Any(str, [str]),
|
||||||
@ -107,7 +107,7 @@ DETECT_SCHEMA = vol.Schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
FILTER_SCHEMA = vol.Schema(
|
FILTER_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
str: {
|
str: {
|
||||||
vol.Optional('min_area', default=0): int,
|
vol.Optional('min_area', default=0): int,
|
||||||
vol.Optional('max_area', default=24000000): int,
|
vol.Optional('max_area', default=24000000): int,
|
||||||
@ -139,14 +139,14 @@ def each_role_used_once(inputs):
|
|||||||
return inputs
|
return inputs
|
||||||
|
|
||||||
CAMERA_FFMPEG_SCHEMA = vol.Schema(
|
CAMERA_FFMPEG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required('inputs'): vol.All([{
|
vol.Required('inputs'): vol.All([{
|
||||||
vol.Required('path'): str,
|
vol.Required('path'): str,
|
||||||
vol.Required('roles'): ['detect', 'clips', 'record', 'rtmp'],
|
vol.Required('roles'): ['detect', 'clips', 'record', 'rtmp'],
|
||||||
'global_args': vol.Any(str, [str]),
|
'global_args': vol.Any(str, [str]),
|
||||||
'hwaccel_args': vol.Any(str, [str]),
|
'hwaccel_args': vol.Any(str, [str]),
|
||||||
'input_args': vol.Any(str, [str]),
|
'input_args': vol.Any(str, [str]),
|
||||||
}], vol.Msg(each_role_used_once, msg="Each input role may only be used once")),
|
}], vol.Msg(each_role_used_once, msg="Each input role may only be used once")),
|
||||||
'output_args': {
|
'output_args': {
|
||||||
vol.Optional('detect', default=DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]),
|
vol.Optional('detect', default=DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]),
|
||||||
vol.Optional('record', default=RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]),
|
vol.Optional('record', default=RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT): vol.Any(str, [str]),
|
||||||
@ -252,7 +252,7 @@ class DatabaseConfig():
|
|||||||
@property
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
return self._path
|
return self._path
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'path': self.path
|
'path': self.path
|
||||||
@ -262,15 +262,15 @@ class ModelConfig():
|
|||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self._width = config['width']
|
self._width = config['width']
|
||||||
self._height = config['height']
|
self._height = config['height']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def width(self):
|
def width(self):
|
||||||
return self._width
|
return self._width
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def height(self):
|
def height(self):
|
||||||
return self._height
|
return self._height
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'width': self.width,
|
'width': self.width,
|
||||||
@ -282,19 +282,19 @@ class DetectorConfig():
|
|||||||
self._type = config['type']
|
self._type = config['type']
|
||||||
self._device = config['device']
|
self._device = config['device']
|
||||||
self._num_threads = config['num_threads']
|
self._num_threads = config['num_threads']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self):
|
def type(self):
|
||||||
return self._type
|
return self._type
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device(self):
|
def device(self):
|
||||||
return self._device
|
return self._device
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def num_threads(self):
|
def num_threads(self):
|
||||||
return self._num_threads
|
return self._num_threads
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'type': self.type,
|
'type': self.type,
|
||||||
@ -306,15 +306,15 @@ class LoggerConfig():
|
|||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self._default = config['default'].upper()
|
self._default = config['default'].upper()
|
||||||
self._logs = {k: v.upper() for k, v in config['logs'].items()}
|
self._logs = {k: v.upper() for k, v in config['logs'].items()}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default(self):
|
def default(self):
|
||||||
return self._default
|
return self._default
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logs(self):
|
def logs(self):
|
||||||
return self._logs
|
return self._logs
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'default': self.default,
|
'default': self.default,
|
||||||
@ -334,23 +334,23 @@ class MqttConfig():
|
|||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self):
|
||||||
return self._host
|
return self._host
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def port(self):
|
def port(self):
|
||||||
return self._port
|
return self._port
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def topic_prefix(self):
|
def topic_prefix(self):
|
||||||
return self._topic_prefix
|
return self._topic_prefix
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def client_id(self):
|
def client_id(self):
|
||||||
return self._client_id
|
return self._client_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user(self):
|
def user(self):
|
||||||
return self._user
|
return self._user
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def password(self):
|
def password(self):
|
||||||
return self._password
|
return self._password
|
||||||
@ -376,19 +376,19 @@ class CameraInput():
|
|||||||
self._global_args = ffmpeg_input.get('global_args', global_config['global_args'])
|
self._global_args = ffmpeg_input.get('global_args', global_config['global_args'])
|
||||||
self._hwaccel_args = ffmpeg_input.get('hwaccel_args', global_config['hwaccel_args'])
|
self._hwaccel_args = ffmpeg_input.get('hwaccel_args', global_config['hwaccel_args'])
|
||||||
self._input_args = ffmpeg_input.get('input_args', global_config['input_args'])
|
self._input_args = ffmpeg_input.get('input_args', global_config['input_args'])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
return self._path
|
return self._path
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def roles(self):
|
def roles(self):
|
||||||
return self._roles
|
return self._roles
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def global_args(self):
|
def global_args(self):
|
||||||
return self._global_args if isinstance(self._global_args, list) else self._global_args.split(' ')
|
return self._global_args if isinstance(self._global_args, list) else self._global_args.split(' ')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hwaccel_args(self):
|
def hwaccel_args(self):
|
||||||
return self._hwaccel_args if isinstance(self._hwaccel_args, list) else self._hwaccel_args.split(' ')
|
return self._hwaccel_args if isinstance(self._hwaccel_args, list) else self._hwaccel_args.split(' ')
|
||||||
@ -401,11 +401,11 @@ class CameraFfmpegConfig():
|
|||||||
def __init__(self, global_config, config):
|
def __init__(self, global_config, config):
|
||||||
self._inputs = [CameraInput(global_config, i) for i in config['inputs']]
|
self._inputs = [CameraInput(global_config, i) for i in config['inputs']]
|
||||||
self._output_args = config.get('output_args', global_config['output_args'])
|
self._output_args = config.get('output_args', global_config['output_args'])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def inputs(self):
|
def inputs(self):
|
||||||
return self._inputs
|
return self._inputs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def output_args(self):
|
def output_args(self):
|
||||||
return {k: v if isinstance(v, list) else v.split(' ') for k, v in self._output_args.items()}
|
return {k: v if isinstance(v, list) else v.split(' ') for k, v in self._output_args.items()}
|
||||||
@ -414,15 +414,15 @@ class RetainConfig():
|
|||||||
def __init__(self, global_config, config):
|
def __init__(self, global_config, config):
|
||||||
self._default = config.get('default', global_config.get('default'))
|
self._default = config.get('default', global_config.get('default'))
|
||||||
self._objects = config.get('objects', global_config.get('objects', {}))
|
self._objects = config.get('objects', global_config.get('objects', {}))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default(self):
|
def default(self):
|
||||||
return self._default
|
return self._default
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def objects(self):
|
def objects(self):
|
||||||
return self._objects
|
return self._objects
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'default': self.default,
|
'default': self.default,
|
||||||
@ -438,7 +438,7 @@ class ClipsConfig():
|
|||||||
@property
|
@property
|
||||||
def max_seconds(self):
|
def max_seconds(self):
|
||||||
return self._max_seconds
|
return self._max_seconds
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tmpfs_cache_size(self):
|
def tmpfs_cache_size(self):
|
||||||
return self._tmpfs_cache_size
|
return self._tmpfs_cache_size
|
||||||
@ -446,7 +446,7 @@ class ClipsConfig():
|
|||||||
@property
|
@property
|
||||||
def retain(self):
|
def retain(self):
|
||||||
return self._retain
|
return self._retain
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'max_seconds': self.max_seconds,
|
'max_seconds': self.max_seconds,
|
||||||
@ -462,11 +462,11 @@ class RecordConfig():
|
|||||||
@property
|
@property
|
||||||
def enabled(self):
|
def enabled(self):
|
||||||
return self._enabled
|
return self._enabled
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def retain_days(self):
|
def retain_days(self):
|
||||||
return self._retain_days
|
return self._retain_days
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'enabled': self.enabled,
|
'enabled': self.enabled,
|
||||||
@ -479,7 +479,7 @@ class FilterConfig():
|
|||||||
self._max_area = config['max_area']
|
self._max_area = config['max_area']
|
||||||
self._threshold = config['threshold']
|
self._threshold = config['threshold']
|
||||||
self._min_score = config.get('min_score')
|
self._min_score = config.get('min_score')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_area(self):
|
def min_area(self):
|
||||||
return self._min_area
|
return self._min_area
|
||||||
@ -491,11 +491,11 @@ class FilterConfig():
|
|||||||
@property
|
@property
|
||||||
def threshold(self):
|
def threshold(self):
|
||||||
return self._threshold
|
return self._threshold
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_score(self):
|
def min_score(self):
|
||||||
return self._min_score
|
return self._min_score
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'min_area': self.min_area,
|
'min_area': self.min_area,
|
||||||
@ -511,11 +511,11 @@ class ObjectConfig():
|
|||||||
self._filters = { name: FilterConfig(c) for name, c in config['filters'].items() }
|
self._filters = { name: FilterConfig(c) for name, c in config['filters'].items() }
|
||||||
else:
|
else:
|
||||||
self._filters = { name: FilterConfig(c) for name, c in global_config['filters'].items() }
|
self._filters = { name: FilterConfig(c) for name, c in global_config['filters'].items() }
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def track(self):
|
def track(self):
|
||||||
return self._track
|
return self._track
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filters(self) -> Dict[str, FilterConfig]:
|
def filters(self) -> Dict[str, FilterConfig]:
|
||||||
return self._filters
|
return self._filters
|
||||||
@ -538,7 +538,7 @@ class CameraSnapshotsConfig():
|
|||||||
@property
|
@property
|
||||||
def enabled(self):
|
def enabled(self):
|
||||||
return self._enabled
|
return self._enabled
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timestamp(self):
|
def timestamp(self):
|
||||||
return self._timestamp
|
return self._timestamp
|
||||||
@ -576,11 +576,11 @@ class CameraMqttConfig():
|
|||||||
self._bounding_box = config['bounding_box']
|
self._bounding_box = config['bounding_box']
|
||||||
self._crop = config['crop']
|
self._crop = config['crop']
|
||||||
self._height = config.get('height')
|
self._height = config.get('height')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enabled(self):
|
def enabled(self):
|
||||||
return self._enabled
|
return self._enabled
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timestamp(self):
|
def timestamp(self):
|
||||||
return self._timestamp
|
return self._timestamp
|
||||||
@ -596,7 +596,7 @@ class CameraMqttConfig():
|
|||||||
@property
|
@property
|
||||||
def height(self):
|
def height(self):
|
||||||
return self._height
|
return self._height
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'enabled': self.enabled,
|
'enabled': self.enabled,
|
||||||
@ -617,11 +617,11 @@ class CameraClipsConfig():
|
|||||||
@property
|
@property
|
||||||
def enabled(self):
|
def enabled(self):
|
||||||
return self._enabled
|
return self._enabled
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pre_capture(self):
|
def pre_capture(self):
|
||||||
return self._pre_capture
|
return self._pre_capture
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def post_capture(self):
|
def post_capture(self):
|
||||||
return self._post_capture
|
return self._post_capture
|
||||||
@ -629,11 +629,11 @@ class CameraClipsConfig():
|
|||||||
@property
|
@property
|
||||||
def objects(self):
|
def objects(self):
|
||||||
return self._objects
|
return self._objects
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def retain(self):
|
def retain(self):
|
||||||
return self._retain
|
return self._retain
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'enabled': self.enabled,
|
'enabled': self.enabled,
|
||||||
@ -646,11 +646,11 @@ class CameraClipsConfig():
|
|||||||
class CameraRtmpConfig():
|
class CameraRtmpConfig():
|
||||||
def __init__(self, global_config, config):
|
def __init__(self, global_config, config):
|
||||||
self._enabled = config['enabled']
|
self._enabled = config['enabled']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enabled(self):
|
def enabled(self):
|
||||||
return self._enabled
|
return self._enabled
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'enabled': self.enabled,
|
'enabled': self.enabled,
|
||||||
@ -663,7 +663,7 @@ class MotionConfig():
|
|||||||
self._delta_alpha = config.get('delta_alpha', global_config.get('delta_alpha', 0.2))
|
self._delta_alpha = config.get('delta_alpha', global_config.get('delta_alpha', 0.2))
|
||||||
self._frame_alpha = config.get('frame_alpha', global_config.get('frame_alpha', 0.2))
|
self._frame_alpha = config.get('frame_alpha', global_config.get('frame_alpha', 0.2))
|
||||||
self._frame_height = config.get('frame_height', global_config.get('frame_height', camera_height//6))
|
self._frame_height = config.get('frame_height', global_config.get('frame_height', camera_height//6))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def threshold(self):
|
def threshold(self):
|
||||||
return self._threshold
|
return self._threshold
|
||||||
@ -683,7 +683,7 @@ class MotionConfig():
|
|||||||
@property
|
@property
|
||||||
def frame_height(self):
|
def frame_height(self):
|
||||||
return self._frame_height
|
return self._frame_height
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'threshold': self.threshold,
|
'threshold': self.threshold,
|
||||||
@ -698,11 +698,11 @@ class MotionConfig():
|
|||||||
class DetectConfig():
|
class DetectConfig():
|
||||||
def __init__(self, global_config, config, camera_fps):
|
def __init__(self, global_config, config, camera_fps):
|
||||||
self._max_disappeared = config.get('max_disappeared', global_config.get('max_disappeared', camera_fps*2))
|
self._max_disappeared = config.get('max_disappeared', global_config.get('max_disappeared', camera_fps*2))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_disappeared(self):
|
def max_disappeared(self):
|
||||||
return self._max_disappeared
|
return self._max_disappeared
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'max_disappeared': self._max_disappeared,
|
'max_disappeared': self._max_disappeared,
|
||||||
@ -721,36 +721,37 @@ class ZoneConfig():
|
|||||||
else:
|
else:
|
||||||
print(f"Unable to parse zone coordinates for {name}")
|
print(f"Unable to parse zone coordinates for {name}")
|
||||||
self._contour = np.array([])
|
self._contour = np.array([])
|
||||||
|
|
||||||
self._color = (0,0,0)
|
self._color = (0,0,0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def coordinates(self):
|
def coordinates(self):
|
||||||
return self._coordinates
|
return self._coordinates
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def contour(self):
|
def contour(self):
|
||||||
return self._contour
|
return self._contour
|
||||||
|
|
||||||
@contour.setter
|
@contour.setter
|
||||||
def contour(self, val):
|
def contour(self, val):
|
||||||
self._contour = val
|
self._contour = val
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def color(self):
|
def color(self):
|
||||||
return self._color
|
return self._color
|
||||||
|
|
||||||
@color.setter
|
@color.setter
|
||||||
def color(self, val):
|
def color(self, val):
|
||||||
self._color = val
|
self._color = val
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filters(self):
|
def filters(self):
|
||||||
return self._filters
|
return self._filters
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'filters': {k: f.to_dict() for k, f in self.filters.items()}
|
'filters': {k: f.to_dict() for k, f in self.filters.items()},
|
||||||
|
'coordinates': self._coordinates
|
||||||
}
|
}
|
||||||
|
|
||||||
class CameraConfig():
|
class CameraConfig():
|
||||||
@ -763,6 +764,7 @@ class CameraConfig():
|
|||||||
self._frame_shape_yuv = (self._frame_shape[0]*3//2, self._frame_shape[1])
|
self._frame_shape_yuv = (self._frame_shape[0]*3//2, self._frame_shape[1])
|
||||||
self._fps = config.get('fps')
|
self._fps = config.get('fps')
|
||||||
self._mask = self._create_mask(config.get('mask'))
|
self._mask = self._create_mask(config.get('mask'))
|
||||||
|
self._raw_mask = config.get('mask')
|
||||||
self._best_image_timeout = config['best_image_timeout']
|
self._best_image_timeout = config['best_image_timeout']
|
||||||
self._zones = { name: ZoneConfig(name, z) for name, z in config['zones'].items() }
|
self._zones = { name: ZoneConfig(name, z) for name, z in config['zones'].items() }
|
||||||
self._clips = CameraClipsConfig(global_config, config['clips'])
|
self._clips = CameraClipsConfig(global_config, config['clips'])
|
||||||
@ -798,9 +800,9 @@ class CameraConfig():
|
|||||||
|
|
||||||
elif isinstance(mask, str):
|
elif isinstance(mask, str):
|
||||||
self._add_mask(mask, mask_img)
|
self._add_mask(mask, mask_img)
|
||||||
|
|
||||||
return mask_img
|
return mask_img
|
||||||
|
|
||||||
def _add_mask(self, mask, mask_img):
|
def _add_mask(self, mask, mask_img):
|
||||||
if mask.startswith('poly,'):
|
if mask.startswith('poly,'):
|
||||||
points = mask.split(',')[1:]
|
points = mask.split(',')[1:]
|
||||||
@ -812,7 +814,7 @@ class CameraConfig():
|
|||||||
logger.warning(f"Could not read mask file {mask}")
|
logger.warning(f"Could not read mask file {mask}")
|
||||||
else:
|
else:
|
||||||
mask_img[np.where(mask_file==[0])] = [0]
|
mask_img[np.where(mask_file==[0])] = [0]
|
||||||
|
|
||||||
def _get_ffmpeg_cmd(self, ffmpeg_input):
|
def _get_ffmpeg_cmd(self, ffmpeg_input):
|
||||||
ffmpeg_output_args = []
|
ffmpeg_output_args = []
|
||||||
if 'detect' in ffmpeg_input.roles:
|
if 'detect' in ffmpeg_input.roles:
|
||||||
@ -831,7 +833,7 @@ class CameraConfig():
|
|||||||
ffmpeg_output_args = self.ffmpeg.output_args['record'] + [
|
ffmpeg_output_args = self.ffmpeg.output_args['record'] + [
|
||||||
f"{os.path.join(RECORD_DIR, self.name)}-%Y%m%d%H%M%S.mp4"
|
f"{os.path.join(RECORD_DIR, self.name)}-%Y%m%d%H%M%S.mp4"
|
||||||
] + ffmpeg_output_args
|
] + ffmpeg_output_args
|
||||||
|
|
||||||
# if there arent any outputs enabled for this input
|
# if there arent any outputs enabled for this input
|
||||||
if len(ffmpeg_output_args) == 0:
|
if len(ffmpeg_output_args) == 0:
|
||||||
return None
|
return None
|
||||||
@ -842,9 +844,9 @@ class CameraConfig():
|
|||||||
ffmpeg_input.input_args +
|
ffmpeg_input.input_args +
|
||||||
['-i', ffmpeg_input.path] +
|
['-i', ffmpeg_input.path] +
|
||||||
ffmpeg_output_args)
|
ffmpeg_output_args)
|
||||||
|
|
||||||
return [part for part in cmd if part != '']
|
return [part for part in cmd if part != '']
|
||||||
|
|
||||||
def _set_zone_colors(self, zones: Dict[str, ZoneConfig]):
|
def _set_zone_colors(self, zones: Dict[str, ZoneConfig]):
|
||||||
# set colors for zones
|
# set colors for zones
|
||||||
all_zone_names = zones.keys()
|
all_zone_names = zones.keys()
|
||||||
@ -852,10 +854,10 @@ class CameraConfig():
|
|||||||
colors = plt.cm.get_cmap('tab10', len(all_zone_names))
|
colors = plt.cm.get_cmap('tab10', len(all_zone_names))
|
||||||
for i, zone in enumerate(all_zone_names):
|
for i, zone in enumerate(all_zone_names):
|
||||||
zone_colors[zone] = tuple(int(round(255 * c)) for c in colors(i)[:3])
|
zone_colors[zone] = tuple(int(round(255 * c)) for c in colors(i)[:3])
|
||||||
|
|
||||||
for name, zone in zones.items():
|
for name, zone in zones.items():
|
||||||
zone.color = zone_colors[name]
|
zone.color = zone_colors[name]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return self._name
|
return self._name
|
||||||
@ -863,59 +865,59 @@ class CameraConfig():
|
|||||||
@property
|
@property
|
||||||
def ffmpeg(self):
|
def ffmpeg(self):
|
||||||
return self._ffmpeg
|
return self._ffmpeg
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def height(self):
|
def height(self):
|
||||||
return self._height
|
return self._height
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def width(self):
|
def width(self):
|
||||||
return self._width
|
return self._width
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def fps(self):
|
def fps(self):
|
||||||
return self._fps
|
return self._fps
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mask(self):
|
def mask(self):
|
||||||
return self._mask
|
return self._mask
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def best_image_timeout(self):
|
def best_image_timeout(self):
|
||||||
return self._best_image_timeout
|
return self._best_image_timeout
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def zones(self)-> Dict[str, ZoneConfig]:
|
def zones(self)-> Dict[str, ZoneConfig]:
|
||||||
return self._zones
|
return self._zones
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def clips(self):
|
def clips(self):
|
||||||
return self._clips
|
return self._clips
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def record(self):
|
def record(self):
|
||||||
return self._record
|
return self._record
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rtmp(self):
|
def rtmp(self):
|
||||||
return self._rtmp
|
return self._rtmp
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def snapshots(self):
|
def snapshots(self):
|
||||||
return self._snapshots
|
return self._snapshots
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mqtt(self):
|
def mqtt(self):
|
||||||
return self._mqtt
|
return self._mqtt
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def objects(self):
|
def objects(self):
|
||||||
return self._objects
|
return self._objects
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def motion(self):
|
def motion(self):
|
||||||
return self._motion
|
return self._motion
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def detect(self):
|
def detect(self):
|
||||||
return self._detect
|
return self._detect
|
||||||
@ -948,6 +950,7 @@ class CameraConfig():
|
|||||||
'objects': self.objects.to_dict(),
|
'objects': self.objects.to_dict(),
|
||||||
'motion': self.motion.to_dict(),
|
'motion': self.motion.to_dict(),
|
||||||
'detect': self.detect.to_dict(),
|
'detect': self.detect.to_dict(),
|
||||||
|
'mask': self._raw_mask,
|
||||||
'frame_shape': self.frame_shape,
|
'frame_shape': self.frame_shape,
|
||||||
'ffmpeg_cmds': [{'roles': c['roles'], 'cmd': ' '.join(c['cmd'])} for c in self.ffmpeg_cmds],
|
'ffmpeg_cmds': [{'roles': c['roles'], 'cmd': ' '.join(c['cmd'])} for c in self.ffmpeg_cmds],
|
||||||
}
|
}
|
||||||
@ -959,7 +962,7 @@ class FrigateConfig():
|
|||||||
raise ValueError('config or config_file must be defined')
|
raise ValueError('config or config_file must be defined')
|
||||||
elif not config_file is None:
|
elif not config_file is None:
|
||||||
config = self._load_file(config_file)
|
config = self._load_file(config_file)
|
||||||
|
|
||||||
config = FRIGATE_CONFIG_SCHEMA(config)
|
config = FRIGATE_CONFIG_SCHEMA(config)
|
||||||
|
|
||||||
config = self._sub_env_vars(config)
|
config = self._sub_env_vars(config)
|
||||||
@ -976,25 +979,25 @@ class FrigateConfig():
|
|||||||
frigate_env_vars = {k: v for k, v in os.environ.items() if k.startswith('FRIGATE_')}
|
frigate_env_vars = {k: v for k, v in os.environ.items() if k.startswith('FRIGATE_')}
|
||||||
|
|
||||||
if 'password' in config['mqtt']:
|
if 'password' in config['mqtt']:
|
||||||
config['mqtt']['password'] = config['mqtt']['password'].format(**frigate_env_vars)
|
config['mqtt']['password'] = config['mqtt']['password'].format(**frigate_env_vars)
|
||||||
|
|
||||||
for camera in config['cameras'].values():
|
for camera in config['cameras'].values():
|
||||||
for i in camera['ffmpeg']['inputs']:
|
for i in camera['ffmpeg']['inputs']:
|
||||||
i['path'] = i['path'].format(**frigate_env_vars)
|
i['path'] = i['path'].format(**frigate_env_vars)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def _load_file(self, config_file):
|
def _load_file(self, config_file):
|
||||||
with open(config_file) as f:
|
with open(config_file) as f:
|
||||||
raw_config = f.read()
|
raw_config = f.read()
|
||||||
|
|
||||||
if config_file.endswith(".yml"):
|
if config_file.endswith(".yml"):
|
||||||
config = yaml.safe_load(raw_config)
|
config = yaml.safe_load(raw_config)
|
||||||
elif config_file.endswith(".json"):
|
elif config_file.endswith(".json"):
|
||||||
config = json.loads(raw_config)
|
config = json.loads(raw_config)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'database': self.database.to_dict(),
|
'database': self.database.to_dict(),
|
||||||
@ -1005,19 +1008,19 @@ class FrigateConfig():
|
|||||||
'cameras': {k: c.to_dict() for k, c in self.cameras.items()},
|
'cameras': {k: c.to_dict() for k, c in self.cameras.items()},
|
||||||
'logger': self.logger.to_dict()
|
'logger': self.logger.to_dict()
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database(self):
|
def database(self):
|
||||||
return self._database
|
return self._database
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model(self):
|
def model(self):
|
||||||
return self._model
|
return self._model
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def detectors(self) -> Dict[str, DetectorConfig]:
|
def detectors(self) -> Dict[str, DetectorConfig]:
|
||||||
return self._detectors
|
return self._detectors
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logger(self):
|
def logger(self):
|
||||||
return self._logger
|
return self._logger
|
||||||
@ -1025,7 +1028,7 @@ class FrigateConfig():
|
|||||||
@property
|
@property
|
||||||
def mqtt(self):
|
def mqtt(self):
|
||||||
return self._mqtt
|
return self._mqtt
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def clips(self):
|
def clips(self):
|
||||||
return self._clips
|
return self._clips
|
||||||
|
@ -36,7 +36,7 @@ def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detecte
|
|||||||
app.frigate_config = frigate_config
|
app.frigate_config = frigate_config
|
||||||
app.stats_tracking = stats_tracking
|
app.stats_tracking = stats_tracking
|
||||||
app.detected_frames_processor = detected_frames_processor
|
app.detected_frames_processor = detected_frames_processor
|
||||||
|
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
@ -50,15 +50,15 @@ def events_summary():
|
|||||||
groups = (
|
groups = (
|
||||||
Event
|
Event
|
||||||
.select(
|
.select(
|
||||||
Event.camera,
|
Event.camera,
|
||||||
Event.label,
|
Event.label,
|
||||||
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')).alias('day'),
|
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')).alias('day'),
|
||||||
Event.zones,
|
Event.zones,
|
||||||
fn.COUNT(Event.id).alias('count')
|
fn.COUNT(Event.id).alias('count')
|
||||||
)
|
)
|
||||||
.group_by(
|
.group_by(
|
||||||
Event.camera,
|
Event.camera,
|
||||||
Event.label,
|
Event.label,
|
||||||
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')),
|
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')),
|
||||||
Event.zones
|
Event.zones
|
||||||
)
|
)
|
||||||
@ -90,18 +90,18 @@ def event_snapshot(id):
|
|||||||
thumbnail_bytes = tracked_obj.get_jpg_bytes()
|
thumbnail_bytes = tracked_obj.get_jpg_bytes()
|
||||||
except:
|
except:
|
||||||
return "Event not found", 404
|
return "Event not found", 404
|
||||||
|
|
||||||
if thumbnail_bytes is None:
|
if thumbnail_bytes is None:
|
||||||
return "Event not found", 404
|
return "Event not found", 404
|
||||||
|
|
||||||
# android notifications prefer a 2:1 ratio
|
# android notifications prefer a 2:1 ratio
|
||||||
if format == 'android':
|
if format == 'android':
|
||||||
jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
||||||
img = cv2.imdecode(jpg_as_np, flags=1)
|
img = cv2.imdecode(jpg_as_np, flags=1)
|
||||||
thumbnail = cv2.copyMakeBorder(img, 0, 0, int(img.shape[1]*0.5), int(img.shape[1]*0.5), cv2.BORDER_CONSTANT, (0,0,0))
|
thumbnail = cv2.copyMakeBorder(img, 0, 0, int(img.shape[1]*0.5), int(img.shape[1]*0.5), cv2.BORDER_CONSTANT, (0,0,0))
|
||||||
ret, jpg = cv2.imencode('.jpg', thumbnail)
|
ret, jpg = cv2.imencode('.jpg', thumbnail)
|
||||||
thumbnail_bytes = jpg.tobytes()
|
thumbnail_bytes = jpg.tobytes()
|
||||||
|
|
||||||
response = make_response(thumbnail_bytes)
|
response = make_response(thumbnail_bytes)
|
||||||
response.headers['Content-Type'] = 'image/jpg'
|
response.headers['Content-Type'] = 'image/jpg'
|
||||||
return response
|
return response
|
||||||
@ -118,19 +118,19 @@ def events():
|
|||||||
has_snapshot = request.args.get('has_snapshot', type=int)
|
has_snapshot = request.args.get('has_snapshot', type=int)
|
||||||
|
|
||||||
clauses = []
|
clauses = []
|
||||||
|
|
||||||
if camera:
|
if camera:
|
||||||
clauses.append((Event.camera == camera))
|
clauses.append((Event.camera == camera))
|
||||||
|
|
||||||
if label:
|
if label:
|
||||||
clauses.append((Event.label == label))
|
clauses.append((Event.label == label))
|
||||||
|
|
||||||
if zone:
|
if zone:
|
||||||
clauses.append((Event.zones.cast('text') % f"*\"{zone}\"*"))
|
clauses.append((Event.zones.cast('text') % f"*\"{zone}\"*"))
|
||||||
|
|
||||||
if after:
|
if after:
|
||||||
clauses.append((Event.start_time >= after))
|
clauses.append((Event.start_time >= after))
|
||||||
|
|
||||||
if before:
|
if before:
|
||||||
clauses.append((Event.start_time <= before))
|
clauses.append((Event.start_time <= before))
|
||||||
|
|
||||||
@ -172,13 +172,13 @@ def best(camera_name, label):
|
|||||||
best_frame = np.zeros((720,1280,3), np.uint8)
|
best_frame = np.zeros((720,1280,3), np.uint8)
|
||||||
else:
|
else:
|
||||||
best_frame = cv2.cvtColor(best_frame, cv2.COLOR_YUV2BGR_I420)
|
best_frame = cv2.cvtColor(best_frame, cv2.COLOR_YUV2BGR_I420)
|
||||||
|
|
||||||
crop = bool(request.args.get('crop', 0, type=int))
|
crop = bool(request.args.get('crop', 0, type=int))
|
||||||
if crop:
|
if crop:
|
||||||
box = best_object.get('box', (0,0,300,300))
|
box = best_object.get('box', (0,0,300,300))
|
||||||
region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
|
region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
|
||||||
best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
|
best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
|
||||||
|
|
||||||
height = int(request.args.get('h', str(best_frame.shape[0])))
|
height = int(request.args.get('h', str(best_frame.shape[0])))
|
||||||
width = int(height*best_frame.shape[1]/best_frame.shape[0])
|
width = int(height*best_frame.shape[1]/best_frame.shape[0])
|
||||||
|
|
||||||
@ -236,7 +236,7 @@ def latest_frame(camera_name):
|
|||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
|
|
||||||
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
||||||
while True:
|
while True:
|
||||||
# max out at specified FPS
|
# max out at specified FPS
|
||||||
|
@ -96,13 +96,19 @@ http {
|
|||||||
root /media/frigate;
|
root /media/frigate;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location /api/ {
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
proxy_pass http://frigate_api/;
|
proxy_pass http://frigate_api/;
|
||||||
proxy_pass_request_headers on;
|
proxy_pass_request_headers on;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /opt/frigate/web;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,4 +125,4 @@ rtmp {
|
|||||||
meta copy;
|
meta copy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
8
web/README.md
Normal file
8
web/README.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Frigate Web UI
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
1. Build the docker images in the root of the repository `make amd64_all` (or appropriate for your system)
|
||||||
|
2. Create a config file in `config/`
|
||||||
|
3. Run the container: `docker run --rm --name frigate --privileged -v $PWD/config:/config:ro -v /etc/localtime:/etc/localtime:ro -p 5000:5000 frigate`
|
||||||
|
4. Run the dev ui: `cd web && npm run start`
|
8083
web/package-lock.json
generated
Normal file
8083
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
web/package.json
Normal file
19
web/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "frigate",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
|
||||||
|
"build": "snowpack build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prefresh/snowpack": "^3.0.1",
|
||||||
|
"@snowpack/plugin-optimize": "^0.2.10",
|
||||||
|
"@snowpack/plugin-postcss": "^1.0.11",
|
||||||
|
"autoprefixer": "^10.2.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"preact": "^10.5.9",
|
||||||
|
"preact-router": "^3.2.1",
|
||||||
|
"snowpack": "^2.18.5",
|
||||||
|
"tailwindcss": "^2.0.2"
|
||||||
|
}
|
||||||
|
}
|
8
web/postcss.config.js
Normal file
8
web/postcss.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
require('tailwindcss'),
|
||||||
|
require('autoprefixer'),
|
||||||
|
],
|
||||||
|
};
|
24
web/public/index.html
Normal file
24
web/public/index.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Frigate</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<script type="module" src="/dist/index.js"></script>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
24
web/snowpack.config.js
Normal file
24
web/snowpack.config.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mount: {
|
||||||
|
public: { url: '/', static: true },
|
||||||
|
src: { url: '/dist' },
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
'@snowpack/plugin-postcss',
|
||||||
|
'@prefresh/snowpack',
|
||||||
|
[
|
||||||
|
'@snowpack/plugin-optimize',
|
||||||
|
{
|
||||||
|
preloadModules: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
installOptions: {
|
||||||
|
sourceMaps: false,
|
||||||
|
},
|
||||||
|
buildOptions: {
|
||||||
|
sourceMaps: true,
|
||||||
|
},
|
||||||
|
};
|
43
web/src/App.jsx
Normal file
43
web/src/App.jsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import Camera from './Camera';
|
||||||
|
import CameraMap from './CameraMap';
|
||||||
|
import Cameras from './Cameras';
|
||||||
|
import Debug from './Debug';
|
||||||
|
import Event from './Event';
|
||||||
|
import Events from './Events';
|
||||||
|
import { Router } from 'preact-router';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
import { ApiHost, Config } from './context';
|
||||||
|
import { useContext, useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const apiHost = useContext(ApiHost);
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
|
||||||
|
useEffect(async () => {
|
||||||
|
const response = await fetch(`${apiHost}/api/config`);
|
||||||
|
const data = response.ok ? await response.json() : {};
|
||||||
|
setConfig(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !config ? (
|
||||||
|
<div />
|
||||||
|
) : (
|
||||||
|
<Config.Provider value={config}>
|
||||||
|
<div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 ">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="p-4">
|
||||||
|
<Router>
|
||||||
|
<CameraMap path="/cameras/:camera/map-editor" />
|
||||||
|
<Camera path="/cameras/:camera" />
|
||||||
|
<Event path="/events/:eventId" />
|
||||||
|
<Events path="/events" />
|
||||||
|
<Debug path="/debug" />
|
||||||
|
<Cameras path="/" />
|
||||||
|
</Router>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Config.Provider>
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
77
web/src/Camera.jsx
Normal file
77
web/src/Camera.jsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import Heading from './components/Heading';
|
||||||
|
import Link from './components/Link';
|
||||||
|
import Switch from './components/Switch';
|
||||||
|
import { route } from 'preact-router';
|
||||||
|
import { useCallback, useContext } from 'preact/hooks';
|
||||||
|
import { ApiHost, Config } from './context';
|
||||||
|
|
||||||
|
export default function Camera({ camera, url }) {
|
||||||
|
const config = useContext(Config);
|
||||||
|
const apiHost = useContext(ApiHost);
|
||||||
|
|
||||||
|
if (!(camera in config.cameras)) {
|
||||||
|
return <div>{`No camera named ${camera}`}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraConfig = config.cameras[camera];
|
||||||
|
|
||||||
|
const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url}`);
|
||||||
|
const searchParamsString = searchParams.toString();
|
||||||
|
|
||||||
|
const handleSetOption = useCallback(
|
||||||
|
(id, value) => {
|
||||||
|
searchParams.set(id, value ? 1 : 0);
|
||||||
|
route(`${pathname}?${searchParams.toString()}`, true);
|
||||||
|
},
|
||||||
|
[searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
function getBoolean(id) {
|
||||||
|
return Boolean(parseInt(searchParams.get(id), 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Heading size="2xl">{camera}</Heading>
|
||||||
|
<img
|
||||||
|
width={cameraConfig.width}
|
||||||
|
height={cameraConfig.height}
|
||||||
|
key={searchParamsString}
|
||||||
|
src={`${apiHost}/api/${camera}?${searchParamsString}`}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
|
||||||
|
<Switch checked={getBoolean('bbox')} id="bbox" label="Bounding box" onChange={handleSetOption} />
|
||||||
|
<Switch checked={getBoolean('timestamp')} id="timestamp" label="Timestamp" onChange={handleSetOption} />
|
||||||
|
<Switch checked={getBoolean('zones')} id="zones" label="Zones" onChange={handleSetOption} />
|
||||||
|
<Switch checked={getBoolean('mask')} id="mask" label="Masks" onChange={handleSetOption} />
|
||||||
|
<Switch checked={getBoolean('motion')} id="motion" label="Motion boxes" onChange={handleSetOption} />
|
||||||
|
<Switch checked={getBoolean('regions')} id="regions" label="Regions" onChange={handleSetOption} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Heading size="sm">Tracked objects</Heading>
|
||||||
|
<ul className="flex flex-row flex-wrap space-x-4">
|
||||||
|
{cameraConfig.objects.track.map((objectType) => {
|
||||||
|
return (
|
||||||
|
<li key={objectType}>
|
||||||
|
<Link href={`/events?camera=${camera}&label=${objectType}`}>
|
||||||
|
<span className="capitalize">{objectType}</span>
|
||||||
|
<img src={`${apiHost}/api/${camera}/${objectType}/best.jpg?crop=1&h=150`} />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Heading size="sm">Options</Heading>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<Link href={`/cameras/${camera}/map-editor`}>Mask & Zone editor</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
279
web/src/CameraMap.jsx
Normal file
279
web/src/CameraMap.jsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import Button from './components/Button';
|
||||||
|
import Heading from './components/Heading';
|
||||||
|
import Switch from './components/Switch';
|
||||||
|
import { route } from 'preact-router';
|
||||||
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import { ApiHost, Config } from './context';
|
||||||
|
|
||||||
|
export default function Camera({ camera, url }) {
|
||||||
|
const config = useContext(Config);
|
||||||
|
const apiHost = useContext(ApiHost);
|
||||||
|
const imageRef = useRef(null);
|
||||||
|
const [imageScale, setImageScale] = useState(1);
|
||||||
|
|
||||||
|
if (!(camera in config.cameras)) {
|
||||||
|
return <div>{`No camera named ${camera}`}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraConfig = config.cameras[camera];
|
||||||
|
const { width, height, mask, zones } = cameraConfig;
|
||||||
|
const [editing, setEditing] = useState('mask');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scaledWidth = imageRef.current.width;
|
||||||
|
const scale = scaledWidth / width;
|
||||||
|
setImageScale(scale);
|
||||||
|
}, [imageRef.current, setImageScale]);
|
||||||
|
|
||||||
|
const initialZonePoints = {};
|
||||||
|
if (mask) {
|
||||||
|
initialZonePoints.mask = getPolylinePoints(mask);
|
||||||
|
}
|
||||||
|
const [zonePoints, setZonePoints] = useState(
|
||||||
|
Object.keys(zones).reduce(
|
||||||
|
(memo, zone) => ({ ...memo, [zone]: getPolylinePoints(zones[zone].coordinates) }),
|
||||||
|
initialZonePoints
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateEditable = useCallback(
|
||||||
|
(newPoints) => {
|
||||||
|
setZonePoints({ ...zonePoints, [editing]: newPoints });
|
||||||
|
},
|
||||||
|
[editing, setZonePoints, zonePoints]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectEditable = useCallback(
|
||||||
|
(name) => {
|
||||||
|
setEditing(name);
|
||||||
|
},
|
||||||
|
[setEditing]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddMask = useCallback(() => {
|
||||||
|
setZonePoints({ mask: [], ...zonePoints });
|
||||||
|
}, [zonePoints, setZonePoints]);
|
||||||
|
|
||||||
|
const handleAddZone = useCallback(() => {
|
||||||
|
const n = Object.keys(zonePoints).length;
|
||||||
|
const zoneName = `zone-${n}`;
|
||||||
|
setZonePoints({ ...zonePoints, [zoneName]: [] });
|
||||||
|
setEditing(zoneName);
|
||||||
|
}, [zonePoints, setZonePoints]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Heading size="2xl">{camera}</Heading>
|
||||||
|
<div className="relative">
|
||||||
|
<img ref={imageRef} width={width} height={height} src={`${apiHost}/api/${camera}/latest.jpg`} />
|
||||||
|
<EditableMask
|
||||||
|
onChange={handleUpdateEditable}
|
||||||
|
points={zonePoints[editing]}
|
||||||
|
scale={imageScale}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-column space-y-4 overflow-hidden">
|
||||||
|
{Object.keys(zonePoints).map((zone) => (
|
||||||
|
<MaskValues
|
||||||
|
editing={editing === zone}
|
||||||
|
onSelect={handleSelectEditable}
|
||||||
|
points={zonePoints[zone]}
|
||||||
|
name={zone}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-grow-0 space-x-4">
|
||||||
|
{!mask ? <Button onClick={handleAddMask}>Add Mask</Button> : null}
|
||||||
|
<Button onClick={handleAddZone}>Add Zone</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableMask({ onChange, points: initialPoints, scale, width, height }) {
|
||||||
|
const [points, setPoints] = useState(initialPoints);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPoints(initialPoints);
|
||||||
|
}, [setPoints, initialPoints]);
|
||||||
|
|
||||||
|
function boundedSize(value, maxValue) {
|
||||||
|
return Math.min(Math.max(0, Math.round(value)), maxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(index, newX, newY) => {
|
||||||
|
if (newX < 0 && newY < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const x = boundedSize(newX / scale, width);
|
||||||
|
const y = boundedSize(newY / scale, height);
|
||||||
|
const newPoints = [...points];
|
||||||
|
newPoints[index] = [x, y];
|
||||||
|
setPoints(newPoints);
|
||||||
|
onChange(newPoints);
|
||||||
|
},
|
||||||
|
[scale, points, setPoints]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a new point between the closest two other points
|
||||||
|
const handleAddPoint = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const { offsetX, offsetY } = event;
|
||||||
|
const scaledX = boundedSize(offsetX / scale, width);
|
||||||
|
const scaledY = boundedSize(offsetY / scale, height);
|
||||||
|
const newPoint = [scaledX, scaledY];
|
||||||
|
const closest = points.reduce((a, b, i) => {
|
||||||
|
if (!a) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
return distance(a, newPoint) < distance(b, newPoint) ? a : b;
|
||||||
|
}, null);
|
||||||
|
const index = points.indexOf(closest);
|
||||||
|
const newPoints = [...points];
|
||||||
|
newPoints.splice(index, 0, newPoint);
|
||||||
|
setPoints(newPoints);
|
||||||
|
onChange(newPoints);
|
||||||
|
},
|
||||||
|
[scale, points, setPoints, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemovePoint = useCallback(
|
||||||
|
(index) => {
|
||||||
|
const newPoints = [...points];
|
||||||
|
newPoints.splice(index, 1);
|
||||||
|
setPoints(newPoints);
|
||||||
|
onChange(newPoints);
|
||||||
|
},
|
||||||
|
[points, setPoints, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scaledPoints = useMemo(() => scalePolylinePoints(points, scale), [points, scale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onclick={handleAddPoint}>
|
||||||
|
{!scaledPoints
|
||||||
|
? null
|
||||||
|
: scaledPoints.map(([x, y], i) => (
|
||||||
|
<PolyPoint index={i} onDragend={handleDragEnd} onRemove={handleRemovePoint} x={x} y={y} />
|
||||||
|
))}
|
||||||
|
<svg width="100%" height="100%" className="absolute" style="top: 0; left: 0; right: 0; bottom: 0;">
|
||||||
|
{!scaledPoints ? null : (
|
||||||
|
<g>
|
||||||
|
<polyline points={polylinePointsToPolyline(scaledPoints)} fill="rgba(244,0,0,0.5)" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaskValues({ editing, name, onSelect, points }) {
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onSelect(name);
|
||||||
|
}, [name, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded border-gray-500 border-solid border p-2 hover:bg-gray-400 cursor-pointer ${
|
||||||
|
editing ? 'bg-gray-300' : ''
|
||||||
|
}`}
|
||||||
|
onclick={handleClick}
|
||||||
|
>
|
||||||
|
<Heading className="mb-4" size="sm">
|
||||||
|
{name}
|
||||||
|
</Heading>
|
||||||
|
<textarea className="select-all font-mono border-gray-300 text-gray-900 dark:text-gray-100 w-full" readonly>
|
||||||
|
{name === 'mask' ? 'poly,' : null}
|
||||||
|
{polylinePointsToPolyline(points)}
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distance([x0, y0], [x1, y1]) {
|
||||||
|
return Math.sqrt(Math.pow(x0 - x1, 2) + Math.pow(y0 - y1, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPolylinePoints(polyline) {
|
||||||
|
if (!polyline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return polyline
|
||||||
|
.replace('poly,', '')
|
||||||
|
.split(',')
|
||||||
|
.reduce((memo, point, i) => {
|
||||||
|
if (i % 2) {
|
||||||
|
memo[memo.length - 1].push(parseInt(point, 10));
|
||||||
|
} else {
|
||||||
|
memo.push([parseInt(point, 10)]);
|
||||||
|
}
|
||||||
|
return memo;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalePolylinePoints(polylinePoints, scale) {
|
||||||
|
if (!polylinePoints) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return polylinePoints.map(([x, y]) => [Math.round(x * scale), Math.round(y * scale)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function polylinePointsToPolyline(polylinePoints) {
|
||||||
|
if (!polylinePoints) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return polylinePoints.reduce((memo, [x, y]) => `${memo}${x},${y},`, '').replace(/,$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const PolyPointRadius = 10;
|
||||||
|
function PolyPoint({ index, x, y, onDragend, onRemove }) {
|
||||||
|
const [hidden, setHidden] = useState(false);
|
||||||
|
const handleDragStart = useCallback(() => {
|
||||||
|
setHidden(true);
|
||||||
|
}, [setHidden]);
|
||||||
|
const handleDrag = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const { offsetX, offsetY } = event;
|
||||||
|
onDragend(index, event.offsetX + x - PolyPointRadius, event.offsetY + y - PolyPointRadius);
|
||||||
|
},
|
||||||
|
[onDragend, index]
|
||||||
|
);
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setHidden(false);
|
||||||
|
}, [setHidden]);
|
||||||
|
|
||||||
|
const handleRightClick = useCallback(
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onRemove(index);
|
||||||
|
},
|
||||||
|
[onRemove, index]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback((event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${hidden ? 'opacity-0' : ''} bg-gray-900 rounded-full absolute z-20`}
|
||||||
|
style={`top: ${y - PolyPointRadius}px; left: ${x - PolyPointRadius}px; width: 20px; height: 20px;`}
|
||||||
|
draggable
|
||||||
|
onclick={handleClick}
|
||||||
|
oncontextmenu={handleRightClick}
|
||||||
|
ondragstart={handleDragStart}
|
||||||
|
ondrag={handleDrag}
|
||||||
|
ondragend={handleDragEnd}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
36
web/src/Cameras.jsx
Normal file
36
web/src/Cameras.jsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import Events from './Events';
|
||||||
|
import Heading from './components/Heading';
|
||||||
|
import { route } from 'preact-router';
|
||||||
|
import { useContext } from 'preact/hooks';
|
||||||
|
import { ApiHost, Config } from './context';
|
||||||
|
|
||||||
|
export default function Cameras() {
|
||||||
|
const config = useContext(Config);
|
||||||
|
|
||||||
|
if (!config.cameras) {
|
||||||
|
return <p>loading…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid lg:grid-cols-2 md:grid-cols-1 gap-4">
|
||||||
|
{Object.keys(config.cameras).map((camera) => (
|
||||||
|
<Camera name={camera} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Camera({ name }) {
|
||||||
|
const apiHost = useContext(ApiHost);
|
||||||
|
const href = `/cameras/${name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-700 shadow-lg rounded-lg p-4 hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900">
|
||||||
|
<a className="dark:hover:text-gray-900" href={href}>
|
||||||
|
<Heading size="base">{name}</Heading>
|
||||||
|
<img className="w-full" src={`${apiHost}/api/${name}/latest.jpg`} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
16
web/src/Debug.jsx
Normal file
16
web/src/Debug.jsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { ApiHost } from './context';
|
||||||
|
import { useContext, useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function Debug() {
|
||||||
|
const apiHost = useContext(ApiHost);
|
||||||
|
const [config, setConfig] = useState({});
|
||||||
|
|
||||||
|
useEffect(async () => {
|
||||||
|
const response = await fetch(`${apiHost}/api/stats`);
|
||||||
|
const data = response.ok ? await response.json() : {};
|
||||||
|
setConfig(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <pre>{JSON.stringify(config, null, 2)}</pre>;
|
||||||
|
}
|
40
web/src/Event.jsx
Normal file
40
web/src/Event.jsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { ApiHost } from './context';
|
||||||
|
import Heading from './components/Heading';
|
||||||
|
import { useContext, useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function Event({ eventId }) {
|
||||||
|
const apiHost = useContext(ApiHost);
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(async () => {
|
||||||
|
const response = await fetch(`${apiHost}/api/events/${eventId}`);
|
||||||
|
const data = response.ok ? await response.json() : null;
|
||||||
|
setData(data);
|
||||||
|
}, [apiHost, eventId]);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Heading>{eventId}</Heading>
|
||||||
|
<p>loading…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const datetime = new Date(data.start_time * 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Heading>
|
||||||
|
{data.camera} {data.label} <span className="text-sm">{datetime.toLocaleString()}</span>
|
||||||
|
</Heading>
|
||||||
|
<img
|
||||||
|
src={`data:image/jpeg;base64,${data.thumbnail}`}
|
||||||
|
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
|
||||||
|
/>
|
||||||
|
<video className="w-96" src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} controls />
|
||||||
|
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
108
web/src/Events.jsx
Normal file
108
web/src/Events.jsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { ApiHost } from './context';
|
||||||
|
import Heading from './components/Heading';
|
||||||
|
import Link from './components/Link';
|
||||||
|
import { route } from 'preact-router';
|
||||||
|
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
|
||||||
|
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function Events({ url } = {}) {
|
||||||
|
const apiHost = useContext(ApiHost);
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
|
||||||
|
const searchParams = new URL(`${window.location.protocol}//${window.location.host}${url || '/events'}`).searchParams;
|
||||||
|
const searchParamsString = searchParams.toString();
|
||||||
|
|
||||||
|
useEffect(async () => {
|
||||||
|
const response = await fetch(`${apiHost}/api/events?${searchParamsString}`);
|
||||||
|
const data = response.ok ? await response.json() : {};
|
||||||
|
setEvents(data);
|
||||||
|
}, [searchParamsString]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Heading>Events</Heading>
|
||||||
|
<div className="flex flex-wrap space-x-2">
|
||||||
|
{Array.from(searchParams.keys()).map((filterKey) => (
|
||||||
|
<UnFilterable
|
||||||
|
paramName={filterKey}
|
||||||
|
searchParams={searchParamsString}
|
||||||
|
name={`${filterKey}: ${searchParams.get(filterKey)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th></Th>
|
||||||
|
<Th>Camera</Th>
|
||||||
|
<Th>Label</Th>
|
||||||
|
<Th>Score</Th>
|
||||||
|
<Th>Zones</Th>
|
||||||
|
<Th>Date</Th>
|
||||||
|
<Th>Start</Th>
|
||||||
|
<Th>End</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{events.map(
|
||||||
|
(
|
||||||
|
{ camera, id, label, start_time: startTime, end_time: endTime, thumbnail, top_score: score, zones },
|
||||||
|
i
|
||||||
|
) => {
|
||||||
|
const start = new Date(parseInt(startTime * 1000, 10));
|
||||||
|
const end = new Date(parseInt(endTime * 1000, 10));
|
||||||
|
return (
|
||||||
|
<Tr key={id} index={i}>
|
||||||
|
<Td>
|
||||||
|
<a href={`/events/${id}`}>
|
||||||
|
<img className="w-32" src={`data:image/jpeg;base64,${thumbnail}`} />
|
||||||
|
</a>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Filterable searchParams={searchParamsString} paramName="camera" name={camera} />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Filterable searchParams={searchParamsString} paramName="label" name={label} />
|
||||||
|
</Td>
|
||||||
|
<Td>{(score * 100).toFixed(2)}%</Td>
|
||||||
|
<Td>
|
||||||
|
<ul>
|
||||||
|
{zones.map((zone) => (
|
||||||
|
<li>
|
||||||
|
<Filterable searchParams={searchParamsString} paramName="zone" name={zone} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Td>
|
||||||
|
<Td>{start.toLocaleDateString()}</Td>
|
||||||
|
<Td>{start.toLocaleTimeString()}</Td>
|
||||||
|
<Td>{end.toLocaleTimeString()}</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Filterable({ searchParams, paramName, name }) {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set(paramName, name);
|
||||||
|
return <Link href={`?${params.toString()}`}>{name}</Link>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnFilterable({ searchParams, paramName, name }) {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.delete(paramName);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="bg-gray-700 text-white px-3 py-1 rounded-md hover:bg-gray-300 hover:text-gray-900 dark:bg-gray-300 dark:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||||
|
href={`?${params.toString()}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
87
web/src/Sidebar.jsx
Normal file
87
web/src/Sidebar.jsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import Link from './components/Link';
|
||||||
|
import { Link as RouterLink } from 'preact-router/match';
|
||||||
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
function HamburgerIcon() {
|
||||||
|
return (
|
||||||
|
<svg fill="currentColor" viewBox="0 0 20 20" className="w-6 h-6">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM9 15a1 1 0 011-1h6a1 1 0 110 2h-6a1 1 0 01-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg fill="currentColor" viewBox="0 0 20 20" className="w-6 h-6">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ className = '', href, text }) {
|
||||||
|
const external = href.startsWith('http');
|
||||||
|
const El = external ? Link : RouterLink;
|
||||||
|
const props = external ? { rel: 'noopener nofollow', target: '_blank' } : {};
|
||||||
|
return (
|
||||||
|
<El
|
||||||
|
activeClassName="bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:bg-gray-600 dark:focus:text-white dark:hover:text-white dark:text-gray-200"
|
||||||
|
className={`block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark:bg-transparent dark:hover:bg-gray-600 dark:focus:bg-gray-600 dark:focus:text-white dark:hover:text-white dark:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline self-end ${className}`}
|
||||||
|
href={href}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</El>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
setOpen(!open);
|
||||||
|
}, [open, setOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full md:w-64 text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||||
|
<div className="flex-shrink-0 px-8 py-4 flex flex-row items-center justify-between">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-lg font-semibold tracking-widest text-gray-900 uppercase rounded-lg dark:text-white focus:outline-none focus:shadow-outline"
|
||||||
|
>
|
||||||
|
Frigate
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="rounded-lg md:hidden rounded-lg focus:outline-none focus:shadow-outline"
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{open ? <CloseIcon /> : <HamburgerIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav
|
||||||
|
className={`flex-col flex-grow md:block overflow-hidden px-4 pb-4 md:pb-0 md:overflow-y-auto ${
|
||||||
|
!open ? 'md:h-0 hidden' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<NavLink href="/" text="Cameras" />
|
||||||
|
<NavLink href="/events" text="Events" />
|
||||||
|
<NavLink href="/debug" text="Debug" />
|
||||||
|
<hr className="border-solid border-gray-500 mt-2" />
|
||||||
|
<NavLink
|
||||||
|
className="self-end"
|
||||||
|
href="https://github.com/blakeblackshear/frigate/blob/master/README.md"
|
||||||
|
text="Documentation"
|
||||||
|
/>
|
||||||
|
<NavLink className="self-end" href="https://github.com/blakeblackshear/frigate" text="GitHub" />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
16
web/src/components/Button.jsx
Normal file
16
web/src/components/Button.jsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
export default function Button({ children, color, onClick, size }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
className="rounded bg-blue-500 text-white pl-4 pr-4 pt-2 pb-2 font-bold shadow hover:bg-blue-400 hover:shadow-lg cursor-pointer"
|
||||||
|
onClick={onClick || noop}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
9
web/src/components/Heading.jsx
Normal file
9
web/src/components/Heading.jsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
export default function Heading({ children, className = '', size = '2xl' }) {
|
||||||
|
return (
|
||||||
|
<h1 className={`font-semibold tracking-widest text-gray-900 uppercase dark:text-white text-${size} ${className}`}>
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
}
|
9
web/src/components/Link.jsx
Normal file
9
web/src/components/Link.jsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
export default function Link({ className, children, href, ...props }) {
|
||||||
|
return (
|
||||||
|
<a className={`text-blue-500 hover:underline ${className}`} href={href} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
26
web/src/components/Switch.jsx
Normal file
26
web/src/components/Switch.jsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function Switch({ checked, label, id, onChange }) {
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(event) => {
|
||||||
|
console.log(event.target.checked, !checked);
|
||||||
|
onChange(id, !checked);
|
||||||
|
},
|
||||||
|
[id, onChange, checked]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label for={id} className="flex items-center cursor-pointer">
|
||||||
|
<div className="relative">
|
||||||
|
<input id={id} type="checkbox" className="hidden" onChange={handleChange} checked={checked} />
|
||||||
|
<div className="toggle__line w-12 h-6 bg-gray-400 rounded-full shadow-inner" />
|
||||||
|
<div
|
||||||
|
className="transition-transform absolute w-6 h-6 bg-white rounded-full shadow-md inset-y-0 left-0"
|
||||||
|
style={checked ? 'transform: translateX(100%);' : 'transform: translateX(0%);'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-gray-700 font-medium dark:text-gray-200">{label}</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
29
web/src/components/Table.jsx
Normal file
29
web/src/components/Table.jsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
export function Table({ children }) {
|
||||||
|
return <table className="table-auto border-collapse text-gray-900 dark:text-gray-200">{children}</table>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Thead({ children }) {
|
||||||
|
return <thead className="">{children}</thead>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tbody({ children }) {
|
||||||
|
return <tbody className="">{children}</tbody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tfoot({ children }) {
|
||||||
|
return <tfoot className="">{children}</tfoot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tr({ children, index }) {
|
||||||
|
return <tr className={`${index % 2 ? 'bg-gray-200 ' : ''}`}>{children}</tr>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Th({ children }) {
|
||||||
|
return <th className="border-b-2 border-gray-400 p-4 text-left">{children}</th>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Td({ children }) {
|
||||||
|
return <td className="p-4">{children}</td>;
|
||||||
|
}
|
5
web/src/context/index.js
Normal file
5
web/src/context/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createContext } from 'preact';
|
||||||
|
|
||||||
|
export const Config = createContext({});
|
||||||
|
|
||||||
|
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || '');
|
3
web/src/index.css
Normal file
3
web/src/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
9
web/src/index.jsx
Normal file
9
web/src/index.jsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import App from './App';
|
||||||
|
import { h, render } from 'preact';
|
||||||
|
import 'preact/devtools';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<App />,
|
||||||
|
document.getElementById('root')
|
||||||
|
);
|
13
web/tailwind.config.js
Normal file
13
web/tailwind.config.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
purge: ['./public/**/*.html', './src/**/*.jsx'],
|
||||||
|
darkMode: 'media',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user