Compare commits

..

23 Commits

Author SHA1 Message Date
Blake Blackshear
e5714f5fbc add missing optional comment in docs 2022-02-13 10:27:58 -06:00
Blake Blackshear
3be0b915ad deregister based on max_frames setting 2022-02-13 10:21:24 -06:00
Blake Blackshear
304ffa86e8 refactor stationary config into section 2022-02-13 10:21:24 -06:00
Nicolas Mowen
889835a59b Always show recording link even if recordings are currently disabled (#2787)
* Always show recording link even if recordings are currently disabled

* Fix test to consider all cameras to have recording link
2022-02-12 13:51:28 -06:00
Blake Blackshear
ee01396b36 update birdseye to handle stationary objects 2022-02-12 06:59:10 -06:00
Blake Blackshear
334e28fe54 use second stream in docs example 2022-02-12 06:43:46 -06:00
Blake Blackshear
6b2bae040c stop forcing detection all the way to stationary_threshold 2022-02-11 07:34:42 -06:00
Blake Blackshear
95ab22d411 bump default stationary_threshold to 10s 2022-02-11 07:30:47 -06:00
Blake Blackshear
4e52461aa9 set stationary_threshold default to 5x fps 2022-02-11 07:12:51 -06:00
Blake Blackshear
7934f8699f fix the bounding box calculation position at 10 2022-02-11 06:54:42 -06:00
Blake Blackshear
adbc54bcfe selectively increment position changes 2022-02-09 21:27:33 -06:00
Jason Hunter
4deb365758 Fix duration for long events and playback rate for top of the hour 2022-02-09 20:28:13 -06:00
Jason Hunter
1171770447 Only send significant update once when motionless count reaches the defined threshold. 2022-02-09 20:27:36 -06:00
Jason Hunter
54d1a223a5 Allow download of in progress clips 2022-02-09 20:26:57 -06:00
Blake Blackshear
62c1a61ed0 remove invalid warning 2022-02-09 06:23:59 -06:00
Jason Hunter
9ecc7920dd Fix playback rate resetting to 1 on source change 2022-02-08 21:10:48 -06:00
Jason Hunter
45b56bdce5 Update package-lock.json 2022-02-08 21:10:13 -06:00
Jason Hunter
54b88fb4a9 Add in progress events to recordings view 2022-02-08 21:10:13 -06:00
Blake Blackshear
a3fa3cb716 update an object once per minute 2022-02-08 21:07:16 -06:00
Blake Blackshear
64f80a4732 signal an update when object becomes stationary 2022-02-08 07:43:43 -06:00
Blake Blackshear
0b02f20b26 make stationary_threshold configurable 2022-02-08 07:40:45 -06:00
Blake Blackshear
8670a3d808 publish an update on position changes 2022-02-08 07:31:07 -06:00
Blake Blackshear
3617a625d3 only update db entry when a stored property changes 2022-02-08 07:12:00 -06:00
16 changed files with 14990 additions and 91 deletions

View File

@@ -159,9 +159,23 @@ detect:
enabled: True enabled: True
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate) # Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
max_disappeared: 25 max_disappeared: 25
# Optional: Frequency for running detection on stationary objects (default: 0) # Optional: Configuration for stationary object tracking
stationary:
# Optional: Frequency for running detection on stationary objects (default: shown below)
# When set to 0, object detection will never be run on stationary objects. If set to 10, it will be run on every 10th frame. # When set to 0, object detection will never be run on stationary objects. If set to 10, it will be run on every 10th frame.
stationary_interval: 0 interval: 0
# Optional: Number of frames without a position change for an object to be considered stationary (default: 10x the frame rate or 10s)
threshold: 50
# Optional: Define a maximum number of frames for tracking a stationary object (default: not set, track forever)
# This can help with false positives for objects that should only be stationary for a limited amount of time.
# It can also be used to disable stationary object tracking. For example, you may want to set a value for person, but leave
# car at the default.
max_frames:
# Optional: Default for all object types (default: not set, track forever)
default: 3000
# Optional: Object specific values
objects:
person: 1000
# Optional: Object configuration # Optional: Object configuration
# NOTE: Can be overridden at the camera level # NOTE: Can be overridden at the camera level

View File

