diff --git a/frigate/api/media.py b/frigate/api/media.py index 911e13f7e..092958581 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -179,14 +179,20 @@ def latest_frame(camera_name): ) -@MediaBp.route("//recordings//snapshot.png") -def get_snapshot_from_recording(camera_name: str, frame_time: str): +@MediaBp.route("//recordings//snapshot.") +def get_snapshot_from_recording(camera_name: str, frame_time: str, format: str): if camera_name not in current_app.frigate_config.cameras: return make_response( jsonify({"success": False, "message": "Camera not found"}), 404, ) + if format not in ["png", "jpg"]: + return make_response( + jsonify({"success": False, "message": "Invalid format"}), + 400, + ) + frame_time = float(frame_time) recording_query = ( Recordings.select( @@ -207,7 +213,13 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str): try: recording: Recordings = recording_query.get() time_in_segment = frame_time - recording.start_time - image_data = get_image_from_recording(recording.path, time_in_segment) + + height = request.args.get("height", type=int) + codec = "png" if format == "png" else "mjpeg" + + image_data = get_image_from_recording( + recording.path, time_in_segment, codec, height + ) if not image_data: return make_response( @@ -221,7 +233,7 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str): ) response = make_response(image_data) - response.headers["Content-Type"] = "image/png" + response.headers["Content-Type"] = f"image/{format}" return response except DoesNotExist: return make_response( @@ -263,7 +275,7 @@ def submit_recording_snapshot_to_plus(camera_name: str, frame_time: str): try: recording: Recordings = recording_query.get() time_in_segment = frame_time - recording.start_time - image_data = get_image_from_recording(recording.path, time_in_segment) + image_data = get_image_from_recording(recording.path, time_in_segment, "png") if not image_data: return make_response( diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 6a0c706a7..2c3051fc0 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -1,5 +1,6 @@ """Utilities for builtin types manipulation.""" +import ast import copy import datetime import logging @@ -210,10 +211,16 @@ def update_yaml_from_url(file_path, url): if len(new_value_list) > 1: update_yaml_file(file_path, key_path, new_value_list) else: - value = str(new_value_list[0]) - - if value.isnumeric(): - value = int(value) + value = new_value_list[0] + if "," in value: + # Skip conversion if we're a mask or zone string + update_yaml_file(file_path, key_path, value) + else: + try: + value = ast.literal_eval(value) + except (ValueError, SyntaxError): + pass + update_yaml_file(file_path, key_path, value) update_yaml_file(file_path, key_path, value) diff --git a/frigate/util/image.py b/frigate/util/image.py index 7c470a2a0..f3186fe6a 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -765,7 +765,7 @@ def add_mask(mask: str, mask_img: np.ndarray): def get_image_from_recording( - file_path: str, relative_frame_time: float + file_path: str, relative_frame_time: float, codec: str, height: Optional[int] = None ) -> Optional[any]: """retrieve a frame from given time in recording file.""" @@ -781,12 +781,16 @@ def get_image_from_recording( "-frames:v", "1", "-c:v", - "png", + codec, "-f", "image2pipe", "-", ] + if height is not None: + ffmpeg_cmd.insert(-3, "-vf") + ffmpeg_cmd.insert(-3, f"scale=-1:{height}") + process = sp.run( ffmpeg_cmd, capture_output=True, diff --git a/web/package-lock.json b/web/package-lock.json index 6b8a0d2a4..15bd003f3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -36,6 +36,7 @@ "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", + "embla-carousel-react": "^8.2.0", "hls.js": "^1.5.14", "idb-keyval": "^6.2.1", "immer": "^10.1.1", @@ -4087,6 +4088,34 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.2.0.tgz", + "integrity": "sha512-rf2GIX8rab9E6ZZN0Uhz05746qu2KrDje9IfFyHzjwxLwhvGjUt6y9+uaY1Sf+B0OPSa3sgas7BE2hWZCtopTA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.2.0.tgz", + "integrity": "sha512-dWqbmaEBQjeAcy/EKrcAX37beVr0ubXuHPuLZkx27z58V1FIvRbbMb4/c3cLZx0PAv/ofngX2QFrwUB+62SPnw==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.2.0", + "embla-carousel-reactive-utils": "8.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.2.0.tgz", + "integrity": "sha512-ZdaPNgMydkPBiDRUv+wRIz3hpZJ3LKrTyz+XWi286qlwPyZFJDjbzPBiXnC3czF9N/nsabSc7LTRvGauUzwKEg==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.2.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/web/package.json b/web/package.json index 54cb39ebd..148d74919 100644 --- a/web/package.json +++ b/web/package.json @@ -42,6 +42,7 @@ "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", + "embla-carousel-react": "^8.2.0", "hls.js": "^1.5.14", "idb-keyval": "^6.2.1", "immer": "^10.1.1", diff --git a/web/src/components/overlay/TimelineDataOverlay.tsx b/web/src/components/overlay/TimelineDataOverlay.tsx index a8dd58fc1..a0d6190f6 100644 --- a/web/src/components/overlay/TimelineDataOverlay.tsx +++ b/web/src/components/overlay/TimelineDataOverlay.tsx @@ -1,8 +1,8 @@ -import { Timeline } from "@/types/timeline"; +import { ObjectLifecycleSequence } from "@/types/timeline"; import { useState } from "react"; type TimelineEventOverlayProps = { - timeline: Timeline; + timeline: ObjectLifecycleSequence; cameraConfig: { detect: { width: number; diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx new file mode 100644 index 000000000..6a14b8390 --- /dev/null +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -0,0 +1,235 @@ +import Heading from "@/components/ui/heading"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Event } from "@/types/event"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { zodResolver } from "@hookform/resolvers/zod"; +import axios from "axios"; +import { useCallback, useState } from "react"; +import { useForm } from "react-hook-form"; +import { LuExternalLink } from "react-icons/lu"; +import { PiWarningCircle } from "react-icons/pi"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; +import useSWR from "swr"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; + +type AnnotationSettingsPaneProps = { + event: Event; + showZones: boolean; + setShowZones: React.Dispatch>; + annotationOffset: number; + setAnnotationOffset: React.Dispatch>; +}; +export function AnnotationSettingsPane({ + event, + showZones, + setShowZones, + annotationOffset, + setAnnotationOffset, +}: AnnotationSettingsPaneProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + + const [isLoading, setIsLoading] = useState(false); + + const formSchema = z.object({ + annotationOffset: z.coerce.number().optional().or(z.literal("")), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + annotationOffset: annotationOffset, + }, + }); + + const saveToConfig = useCallback( + async (annotation_offset: number | string) => { + if (!config || !event) { + return; + } + + axios + .put( + `config/set?cameras.${event?.camera}.detect.annotation_offset=${annotation_offset}`, + { + requires_restart: 0, + }, + ) + .then((res) => { + if (res.status === 200) { + toast.success( + `Annotation offset for ${event?.camera} has been saved to the config file. Restart Frigate to apply your changes.`, + { + position: "top-center", + }, + ); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [updateConfig, config, event], + ); + + function onSubmit(values: z.infer) { + if (!values || values.annotationOffset == null || !config) { + return; + } + setIsLoading(true); + + saveToConfig(values.annotationOffset); + } + + function onApply(values: z.infer) { + if ( + !values || + values.annotationOffset == null || + values.annotationOffset == "" || + !config + ) { + return; + } + setAnnotationOffset(values.annotationOffset); + } + + return ( +
+ + Annotation Settings + +
+
+ + +
+
+ Always show zones on frames where objects have entered a zone. +
+
+ +
+ + ( + + Annotation Offset +
+
+ +
+ This data comes from your camera's detect feed but is + overlayed on images from the the record feed. It is + unlikely that the two streams are perfectly in sync. As a + result, the bounding box and the footage will not line up + perfectly. However, the annotation_offset{" "} + field in your config can be used to adjust this. +
+ + Read the documentation{" "} + + +
+
+
+
+ + + + + Milliseconds to offset detect annotations by.{" "} + Default: 0 +
+ TIP: Imagine there is an event clip with a person + walking from left to right. If the event timeline + bounding box is consistently to the left of the person + then the value should be decreased. Similarly, if a + person is walking from left to right and the bounding + box is consistently ahead of the person then the value + should be increased. +
+
+
+
+ +
+ )} + /> + +
+
+ + +
+
+ + +
+ ); +} diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx new file mode 100644 index 000000000..4aef59fb3 --- /dev/null +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -0,0 +1,592 @@ +import useSWR from "swr"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Event } from "@/types/event"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { + Carousel, + CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; +import { Button } from "@/components/ui/button"; +import { ObjectLifecycleSequence } from "@/types/timeline"; +import Heading from "@/components/ui/heading"; +import { ReviewDetailPaneType, ReviewSegment } from "@/types/review"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { getIconForLabel } from "@/utils/iconUtil"; +import { + LuCircle, + LuCircleDot, + LuEar, + LuFolderX, + LuPlay, + LuPlayCircle, + LuSettings, + LuTruck, +} from "react-icons/lu"; +import { IoMdArrowRoundBack, IoMdExit } from "react-icons/io"; +import { + MdFaceUnlock, + MdOutlineLocationOn, + MdOutlinePictureInPictureAlt, +} from "react-icons/md"; +import { cn } from "@/lib/utils"; +import { Card, CardContent } from "@/components/ui/card"; +import { useApiHost } from "@/api"; +import { isDesktop, isIOS, isSafari } from "react-device-detect"; +import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { AnnotationSettingsPane } from "./AnnotationSettingsPane"; + +type ObjectLifecycleProps = { + review: ReviewSegment; + event: Event; + setPane: React.Dispatch>; +}; + +export default function ObjectLifecycle({ + review, + event, + setPane, +}: ObjectLifecycleProps) { + const { data: eventSequence } = useSWR([ + "timeline", + { + source_id: event.id, + }, + ]); + + const { data: config } = useSWR("config"); + const apiHost = useApiHost(); + + const [imgLoaded, setImgLoaded] = useState(false); + const imgRef = useRef(null); + + const [selectedZone, setSelectedZone] = useState(""); + const [lifecycleZones, setLifecycleZones] = useState([]); + const [showControls, setShowControls] = useState(false); + const [showZones, setShowZones] = useState(true); + + const getZoneColor = useCallback( + (zoneName: string) => { + const zoneColor = + config?.cameras?.[review.camera]?.zones?.[zoneName]?.color; + if (zoneColor) { + const reversed = [...zoneColor].reverse(); + return reversed; + } + }, + [config, review], + ); + + const getZonePolygon = useCallback( + (zoneName: string) => { + if (!imgRef.current || !config) { + return; + } + const zonePoints = + config?.cameras[review.camera].zones[zoneName].coordinates; + const imgElement = imgRef.current; + const imgRect = imgElement.getBoundingClientRect(); + + return zonePoints + .split(",") + .map(parseFloat) + .reduce((acc, value, index) => { + const isXCoordinate = index % 2 === 0; + const coordinate = isXCoordinate + ? value * imgRect.width + : value * imgRect.height; + acc.push(coordinate); + return acc; + }, [] as number[]) + .join(","); + }, + [config, imgRef, review], + ); + + const [boxStyle, setBoxStyle] = useState(null); + + const configAnnotationOffset = useMemo(() => { + if (!config) { + return 0; + } + + return config.cameras[event.camera]?.detect?.annotation_offset || 0; + }, [config, event]); + + const [annotationOffset, setAnnotationOffset] = useState( + configAnnotationOffset, + ); + + const detectArea = useMemo(() => { + if (!config) { + return 0; + } + return ( + config.cameras[event.camera]?.detect?.width * + config.cameras[event.camera]?.detect?.height + ); + }, [config, event.camera]); + + const [timeIndex, setTimeIndex] = useState(0); + + const handleSetBox = useCallback( + (box: number[]) => { + if (imgRef.current && Array.isArray(box) && box.length === 4) { + const imgElement = imgRef.current; + const imgRect = imgElement.getBoundingClientRect(); + + const style = { + left: `${box[0] * imgRect.width}px`, + top: `${box[1] * imgRect.height}px`, + width: `${box[2] * imgRect.width}px`, + height: `${box[3] * imgRect.height}px`, + }; + + setBoxStyle(style); + } + }, + [imgRef], + ); + + // image + + const [src, setSrc] = useState( + `${apiHost}api/${event.camera}/recordings/${event.start_time + annotationOffset / 1000}/snapshot.jpg?height=500`, + ); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + if (timeIndex) { + const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`; + setSrc(newSrc); + } + setImgLoaded(false); + setHasError(false); + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeIndex, annotationOffset]); + + // carousels + + const [mainApi, setMainApi] = useState(); + const [thumbnailApi, setThumbnailApi] = useState(); + const [current, setCurrent] = useState(0); + + const handleThumbnailClick = (index: number) => { + if (!mainApi || !thumbnailApi) { + return; + } + thumbnailApi.scrollTo(index); + mainApi.scrollTo(index); + setCurrent(index); + }; + + useEffect(() => { + if (eventSequence) { + setTimeIndex(eventSequence?.[current].timestamp); + handleSetBox(eventSequence?.[current].data.box ?? []); + setLifecycleZones(eventSequence?.[current].data.zones); + setSelectedZone(""); + } + }, [current, imgLoaded, handleSetBox, eventSequence]); + + useEffect(() => { + if (!mainApi || !thumbnailApi || !eventSequence || !event) { + return; + } + + const handleTopSelect = () => { + const selected = mainApi.selectedScrollSnap(); + setCurrent(selected); + thumbnailApi.scrollTo(selected); + }; + + const handleBottomSelect = () => { + const selected = thumbnailApi.selectedScrollSnap(); + setCurrent(selected); + mainApi.scrollTo(selected); + }; + + mainApi.on("select", handleTopSelect); + thumbnailApi.on("select", handleBottomSelect); + + return () => { + mainApi.off("select", handleTopSelect); + thumbnailApi.off("select", handleBottomSelect); + }; + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mainApi, thumbnailApi]); + + if (!event.id || !eventSequence || !config || !timeIndex) { + return ; + } + + return ( + <> +
+ +
+ +
+ + {hasError && ( +
+
+ + No image found for this timestamp. +
+
+ )} +
+ setImgLoaded(true)} + onError={() => setHasError(true)} + /> + + {showZones && + lifecycleZones?.map((zone) => ( +
+ + + +
+ ))} + + {boxStyle && ( +
+
+
+ )} +
+
+ +
+ Object Lifecycle + +
+ + + + + Adjust annotation settings + +
+
+
+
+ Scroll to view the significant moments of this object's lifecycle. +
+
+ {current + 1} of {eventSequence.length} +
+
+ {showControls && ( + + )} + +
+ + + {eventSequence.map((item, index) => ( + + + +
+
+
+ {getIconForLabel( + item.data.label, + "size-4 md:size-6 absolute left-0 top-0", + )} + +
+
+
+
+ {getLifecycleItemDescription(item)} +
+
+ {formatUnixTimestampToDateTime(item.timestamp, { + strftime_fmt: + config.ui.time_format == "24hour" + ? "%d %b %H:%M:%S" + : "%m/%d %I:%M:%S%P", + time_style: "medium", + date_style: "medium", + })} +
+
+
+
+
+
+

+ Zones +

+ {item.class_type === "entered_zone" + ? item.data.zones.map((zone, index) => ( +
+ {true && ( +
+ )} +
setSelectedZone(zone)} + > + {zone.replaceAll("_", " ")} +
+
+ )) + : "-"} +
+
+
+
+

+ Ratio +

+ {Array.isArray(item.data.box) && + item.data.box.length >= 4 + ? (item.data.box[2] / item.data.box[3]).toFixed(2) + : "N/A"} +
+
+
+
+

+ Area +

+ {Array.isArray(item.data.box) && + item.data.box.length >= 4 + ? Math.round( + detectArea * + (item.data.box[2] * item.data.box[3]), + ) + : "N/A"} +
+
+
+ + + + ))} + + +
+
+ + + {eventSequence.map((item, index) => ( + handleThumbnailClick(index)} + > +
+ + + + + +
+
+ ))} +
+ + +
+
+ + ); +} + +type GetTimelineIconParams = { + lifecycleItem: ObjectLifecycleSequence; + className?: string; +}; + +export function LifecycleIcon({ + lifecycleItem, + className, +}: GetTimelineIconParams) { + switch (lifecycleItem.class_type) { + case "visible": + return ; + case "gone": + return ; + case "active": + return ; + case "stationary": + return ; + case "entered_zone": + return ; + case "attribute": + switch (lifecycleItem.data?.attribute) { + case "face": + return ; + case "license_plate": + return ; + default: + return ; + } + case "heard": + return ; + case "external": + return ; + default: + return null; + } +} + +function getLifecycleItemDescription(lifecycleItem: ObjectLifecycleSequence) { + const label = ( + (Array.isArray(lifecycleItem.data.sub_label) + ? lifecycleItem.data.sub_label[0] + : lifecycleItem.data.sub_label) || lifecycleItem.data.label + ).replaceAll("_", " "); + + switch (lifecycleItem.class_type) { + case "visible": + return `${label} detected`; + case "entered_zone": + return `${label} entered ${lifecycleItem.data.zones + .join(" and ") + .replaceAll("_", " ")}`; + case "active": + return `${label} became active`; + case "stationary": + return `${label} became stationary`; + case "attribute": { + let title = ""; + if ( + lifecycleItem.data.attribute == "face" || + lifecycleItem.data.attribute == "license_plate" + ) { + title = `${lifecycleItem.data.attribute.replaceAll( + "_", + " ", + )} detected for ${label}`; + } else { + title = `${ + lifecycleItem.data.sub_label + } recognized as ${lifecycleItem.data.attribute.replaceAll("_", " ")}`; + } + return title; + } + case "gone": + return `${label} left`; + case "heard": + return `${label} heard`; + case "external": + return `${label} detected`; + } +} diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 7f5114f73..1742c1fa2 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -1,4 +1,4 @@ -import { isDesktop, isIOS } from "react-device-detect"; +import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { Sheet, SheetContent } from "../../ui/sheet"; import { Drawer, DrawerContent } from "../../ui/drawer"; import useSWR from "swr"; @@ -6,11 +6,21 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { getIconForLabel } from "@/utils/iconUtil"; import { useApiHost } from "@/api"; -import { ReviewSegment } from "@/types/review"; +import { ReviewDetailPaneType, ReviewSegment } from "@/types/review"; import { Event } from "@/types/event"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog"; +import ObjectLifecycle from "./ObjectLifecycle"; +import Chip from "@/components/indicators/Chip"; +import { FaDownload } from "react-icons/fa"; +import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; +import { FaArrowsRotate } from "react-icons/fa6"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; type ReviewDetailDialogProps = { review?: ReviewSegment; @@ -24,8 +34,6 @@ export default function ReviewDetailDialog({ revalidateOnFocus: false, }); - const apiHost = useApiHost(); - // upload const [upload, setUpload] = useState(); @@ -53,133 +61,258 @@ export default function ReviewDetailDialog({ // content + const [selectedEvent, setSelectedEvent] = useState(); + const [pane, setPane] = useState("overview"); + const Overlay = isDesktop ? Sheet : Drawer; const Content = isDesktop ? SheetContent : DrawerContent; + if (!review) { + return; + } + return ( - { - if (!open) { - setReview(undefined); - } - }} - > - setUpload(undefined)} - onEventUploaded={() => { - if (upload) { - upload.plus_id = "new_upload"; + <> + { + if (!open) { + setReview(undefined); + setSelectedEvent(undefined); + setPane("overview"); } }} - /> - - - {review && ( -
-
-
-
-
Camera
-
- {review.camera.replaceAll("_", " ")} -
-
-
-
Timestamp
-
{formattedDate}
-
-
-
-
-
Objects
-
- {events?.map((event) => { - return ( -
- {getIconForLabel(event.label, "size-3 text-white")} - {event.sub_label ?? event.label} ( - {Math.round(event.data.top_score * 100)}%) -
- ); - })} -
-
- {review.data.zones.length > 0 && ( + setUpload(undefined)} + onEventUploaded={() => { + if (upload) { + upload.plus_id = "new_upload"; + } + }} + /> + + + {pane == "overview" && ( +
+
+
-
Zones
+
Camera
+
+ {review.camera.replaceAll("_", " ")} +
+
+
+
Timestamp
+
{formattedDate}
+
+
+
+
+
Objects
- {review.data.zones.map((zone) => { + {events?.map((event) => { return (
- {zone.replaceAll("_", " ")} + {getIconForLabel( + event.label, + "size-3 text-primary", + )} + {event.sub_label ?? event.label} ( + {Math.round(event.data.top_score * 100)}%)
); })}
- )} + {review.data.zones.length > 0 && ( +
+
Zones
+
+ {review.data.zones.map((zone) => { + return ( +
+ {zone.replaceAll("_", " ")} +
+ ); + })} +
+
+ )} +
+
+ {hasMismatch && ( +
+ Some objects that were detected are not included in this list + because the object does not have a snapshot +
+ )} +
+ {events?.map((event) => ( + + ))}
- {hasMismatch && ( -
- Some objects that were detected are not included in this list - because the object does not have a snapshot -
- )} -
- {events?.map((event) => { - return ( - + +
+ )} +
+ + + ); +} + +type EventItemProps = { + event: Event; + setPane: React.Dispatch>; + setSelectedEvent: React.Dispatch>; + setUpload?: React.Dispatch>; +}; + +function EventItem({ + event, + setPane, + setSelectedEvent, + setUpload, +}: EventItemProps) { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + const apiHost = useApiHost(); + + const imgRef = useRef(null); + + const [hovered, setHovered] = useState(isMobile); + + return ( + <> +
setHovered(true) : undefined} + onMouseLeave={isDesktop ? () => setHovered(false) : undefined} + key={event.id} + > + {event.has_snapshot && ( + <> +
+
+ + )} + + {hovered && ( +
+
+ + + { - if ( - event.has_snapshot && - event.plus_id == undefined && - config?.plus.enabled - ) { - setUpload(event); - } - }} - /> - ); - })} + > + + + + + + Download + + + {event.has_snapshot && + event.plus_id == undefined && + config?.plus.enabled && ( + + + { + setUpload?.(event); + }} + > + + + + Submit to Frigate+ + + )} + + {event.has_clip && ( + + + { + setPane("details"); + setSelectedEvent(event); + }} + > + + + + View Object Lifecycle + + )}
)} - - +
+ ); } diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 69ced8b9c..2d91c5e3d 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -266,7 +266,6 @@ export default function PreviewThumbnailPlayer({ .sort() .join(", ") .replaceAll("-verified", "")} - {` • Click To View Detection Details`}
diff --git a/web/src/components/player/SearchThumbnailPlayer.tsx b/web/src/components/player/SearchThumbnailPlayer.tsx index f1a58c061..3391bf445 100644 --- a/web/src/components/player/SearchThumbnailPlayer.tsx +++ b/web/src/components/player/SearchThumbnailPlayer.tsx @@ -231,8 +231,7 @@ export default function SearchThumbnailPlayer({ .map((text) => capitalizeFirstLetter(text)) .sort() .join(", ") - .replaceAll("-verified", "")}{" "} - {` • Click To View Detection Details`} + .replaceAll("-verified", "")}
diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts index 9ccc06c23..63686b84e 100644 --- a/web/src/components/player/dynamic/DynamicVideoController.ts +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -1,7 +1,7 @@ import { Recording } from "@/types/record"; import { DynamicPlayback } from "@/types/playback"; import { PreviewController } from "../PreviewPlayer"; -import { TimeRange, Timeline } from "@/types/timeline"; +import { TimeRange, ObjectLifecycleSequence } from "@/types/timeline"; type PlayerMode = "playback" | "scrubbing"; @@ -11,7 +11,7 @@ export class DynamicVideoController { private playerController: HTMLVideoElement; private previewController: PreviewController; private setNoRecording: (noRecs: boolean) => void; - private setFocusedItem: (timeline: Timeline) => void; + private setFocusedItem: (timeline: ObjectLifecycleSequence) => void; private playerMode: PlayerMode = "playback"; // playback @@ -27,7 +27,7 @@ export class DynamicVideoController { annotationOffset: number, defaultMode: PlayerMode, setNoRecording: (noRecs: boolean) => void, - setFocusedItem: (timeline: Timeline) => void, + setFocusedItem: (timeline: ObjectLifecycleSequence) => void, ) { this.camera = camera; this.playerController = playerController; @@ -119,7 +119,7 @@ export class DynamicVideoController { }); } - seekToTimelineItem(timeline: Timeline) { + seekToTimelineItem(timeline: ObjectLifecycleSequence) { this.playerController.pause(); this.seekToTimestamp(timeline.timestamp + this.annotationOffset); this.setFocusedItem(timeline); diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index f1c23c705..3b9f0dd7e 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -114,7 +114,10 @@ export default function ZoneEditPane({ { message: "Zone name must not contain a period.", }, - ), + ) + .refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), { + message: "Zone name has an illegal character.", + }), inertia: z.coerce .number() .min(1, { diff --git a/web/src/components/ui/carousel.tsx b/web/src/components/ui/carousel.tsx new file mode 100644 index 000000000..9c2b9bf37 --- /dev/null +++ b/web/src/components/ui/carousel.tsx @@ -0,0 +1,260 @@ +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( +
+ ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/web/src/types/review.ts b/web/src/types/review.ts index f895e3833..d1d03e637 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -60,3 +60,5 @@ export type MotionData = { }; export const REVIEW_PADDING = 4; + +export type ReviewDetailPaneType = "overview" | "details"; diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts index b7945314d..94ef75eba 100644 --- a/web/src/types/timeline.ts +++ b/web/src/types/timeline.ts @@ -1,4 +1,4 @@ -export type Timeline = { +export type ObjectLifecycleSequence = { camera: string; timestamp: number; data: { diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index efa383615..eed6dbbc7 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -1,125 +1,5 @@ -import { - LuCamera, - LuCar, - LuCat, - LuCircle, - LuCircleDot, - LuDog, - LuEar, - LuPackage, - LuPersonStanding, - LuPlay, - LuPlayCircle, - LuTruck, -} from "react-icons/lu"; -import { GiDeer } from "react-icons/gi"; -import { IoMdExit } from "react-icons/io"; -import { - MdFaceUnlock, - MdOutlineLocationOn, - MdOutlinePictureInPictureAlt, -} from "react-icons/md"; -import { FaBicycle } from "react-icons/fa"; import { endOfHourOrCurrentTime } from "./dateUtil"; -import { TimeRange, Timeline } from "@/types/timeline"; - -export function getTimelineIcon(timelineItem: Timeline) { - switch (timelineItem.class_type) { - case "visible": - return ; - case "gone": - return ; - case "active": - return ; - case "stationary": - return ; - case "entered_zone": - return ; - case "attribute": - switch (timelineItem.data.attribute) { - case "face": - return ; - case "license_plate": - return ; - default: - return ; - } - case "heard": - return ; - case "external": - return ; - } -} - -/** - * Get icon representing detection, either label specific or generic detection icon - * @param timelineItem timeline item - * @returns icon for label - */ -export function getTimelineDetectionIcon(timelineItem: Timeline) { - switch (timelineItem.data.label) { - case "bicycle": - return ; - case "car": - return ; - case "cat": - return ; - case "deer": - return ; - case "dog": - return ; - case "package": - return ; - case "person": - return ; - default: - return ; - } -} - -export function getTimelineItemDescription(timelineItem: Timeline) { - const label = ( - (Array.isArray(timelineItem.data.sub_label) - ? timelineItem.data.sub_label[0] - : timelineItem.data.sub_label) || timelineItem.data.label - ).replaceAll("_", " "); - - switch (timelineItem.class_type) { - case "visible": - return `${label} detected`; - case "entered_zone": - return `${label} entered ${timelineItem.data.zones - .join(" and ") - .replaceAll("_", " ")}`; - case "active": - return `${label} became active`; - case "stationary": - return `${label} became stationary`; - case "attribute": { - let title = ""; - if ( - timelineItem.data.attribute == "face" || - timelineItem.data.attribute == "license_plate" - ) { - title = `${timelineItem.data.attribute.replaceAll( - "_", - " ", - )} detected for ${label}`; - } else { - title = `${ - timelineItem.data.sub_label - } recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`; - } - return title; - } - case "gone": - return `${label} left`; - case "heard": - return `${label} heard`; - case "external": - return `${label} detected`; - } -} +import { TimeRange } from "@/types/timeline"; /** * diff --git a/web/src/views/settings/MotionTunerView.tsx b/web/src/views/settings/MotionTunerView.tsx index 7ff89d04b..d2eb93089 100644 --- a/web/src/views/settings/MotionTunerView.tsx +++ b/web/src/views/settings/MotionTunerView.tsx @@ -112,7 +112,7 @@ export default function MotionTunerView({ axios .put( - `config/set?cameras.${selectedCamera}.motion.threshold=${motionSettings.threshold}&cameras.${selectedCamera}.motion.contour_area=${motionSettings.contour_area}&cameras.${selectedCamera}.motion.improve_contrast=${motionSettings.improve_contrast}`, + `config/set?cameras.${selectedCamera}.motion.threshold=${motionSettings.threshold}&cameras.${selectedCamera}.motion.contour_area=${motionSettings.contour_area}&cameras.${selectedCamera}.motion.improve_contrast=${motionSettings.improve_contrast ? "True" : "False"}`, { requires_restart: 0 }, ) .then((res) => {