feat!: web user interface

This commit is contained in:
Paul Armstrong 2021-01-09 09:26:46 -08:00 committed by Blake Blackshear
parent 5ad4017510
commit c618867941
29 changed files with 9112 additions and 123 deletions

7
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
View 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

File diff suppressed because it is too large Load Diff

19
web/package.json Normal file
View 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
View File

@ -0,0 +1,8 @@
'use strict';
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
],
};

24
web/public/index.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

9
web/src/index.jsx Normal file
View 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
View File

@ -0,0 +1,13 @@
'use strict';
module.exports = {
purge: ['./public/**/*.html', './src/**/*.jsx'],
darkMode: 'media',
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
};