@@ -167,13 +167,17 @@ cameras:
roles: roles:
- detect - detect
- rtmp - rtmp
- record # <----- Add role - path: rtsp://10.0.10.10:554/high_res_stream # <----- Add high res stream
roles:
- record
detect: ... detect: ...
record: # <----- Enable recording record: # <----- Enable recording
enabled: True enabled: True
motion: ... motion: ...
``` ```
If you don't have separate streams for detect and record, you would just add the record role to the list on the first input.
By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](/configuration/index#full-configuration-reference). By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](/configuration/index#full-configuration-reference).
### Step 8: Enable snapshots (optional) ### Step 8: Enable snapshots (optional)

View File

@@ -56,8 +56,9 @@ Message published for each changed event. The first message is published when th
"thumbnail": null, "thumbnail": null,
"has_snapshot": false, "has_snapshot": false,
"has_clip": false, "has_clip": false,
"stationary": false, // whether or not the object is considered stationary
"motionless_count": 0, // number of frames the object has been motionless "motionless_count": 0, // number of frames the object has been motionless
"position_changes": 2 // number of times the object has changed position "position_changes": 2 // number of times the object has moved from a stationary position
}, },
"after": { "after": {
"id": "1607123955.475377-mxklsc", "id": "1607123955.475377-mxklsc",
@@ -78,6 +79,7 @@ Message published for each changed event. The first message is published when th
"thumbnail": null, "thumbnail": null,
"has_snapshot": false, "has_snapshot": false,
"has_clip": false, "has_clip": false,
"stationary": false, // whether or not the object is considered stationary
"motionless_count": 0, // number of frames the object has been motionless "motionless_count": 0, // number of frames the object has been motionless
"position_changes": 2 // number of times the object has changed position "position_changes": 2 // number of times the object has changed position
} }

View File

@@ -162,6 +162,29 @@ class RuntimeMotionConfig(MotionConfig):
extra = Extra.ignore extra = Extra.ignore
class StationaryMaxFramesConfig(FrigateBaseModel):
default: Optional[int] = Field(title="Default max frames.", ge=1)
objects: Dict[str, int] = Field(
default_factory=dict, title="Object specific max frames."
)
class StationaryConfig(FrigateBaseModel):
interval: Optional[int] = Field(
default=0,
title="Frame interval for checking stationary objects.",
ge=0,
)
threshold: Optional[int] = Field(
title="Number of frames without a position change for an object to be considered stationary",
ge=1,
)
max_frames: StationaryMaxFramesConfig = Field(
default_factory=StationaryMaxFramesConfig,
title="Max frames for stationary objects.",
)
class DetectConfig(FrigateBaseModel): class DetectConfig(FrigateBaseModel):
height: int = Field(default=720, 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(default=1280, title="Width of the stream for the detect role.") width: int = Field(default=1280, title="Width of the stream for the detect role.")
@@ -172,10 +195,9 @@ class DetectConfig(FrigateBaseModel):
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."
) )
stationary_interval: Optional[int] = Field( stationary: StationaryConfig = Field(
default=0, default_factory=StationaryConfig,
title="Frame interval for checking stationary objects.", title="Stationary objects config.",
ge=0,
) )
@@ -766,6 +788,11 @@ class FrigateConfig(FrigateBaseModel):
if camera_config.detect.max_disappeared is None: if camera_config.detect.max_disappeared is None:
camera_config.detect.max_disappeared = max_disappeared camera_config.detect.max_disappeared = max_disappeared
# Default stationary_threshold configuration
stationary_threshold = camera_config.detect.fps * 10
if camera_config.detect.stationary.threshold is None:
camera_config.detect.stationary.threshold = stationary_threshold
# 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)

View File

