mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-02 17:25:22 +03:00
Merge remote-tracking branch 'origin/release-0.10.0' into gstreamer
This commit is contained in:
commit
85ad8ecd14
@ -159,11 +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: shown below)
|
# Optional: Configuration for stationary object tracking
|
||||||
# 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:
|
||||||
stationary_interval: 0
|
# Optional: Frequency for running detection on stationary objects (default: shown below)
|
||||||
# Optional: Number of frames without a position change for an object to be considered stationary (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.
|
||||||
stationary_threshold: 10
|
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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -170,6 +170,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.")
|
||||||
@ -180,15 +203,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,
|
|
||||||
)
|
|
||||||
stationary_threshold: Optional[int] = Field(
|
|
||||||
default=10,
|
|
||||||
title="Number of frames without a position change for an object to be considered stationary",
|
|
||||||
ge=1,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -933,6 +950,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
|
||||||
if "ffmpeg" in camera_config:
|
if "ffmpeg" in camera_config:
|
||||||
for input in camera_config.ffmpeg.inputs:
|
for input in camera_config.ffmpeg.inputs:
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -158,10 +158,10 @@ class TrackedObject:
|
|||||||
if self.obj_data["position_changes"] != obj_data["position_changes"]:
|
if self.obj_data["position_changes"] != obj_data["position_changes"]:
|
||||||
significant_change = True
|
significant_change = True
|
||||||
|
|
||||||
# if the motionless_count crosses the stationary threshold
|
# if the motionless_count reaches the stationary threshold
|
||||||
if (
|
if (
|
||||||
self.obj_data["motionless_count"]
|
self.obj_data["motionless_count"]
|
||||||
> self.camera_config.detect.stationary_threshold
|
== self.camera_config.detect.stationary.threshold
|
||||||
):
|
):
|
||||||
significant_change = True
|
significant_change = True
|
||||||
|
|
||||||
@ -193,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(),
|
||||||
|
|||||||
@ -78,9 +78,9 @@ class ObjectTracker:
|
|||||||
}
|
}
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# if there are less than stationary_threshold entries for the position, add the bounding box
|
# if there are less than 10 entries for the position, add the bounding box
|
||||||
# and recompute the position box
|
# and recompute the position box
|
||||||
if len(position["xmins"]) < self.detect_config.stationary_threshold:
|
if len(position["xmins"]) < 10:
|
||||||
position["xmins"].append(xmin)
|
position["xmins"].append(xmin)
|
||||||
position["ymins"].append(ymin)
|
position["ymins"].append(ymin)
|
||||||
position["xmaxs"].append(xmax)
|
position["xmaxs"].append(xmax)
|
||||||
@ -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:
|
||||||
|
# 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]["motionless_count"] = 0
|
||||||
self.tracked_objects[id]["position_changes"] += 1
|
|
||||||
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
|
||||||
|
|||||||
@ -192,10 +192,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:
|
||||||
@ -426,7 +423,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,
|
||||||
|
|||||||
@ -56,7 +56,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 = {}
|
||||||
|
|
||||||
@ -346,12 +345,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...")
|
||||||
|
|
||||||
|
|||||||
@ -506,12 +506,12 @@ def process_frames(
|
|||||||
stationary_object_ids = [
|
stationary_object_ids = [
|
||||||
obj["id"]
|
obj["id"]
|
||||||
for obj in object_tracker.tracked_objects.values()
|
for obj in object_tracker.tracked_objects.values()
|
||||||
# if there hasn't been motion for N frames
|
# if there hasn't been motion for 10 frames
|
||||||
if obj["motionless_count"] >= detect_config.stationary_threshold
|
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
|
||||||
|
|||||||
@ -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';
|
||||||
@ -89,9 +97,16 @@ 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 = 0;
|
let duration = 'In Progress';
|
||||||
if (event.end_time) {
|
if (event.end_time) {
|
||||||
duration = addSeconds(new Date(0), differenceInSeconds(fromUnixTime(event.end_time), start));
|
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 position = differenceInSeconds(start, startOfHour(start));
|
||||||
const offset = Object.entries(delay)
|
const offset = Object.entries(delay)
|
||||||
@ -110,9 +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">
|
<div className="text-xs md:text-normal text-gray-300">Duration: {duration}</div>
|
||||||
Duration: {duration ? format(duration, 'mm:ss') : 'In Progress'}
|
|
||||||
</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>
|
||||||
|
|||||||
@ -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(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -64,11 +64,11 @@ export default function Recording({ camera, date, hour, seconds }) {
|
|||||||
this.player.playlist.currentItem(selectedHour);
|
this.player.playlist.currentItem(selectedHour);
|
||||||
if (seconds !== undefined) {
|
if (seconds !== undefined) {
|
||||||
this.player.currentTime(seconds);
|
this.player.currentTime(seconds);
|
||||||
// Force playback rate to be correct
|
|
||||||
const playbackRate = this.player.playbackRate();
|
|
||||||
this.player.defaultPlaybackRate(playbackRate);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Force playback rate to be correct
|
||||||
|
const playbackRate = this.player.playbackRate();
|
||||||
|
this.player.defaultPlaybackRate(playbackRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user