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 { 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 { 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"; import { useTranslation } from "react-i18next"; import { getTranslatedLabel } from "@/utils/i18n"; type ObjectLifecycleProps = { className?: string; event: Event; fullscreen?: boolean; setPane: React.Dispatch>; }; export default function ObjectLifecycle({ className, event, fullscreen = false, setPane, }: ObjectLifecycleProps) { const { t } = useTranslation(["views/explore"]); 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 [attributeBoxStyle, setAttributeBoxStyle] = 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 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[], attrBox: number[] | undefined) => { 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(",")})`, }; if (attrBox) { const attrStyle = { left: `${attrBox[0] * imgRect.width}px`, top: `${attrBox[1] * imgRect.height}px`, width: `${attrBox[2] * imgRect.width}px`, height: `${attrBox[3] * imgRect.height}px`, borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`, }; setAttributeBoxStyle(attrStyle); } else { setAttributeBoxStyle(null); } 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 // Selected lifecycle item index; -1 when viewing a path-only point const handlePathPointClick = useCallback( (index: number) => { if (!eventSequence) return; const sequenceIndex = eventSequence.findIndex( (item) => item.timestamp === pathPoints[index].timestamp, ); if (sequenceIndex !== -1) { setTimeIndex(eventSequence[sequenceIndex].timestamp); handleSetBox( eventSequence[sequenceIndex]?.data.box ?? [], eventSequence[sequenceIndex]?.data?.attribute_box, ); setLifecycleZones(eventSequence[sequenceIndex]?.data.zones); } else { // click on a normal path point, not a lifecycle point setTimeIndex(pathPoints[index].timestamp); setBoxStyle(null); setLifecycleZones([]); } }, [eventSequence, pathPoints, handleSetBox], ); const formattedStart = config ? formatUnixTimestampToDateTime(event.start_time ?? 0, { timezone: config.ui.timezone, date_format: config.ui.time_format == "24hour" ? t("time.formattedTimestampHourMinuteSecond.24hour", { ns: "common", }) : t("time.formattedTimestampHourMinuteSecond.12hour", { ns: "common", }), time_style: "medium", date_style: "medium", }) : ""; const formattedEnd = config ? formatUnixTimestampToDateTime(event.end_time ?? 0, { timezone: config.ui.timezone, date_format: config.ui.time_format == "24hour" ? t("time.formattedTimestampHourMinuteSecond.24hour", { ns: "common", }) : t("time.formattedTimestampHourMinuteSecond.12hour", { ns: "common", }), time_style: "medium", date_style: "medium", }) : ""; useEffect(() => { if (!eventSequence || eventSequence.length === 0) return; // If timeIndex hasn't been set to a non-zero value, prefer the first lifecycle timestamp if (!timeIndex) { setTimeIndex(eventSequence[0].timestamp); handleSetBox( eventSequence[0]?.data.box ?? [], eventSequence[0]?.data?.attribute_box, ); setLifecycleZones(eventSequence[0]?.data.zones); } }, [eventSequence, timeIndex, handleSetBox]); // When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear useEffect(() => { if (!eventSequence || timeIndex == null) return; const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex); if (idx !== -1) { if (imgLoaded) { handleSetBox( eventSequence[idx]?.data.box ?? [], eventSequence[idx]?.data?.attribute_box, ); } setLifecycleZones(eventSequence[idx]?.data.zones); } else { // Non-lifecycle point (e.g., saved path point) setBoxStyle(null); setLifecycleZones([]); } }, [timeIndex, imgLoaded, eventSequence, handleSetBox]); const selectedLifecycle = useMemo(() => { if (!eventSequence || eventSequence.length === 0) return undefined; const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex); return idx !== -1 ? eventSequence[idx] : eventSequence[0]; }, [eventSequence, timeIndex]); const selectedIndex = useMemo(() => { if (!eventSequence || eventSequence.length === 0) return 0; const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex); return idx === -1 ? 0 : idx; }, [eventSequence, timeIndex]); if (!config) { return ; } return (
{!fullscreen && (
)}
{hasError && (
{t("objectLifecycle.noImageFound")}
)}
setImgLoaded(true)} onError={() => setHasError(true)} /> {showZones && imgRef.current?.width && imgRef.current?.height && lifecycleZones?.map((zone) => (
))} {boxStyle && (
)} {attributeBoxStyle && (
)} {imgRef.current?.width && imgRef.current?.height && pathPoints && pathPoints.length > 0 && (
)}
navigate( `/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`, ) } >
{t("objectLifecycle.createObjectMask")}
{t("objectLifecycle.title")}
{t("objectLifecycle.adjustAnnotationSettings")}
{t("objectLifecycle.scrollViewTips")}
{t("objectLifecycle.count", { first: selectedIndex + 1, second: eventSequence?.length ?? 0, })}
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
{t("objectLifecycle.autoTrackingTips")}
)} {showControls && ( )}
{ e.stopPropagation(); setTimeIndex(event.start_time ?? 0); }} role="button" > {getIconForLabel( event.label, "size-6 text-primary dark:text-white", )}
{getTranslatedLabel(event.label)} {formattedStart ?? ""} - {formattedEnd ?? ""}
{!eventSequence ? ( ) : eventSequence.length === 0 ? (
{t("detail.noObjectDetailData", { ns: "views/events" })}
) : (
{eventSequence.map((item, idx) => { const isActive = Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5; const formattedEventTimestamp = config ? formatUnixTimestampToDateTime(item.timestamp ?? 0, { timezone: config.ui.timezone, date_format: config.ui.time_format == "24hour" ? t( "time.formattedTimestampHourMinuteSecond.24hour", { ns: "common" }, ) : t( "time.formattedTimestampHourMinuteSecond.12hour", { ns: "common" }, ), time_style: "medium", date_style: "medium", }) : ""; const ratio = Array.isArray(item.data.box) && item.data.box.length >= 4 ? ( aspectRatio * (item.data.box[2] / item.data.box[3]) ).toFixed(2) : "N/A"; const areaPx = Array.isArray(item.data.box) && item.data.box.length >= 4 ? Math.round( (config.cameras[event.camera]?.detect?.width ?? 0) * (config.cameras[event.camera]?.detect?.height ?? 0) * (item.data.box[2] * item.data.box[3]), ) : undefined; const areaPct = Array.isArray(item.data.box) && item.data.box.length >= 4 ? (item.data.box[2] * item.data.box[3]).toFixed(4) : undefined; return (
{ setTimeIndex(item.timestamp ?? 0); handleSetBox( item.data.box ?? [], item.data.attribute_box, ); setLifecycleZones(item.data.zones); setSelectedZone(""); }} className={cn( "flex cursor-pointer flex-col gap-1 rounded-md p-2 text-sm text-primary-variant", isActive ? "bg-secondary-highlight font-semibold text-primary outline-[1.5px] -outline-offset-[1.1px] outline-primary/40 dark:font-normal" : "duration-500", )} >
{getLifecycleItemDescription(item)}
{formattedEventTimestamp}
{t( "objectLifecycle.lifecycleItemDesc.header.ratio", )} {ratio}
{t( "objectLifecycle.lifecycleItemDesc.header.area", )} {areaPx !== undefined && areaPct !== undefined ? ( px: {areaPx} ยท %: {areaPct} ) : ( N/A )}
{item.class_type === "entered_zone" && (
{t( "objectLifecycle.lifecycleItemDesc.header.zones", )}
{item.data.zones.map((zone, zidx) => (
{ e.stopPropagation(); setSelectedZone(zone); }} >
{zone.replaceAll("_", " ")}
))}
)}
); })}
)}
); } 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; } }