@@ -15,6 +15,16 @@ from frigate.models import Event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def should_update_db(prev_event, current_event):
return (
prev_event["top_score"] != current_event["top_score"]
or prev_event["entered_zones"] != current_event["entered_zones"]
or prev_event["thumbnail"] != current_event["thumbnail"]
or prev_event["has_clip"] != current_event["has_clip"]
or prev_event["has_snapshot"] != current_event["has_snapshot"]
)
class EventProcessor(threading.Thread): class EventProcessor(threading.Thread):
def __init__( def __init__(
self, config, camera_processes, event_queue, event_processed_queue, stop_event self, config, camera_processes, event_queue, event_processed_queue, stop_event
@@ -48,7 +58,9 @@ class EventProcessor(threading.Thread):
if event_type == "start": if event_type == "start":
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
elif event_type == "update": elif event_type == "update" and should_update_db(
self.events_in_process[event_data["id"]], event_data
):
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
# TODO: this will generate a lot of db activity possibly # TODO: this will generate a lot of db activity possibly
if event_data["has_clip"] or event_data["has_snapshot"]: if event_data["has_clip"] or event_data["has_snapshot"]:

View File

@@ -249,7 +249,10 @@ def event_clip(id):
clip_path = os.path.join(CLIPS_DIR, file_name) clip_path = os.path.join(CLIPS_DIR, file_name)
if not os.path.isfile(clip_path): if not os.path.isfile(clip_path):
return recording_clip(event.camera, event.start_time, event.end_time) end_ts = (
datetime.now().timestamp() if event.end_time is None else event.end_time
)
return recording_clip(event.camera, event.start_time, end_ts)
response = make_response() response = make_response()
response.headers["Content-Description"] = "File Transfer" response.headers["Content-Description"] = "File Transfer"
@@ -524,12 +527,17 @@ def recordings(camera_name):
FROM C2 FROM C2
WHERE cnt = 0 WHERE cnt = 0
) )
SELECT id, label, camera, top_score, start_time, end_time
FROM event
WHERE camera = ? AND end_time IS NULL
UNION ALL
SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time
FROM C3 FROM C3
GROUP BY label, grpnum GROUP BY label, grpnum
ORDER BY start_time;""", ORDER BY start_time;""",
camera_name, camera_name,
camera_name, camera_name,
camera_name,
) )
event: Event event: Event

View File

