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`; } }