forked from Github/frigate
Compare commits
8 Commits
v0.14.0-rc
...
v0.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da913d8d31 | ||
|
|
88d4b694f8 | ||
|
|
b28cc45510 | ||
|
|
c0b23ca938 | ||
|
|
8e7b83d2f1 | ||
|
|
599dd7eecb | ||
|
|
84348350fe | ||
|
|
7d03d99852 |
@@ -66,6 +66,40 @@ RUN --mount=type=bind,source=docker/main/build_ov_model.py,target=/build_ov_mode
|
||||
&& tar -xvf ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \
|
||||
&& python3 /build_ov_model.py
|
||||
|
||||
####
|
||||
#
|
||||
# Coral Compatibility
|
||||
#
|
||||
# Builds libusb without udev. Needed for synology and other devices with USB coral
|
||||
####
|
||||
# libUSB - No Udev
|
||||
FROM wget as libusb-build
|
||||
ARG TARGETARCH
|
||||
ARG DEBIAN_FRONTEND
|
||||
ENV CCACHE_DIR /root/.ccache
|
||||
ENV CCACHE_MAXSIZE 2G
|
||||
|
||||
# Build libUSB without udev. Needed for Openvino NCS2 support
|
||||
WORKDIR /opt
|
||||
RUN apt-get update && apt-get install -y unzip build-essential automake libtool ccache pkg-config
|
||||
RUN --mount=type=cache,target=/root/.ccache wget -q https://github.com/libusb/libusb/archive/v1.0.26.zip -O v1.0.26.zip && \
|
||||
unzip v1.0.26.zip && cd libusb-1.0.26 && \
|
||||
./bootstrap.sh && \
|
||||
./configure CC='ccache gcc' CCX='ccache g++' --disable-udev --enable-shared && \
|
||||
make -j $(nproc --all)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends libusb-1.0-0-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /opt/libusb-1.0.26/libusb
|
||||
RUN /bin/mkdir -p '/usr/local/lib' && \
|
||||
/bin/bash ../libtool --mode=install /usr/bin/install -c libusb-1.0.la '/usr/local/lib' && \
|
||||
/bin/mkdir -p '/usr/local/include/libusb-1.0' && \
|
||||
/usr/bin/install -c -m 644 libusb.h '/usr/local/include/libusb-1.0' && \
|
||||
/bin/mkdir -p '/usr/local/lib/pkgconfig' && \
|
||||
cd /opt/libusb-1.0.26/ && \
|
||||
/usr/bin/install -c -m 644 libusb-1.0.pc '/usr/local/lib/pkgconfig' && \
|
||||
ldconfig
|
||||
|
||||
FROM wget AS models
|
||||
|
||||
# Get model and labels
|
||||
@@ -78,7 +112,7 @@ COPY --from=ov-converter /models/ssdlite_mobilenet_v2.bin openvino-model/
|
||||
RUN wget -q https://github.com/openvinotoolkit/open_model_zoo/raw/master/data/dataset_classes/coco_91cl_bkgr.txt -O openvino-model/coco_91cl_bkgr.txt && \
|
||||
sed -i 's/truck/car/g' openvino-model/coco_91cl_bkgr.txt
|
||||
# Get Audio Model and labels
|
||||
RUN wget -qO - https://www.kaggle.com/api/v1/models/google/yamnet/tfLite/classification-tflite/1/download | tar xvz && mv 1.tflite cpu_audio_model.tflite
|
||||
RUN wget -qO - https://www.kaggle.com/api/v1/models/google/yamnet/tfLite/classification-tflite/1/download | tar xvz && mv 1.tflite cpu_audio_model.tflite
|
||||
COPY audio-labelmap.txt .
|
||||
|
||||
|
||||
@@ -135,6 +169,7 @@ RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt
|
||||
FROM scratch AS deps-rootfs
|
||||
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
|
||||
COPY --from=go2rtc /rootfs/ /
|
||||
COPY --from=libusb-build /usr/local/lib /usr/local/lib
|
||||
COPY --from=tempio /rootfs/ /
|
||||
COPY --from=s6-overlay /rootfs/ /
|
||||
COPY --from=models /rootfs/ /
|
||||
@@ -165,6 +200,8 @@ RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
|
||||
|
||||
COPY --from=deps-rootfs / /
|
||||
|
||||
RUN ldconfig
|
||||
|
||||
EXPOSE 5000
|
||||
EXPOSE 8554
|
||||
EXPOSE 8555/tcp 8555/udp
|
||||
|
||||
@@ -80,6 +80,14 @@ model:
|
||||
input_pixel_format: "bgr"
|
||||
```
|
||||
|
||||
#### `labelmap`
|
||||
|
||||
:::warning
|
||||
|
||||
If the labelmap is customized then the labels used for alerts will need to be adjusted as well. See [alert labels](../configuration/review.md#restricting-alerts-to-specific-labels) for more info.
|
||||
|
||||
:::
|
||||
|
||||
The labelmap can be customized to your needs. A common reason to do this is to combine multiple object types that are easily confused when you don't need to be as granular such as car/truck. By default, truck is renamed to car because they are often confused. You cannot add new object types, but you can change the names of existing objects in the model.
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -111,6 +111,6 @@ camera_groups:
|
||||
cameras:
|
||||
- driveway_cam
|
||||
- garage_cam
|
||||
icon: car
|
||||
icon: LuCar
|
||||
order: 0
|
||||
```
|
||||
|
||||
@@ -81,6 +81,15 @@ detectors:
|
||||
device: ""
|
||||
```
|
||||
|
||||
### Single PCIE/M.2 Coral
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
coral:
|
||||
type: edgetpu
|
||||
device: pci
|
||||
```
|
||||
|
||||
### Multiple PCIE/M.2 Corals
|
||||
|
||||
```yaml
|
||||
@@ -136,23 +145,7 @@ model:
|
||||
|
||||
#### YOLOX
|
||||
|
||||
This detector also supports YOLOX. Frigate does not come with any YOLOX models preloaded, so you will need to supply your own models. This detector has been verified to work with the [yolox_tiny](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/yolox-tiny) model from Intel's Open Model Zoo. You can follow [these instructions](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/yolox-tiny#download-a-model-and-convert-it-into-openvino-ir-format) to retrieve the OpenVINO-compatible `yolox_tiny` model. Make sure that the model input dimensions match the `width` and `height` parameters, and `model_type` is set accordingly. See [Full Configuration Reference](/configuration/reference.md) for a list of possible `model_type` options. Below is an example of how `yolox_tiny` can be used in Frigate:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
ov:
|
||||
type: openvino
|
||||
device: GPU
|
||||
|
||||
model:
|
||||
width: 416
|
||||
height: 416
|
||||
input_tensor: nchw
|
||||
input_pixel_format: bgr
|
||||
model_type: yolox
|
||||
path: /path/to/yolox_tiny.xml
|
||||
labelmap_path: /path/to/coco_80cl.txt
|
||||
```
|
||||
This detector also supports YOLOX. Frigate does not come with any YOLOX models preloaded, so you will need to supply your own models.
|
||||
|
||||
#### YOLO-NAS
|
||||
|
||||
|
||||
@@ -274,13 +274,11 @@ cameras:
|
||||
- 0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432
|
||||
```
|
||||
|
||||
### Step 6: Enable recording and/or snapshots
|
||||
### Step 6: Enable recordings
|
||||
|
||||
In order to see Events in the Frigate UI, either snapshots or record will need to be enabled.
|
||||
In order to review activity in the Frigate UI, recordings need to be enabled.
|
||||
|
||||
#### Record
|
||||
|
||||
To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, turning it on via the UI will not have any effect.
|
||||
To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, it won't be possible to enable it in the UI.
|
||||
|
||||
```yaml
|
||||
mqtt: ...
|
||||
@@ -307,26 +305,6 @@ If you don't have separate streams for detect and record, you would just add the
|
||||
|
||||
By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](../configuration/reference.md).
|
||||
|
||||
#### Snapshots
|
||||
|
||||
To enable snapshots of your events, just enable it in the config. Snapshots are taken from the detect stream because it is the only stream decoded.
|
||||
|
||||
```yaml
|
||||
mqtt: ...
|
||||
|
||||
detectors: ...
|
||||
|
||||
cameras:
|
||||
name_of_your_camera: ...
|
||||
detect: ...
|
||||
record: ...
|
||||
snapshots: # <----- Enable snapshots
|
||||
enabled: True
|
||||
motion: ...
|
||||
```
|
||||
|
||||
By default, Frigate will retain snapshots of all events for 10 days. The full set of options for snapshots can be found [here](../configuration/reference.md).
|
||||
|
||||
### Step 7: Complete config
|
||||
|
||||
At this point you have a complete config with basic functionality. You can see the [full config reference](../configuration/reference.md) for a complete list of configuration options.
|
||||
@@ -336,6 +314,8 @@ At this point you have a complete config with basic functionality. You can see t
|
||||
Now that you have a working install, you can use the following documentation for additional features:
|
||||
|
||||
1. [Configuring go2rtc](configuring_go2rtc.md) - Additional live view options and RTSP relay
|
||||
2. [Home Assistant Integration](../integrations/home-assistant.md) - Integrate with Home Assistant
|
||||
3. [Masks](../configuration/masks.md)
|
||||
4. [Zones](../configuration/zones.md)
|
||||
2. [Zones](../configuration/zones.md)
|
||||
3. [Review](../configuration/review.md)
|
||||
4. [Masks](../configuration/masks.md)
|
||||
5. [Home Assistant Integration](../integrations/home-assistant.md) - Integrate with Home Assistant
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
type AnimatedEventCardProps = {
|
||||
event: ReviewSegment;
|
||||
@@ -57,6 +58,8 @@ export function AnimatedEventCard({
|
||||
};
|
||||
}, [visibilityListener]);
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
// interaction
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -109,6 +112,7 @@ export function AnimatedEventCard({
|
||||
className="size-full select-none"
|
||||
src={`${apiHost}${event.thumb_path.replace("/media/frigate/", "")}`}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -122,6 +126,11 @@ export function AnimatedEventCard({
|
||||
setReviewed={() => {}}
|
||||
setIgnoreClick={() => {}}
|
||||
isPlayingBack={() => {}}
|
||||
onTimeUpdate={() => {
|
||||
if (!isLoaded) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}}
|
||||
windowVisible={windowVisible}
|
||||
/>
|
||||
) : (
|
||||
@@ -132,6 +141,11 @@ export function AnimatedEventCard({
|
||||
muted
|
||||
disableRemotePlayback
|
||||
loop
|
||||
onTimeUpdate={() => {
|
||||
if (!isLoaded) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<source
|
||||
src={`${baseUrl}api/review/${event.id}/preview?format=mp4`}
|
||||
@@ -142,11 +156,14 @@ export function AnimatedEventCard({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent">
|
||||
<div className="absolute bottom-0 left-1 w-full text-xs text-white">
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
{isLoaded && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent">
|
||||
<div className="absolute bottom-0 left-1 w-full text-xs text-white">
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoaded && <Skeleton className="absolute inset-0" />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -234,11 +234,11 @@ export default function PreviewThumbnailPlayer({
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-t-l pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent",
|
||||
!isIOS && "z-10",
|
||||
!isSafari && "z-10",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("absolute left-0 top-2", !isIOS && "z-40")}>
|
||||
<div className={cn("absolute left-0 top-2", !isSafari && "z-40")}>
|
||||
<Tooltip>
|
||||
<div
|
||||
className="flex"
|
||||
@@ -287,7 +287,7 @@ export default function PreviewThumbnailPlayer({
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 h-[20%] w-full bg-gradient-to-t from-black/60 to-transparent",
|
||||
!isIOS && "z-10",
|
||||
!isSafari && "z-10",
|
||||
)}
|
||||
>
|
||||
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white">
|
||||
|
||||
@@ -13,9 +13,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DeleteClipType, Export } from "@/types/export";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { LuFolderX } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
@@ -92,6 +94,7 @@ function Exports() {
|
||||
// Viewing
|
||||
|
||||
const [selected, setSelected] = useState<Export>();
|
||||
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
||||
@@ -129,15 +132,27 @@ function Exports() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-7xl">
|
||||
<DialogTitle>{selected?.name}</DialogTitle>
|
||||
<DialogContent
|
||||
className={cn("max-w-[80%]", isMobile && "landscape:max-w-[60%]")}
|
||||
>
|
||||
<DialogTitle className="capitalize">
|
||||
{selected?.name?.replaceAll("_", " ")}
|
||||
</DialogTitle>
|
||||
<video
|
||||
className="size-full rounded-lg md:rounded-2xl"
|
||||
className={cn(
|
||||
"size-full rounded-lg md:rounded-2xl",
|
||||
selectedAspect < 1.5 && "aspect-video h-full",
|
||||
)}
|
||||
playsInline
|
||||
preload="auto"
|
||||
autoPlay
|
||||
controls
|
||||
muted
|
||||
onLoadedData={(e) =>
|
||||
setSelectedAspect(
|
||||
e.currentTarget.videoWidth / e.currentTarget.videoHeight,
|
||||
)
|
||||
}
|
||||
>
|
||||
<source
|
||||
src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
FaSortAmountDown,
|
||||
FaSortAmountUp,
|
||||
} from "react-icons/fa";
|
||||
import { LuFolderX } from "react-icons/lu";
|
||||
import { PiSlidersHorizontalFill } from "react-icons/pi";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
@@ -240,96 +241,104 @@ export default function SubmitPlus() {
|
||||
<PlusSortSelector selectedSort={sort} setSelectedSort={setSort} />
|
||||
</div>
|
||||
<div className="no-scrollbar flex size-full flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
||||
<div className="grid w-full gap-2 p-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<Dialog
|
||||
open={upload != undefined}
|
||||
onOpenChange={(open) => (!open ? setUpload(undefined) : null)}
|
||||
>
|
||||
<DialogContent className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Submit To Frigate+</DialogTitle>
|
||||
<DialogDescription>
|
||||
Objects in locations you want to avoid are not false
|
||||
positives. Submitting them as false positives will confuse the
|
||||
model.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<img
|
||||
className={`w-full ${grow} bg-black`}
|
||||
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
|
||||
alt={`${upload?.label}`}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setUpload(undefined)}>Cancel</Button>
|
||||
<Button
|
||||
className="bg-success"
|
||||
onClick={() => onSubmitToPlus(false)}
|
||||
>
|
||||
This is a {upload?.label}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="destructive"
|
||||
onClick={() => onSubmitToPlus(true)}
|
||||
>
|
||||
This is not a {upload?.label}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{events?.map((event) => {
|
||||
if (event.data.type != "object" || event.plus_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="relative flex aspect-video w-full cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl"
|
||||
onClick={() => setUpload(event)}
|
||||
>
|
||||
<div className="absolute left-0 top-2 z-40">
|
||||
<Tooltip>
|
||||
<div className="flex">
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mx-3 pb-1 text-sm text-white">
|
||||
<Chip
|
||||
className={`z-0 flex items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
|
||||
>
|
||||
{[event.label].map((object) => {
|
||||
return getIconForLabel(
|
||||
object,
|
||||
"size-3 text-white",
|
||||
);
|
||||
})}
|
||||
<div className="text-xs">
|
||||
{Math.round(event.data.score * 100)}%
|
||||
</div>
|
||||
</Chip>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
<TooltipContent className="capitalize">
|
||||
{[event.label]
|
||||
.map((text) => capitalizeFirstLetter(text))
|
||||
.sort()
|
||||
.join(", ")
|
||||
.replaceAll("-verified", "")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isValidating ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : events?.length === 0 ? (
|
||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||
<LuFolderX className="size-16" />
|
||||
No snapshots found
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-full gap-2 p-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<Dialog
|
||||
open={upload != undefined}
|
||||
onOpenChange={(open) => (!open ? setUpload(undefined) : null)}
|
||||
>
|
||||
<DialogContent className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Submit To Frigate+</DialogTitle>
|
||||
<DialogDescription>
|
||||
Objects in locations you want to avoid are not false
|
||||
positives. Submitting them as false positives will confuse
|
||||
the model.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<img
|
||||
className="aspect-video h-full rounded-lg object-contain md:rounded-2xl"
|
||||
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
|
||||
loading="lazy"
|
||||
className={`w-full ${grow} bg-black`}
|
||||
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
|
||||
alt={`${upload?.label}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!isValidating && !isDone && <div ref={lastEventRef} />}
|
||||
{isValidating && <ActivityIndicator />}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setUpload(undefined)}>Cancel</Button>
|
||||
<Button
|
||||
className="bg-success"
|
||||
onClick={() => onSubmitToPlus(false)}
|
||||
>
|
||||
This is a {upload?.label}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="destructive"
|
||||
onClick={() => onSubmitToPlus(true)}
|
||||
>
|
||||
This is not a {upload?.label}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{events?.map((event) => {
|
||||
if (event.data.type != "object" || event.plus_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="relative flex aspect-video w-full cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl"
|
||||
onClick={() => setUpload(event)}
|
||||
>
|
||||
<div className="absolute left-0 top-2 z-40">
|
||||
<Tooltip>
|
||||
<div className="flex">
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mx-3 pb-1 text-sm text-white">
|
||||
<Chip
|
||||
className={`z-0 flex items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
|
||||
>
|
||||
{[event.label].map((object) => {
|
||||
return getIconForLabel(
|
||||
object,
|
||||
"size-3 text-white",
|
||||
);
|
||||
})}
|
||||
<div className="text-xs">
|
||||
{Math.round(event.data.score * 100)}%
|
||||
</div>
|
||||
</Chip>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
<TooltipContent className="capitalize">
|
||||
{[event.label]
|
||||
.map((text) => capitalizeFirstLetter(text))
|
||||
.sort()
|
||||
.join(", ")
|
||||
.replaceAll("-verified", "")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<img
|
||||
className="aspect-video h-full rounded-lg object-contain md:rounded-2xl"
|
||||
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!isValidating && !isDone && <div ref={lastEventRef} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -708,25 +708,31 @@ function Timeline({
|
||||
isMobile && "sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{mainCameraReviewItems.map((review) => {
|
||||
if (review.severity == "significant_motion") {
|
||||
return;
|
||||
}
|
||||
{mainCameraReviewItems.length === 0 ? (
|
||||
<div className="mt-5 text-center text-primary">
|
||||
No events found for this time period.
|
||||
</div>
|
||||
) : (
|
||||
mainCameraReviewItems.map((review) => {
|
||||
if (review.severity === "significant_motion") {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReviewCard
|
||||
key={review.id}
|
||||
event={review}
|
||||
currentTime={currentTime}
|
||||
onClick={() => {
|
||||
manuallySetCurrentTime(
|
||||
review.start_time - REVIEW_PADDING,
|
||||
true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<ReviewCard
|
||||
key={review.id}
|
||||
event={review}
|
||||
currentTime={currentTime}
|
||||
onClick={() => {
|
||||
manuallySetCurrentTime(
|
||||
review.start_time - REVIEW_PADDING,
|
||||
true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user