@@ -101,14 +101,13 @@ class TrackedObject:
return median(scores) return median(scores)
def update(self, current_frame_time, obj_data): def update(self, current_frame_time, obj_data):
significant_update = False thumb_update = False
zone_change = False significant_change = False
self.obj_data.update(obj_data)
# if the object is not in the current frame, add a 0.0 to the score history # if the object is not in the current frame, add a 0.0 to the score history
if self.obj_data["frame_time"] != current_frame_time: if obj_data["frame_time"] != current_frame_time:
self.score_history.append(0.0) self.score_history.append(0.0)
else: else:
self.score_history.append(self.obj_data["score"]) self.score_history.append(obj_data["score"])
# only keep the last 10 scores # only keep the last 10 scores
if len(self.score_history) > 10: if len(self.score_history) > 10:
self.score_history = self.score_history[-10:] self.score_history = self.score_history[-10:]
@@ -122,24 +121,24 @@ class TrackedObject:
if not self.false_positive: if not self.false_positive:
# determine if this frame is a better thumbnail # determine if this frame is a better thumbnail
if self.thumbnail_data is None or is_better_thumbnail( if self.thumbnail_data is None or is_better_thumbnail(
self.thumbnail_data, self.obj_data, self.camera_config.frame_shape self.thumbnail_data, obj_data, self.camera_config.frame_shape
): ):
self.thumbnail_data = { self.thumbnail_data = {
"frame_time": self.obj_data["frame_time"], "frame_time": obj_data["frame_time"],
"box": self.obj_data["box"], "box": obj_data["box"],
"area": self.obj_data["area"], "area": obj_data["area"],
"region": self.obj_data["region"], "region": obj_data["region"],
"score": self.obj_data["score"], "score": obj_data["score"],
} }
significant_update = True thumb_update = True
# check zones # check zones
current_zones = [] current_zones = []
bottom_center = (self.obj_data["centroid"][0], self.obj_data["box"][3]) bottom_center = (obj_data["centroid"][0], obj_data["box"][3])
# check each zone # check each zone
for name, zone in self.camera_config.zones.items(): for name, zone in self.camera_config.zones.items():
# if the zone is not for this object type, skip # if the zone is not for this object type, skip
if len(zone.objects) > 0 and not self.obj_data["label"] in zone.objects: if len(zone.objects) > 0 and not obj_data["label"] in zone.objects:
continue continue
contour = zone.contour contour = zone.contour
# check if the object is in the zone # check if the object is in the zone
@@ -150,12 +149,29 @@ class TrackedObject:
if name not in self.entered_zones: if name not in self.entered_zones:
self.entered_zones.append(name) self.entered_zones.append(name)
if not self.false_positive:
# if the zones changed, signal an update # if the zones changed, signal an update
if not self.false_positive and set(self.current_zones) != set(current_zones): if set(self.current_zones) != set(current_zones):
zone_change = True significant_change = True
# if the position changed, signal an update
if self.obj_data["position_changes"] != obj_data["position_changes"]:
significant_change = True
# if the motionless_count reaches the stationary threshold
if (
self.obj_data["motionless_count"]
== self.camera_config.detect.stationary.threshold
):
significant_change = True
# update at least once per minute
if self.obj_data["frame_time"] - self.previous["frame_time"] > 60:
significant_change = True
self.obj_data.update(obj_data)
self.current_zones = current_zones self.current_zones = current_zones
return (significant_update, zone_change) return (thumb_update, significant_change)
def to_dict(self, include_thumbnail: bool = False): def to_dict(self, include_thumbnail: bool = False):
snapshot_time = ( snapshot_time = (
@@ -177,6 +193,8 @@ class TrackedObject:
"box": self.obj_data["box"], "box": self.obj_data["box"],
"area": self.obj_data["area"], "area": self.obj_data["area"],
"region": self.obj_data["region"], "region": self.obj_data["region"],
"stationary": self.obj_data["motionless_count"]
> self.camera_config.detect.stationary.threshold,
"motionless_count": self.obj_data["motionless_count"], "motionless_count": self.obj_data["motionless_count"],
"position_changes": self.obj_data["position_changes"], "position_changes": self.obj_data["position_changes"],
"current_zones": self.current_zones.copy(), "current_zones": self.current_zones.copy(),
@@ -466,11 +484,11 @@ class CameraState:
for id in updated_ids: for id in updated_ids:
updated_obj = tracked_objects[id] updated_obj = tracked_objects[id]
significant_update, zone_change = updated_obj.update( thumb_update, significant_update = updated_obj.update(
frame_time, current_detections[id] frame_time, current_detections[id]
) )
if significant_update: if thumb_update:
# ensure this frame is stored in the cache # ensure this frame is stored in the cache
if ( if (
updated_obj.thumbnail_data["frame_time"] == frame_time updated_obj.thumbnail_data["frame_time"] == frame_time
@@ -480,13 +498,13 @@ class CameraState:
updated_obj.last_updated = frame_time updated_obj.last_updated = frame_time
# if it has been more than 5 seconds since the last publish # if it has been more than 5 seconds since the last thumb update
# and the last update is greater than the last publish or # and the last update is greater than the last publish or
# the object has changed zones # the object has changed significantly
if ( if (
frame_time - updated_obj.last_published > 5 frame_time - updated_obj.last_published > 5
and updated_obj.last_updated > updated_obj.last_published and updated_obj.last_updated > updated_obj.last_published
) or zone_change: ) or significant_update:
# call event handlers # call event handlers
for c in self.callbacks["update"]: for c in self.callbacks["update"]:
c(self.name, updated_obj, frame_time) c(self.name, updated_obj, frame_time)

View File

@@ -48,7 +48,7 @@ class ObjectTracker:
del self.tracked_objects[id] del self.tracked_objects[id]
del self.disappeared[id] del self.disappeared[id]
# tracks the current position of the object based on the last 10 bounding boxes # tracks the current position of the object based on the last N bounding boxes
# returns False if the object has moved outside its previous position # returns False if the object has moved outside its previous position
def update_position(self, id, box): def update_position(self, id, box):
position = self.positions[id] position = self.positions[id]
@@ -93,20 +93,52 @@ class ObjectTracker:
return True return True
def is_expired(self, id):
obj = self.tracked_objects[id]
# get the max frames for this label type or the default
max_frames = self.detect_config.stationary.max_frames.objects.get(
obj["label"], self.detect_config.stationary.max_frames.default
)
# if there is no max_frames for this label type, continue
if max_frames is None:
return False
# if the object has exceeded the max_frames setting, deregister
if (
obj["motionless_count"] - self.detect_config.stationary.threshold
> max_frames
):
print(f"expired: {obj['motionless_count']}")
return True
def update(self, id, new_obj): def update(self, id, new_obj):
self.disappeared[id] = 0 self.disappeared[id] = 0
# update the motionless count if the object has not moved to a new position # update the motionless count if the object has not moved to a new position
if self.update_position(id, new_obj["box"]): if self.update_position(id, new_obj["box"]):
self.tracked_objects[id]["motionless_count"] += 1 self.tracked_objects[id]["motionless_count"] += 1
if self.is_expired(id):
self.deregister(id)
return
else: else:
self.tracked_objects[id]["motionless_count"] = 0 # register the first position change and then only increment if
# the object was previously stationary
if (
self.tracked_objects[id]["position_changes"] == 0
or self.tracked_objects[id]["motionless_count"]
>= self.detect_config.stationary.threshold
):
self.tracked_objects[id]["position_changes"] += 1 self.tracked_objects[id]["position_changes"] += 1
self.tracked_objects[id]["motionless_count"] = 0
self.tracked_objects[id].update(new_obj) self.tracked_objects[id].update(new_obj)
def update_frame_times(self, frame_time): def update_frame_times(self, frame_time):
for id in self.tracked_objects.keys(): for id in list(self.tracked_objects.keys()):
self.tracked_objects[id]["frame_time"] = frame_time self.tracked_objects[id]["frame_time"] = frame_time
self.tracked_objects[id]["motionless_count"] += 1 self.tracked_objects[id]["motionless_count"] += 1
if self.is_expired(id):
self.deregister(id)
def match_and_update(self, frame_time, new_objects): def match_and_update(self, frame_time, new_objects):
# group by name # group by name

View File

@@ -184,10 +184,7 @@ class BirdsEyeFrameManager:
if self.mode == BirdseyeModeEnum.continuous: if self.mode == BirdseyeModeEnum.continuous:
return True return True
if ( if self.mode == BirdseyeModeEnum.motion and motion_box_count > 0:
self.mode == BirdseyeModeEnum.motion
and object_box_count + motion_box_count > 0
):
return True return True
if self.mode == BirdseyeModeEnum.objects and object_box_count > 0: if self.mode == BirdseyeModeEnum.objects and object_box_count > 0:
@@ -418,7 +415,7 @@ def output_frames(config: FrigateConfig, video_output_queue):
): ):
if birdseye_manager.update( if birdseye_manager.update(
camera, camera,
len(current_tracked_objects), len([o for o in current_tracked_objects if not o["stationary"]]),
len(motion_boxes), len(motion_boxes),
frame_time, frame_time,
frame, frame,

View File

@@ -51,7 +51,6 @@ class RecordingMaintainer(threading.Thread):
self.config = config self.config = config
self.recordings_info_queue = recordings_info_queue self.recordings_info_queue = recordings_info_queue
self.stop_event = stop_event self.stop_event = stop_event
self.first_pass = True
self.recordings_info = defaultdict(list) self.recordings_info = defaultdict(list)
self.end_time_cache = {} self.end_time_cache = {}
@@ -334,12 +333,6 @@ class RecordingMaintainer(threading.Thread):
logger.error(e) logger.error(e)
duration = datetime.datetime.now().timestamp() - run_start duration = datetime.datetime.now().timestamp() - run_start
wait_time = max(0, 5 - duration) wait_time = max(0, 5 - duration)
if wait_time == 0 and not self.first_pass:
logger.warning(
"Cache is taking longer than 5 seconds to clear. Your recordings disk may be too slow."
)
if self.first_pass:
self.first_pass = False
logger.info(f"Exiting recording maintenance...") logger.info(f"Exiting recording maintenance...")

View File

@@ -511,8 +511,8 @@ def process_frames(
if obj["motionless_count"] >= 10 if obj["motionless_count"] >= 10
# and it isn't due for a periodic check # and it isn't due for a periodic check
and ( and (
detect_config.stationary_interval == 0 detect_config.stationary.interval == 0
or obj["motionless_count"] % detect_config.stationary_interval != 0 or obj["motionless_count"] % detect_config.stationary.interval != 0
) )
# and it hasn't disappeared # and it hasn't disappeared
and object_tracker.disappeared[obj["id"]] == 0 and object_tracker.disappeared[obj["id"]] == 0

14794
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,14 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { addSeconds, differenceInSeconds, fromUnixTime, format, parseISO, startOfHour } from 'date-fns'; import {
differenceInSeconds,
fromUnixTime,
format,
parseISO,
startOfHour,
differenceInMinutes,
differenceInHours,
} from 'date-fns';
import ArrowDropdown from '../icons/ArrowDropdown'; import ArrowDropdown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup'; import ArrowDropup from '../icons/ArrowDropup';
import Link from '../components/Link'; import Link from '../components/Link';
@@ -21,7 +29,10 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
events={recording.events} events={recording.events}
selected={recording.date === selectedDate} selected={recording.date === selectedDate}
> >
{recording.recordings.slice().reverse().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 ${
@@ -35,7 +46,10 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
</div> </div>
<div className="flex-1 text-right">{item.events.length} Events</div> <div className="flex-1 text-right">{item.events.length} Events</div>
</div> </div>
{item.events.slice().reverse().map((event) => ( {item.events
.slice()
.reverse()
.map((event) => (
<EventCard camera={camera} event={event} delay={item.delay} /> <EventCard camera={camera} event={event} delay={item.delay} />
))} ))}
</div> </div>
@@ -83,8 +97,17 @@ export function ExpandableList({ title, events = 0, children, selected = false }
export function EventCard({ camera, event, delay }) { export function EventCard({ camera, event, delay }) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const start = fromUnixTime(event.start_time); const start = fromUnixTime(event.start_time);
let duration = 'In Progress';
if (event.end_time) {
const end = fromUnixTime(event.end_time); const end = fromUnixTime(event.end_time);
const duration = addSeconds(new Date(0), differenceInSeconds(end, start)); const hours = differenceInHours(end, start);
const minutes = differenceInMinutes(end, start) - hours * 60;
const seconds = differenceInSeconds(end, start) - hours * 60 - minutes * 60;
duration = '';
if (hours) duration += `${hours}h `;
if (minutes) duration += `${minutes}m `;
duration += `${seconds}s`;
}
const position = differenceInSeconds(start, startOfHour(start)); const position = differenceInSeconds(start, startOfHour(start));
const offset = Object.entries(delay) const offset = Object.entries(delay)
.map(([p, d]) => (position > p ? d : 0)) .map(([p, d]) => (position > p ? d : 0))
@@ -102,7 +125,7 @@ export function EventCard({ camera, event, delay }) {
<div className="flex-1"> <div className="flex-1">
<div className="text-2xl text-white leading-tight capitalize">{event.label}</div> <div className="text-2xl text-white leading-tight capitalize">{event.label}</div>
<div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div> <div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div>
<div className="text-xs md:text-normal text-gray-300">Duration: {format(duration, 'mm:ss')}</div> <div className="text-xs md:text-normal text-gray-300">Duration: {duration}</div>
</div> </div>
<div className="text-lg text-white text-right leading-tight">{(event.top_score * 100).toFixed(1)}%</div> <div className="text-lg text-white text-right leading-tight">{(event.top_score * 100).toFixed(1)}%</div>
</div> </div>

View File

@@ -29,12 +29,8 @@ function Camera({ name, conf }) {
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name); const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
const href = `/cameras/${name}`; const href = `/cameras/${name}`;
const buttons = useMemo(() => { const buttons = useMemo(() => {
const result = [{ name: 'Events', href: `/events?camera=${name}` }]; return [{ name: 'Events', href: `/events?camera=${name}` }, { name: 'Recordings', href: `/recording/${name}` }];
if (conf.record.enabled) { }, [name]);
result.push({ name: 'Recordings', href: `/recording/${name}` });
}
return result;
}, [name, conf.record.enabled]);
const icons = useMemo( const icons = useMemo(
() => [ () => [
{ {

View File

@@ -66,6 +66,9 @@ export default function Recording({ camera, date, hour, seconds }) {
this.player.currentTime(seconds); this.player.currentTime(seconds);
} }
} }
// Force playback rate to be correct
const playbackRate = this.player.playbackRate();
this.player.defaultPlaybackRate(playbackRate);
} }
return ( return (

View File

@@ -46,7 +46,7 @@ describe('Cameras Route', () => {
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
expect(screen.queryAllByText('Recordings')).toHaveLength(1); expect(screen.queryAllByText('Recordings')).toHaveLength(2);
}); });
test('buttons toggle detect, clips, and snapshots', async () => { test('buttons toggle detect, clips, and snapshots', async () => {