forked from Github/frigate
Compare commits
23 Commits
v0.10.0-be
...
v0.10.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5714f5fbc | ||
|
|
3be0b915ad | ||
|
|
304ffa86e8 | ||
|
|
889835a59b | ||
|
|
ee01396b36 | ||
|
|
334e28fe54 | ||
|
|
6b2bae040c | ||
|
|
95ab22d411 | ||
|
|
4e52461aa9 | ||
|
|
7934f8699f | ||
|
|
adbc54bcfe | ||
|
|
4deb365758 | ||
|
|
1171770447 | ||
|
|
54d1a223a5 | ||
|
|
62c1a61ed0 | ||
|
|
9ecc7920dd | ||
|
|
45b56bdce5 | ||
|
|
54b88fb4a9 | ||
|
|
a3fa3cb716 | ||
|
|
64f80a4732 | ||
|
|
0b02f20b26 | ||
|
|
8670a3d808 | ||
|
|
3617a625d3 |
@@ -159,9 +159,23 @@ detect:
|
||||
enabled: True
|
||||
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
|
||||
max_disappeared: 25
|
||||
# Optional: Frequency for running detection on stationary objects (default: 0)
|
||||
# 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
|
||||
# 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.
|
||||
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
|
||||
# NOTE: Can be overridden at the camera level
|
||||
|
||||
@@ -167,13 +167,17 @@ cameras:
|
||||
roles:
|
||||
- detect
|
||||
- rtmp
|
||||
- record # <----- Add role
|
||||
- path: rtsp://10.0.10.10:554/high_res_stream # <----- Add high res stream
|
||||
roles:
|
||||
- record
|
||||
detect: ...
|
||||
record: # <----- Enable recording
|
||||
enabled: True
|
||||
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).
|
||||
|
||||
### Step 8: Enable snapshots (optional)
|
||||
|
||||
@@ -56,8 +56,9 @@ Message published for each changed event. The first message is published when th
|
||||
"thumbnail": null,
|
||||
"has_snapshot": 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
|
||||
"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": {
|
||||
"id": "1607123955.475377-mxklsc",
|
||||
@@ -78,6 +79,7 @@ Message published for each changed event. The first message is published when th
|
||||
"thumbnail": null,
|
||||
"has_snapshot": 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
|
||||
"position_changes": 2 // number of times the object has changed position
|
||||
}
|
||||
|
||||
@@ -162,6 +162,29 @@ class RuntimeMotionConfig(MotionConfig):
|
||||
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):
|
||||
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.")
|
||||
@@ -172,10 +195,9 @@ class DetectConfig(FrigateBaseModel):
|
||||
max_disappeared: Optional[int] = Field(
|
||||
title="Maximum number of frames the object can dissapear before detection ends."
|
||||
)
|
||||
stationary_interval: Optional[int] = Field(
|
||||
default=0,
|
||||
title="Frame interval for checking stationary objects.",
|
||||
ge=0,
|
||||
stationary: StationaryConfig = Field(
|
||||
default_factory=StationaryConfig,
|
||||
title="Stationary objects config.",
|
||||
)
|
||||
|
||||
|
||||
@@ -766,6 +788,11 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if camera_config.detect.max_disappeared is None:
|
||||
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
|
||||
for input in camera_config.ffmpeg.inputs:
|
||||
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
||||
|
||||
@@ -15,6 +15,16 @@ from frigate.models import Event
|
||||
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):
|
||||
def __init__(
|
||||
self, config, camera_processes, event_queue, event_processed_queue, stop_event
|
||||
@@ -48,7 +58,9 @@ class EventProcessor(threading.Thread):
|
||||
if event_type == "start":
|
||||
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
|
||||
# TODO: this will generate a lot of db activity possibly
|
||||
if event_data["has_clip"] or event_data["has_snapshot"]:
|
||||
|
||||
@@ -249,7 +249,10 @@ def event_clip(id):
|
||||
clip_path = os.path.join(CLIPS_DIR, file_name)
|
||||
|
||||
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.headers["Content-Description"] = "File Transfer"
|
||||
@@ -524,12 +527,17 @@ def recordings(camera_name):
|
||||
FROM C2
|
||||
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
|
||||
FROM C3
|
||||
GROUP BY label, grpnum
|
||||
ORDER BY start_time;""",
|
||||
camera_name,
|
||||
camera_name,
|
||||
camera_name,
|
||||
)
|
||||
|
||||
event: Event
|
||||
|
||||
@@ -101,14 +101,13 @@ class TrackedObject:
|
||||
return median(scores)
|
||||
|
||||
def update(self, current_frame_time, obj_data):
|
||||
significant_update = False
|
||||
zone_change = False
|
||||
self.obj_data.update(obj_data)
|
||||
thumb_update = False
|
||||
significant_change = False
|
||||
# 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)
|
||||
else:
|
||||
self.score_history.append(self.obj_data["score"])
|
||||
self.score_history.append(obj_data["score"])
|
||||
# only keep the last 10 scores
|
||||
if len(self.score_history) > 10:
|
||||
self.score_history = self.score_history[-10:]
|
||||
@@ -122,24 +121,24 @@ class TrackedObject:
|
||||
if not self.false_positive:
|
||||
# determine if this frame is a 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 = {
|
||||
"frame_time": self.obj_data["frame_time"],
|
||||
"box": self.obj_data["box"],
|
||||
"area": self.obj_data["area"],
|
||||
"region": self.obj_data["region"],
|
||||
"score": self.obj_data["score"],
|
||||
"frame_time": obj_data["frame_time"],
|
||||
"box": obj_data["box"],
|
||||
"area": obj_data["area"],
|
||||
"region": obj_data["region"],
|
||||
"score": obj_data["score"],
|
||||
}
|
||||
significant_update = True
|
||||
thumb_update = True
|
||||
|
||||
# check 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
|
||||
for name, zone in self.camera_config.zones.items():
|
||||
# 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
|
||||
contour = zone.contour
|
||||
# check if the object is in the zone
|
||||
@@ -150,12 +149,29 @@ class TrackedObject:
|
||||
if name not in self.entered_zones:
|
||||
self.entered_zones.append(name)
|
||||
|
||||
# if the zones changed, signal an update
|
||||
if not self.false_positive and set(self.current_zones) != set(current_zones):
|
||||
zone_change = True
|
||||
if not self.false_positive:
|
||||
# if the zones changed, signal an update
|
||||
if set(self.current_zones) != set(current_zones):
|
||||
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
|
||||
return (significant_update, zone_change)
|
||||
return (thumb_update, significant_change)
|
||||
|
||||
def to_dict(self, include_thumbnail: bool = False):
|
||||
snapshot_time = (
|
||||
@@ -177,6 +193,8 @@ class TrackedObject:
|
||||
"box": self.obj_data["box"],
|
||||
"area": self.obj_data["area"],
|
||||
"region": self.obj_data["region"],
|
||||
"stationary": self.obj_data["motionless_count"]
|
||||
> self.camera_config.detect.stationary.threshold,
|
||||
"motionless_count": self.obj_data["motionless_count"],
|
||||
"position_changes": self.obj_data["position_changes"],
|
||||
"current_zones": self.current_zones.copy(),
|
||||
@@ -466,11 +484,11 @@ class CameraState:
|
||||
|
||||
for id in updated_ids:
|
||||
updated_obj = tracked_objects[id]
|
||||
significant_update, zone_change = updated_obj.update(
|
||||
thumb_update, significant_update = updated_obj.update(
|
||||
frame_time, current_detections[id]
|
||||
)
|
||||
|
||||
if significant_update:
|
||||
if thumb_update:
|
||||
# ensure this frame is stored in the cache
|
||||
if (
|
||||
updated_obj.thumbnail_data["frame_time"] == frame_time
|
||||
@@ -480,13 +498,13 @@ class CameraState:
|
||||
|
||||
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
|
||||
# the object has changed zones
|
||||
# the object has changed significantly
|
||||
if (
|
||||
frame_time - updated_obj.last_published > 5
|
||||
and updated_obj.last_updated > updated_obj.last_published
|
||||
) or zone_change:
|
||||
) or significant_update:
|
||||
# call event handlers
|
||||
for c in self.callbacks["update"]:
|
||||
c(self.name, updated_obj, frame_time)
|
||||
|
||||
@@ -48,7 +48,7 @@ class ObjectTracker:
|
||||
del self.tracked_objects[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
|
||||
def update_position(self, id, box):
|
||||
position = self.positions[id]
|
||||
@@ -93,20 +93,52 @@ class ObjectTracker:
|
||||
|
||||
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):
|
||||
self.disappeared[id] = 0
|
||||
# update the motionless count if the object has not moved to a new position
|
||||
if self.update_position(id, new_obj["box"]):
|
||||
self.tracked_objects[id]["motionless_count"] += 1
|
||||
if self.is_expired(id):
|
||||
self.deregister(id)
|
||||
return
|
||||
else:
|
||||
# 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]["motionless_count"] = 0
|
||||
self.tracked_objects[id]["position_changes"] += 1
|
||||
|
||||
self.tracked_objects[id].update(new_obj)
|
||||
|
||||
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]["motionless_count"] += 1
|
||||
if self.is_expired(id):
|
||||
self.deregister(id)
|
||||
|
||||
def match_and_update(self, frame_time, new_objects):
|
||||
# group by name
|
||||
|
||||
@@ -184,10 +184,7 @@ class BirdsEyeFrameManager:
|
||||
if self.mode == BirdseyeModeEnum.continuous:
|
||||
return True
|
||||
|
||||
if (
|
||||
self.mode == BirdseyeModeEnum.motion
|
||||
and object_box_count + motion_box_count > 0
|
||||
):
|
||||
if self.mode == BirdseyeModeEnum.motion and motion_box_count > 0:
|
||||
return True
|
||||
|
||||
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(
|
||||
camera,
|
||||
len(current_tracked_objects),
|
||||
len([o for o in current_tracked_objects if not o["stationary"]]),
|
||||
len(motion_boxes),
|
||||
frame_time,
|
||||
frame,
|
||||
|
||||
@@ -51,7 +51,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
self.config = config
|
||||
self.recordings_info_queue = recordings_info_queue
|
||||
self.stop_event = stop_event
|
||||
self.first_pass = True
|
||||
self.recordings_info = defaultdict(list)
|
||||
self.end_time_cache = {}
|
||||
|
||||
@@ -334,12 +333,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
logger.error(e)
|
||||
duration = datetime.datetime.now().timestamp() - run_start
|
||||
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...")
|
||||
|
||||
|
||||
@@ -511,8 +511,8 @@ def process_frames(
|
||||
if obj["motionless_count"] >= 10
|
||||
# and it isn't due for a periodic check
|
||||
and (
|
||||
detect_config.stationary_interval == 0
|
||||
or obj["motionless_count"] % detect_config.stationary_interval != 0
|
||||
detect_config.stationary.interval == 0
|
||||
or obj["motionless_count"] % detect_config.stationary.interval != 0
|
||||
)
|
||||
# and it hasn't disappeared
|
||||
and object_tracker.disappeared[obj["id"]] == 0
|
||||
|
||||
14794
web/package-lock.json
generated
14794
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,14 @@
|
||||
import { h } from 'preact';
|
||||
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 ArrowDropup from '../icons/ArrowDropup';
|
||||
import Link from '../components/Link';
|
||||
@@ -21,25 +29,31 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
|
||||
events={recording.events}
|
||||
selected={recording.date === selectedDate}
|
||||
>
|
||||
{recording.recordings.slice().reverse().map((item, i) => (
|
||||
<div className="mb-2 w-full">
|
||||
<div
|
||||
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
||||
i === 0 ? 'border-t border-white border-opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Link href={`/recording/${camera}/${recording.date}/${item.hour}`} type="text">
|
||||
{item.hour}:00
|
||||
</Link>
|
||||
{recording.recordings
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((item, i) => (
|
||||
<div className="mb-2 w-full">
|
||||
<div
|
||||
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
||||
i === 0 ? 'border-t border-white border-opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Link href={`/recording/${camera}/${recording.date}/${item.hour}`} type="text">
|
||||
{item.hour}:00
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 text-right">{item.events.length} Events</div>
|
||||
</div>
|
||||
<div className="flex-1 text-right">{item.events.length} Events</div>
|
||||
{item.events
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event) => (
|
||||
<EventCard camera={camera} event={event} delay={item.delay} />
|
||||
))}
|
||||
</div>
|
||||
{item.events.slice().reverse().map((event) => (
|
||||
<EventCard camera={camera} event={event} delay={item.delay} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</ExpandableList>
|
||||
);
|
||||
}
|
||||
@@ -83,8 +97,17 @@ export function ExpandableList({ title, events = 0, children, selected = false }
|
||||
export function EventCard({ camera, event, delay }) {
|
||||
const apiHost = useApiHost();
|
||||
const start = fromUnixTime(event.start_time);
|
||||
const end = fromUnixTime(event.end_time);
|
||||
const duration = addSeconds(new Date(0), differenceInSeconds(end, start));
|
||||
let duration = 'In Progress';
|
||||
if (event.end_time) {
|
||||
const end = fromUnixTime(event.end_time);
|
||||
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 offset = Object.entries(delay)
|
||||
.map(([p, d]) => (position > p ? d : 0))
|
||||
@@ -102,7 +125,7 @@ export function EventCard({ camera, event, delay }) {
|
||||
<div className="flex-1">
|
||||
<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">Duration: {format(duration, 'mm:ss')}</div>
|
||||
<div className="text-xs md:text-normal text-gray-300">Duration: {duration}</div>
|
||||
</div>
|
||||
<div className="text-lg text-white text-right leading-tight">{(event.top_score * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
|
||||
@@ -29,12 +29,8 @@ function Camera({ name, conf }) {
|
||||
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
||||
const href = `/cameras/${name}`;
|
||||
const buttons = useMemo(() => {
|
||||
const result = [{ name: 'Events', href: `/events?camera=${name}` }];
|
||||
if (conf.record.enabled) {
|
||||
result.push({ name: 'Recordings', href: `/recording/${name}` });
|
||||
}
|
||||
return result;
|
||||
}, [name, conf.record.enabled]);
|
||||
return [{ name: 'Events', href: `/events?camera=${name}` }, { name: 'Recordings', href: `/recording/${name}` }];
|
||||
}, [name]);
|
||||
const icons = useMemo(
|
||||
() => [
|
||||
{
|
||||
|
||||
@@ -66,6 +66,9 @@ export default function Recording({ camera, date, hour, seconds }) {
|
||||
this.player.currentTime(seconds);
|
||||
}
|
||||
}
|
||||
// Force playback rate to be correct
|
||||
const playbackRate = this.player.playbackRate();
|
||||
this.player.defaultPlaybackRate(playbackRate);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('Cameras Route', () => {
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user