forked from Github/frigate
Compare commits
14 Commits
v0.9.0-bet
...
v0.9.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46fe06e779 | ||
|
|
fbea51372f | ||
|
|
fa5ec8d019 | ||
|
|
11c425a7eb | ||
|
|
0d352f3d8a | ||
|
|
6ccff71408 | ||
|
|
41fea2a531 | ||
|
|
3d6dad7e7e | ||
|
|
4efc584816 | ||
|
|
10ab70080a | ||
|
|
29de723267 | ||
|
|
354a9240f0 | ||
|
|
5ae4f47e96 | ||
|
|
26424488a5 |
@@ -40,8 +40,8 @@ COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
|
|||||||
|
|
||||||
# get model and labels
|
# get model and labels
|
||||||
COPY labelmap.txt /labelmap.txt
|
COPY labelmap.txt /labelmap.txt
|
||||||
RUN wget -q https://github.com/google-coral/test_data/raw/master/ssdlite_mobiledet_coco_qat_postprocess_edgetpu.tflite -O /edgetpu_model.tflite
|
RUN wget -q https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess_edgetpu.tflite -O /edgetpu_model.tflite
|
||||||
RUN wget -q https://github.com/google-coral/test_data/raw/master/ssdlite_mobiledet_coco_qat_postprocess.tflite -O /cpu_model.tflite
|
RUN wget -q https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess.tflite -O /cpu_model.tflite
|
||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
ADD frigate frigate/
|
ADD frigate frigate/
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ http {
|
|||||||
vod_mode mapped;
|
vod_mode mapped;
|
||||||
vod_max_mapping_response_size 1m;
|
vod_max_mapping_response_size 1m;
|
||||||
vod_upstream_location /api;
|
vod_upstream_location /api;
|
||||||
|
vod_align_segments_to_key_frames on;
|
||||||
|
vod_manifest_segment_durations_mode accurate;
|
||||||
|
|
||||||
# vod caches
|
# vod caches
|
||||||
vod_metadata_cache metadata_cache 512m;
|
vod_metadata_cache metadata_cache 512m;
|
||||||
|
|||||||
@@ -284,11 +284,11 @@ cameras:
|
|||||||
|
|
||||||
# Required: Camera level detect settings
|
# Required: Camera level detect settings
|
||||||
detect:
|
detect:
|
||||||
# Required: width of the frame for the input with the detect role
|
# Optional: width of the frame for the input with the detect role (default: shown below)
|
||||||
width: 1280
|
width: 1280
|
||||||
# Required: height of the frame for the input with the detect role
|
# Optional: height of the frame for the input with the detect role (default: shown below)
|
||||||
height: 720
|
height: 720
|
||||||
# Required: desired fps for your camera for the input with the detect role
|
# Optional: desired fps for your camera for the input with the detect role (default: shown below)
|
||||||
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
|
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
|
||||||
fps: 5
|
fps: 5
|
||||||
# Optional: enables detection for the camera (default: True)
|
# Optional: enables detection for the camera (default: True)
|
||||||
|
|||||||
@@ -167,21 +167,6 @@ record:
|
|||||||
person: 15
|
person: 15
|
||||||
```
|
```
|
||||||
|
|
||||||
## `snapshots`
|
|
||||||
|
|
||||||
Can be overridden at the camera level. Global snapshot retention settings.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Optional: Configuration for the jpg snapshots written to the clips directory for each event
|
|
||||||
snapshots:
|
|
||||||
retain:
|
|
||||||
# Required: Default retention days (default: shown below)
|
|
||||||
default: 10
|
|
||||||
# Optional: Per object retention days
|
|
||||||
objects:
|
|
||||||
person: 15
|
|
||||||
```
|
|
||||||
|
|
||||||
### `ffmpeg`
|
### `ffmpeg`
|
||||||
|
|
||||||
Can be overridden at the camera level.
|
Can be overridden at the camera level.
|
||||||
|
|||||||
@@ -149,9 +149,11 @@ class RuntimeMotionConfig(MotionConfig):
|
|||||||
|
|
||||||
|
|
||||||
class DetectConfig(BaseModel):
|
class DetectConfig(BaseModel):
|
||||||
height: int = Field(title="Height of the stream for the detect role.")
|
height: int = Field(default=720, title="Height of the stream for the detect role.")
|
||||||
width: int = Field(title="Width of the stream for the detect role.")
|
width: int = Field(default=1280, title="Width of the stream for the detect role.")
|
||||||
fps: int = Field(title="Number of frames per second to process through detection.")
|
fps: int = Field(
|
||||||
|
default=5, title="Number of frames per second to process through detection."
|
||||||
|
)
|
||||||
enabled: bool = Field(default=True, title="Detection Enabled.")
|
enabled: bool = Field(default=True, title="Detection Enabled.")
|
||||||
max_disappeared: Optional[int] = Field(
|
max_disappeared: Optional[int] = Field(
|
||||||
title="Maximum number of frames the object can dissapear before detection ends."
|
title="Maximum number of frames the object can dissapear before detection ends."
|
||||||
@@ -332,9 +334,15 @@ class FfmpegConfig(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CameraRoleEnum(str, Enum):
|
||||||
|
record = "record"
|
||||||
|
rtmp = "rtmp"
|
||||||
|
detect = "detect"
|
||||||
|
|
||||||
|
|
||||||
class CameraInput(BaseModel):
|
class CameraInput(BaseModel):
|
||||||
path: str = Field(title="Camera input path.")
|
path: str = Field(title="Camera input path.")
|
||||||
roles: List[str] = Field(title="Roles assigned to this input.")
|
roles: List[CameraRoleEnum] = Field(title="Roles assigned to this input.")
|
||||||
global_args: Union[str, List[str]] = Field(
|
global_args: Union[str, List[str]] = Field(
|
||||||
default_factory=list, title="FFmpeg global arguments."
|
default_factory=list, title="FFmpeg global arguments."
|
||||||
)
|
)
|
||||||
@@ -363,7 +371,7 @@ class CameraFfmpegConfig(FfmpegConfig):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class CameraSnapshotsConfig(BaseModel):
|
class SnapshotsConfig(BaseModel):
|
||||||
enabled: bool = Field(default=False, title="Snapshots enabled.")
|
enabled: bool = Field(default=False, title="Snapshots enabled.")
|
||||||
clean_copy: bool = Field(
|
clean_copy: bool = Field(
|
||||||
default=True, title="Create a clean copy of the snapshot image."
|
default=True, title="Create a clean copy of the snapshot image."
|
||||||
@@ -449,9 +457,11 @@ class CameraConfig(BaseModel):
|
|||||||
rtmp: CameraRtmpConfig = Field(
|
rtmp: CameraRtmpConfig = Field(
|
||||||
default_factory=CameraRtmpConfig, title="RTMP restreaming configuration."
|
default_factory=CameraRtmpConfig, title="RTMP restreaming configuration."
|
||||||
)
|
)
|
||||||
live: Optional[CameraLiveConfig] = Field(title="Live playback settings.")
|
live: CameraLiveConfig = Field(
|
||||||
snapshots: CameraSnapshotsConfig = Field(
|
default_factory=CameraLiveConfig, title="Live playback settings."
|
||||||
default_factory=CameraSnapshotsConfig, title="Snapshot configuration."
|
)
|
||||||
|
snapshots: SnapshotsConfig = Field(
|
||||||
|
default_factory=SnapshotsConfig, title="Snapshot configuration."
|
||||||
)
|
)
|
||||||
mqtt: CameraMqttConfig = Field(
|
mqtt: CameraMqttConfig = Field(
|
||||||
default_factory=CameraMqttConfig, title="MQTT configuration."
|
default_factory=CameraMqttConfig, title="MQTT configuration."
|
||||||
@@ -460,7 +470,9 @@ class CameraConfig(BaseModel):
|
|||||||
default_factory=ObjectConfig, title="Object configuration."
|
default_factory=ObjectConfig, title="Object configuration."
|
||||||
)
|
)
|
||||||
motion: Optional[MotionConfig] = Field(title="Motion detection configuration.")
|
motion: Optional[MotionConfig] = Field(title="Motion detection configuration.")
|
||||||
detect: DetectConfig = Field(title="Object detection configuration.")
|
detect: DetectConfig = Field(
|
||||||
|
default_factory=DetectConfig, title="Object detection configuration."
|
||||||
|
)
|
||||||
timestamp_style: TimestampStyleConfig = Field(
|
timestamp_style: TimestampStyleConfig = Field(
|
||||||
default_factory=TimestampStyleConfig, title="Timestamp style configuration."
|
default_factory=TimestampStyleConfig, title="Timestamp style configuration."
|
||||||
)
|
)
|
||||||
@@ -620,12 +632,6 @@ class LoggerConfig(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SnapshotsConfig(BaseModel):
|
|
||||||
retain: RetainConfig = Field(
|
|
||||||
default_factory=RetainConfig, title="Global snapshot retention configuration."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FrigateConfig(BaseModel):
|
class FrigateConfig(BaseModel):
|
||||||
mqtt: MqttConfig = Field(title="MQTT Configuration.")
|
mqtt: MqttConfig = Field(title="MQTT Configuration.")
|
||||||
database: DatabaseConfig = Field(
|
database: DatabaseConfig = Field(
|
||||||
@@ -662,8 +668,8 @@ class FrigateConfig(BaseModel):
|
|||||||
motion: Optional[MotionConfig] = Field(
|
motion: Optional[MotionConfig] = Field(
|
||||||
title="Global motion detection configuration."
|
title="Global motion detection configuration."
|
||||||
)
|
)
|
||||||
detect: Optional[DetectConfig] = Field(
|
detect: DetectConfig = Field(
|
||||||
title="Global object tracking configuration."
|
default_factory=DetectConfig, title="Global object tracking configuration."
|
||||||
)
|
)
|
||||||
cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.")
|
cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.")
|
||||||
|
|
||||||
@@ -695,6 +701,11 @@ class FrigateConfig(BaseModel):
|
|||||||
{"name": name, **merged_config}
|
{"name": name, **merged_config}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Default max_disappeared configuration
|
||||||
|
max_disappeared = camera_config.detect.fps * 5
|
||||||
|
if camera_config.detect.max_disappeared is None:
|
||||||
|
camera_config.detect.max_disappeared = max_disappeared
|
||||||
|
|
||||||
# FFMPEG input substitution
|
# FFMPEG input substitution
|
||||||
for input in camera_config.ffmpeg.inputs:
|
for input in camera_config.ffmpeg.inputs:
|
||||||
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
||||||
@@ -742,15 +753,6 @@ class FrigateConfig(BaseModel):
|
|||||||
**camera_config.motion.dict(exclude_unset=True),
|
**camera_config.motion.dict(exclude_unset=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default detect configuration
|
|
||||||
max_disappeared = camera_config.detect.fps * 5
|
|
||||||
if camera_config.detect.max_disappeared is None:
|
|
||||||
camera_config.detect.max_disappeared = max_disappeared
|
|
||||||
|
|
||||||
# Default live configuration
|
|
||||||
if camera_config.live is None:
|
|
||||||
camera_config.live = CameraLiveConfig()
|
|
||||||
|
|
||||||
config.cameras[name] = camera_config
|
config.cameras[name] = camera_config
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -96,14 +96,14 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
threading.current_thread().name = "mqtt"
|
threading.current_thread().name = "mqtt"
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
if rc == 3:
|
if rc == 3:
|
||||||
logger.error("MQTT Server unavailable")
|
logger.error("Unable to connect to MQTT server: MQTT Server unavailable")
|
||||||
elif rc == 4:
|
elif rc == 4:
|
||||||
logger.error("MQTT Bad username or password")
|
logger.error("Unable to connect to MQTT server: MQTT Bad username or password")
|
||||||
elif rc == 5:
|
elif rc == 5:
|
||||||
logger.error("MQTT Not authorized")
|
logger.error("Unable to connect to MQTT server: MQTT Not authorized")
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Unable to connect to MQTT: Connection refused. Error code: "
|
"Unable to connect to MQTT server: Connection refused. Error code: "
|
||||||
+ str(rc)
|
+ str(rc)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,9 @@ class BroadcastThread(threading.Thread):
|
|||||||
ws_iter = iter(websockets.values())
|
ws_iter = iter(websockets.values())
|
||||||
|
|
||||||
for ws in ws_iter:
|
for ws in ws_iter:
|
||||||
if not ws.terminated and ws.environ["PATH_INFO"].endswith(
|
if (
|
||||||
self.camera
|
not ws.terminated
|
||||||
|
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
ws.send(buf, binary=True)
|
ws.send(buf, binary=True)
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
|
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
|
||||||
|
|
||||||
# Just delete files if recordings are turned off
|
# Just delete files if recordings are turned off
|
||||||
if not self.config.cameras[camera].record.enabled:
|
if (
|
||||||
|
not camera in self.config.cameras
|
||||||
|
or not self.config.cameras[camera].record.enabled
|
||||||
|
):
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -244,29 +247,48 @@ class RecordingCleanup(threading.Thread):
|
|||||||
|
|
||||||
def expire_files(self):
|
def expire_files(self):
|
||||||
logger.debug("Start expire files (legacy).")
|
logger.debug("Start expire files (legacy).")
|
||||||
|
|
||||||
default_expire = (
|
default_expire = (
|
||||||
datetime.datetime.now().timestamp()
|
datetime.datetime.now().timestamp()
|
||||||
- SECONDS_IN_DAY * self.config.record.retain_days
|
- SECONDS_IN_DAY * self.config.record.retain_days
|
||||||
)
|
)
|
||||||
delete_before = {}
|
delete_before = {}
|
||||||
|
|
||||||
for name, camera in self.config.cameras.items():
|
for name, camera in self.config.cameras.items():
|
||||||
delete_before[name] = (
|
delete_before[name] = (
|
||||||
datetime.datetime.now().timestamp()
|
datetime.datetime.now().timestamp()
|
||||||
- SECONDS_IN_DAY * camera.record.retain_days
|
- SECONDS_IN_DAY * camera.record.retain_days
|
||||||
)
|
)
|
||||||
|
|
||||||
for p in Path("/media/frigate/recordings").rglob("*.mp4"):
|
# find all the recordings older than the oldest recording in the db
|
||||||
# Ignore files that have a record in the recordings DB
|
oldest_recording = (
|
||||||
if Recordings.select().where(Recordings.path == str(p)).count():
|
Recordings.select().order_by(Recordings.start_time.desc()).get()
|
||||||
continue
|
)
|
||||||
|
|
||||||
|
oldest_timestamp = (
|
||||||
|
oldest_recording.start_time
|
||||||
|
if oldest_recording
|
||||||
|
else datetime.datetime.now().timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
|
||||||
|
process = sp.run(
|
||||||
|
["find", RECORD_DIR, "-type", "f", "-newermt", f"@{oldest_timestamp}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
files_to_check = process.stdout.splitlines()
|
||||||
|
|
||||||
|
for f in files_to_check:
|
||||||
|
p = Path(f)
|
||||||
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
|
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
|
||||||
p.unlink(missing_ok=True)
|
p.unlink(missing_ok=True)
|
||||||
|
|
||||||
logger.debug("End expire files (legacy).")
|
logger.debug("End expire files (legacy).")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
# Expire recordings every minute, clean directories every 5 minutes.
|
# Expire recordings every minute, clean directories every hour.
|
||||||
for counter in itertools.cycle(range(5)):
|
for counter in itertools.cycle(range(60)):
|
||||||
if self.stop_event.wait(60):
|
if self.stop_event.wait(60):
|
||||||
logger.info(f"Exiting recording cleanup...")
|
logger.info(f"Exiting recording cleanup...")
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert self.minimal == frigate_config.dict(exclude_unset=True)
|
assert self.minimal == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config
|
runtime_config = frigate_config.runtime_config
|
||||||
assert "coral" in runtime_config.detectors.keys()
|
assert "cpu" in runtime_config.detectors.keys()
|
||||||
assert runtime_config.detectors["coral"].type == DetectorTypeEnum.edgetpu
|
assert runtime_config.detectors["cpu"].type == DetectorTypeEnum.cpu
|
||||||
|
|
||||||
def test_invalid_mqtt_config(self):
|
def test_invalid_mqtt_config(self):
|
||||||
config = {
|
config = {
|
||||||
@@ -692,6 +692,198 @@ class TestConfig(unittest.TestCase):
|
|||||||
runtime_config = frigate_config.runtime_config
|
runtime_config = frigate_config.runtime_config
|
||||||
assert runtime_config.model.merged_labelmap[0] == "person"
|
assert runtime_config.model.merged_labelmap[0] == "person"
|
||||||
|
|
||||||
|
def test_fails_on_invalid_role(self):
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect", "clips"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
|
||||||
|
|
||||||
|
def test_global_detect(self):
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"detect": {"max_disappeared": 1},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
runtime_config = frigate_config.runtime_config
|
||||||
|
assert runtime_config.cameras["back"].detect.max_disappeared == 1
|
||||||
|
assert runtime_config.cameras["back"].detect.height == 1080
|
||||||
|
|
||||||
|
def test_default_detect(self):
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
runtime_config = frigate_config.runtime_config
|
||||||
|
assert runtime_config.cameras["back"].detect.max_disappeared == 25
|
||||||
|
assert runtime_config.cameras["back"].detect.height == 720
|
||||||
|
|
||||||
|
def test_global_detect_merge(self):
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"detect": {"max_disappeared": 1, "height": 720},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
runtime_config = frigate_config.runtime_config
|
||||||
|
assert runtime_config.cameras["back"].detect.max_disappeared == 1
|
||||||
|
assert runtime_config.cameras["back"].detect.height == 1080
|
||||||
|
assert runtime_config.cameras["back"].detect.width == 1920
|
||||||
|
|
||||||
|
def test_global_snapshots(self):
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"snapshots": {"enabled": True},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"snapshots": {
|
||||||
|
"height": 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
runtime_config = frigate_config.runtime_config
|
||||||
|
assert runtime_config.cameras["back"].snapshots.enabled
|
||||||
|
assert runtime_config.cameras["back"].snapshots.height == 100
|
||||||
|
|
||||||
|
def test_default_snapshots(self):
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
runtime_config = frigate_config.runtime_config
|
||||||
|
assert runtime_config.cameras["back"].snapshots.bounding_box
|
||||||
|
assert runtime_config.cameras["back"].snapshots.quality == 70
|
||||||
|
|
||||||
|
def test_global_snapshots_merge(self):
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"snapshots": {"bounding_box": False, "height": 300},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"snapshots": {
|
||||||
|
"height": 150,
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
runtime_config = frigate_config.runtime_config
|
||||||
|
assert runtime_config.cameras["back"].snapshots.bounding_box == False
|
||||||
|
assert runtime_config.cameras["back"].snapshots.height == 150
|
||||||
|
assert runtime_config.cameras["back"].snapshots.enabled
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -23,12 +23,11 @@ export default function App() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="w-full flex-auto p-2 mt-24 px-4 min-w-0">
|
<div className="w-full flex-auto p-2 mt-16 px-4 min-w-0">
|
||||||
<Router>
|
<Router>
|
||||||
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
|
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
|
||||||
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
|
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
|
||||||
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
|
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
|
||||||
<AsyncRoute path="/events/:eventId" getComponent={Routes.getEvent} />
|
|
||||||
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
||||||
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
|
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
|
||||||
<AsyncRoute path="/debug" getComponent={Routes.getDebug} />
|
<AsyncRoute path="/debug" getComponent={Routes.getDebug} />
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function AppBar() {
|
|||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MenuItem icon={FrigateRestartIcon} label="Restart Frigate" onSelect={handleRestart} />
|
<MenuItem icon={FrigateRestartIcon} label="Restart Frigate" onSelect={handleRestart} />
|
||||||
</Menu>
|
</Menu>
|
||||||
) : null},
|
) : null}
|
||||||
{showDialog ? (
|
{showDialog ? (
|
||||||
<Dialog
|
<Dialog
|
||||||
onDismiss={handleDismissRestartDialog}
|
onDismiss={handleDismissRestartDialog}
|
||||||
@@ -74,7 +74,7 @@ export default function AppBar() {
|
|||||||
{ text: 'Cancel', onClick: handleDismissRestartDialog },
|
{ text: 'Cancel', onClick: handleDismissRestartDialog },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
) : null},
|
) : null}
|
||||||
{showDialogWait ? (
|
{showDialogWait ? (
|
||||||
<Dialog
|
<Dialog
|
||||||
title="Restart in progress"
|
title="Restart in progress"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const initialState = Object.freeze({
|
|||||||
|
|
||||||
const Api = createContext(initialState);
|
const Api = createContext(initialState);
|
||||||
|
|
||||||
function reducer(state, { type, payload, meta }) {
|
function reducer(state, { type, payload }) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'REQUEST': {
|
case 'REQUEST': {
|
||||||
const { url, fetchId } = payload;
|
const { url, fetchId } = payload;
|
||||||
@@ -36,22 +36,9 @@ function reducer(state, { type, payload, meta }) {
|
|||||||
}
|
}
|
||||||
case 'DELETE': {
|
case 'DELETE': {
|
||||||
const { eventId } = payload;
|
const { eventId } = payload;
|
||||||
|
|
||||||
return produce(state, (draftState) => {
|
return produce(state, (draftState) => {
|
||||||
Object.keys(draftState.queries).map((url, index) => {
|
Object.keys(draftState.queries).map((url) => {
|
||||||
// If data has no array length then just return state.
|
draftState.queries[url].deletedId = eventId;
|
||||||
if (!('data' in draftState.queries[url]) || !draftState.queries[url].data.length) return state;
|
|
||||||
|
|
||||||
//Find the index to remove
|
|
||||||
const removeIndex = draftState.queries[url].data.map((event) => event.id).indexOf(eventId);
|
|
||||||
if (removeIndex === -1) return state;
|
|
||||||
|
|
||||||
// We need to keep track of deleted items, This will be used to re-calculate "ReachEnd" for auto load new events. Events.jsx
|
|
||||||
const totDeleted = state.queries[url].deleted || 0;
|
|
||||||
|
|
||||||
// Splice the deleted index.
|
|
||||||
draftState.queries[url].data.splice(removeIndex, 1);
|
|
||||||
draftState.queries[url].deleted = totDeleted + 1;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,9 +98,9 @@ export function useFetch(url, fetchId) {
|
|||||||
|
|
||||||
const data = state.queries[url].data || null;
|
const data = state.queries[url].data || null;
|
||||||
const status = state.queries[url].status;
|
const status = state.queries[url].status;
|
||||||
const deleted = state.queries[url].deleted || 0;
|
const deletedId = state.queries[url].deletedId || 0;
|
||||||
|
|
||||||
return { data, status, deleted };
|
return { data, status, deletedId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDelete() {
|
export function useDelete() {
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-4 space-x-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
|
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
|
||||||
!show ? '-translate-y-full' : 'translate-y-0'
|
!show ? '-translate-y-full' : 'translate-y-0'
|
||||||
} ${!atZero ? 'shadow-sm' : ''}`}
|
} ${!atZero ? 'shadow-sm' : ''}`}
|
||||||
data-testid="appbar"
|
data-testid="appbar"
|
||||||
>
|
>
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<Button color="black" className="rounded-full w-12 h-12" onClick={handleShowDrawer} type="text">
|
<Button color="black" className="rounded-full w-10 h-10" onClick={handleShowDrawer} type="text">
|
||||||
<MenuIcon className="w-10 h-10" />
|
<MenuIcon className="w-10 h-10" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +54,7 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
|
|||||||
<Button
|
<Button
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
color="black"
|
color="black"
|
||||||
className="rounded-full w-12 h-12"
|
className="rounded-full w-9 h-9"
|
||||||
onClick={onOverflowClick}
|
onClick={onOverflowClick}
|
||||||
type="text"
|
type="text"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function Dialog({ actions = [], portalRootID = 'dialogs', title,
|
|||||||
<div
|
<div
|
||||||
data-testid="scrim"
|
data-testid="scrim"
|
||||||
key="scrim"
|
key="scrim"
|
||||||
className="absolute inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
|
className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="modal"
|
role="modal"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function NavigationDrawer({ children, header }) {
|
|||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
>
|
>
|
||||||
{header ? (
|
{header ? (
|
||||||
<div className="flex-shrink-0 p-5 flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
|
<div className="flex-shrink-0 p-2 flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
|
||||||
{header}
|
{header}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
|
|||||||
events={recording.events}
|
events={recording.events}
|
||||||
selected={recording.date === selectedDate}
|
selected={recording.date === selectedDate}
|
||||||
>
|
>
|
||||||
{recording.recordings.map((item, i) => (
|
{recording.recordings.slice().reverse().map((item, i) => (
|
||||||
<div className="mb-2 w-full">
|
<div className="mb-2 w-full">
|
||||||
<div
|
<div
|
||||||
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
||||||
|
|||||||
13
web/src/icons/Close.jsx
Normal file
13
web/src/icons/Close.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Close({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Close);
|
||||||
@@ -29,3 +29,12 @@
|
|||||||
.jsmpeg canvas {
|
.jsmpeg canvas {
|
||||||
position: static !important;
|
position: static !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Event.js
|
||||||
|
Maintain aspect ratio and scale down the video container
|
||||||
|
Could not find a proper tailwind css.
|
||||||
|
*/
|
||||||
|
.outer-max-width {
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { useCallback, useState } from 'preact/hooks';
|
import { useCallback, useState, useEffect } from 'preact/hooks';
|
||||||
import { route } from 'preact-router';
|
|
||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
import Button from '../components/Button';
|
import Button from '../components/Button';
|
||||||
import Clip from '../icons/Clip';
|
import Clip from '../icons/Clip';
|
||||||
|
import Close from '../icons/Close';
|
||||||
import Delete from '../icons/Delete';
|
import Delete from '../icons/Delete';
|
||||||
import Snapshot from '../icons/Snapshot';
|
import Snapshot from '../icons/Snapshot';
|
||||||
import Dialog from '../components/Dialog';
|
import Dialog from '../components/Dialog';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Link from '../components/Link';
|
|
||||||
import VideoPlayer from '../components/VideoPlayer';
|
import VideoPlayer from '../components/VideoPlayer';
|
||||||
import { FetchStatus, useApiHost, useEvent, useDelete } from '../api';
|
import { FetchStatus, useApiHost, useEvent, useDelete } from '../api';
|
||||||
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
|
|
||||||
|
|
||||||
export default function Event({ eventId }) {
|
export default function Event({ eventId, close, scrollRef }) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data, status } = useEvent(eventId);
|
const { data, status } = useEvent(eventId);
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [shouldScroll, setShouldScroll] = useState(true);
|
||||||
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
|
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
|
||||||
const setDeleteEvent = useDelete();
|
const setDeleteEvent = useDelete();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Scroll event into view when component has been mounted.
|
||||||
|
if (shouldScroll && scrollRef && scrollRef[eventId]) {
|
||||||
|
scrollRef[eventId].scrollIntoView();
|
||||||
|
setShouldScroll(false);
|
||||||
|
}
|
||||||
|
}, [data, scrollRef, eventId, shouldScroll]);
|
||||||
|
|
||||||
const handleClickDelete = () => {
|
const handleClickDelete = () => {
|
||||||
setShowDialog(true);
|
setShowDialog(true);
|
||||||
};
|
};
|
||||||
@@ -40,7 +47,6 @@ export default function Event({ eventId }) {
|
|||||||
if (success) {
|
if (success) {
|
||||||
setDeleteStatus(FetchStatus.LOADED);
|
setDeleteStatus(FetchStatus.LOADED);
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
route('/events', true);
|
|
||||||
}
|
}
|
||||||
}, [eventId, setShowDialog, setDeleteEvent]);
|
}, [eventId, setShowDialog, setDeleteEvent]);
|
||||||
|
|
||||||
@@ -48,18 +54,25 @@ export default function Event({ eventId }) {
|
|||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startime = new Date(data.start_time * 1000);
|
|
||||||
const endtime = new Date(data.end_time * 1000);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex">
|
<div className="grid grid-cols-6 gap-4">
|
||||||
<Heading className="flex-grow">
|
<div class="col-start-1 col-end-8 md:space-x-4">
|
||||||
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
|
<Button color="blue" href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`} download>
|
||||||
</Heading>
|
<Clip className="w-6" /> Download Clip
|
||||||
<Button className="self-start" color="red" onClick={handleClickDelete}>
|
</Button>
|
||||||
<Delete className="w-6" /> Delete event
|
<Button color="blue" href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`} download>
|
||||||
</Button>
|
<Snapshot className="w-6" /> Download Snapshot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="col-end-10 col-span-2 space-x-4">
|
||||||
|
<Button className="self-start" color="red" onClick={handleClickDelete}>
|
||||||
|
<Delete className="w-6" /> Delete event
|
||||||
|
</Button>
|
||||||
|
<Button color="gray" className="self-start" onClick={() => close()}>
|
||||||
|
<Close className="w-6" /> Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{showDialog ? (
|
{showDialog ? (
|
||||||
<Dialog
|
<Dialog
|
||||||
onDismiss={handleDismissDeleteDialog}
|
onDismiss={handleDismissDeleteDialog}
|
||||||
@@ -78,86 +91,42 @@ export default function Event({ eventId }) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="outer-max-width m-auto">
|
||||||
<Table class="w-full">
|
<div className="w-full pt-5 relative pb-20">
|
||||||
<Thead>
|
{data.has_clip ? (
|
||||||
<Th>Key</Th>
|
<Fragment>
|
||||||
<Th>Value</Th>
|
<Heading size="lg">Clip</Heading>
|
||||||
</Thead>
|
<VideoPlayer
|
||||||
<Tbody>
|
options={{
|
||||||
<Tr>
|
sources: [
|
||||||
<Td>Camera</Td>
|
{
|
||||||
<Td>
|
src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
|
||||||
<Link href={`/cameras/${data.camera}`}>{data.camera}</Link>
|
type: 'application/vnd.apple.mpegurl',
|
||||||
</Td>
|
},
|
||||||
</Tr>
|
],
|
||||||
<Tr index={1}>
|
poster: data.has_snapshot
|
||||||
<Td>Timeframe</Td>
|
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
|
||||||
<Td>
|
: `data:image/jpeg;base64,${data.thumbnail}`,
|
||||||
{startime.toLocaleString()} – {endtime.toLocaleString()}
|
}}
|
||||||
</Td>
|
seekOptions={{ forward: 10, back: 5 }}
|
||||||
</Tr>
|
onReady={() => {}}
|
||||||
<Tr>
|
/>
|
||||||
<Td>Score</Td>
|
</Fragment>
|
||||||
<Td>{(data.top_score * 100).toFixed(2)}%</Td>
|
) : (
|
||||||
</Tr>
|
<Fragment>
|
||||||
<Tr index={1}>
|
<Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
|
||||||
<Td>Zones</Td>
|
<img
|
||||||
<Td>{data.zones.join(', ')}</Td>
|
src={
|
||||||
</Tr>
|
data.has_snapshot
|
||||||
</Tbody>
|
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
|
||||||
</Table>
|
: `data:image/jpeg;base64,${data.thumbnail}`
|
||||||
|
}
|
||||||
{data.has_clip ? (
|
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
|
||||||
<Fragment>
|
/>
|
||||||
<Heading size="lg">Clip</Heading>
|
</Fragment>
|
||||||
<VideoPlayer
|
)}
|
||||||
options={{
|
</div>
|
||||||
sources: [
|
</div>
|
||||||
{
|
|
||||||
src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
|
|
||||||
type: 'application/vnd.apple.mpegurl',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
poster: data.has_snapshot
|
|
||||||
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
|
|
||||||
: `data:image/jpeg;base64,${data.thumbnail}`,
|
|
||||||
}}
|
|
||||||
seekOptions={{ forward: 10, back: 5 }}
|
|
||||||
onReady={(player) => {}}
|
|
||||||
/>
|
|
||||||
<div className="text-center">
|
|
||||||
<Button
|
|
||||||
className="mx-2"
|
|
||||||
color="blue"
|
|
||||||
href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
<Clip className="w-6" /> Download Clip
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="mx-2"
|
|
||||||
color="blue"
|
|
||||||
href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
<Snapshot className="w-6" /> Download Snapshot
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
) : (
|
|
||||||
<Fragment>
|
|
||||||
<Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
data.has_snapshot
|
|
||||||
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
|
|
||||||
: `data:image/jpeg;base64,${data.thumbnail}`
|
|
||||||
}
|
|
||||||
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { h } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Link from '../components/Link';
|
import Link from '../components/Link';
|
||||||
import Select from '../components/Select';
|
import Select from '../components/Select';
|
||||||
import produce from 'immer';
|
import produce from 'immer';
|
||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
|
import Event from './Event';
|
||||||
import { useIntersectionObserver } from '../hooks';
|
import { useIntersectionObserver } from '../hooks';
|
||||||
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
|
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
|
||||||
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
||||||
@@ -12,9 +13,20 @@ import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/ho
|
|||||||
|
|
||||||
const API_LIMIT = 25;
|
const API_LIMIT = 25;
|
||||||
|
|
||||||
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
|
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 });
|
||||||
const reducer = (state = initialState, action) => {
|
const reducer = (state = initialState, action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
case 'DELETE_EVENT': {
|
||||||
|
const { deletedId } = action;
|
||||||
|
|
||||||
|
return produce(state, (draftState) => {
|
||||||
|
const idx = draftState.events.findIndex((e) => e.id === deletedId);
|
||||||
|
if (idx === -1) return state;
|
||||||
|
|
||||||
|
draftState.events.splice(idx, 1);
|
||||||
|
draftState.deleted++;
|
||||||
|
});
|
||||||
|
}
|
||||||
case 'APPEND_EVENTS': {
|
case 'APPEND_EVENTS': {
|
||||||
const {
|
const {
|
||||||
meta: { searchString },
|
meta: { searchString },
|
||||||
@@ -24,6 +36,7 @@ const reducer = (state = initialState, action) => {
|
|||||||
return produce(state, (draftState) => {
|
return produce(state, (draftState) => {
|
||||||
draftState.searchStrings[searchString] = true;
|
draftState.searchStrings[searchString] = true;
|
||||||
draftState.events.push(...payload);
|
draftState.events.push(...payload);
|
||||||
|
draftState.deleted = 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +67,13 @@ function removeDefaultSearchKeys(searchParams) {
|
|||||||
|
|
||||||
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState);
|
const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState);
|
||||||
const { searchParams: initialSearchParams } = new URL(window.location);
|
const { searchParams: initialSearchParams } = new URL(window.location);
|
||||||
|
const [viewEvent, setViewEvent] = useState(null);
|
||||||
const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`);
|
const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`);
|
||||||
const { data, status, deleted } = useEvents(searchString);
|
const { data, status, deletedId } = useEvents(searchString);
|
||||||
|
|
||||||
|
const scrollToRef = {};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && !(searchString in searchStrings)) {
|
if (data && !(searchString in searchStrings)) {
|
||||||
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
|
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
|
||||||
@@ -67,7 +82,11 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|||||||
if (data && Array.isArray(data) && data.length + deleted < limit) {
|
if (data && Array.isArray(data) && data.length + deleted < limit) {
|
||||||
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
||||||
}
|
}
|
||||||
}, [data, limit, searchString, searchStrings, deleted]);
|
|
||||||
|
if (deletedId) {
|
||||||
|
dispatch({ type: 'DELETE_EVENT', deletedId });
|
||||||
|
}
|
||||||
|
}, [data, limit, searchString, searchStrings, deleted, deletedId]);
|
||||||
|
|
||||||
const [entry, setIntersectNode] = useIntersectionObserver();
|
const [entry, setIntersectNode] = useIntersectionObserver();
|
||||||
|
|
||||||
@@ -100,7 +119,16 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|||||||
[limit, pathname, setSearchString]
|
[limit, pathname, setSearchString]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const viewEventHandler = (id) => {
|
||||||
|
//Toggle event view
|
||||||
|
if (viewEvent === id) return setViewEvent(null);
|
||||||
|
|
||||||
|
//Set event id to be rendered.
|
||||||
|
setViewEvent(id);
|
||||||
|
};
|
||||||
|
|
||||||
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
|
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 w-full">
|
<div className="space-y-4 w-full">
|
||||||
<Heading>Events</Heading>
|
<Heading>Events</Heading>
|
||||||
@@ -123,70 +151,83 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{events.map(
|
{events.map(
|
||||||
(
|
({ camera, id, label, start_time: startTime, end_time: endTime, top_score: score, zones }, i) => {
|
||||||
{ camera, id, label, start_time: startTime, end_time: endTime, thumbnail, top_score: score, zones },
|
|
||||||
i
|
|
||||||
) => {
|
|
||||||
const start = new Date(parseInt(startTime * 1000, 10));
|
const start = new Date(parseInt(startTime * 1000, 10));
|
||||||
const end = new Date(parseInt(endTime * 1000, 10));
|
const end = new Date(parseInt(endTime * 1000, 10));
|
||||||
const ref = i === events.length - 1 ? lastCellRef : undefined;
|
const ref = i === events.length - 1 ? lastCellRef : undefined;
|
||||||
return (
|
return (
|
||||||
<Tr data-testid={`event-${id}`} key={id}>
|
<Fragment key={id}>
|
||||||
<Td className="w-40">
|
<Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
|
||||||
<a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
|
<Td className="w-40">
|
||||||
<img
|
<a
|
||||||
width="150"
|
onClick={() => viewEventHandler(id)}
|
||||||
height="150"
|
ref={ref}
|
||||||
style="min-height: 48px; min-width: 48px;"
|
data-start-time={startTime}
|
||||||
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
data-reached-end={reachedEnd}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={(el) => (scrollToRef[id] = el)}
|
||||||
|
width="150"
|
||||||
|
height="150"
|
||||||
|
className="cursor-pointer"
|
||||||
|
style="min-height: 48px; min-width: 48px;"
|
||||||
|
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Filterable
|
||||||
|
onFilter={handleFilter}
|
||||||
|
pathname={pathname}
|
||||||
|
searchParams={searchParams}
|
||||||
|
paramName="camera"
|
||||||
|
name={camera}
|
||||||
/>
|
/>
|
||||||
</a>
|
</Td>
|
||||||
</Td>
|
<Td>
|
||||||
<Td>
|
<Filterable
|
||||||
<Filterable
|
onFilter={handleFilter}
|
||||||
onFilter={handleFilter}
|
pathname={pathname}
|
||||||
pathname={pathname}
|
searchParams={searchParams}
|
||||||
searchParams={searchParams}
|
paramName="label"
|
||||||
paramName="camera"
|
name={label}
|
||||||
name={camera}
|
/>
|
||||||
/>
|
</Td>
|
||||||
</Td>
|
<Td>{(score * 100).toFixed(2)}%</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<Filterable
|
<ul>
|
||||||
onFilter={handleFilter}
|
{zones.map((zone) => (
|
||||||
pathname={pathname}
|
<li>
|
||||||
searchParams={searchParams}
|
<Filterable
|
||||||
paramName="label"
|
onFilter={handleFilter}
|
||||||
name={label}
|
pathname={pathname}
|
||||||
/>
|
searchParams={searchString}
|
||||||
</Td>
|
paramName="zone"
|
||||||
<Td>{(score * 100).toFixed(2)}%</Td>
|
name={zone}
|
||||||
<Td>
|
/>
|
||||||
<ul>
|
</li>
|
||||||
{zones.map((zone) => (
|
))}
|
||||||
<li>
|
</ul>
|
||||||
<Filterable
|
</Td>
|
||||||
onFilter={handleFilter}
|
<Td>{start.toLocaleDateString()}</Td>
|
||||||
pathname={pathname}
|
<Td>{start.toLocaleTimeString()}</Td>
|
||||||
searchParams={searchString}
|
<Td>{end.toLocaleTimeString()}</Td>
|
||||||
paramName="zone"
|
</Tr>
|
||||||
name={zone}
|
{viewEvent === id ? (
|
||||||
/>
|
<Tr className="border-b-1">
|
||||||
</li>
|
<Td colSpan="8">
|
||||||
))}
|
<Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
|
||||||
</ul>
|
</Td>
|
||||||
</Td>
|
</Tr>
|
||||||
<Td>{start.toLocaleDateString()}</Td>
|
) : null}
|
||||||
<Td>{start.toLocaleTimeString()}</Td>
|
</Fragment>
|
||||||
<Td>{end.toLocaleTimeString()}</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
<Tfoot>
|
<Tfoot>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Td className="text-center p-4" colspan="8">
|
<Td className="text-center p-4" colSpan="8">
|
||||||
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user