diff --git a/web-new/src/components/card/HistoryCard.tsx b/web-new/src/components/card/HistoryCard.tsx index c297aeaaa..3a2e79920 100644 --- a/web-new/src/components/card/HistoryCard.tsx +++ b/web-new/src/components/card/HistoryCard.tsx @@ -3,21 +3,13 @@ import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer"; import { Card } from "../ui/card"; import { FrigateConfig } from "@/types/frigateConfig"; import ActivityIndicator from "../ui/activity-indicator"; -import { - LuCircle, - LuClock, - LuPlay, - LuPlayCircle, - LuTruck, -} from "react-icons/lu"; -import { IoMdExit } from "react-icons/io"; -import { - MdFaceUnlock, - MdOutlineLocationOn, - MdOutlinePictureInPictureAlt, -} from "react-icons/md"; +import { LuClock } from "react-icons/lu"; import { HiOutlineVideoCamera } from "react-icons/hi"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { + getTimelineIcon, + getTimelineItemDescription, +} from "@/utils/timelineUtil"; type HistoryCardProps = { timeline: Card; @@ -78,76 +70,3 @@ export default function HistoryCard({ ); } - -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 "sub_label": - switch (timelineItem.data.label) { - case "person": - return ; - case "car": - return ; - } - } -} - -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 "sub_label": - return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`; - case "gone": - return `${label} left`; - } -} diff --git a/web-new/src/components/card/TimelineCardPlayer.tsx b/web-new/src/components/card/TimelineCardPlayer.tsx index 5291d555c..0619d634e 100644 --- a/web-new/src/components/card/TimelineCardPlayer.tsx +++ b/web-new/src/components/card/TimelineCardPlayer.tsx @@ -3,8 +3,23 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import VideoPlayer from "../player/VideoPlayer"; -import { useMemo } from "react"; +import { useMemo, useRef, useState } from "react"; import { useApiHost } from "@/api"; +import TimelineEventOverlay from "../overlay/TimelineDataOverlay"; +import ActivityIndicator from "../ui/activity-indicator"; +import { Button } from "../ui/button"; +import { + getTimelineIcon, + getTimelineItemDescription, +} from "@/utils/timelineUtil"; +import { LuAlertCircle } from "react-icons/lu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import Player from "video.js/dist/types/player"; type TimelinePlayerCardProps = { timeline?: Card; @@ -17,6 +32,18 @@ export default function TimelinePlayerCard({ }: TimelinePlayerCardProps) { const { data: config } = useSWR("config"); const apiHost = useApiHost(); + const playerRef = useRef(); + + const annotationOffset = useMemo(() => { + if (!config || !timeline) { + return 0; + } + + return ( + (config.cameras[timeline.camera]?.detect?.annotation_offset || 0) / 1000 + ); + }, [config, timeline]); + const [selectedItem, setSelectedItem] = useState(); const recordingParams = useMemo(() => { if (!timeline) { @@ -28,15 +55,37 @@ export default function TimelinePlayerCard({ after: timeline.entries.at(0)!!.timestamp, }; }, [timeline]); + const { data: recordings } = useSWR( timeline ? [`${timeline.camera}/recordings`, recordingParams] : null, { revalidateOnFocus: false } ); + const playbackUri = useMemo(() => { + if (!timeline) { + return ""; + } + + const end = timeline.entries.at(-1)!!.timestamp + 30; + const start = timeline.entries.at(0)!!.timestamp; + return `${apiHost}vod/${timeline?.camera}/start/${ + Number.isInteger(start) ? start.toFixed(1) : start + }/end/${Number.isInteger(end) ? end.toFixed(1) : end}/master.m3u8`; + }, [timeline]); + return ( <> - - + { + setSelectedItem(undefined); + onDismiss(); + }} + > + e.preventDefault()} + > {`${timeline?.camera?.replaceAll( @@ -48,27 +97,166 @@ export default function TimelinePlayerCard({ })}`} - {recordings && recordings.length > 0 && ( - + {config && timeline && recordings && recordings.length > 0 && ( + <> + { + setSelectedItem(selected); + playerRef.current?.pause(); + playerRef.current?.currentTime(seekTime); + }} + /> +
+ { + playerRef.current = player; + player.on("playing", () => { + setSelectedItem(undefined); + }); + }} + onDispose={() => { + playerRef.current = undefined; + }} + > + {selectedItem ? ( + + ) : undefined} + +
+ )}
); } + +type TimelineSummaryProps = { + timeline: Card; + annotationOffset: number; + recordings: Recording[]; + onFrameSelected: (timeline: Timeline, frameTime: number) => void; +}; + +function TimelineSummary({ + timeline, + annotationOffset, + recordings, + onFrameSelected, +}: TimelineSummaryProps) { + const [timeIndex, setTimeIndex] = useState(-1); + + // calculates the seek seconds by adding up all the seconds in the segments prior to the playback time + const getSeekSeconds = (seekUnix: number) => { + if (!recordings) { + return 0; + } + + let seekSeconds = 0; + recordings.every((segment) => { + // if the next segment is past the desired time, stop calculating + if (segment.start_time > seekUnix) { + return false; + } + + if (segment.end_time < seekUnix) { + seekSeconds += segment.end_time - segment.start_time; + return true; + } + + seekSeconds += + segment.end_time - segment.start_time - (segment.end_time - seekUnix); + return true; + }); + + return seekSeconds; + }; + + const onSelectMoment = async (index: number) => { + setTimeIndex(index); + onFrameSelected( + timeline.entries[index], + getSeekSeconds(timeline.entries[index].timestamp + annotationOffset) + ); + }; + + if (!timeline || !recordings) { + return ; + } + + return ( +
+
+
+ {timeline.entries.map((item, index) => ( + + + + + + +

{getTimelineItemDescription(item)}

+
+
+
+ ))} +
+
+ {timeIndex >= 0 ? ( +
+
+
+ Bounding boxes may not align +
+ + + + + + +

+ Disclaimer: This data comes from the detect feed but is + shown on the recordings. +

+

+ It is unlikely that the streams are perfectly in sync so the + bounding box and the footage will not line up perfectly. +

+

The annotation_offset field can be used to adjust this.

+
+
+
+
+
+ ) : null} +
+ ); +} diff --git a/web-new/src/components/overlay/TimelineDataOverlay.tsx b/web-new/src/components/overlay/TimelineDataOverlay.tsx new file mode 100644 index 000000000..9ab08bc16 --- /dev/null +++ b/web-new/src/components/overlay/TimelineDataOverlay.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; + +type TimelineEventOverlayProps = { + timeline: Timeline; + cameraConfig: { + detect: { + width: number; + height: number; + }; + }; +}; + +export default function TimelineEventOverlay({ + timeline, + cameraConfig, +}: TimelineEventOverlayProps) { + const boxLeftEdge = Math.round(timeline.data.box[0] * 100); + const boxTopEdge = Math.round(timeline.data.box[1] * 100); + const boxRightEdge = Math.round( + (1 - timeline.data.box[2] - timeline.data.box[0]) * 100 + ); + const boxBottomEdge = Math.round( + (1 - timeline.data.box[3] - timeline.data.box[1]) * 100 + ); + + const [isHovering, setIsHovering] = useState(false); + const getHoverStyle = () => { + if (boxLeftEdge < 15) { + // show object stats on right side + return { + left: `${boxLeftEdge + timeline.data.box[2] * 100 + 1}%`, + top: `${boxTopEdge}%`, + }; + } + + return { + right: `${boxRightEdge + timeline.data.box[2] * 100 + 1}%`, + top: `${boxTopEdge}%`, + }; + }; + + const getObjectArea = () => { + const width = timeline.data.box[2] * cameraConfig.detect.width; + const height = timeline.data.box[3] * cameraConfig.detect.height; + return Math.round(width * height); + }; + + const getObjectRatio = () => { + const width = timeline.data.box[2] * cameraConfig.detect.width; + const height = timeline.data.box[3] * cameraConfig.detect.height; + return Math.round(100 * (width / height)) / 100; + }; + + return ( + <> +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onTouchStart={() => setIsHovering(true)} + onTouchEnd={() => setIsHovering(false)} + style={{ + left: `${boxLeftEdge}%`, + top: `${boxTopEdge}%`, + right: `${boxRightEdge}%`, + bottom: `${boxBottomEdge}%`, + }} + > + {timeline.class_type == "entered_zone" ? ( +
+ ) : null} +
+ {isHovering && ( +
+
{`Area: ${getObjectArea()} px`}
+
{`Ratio: ${getObjectRatio()}`}
+
+ )} + + ); +} diff --git a/web-new/src/components/player/VideoPlayer.tsx b/web-new/src/components/player/VideoPlayer.tsx index 36b600104..c7d6aa3a9 100644 --- a/web-new/src/components/player/VideoPlayer.tsx +++ b/web-new/src/components/player/VideoPlayer.tsx @@ -5,7 +5,7 @@ import 'video.js/dist/video-js.css'; import Player from "video.js/dist/types/player"; type VideoPlayerProps = { - children?: ReactElement | ReactElement[], + children?: ReactElement | ReactElement[], options?: { [key: string]: any }, diff --git a/web-new/src/pages/History.tsx b/web-new/src/pages/History.tsx index 07fe83d68..0e671b75d 100644 --- a/web-new/src/pages/History.tsx +++ b/web-new/src/pages/History.tsx @@ -9,7 +9,7 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import axios from "axios"; import TimelinePlayerCard from "@/components/card/TimelineCardPlayer"; -const API_LIMIT = 100; +const API_LIMIT = 120; function History() { const { data: config } = useSWR("config"); diff --git a/web-new/src/pages/Storage.tsx b/web-new/src/pages/Storage.tsx index a37c8983d..b3e99cf18 100644 --- a/web-new/src/pages/Storage.tsx +++ b/web-new/src/pages/Storage.tsx @@ -71,7 +71,7 @@ function Storage() {
-
+
Data @@ -137,7 +137,7 @@ function Storage() { -
+
Memory @@ -187,7 +187,7 @@ function Storage() {
-
+
Cameras diff --git a/web-new/src/utils/timelineUtil.tsx b/web-new/src/utils/timelineUtil.tsx new file mode 100644 index 000000000..f85be6ad8 --- /dev/null +++ b/web-new/src/utils/timelineUtil.tsx @@ -0,0 +1,80 @@ +import { LuCircle, LuPlay, LuPlayCircle, LuTruck } from "react-icons/lu"; +import { IoMdExit } from "react-icons/io"; +import { + MdFaceUnlock, + MdOutlineLocationOn, + MdOutlinePictureInPictureAlt, +} from "react-icons/md"; + +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 "sub_label": + switch (timelineItem.data.label) { + case "person": + return ; + case "car": + 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 "sub_label": + return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`; + case "gone": + return `${label} left`; + } +}