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 } from "@/types/review"; import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { getIconForLabel } from "@/utils/iconUtil"; import { LuCircle, LuCircleDot, LuEar, LuFolderX, LuPlay, 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"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { useNavigate } from "react-router-dom"; import { ObjectPath } from "./ObjectPath"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { IoPlayCircleOutline } from "react-icons/io5"; type ObjectLifecycleProps = { className?: string; event: Event; fullscreen?: boolean; setPane: React.Dispatch>; }; export default function ObjectLifecycle({ className, event, fullscreen = false, setPane, }: ObjectLifecycleProps) { const { data: eventSequence } = useSWR([ "timeline", { source_id: event.id, }, ]); const { data: config } = useSWR("config"); const apiHost = useApiHost(); const navigate = useNavigate(); 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 aspectRatio = useMemo(() => { if (!config) { return 16 / 9; } return ( config.cameras[event.camera].detect.width / config.cameras[event.camera].detect.height ); }, [config, event]); const getZoneColor = useCallback( (zoneName: string) => { const zoneColor = config?.cameras?.[event.camera]?.zones?.[zoneName]?.color; if (zoneColor) { const reversed = [...zoneColor].reverse(); return reversed; } }, [config, event], ); const getObjectColor = useCallback( (label: string) => { const objectColor = config?.model?.colormap[label]; if (objectColor) { const reversed = [...objectColor].reverse(); return reversed; } }, [config], ); const getZonePolygon = useCallback( (zoneName: string) => { if (!imgRef.current || !config) { return; } const zonePoints = config?.cameras[event.camera].zones[zoneName].coordinates; const imgElement = imgRef.current; const imgRect = imgElement.getBoundingClientRect(); return zonePoints .split(",") .map(Number.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, event], ); 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 savedPathPoints = useMemo(() => { return ( event.data.path_data?.map(([coords, timestamp]: [number[], number]) => ({ x: coords[0], y: coords[1], timestamp, lifecycle_item: undefined, })) || [] ); }, [event.data.path_data]); const eventSequencePoints = useMemo(() => { return ( eventSequence ?.filter((event) => event.data.box !== undefined) .map((event) => { const [left, top, width, height] = event.data.box!; return { x: left + width / 2, // Center x-coordinate y: top + height, // Bottom y-coordinate timestamp: event.timestamp, lifecycle_item: event, }; }) || [] ); }, [eventSequence]); // final object path with timeline points included const pathPoints = useMemo(() => { // don't display a path if we don't have any saved path points if ( savedPathPoints.length === 0 || config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config ) return []; return [...savedPathPoints, ...eventSequencePoints].sort( (a, b) => a.timestamp - b.timestamp, ); }, [savedPathPoints, eventSequencePoints, config, event]); 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`, borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`, }; setBoxStyle(style); } }, [imgRef, event, getObjectColor], ); // 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; } mainApi.scrollTo(index); setCurrent(index); }; const handleThumbnailNavigation = useCallback( (direction: "next" | "previous") => { if (!mainApi || !thumbnailApi || !eventSequence) return; const newIndex = direction === "next" ? Math.min(current + 1, eventSequence.length - 1) : Math.max(current - 1, 0); mainApi.scrollTo(newIndex); thumbnailApi.scrollTo(newIndex); setCurrent(newIndex); }, [mainApi, thumbnailApi, current, eventSequence], ); useEffect(() => { if (eventSequence && eventSequence.length > 0) { 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); }; mainApi.on("select", handleTopSelect).on("reInit", handleTopSelect); return () => { mainApi.off("select", handleTopSelect); }; // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [mainApi, thumbnailApi]); const handlePathPointClick = useCallback( (index: number) => { if (!mainApi || !thumbnailApi || !eventSequence) return; const sequenceIndex = eventSequence.findIndex( (item) => item.timestamp === pathPoints[index].timestamp, ); if (sequenceIndex !== -1) { mainApi.scrollTo(sequenceIndex); thumbnailApi.scrollTo(sequenceIndex); setCurrent(sequenceIndex); } }, [mainApi, thumbnailApi, eventSequence, pathPoints], ); if (!event.id || !eventSequence || !config || !timeIndex) { return ; } return (
{!fullscreen && (
)}
{hasError && (
No image found for this timestamp.
)}
setImgLoaded(true)} onError={() => setHasError(true)} /> {showZones && imgRef.current?.width && imgRef.current?.height && lifecycleZones?.map((zone) => (
))} {boxStyle && (
)} {imgRef.current?.width && imgRef.current?.height && pathPoints && pathPoints.length > 0 && (
)}
navigate( `/settings?page=masks%20/%20zones&camera=${event.camera}&object_mask=${eventSequence?.[current].data.box}`, ) } >
Create Object Mask
Object Lifecycle
Adjust annotation settings
Scroll to view the significant moments of this object's lifecycle.
{current + 1} of {eventSequence.length}
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
Bounding box positions will be inaccurate for autotracking cameras.
)} {showControls && ( )}
{eventSequence.map((item, index) => (
{getIconForLabel( item.data.label, "size-4 md:size-6 absolute left-0 top-0", )}
{getLifecycleItemDescription(item)}
{formatUnixTimestampToDateTime(item.timestamp, { timezone: config.ui.timezone, 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 ? ( aspectRatio * (item.data.box[2] / item.data.box[3]) ).toFixed(2) : "N/A"}

Area

{Array.isArray(item.data.box) && item.data.box.length >= 4 ? ( <>
px:{" "} {Math.round( detectArea * (item.data.box[2] * item.data.box[3]), )}
%:{" "} {( (detectArea * (item.data.box[2] * item.data.box[3])) / detectArea ).toFixed(4)}
) : ( "N/A" )}
))}
4 ? "justify-start" : "justify-center", )} > {eventSequence.map((item, index) => ( handleThumbnailClick(index)} >
{getLifecycleItemDescription(item)}
))}
handleThumbnailNavigation("previous")} /> handleThumbnailNavigation("next")} />
); } 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; } }