mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +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