Refactor Tracked Object Details dialog (#20748)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* detail stream settings

* remove old review detail dialog

* change layout

* use detail stream in tracking details

* reusable tabs component

* pass in tabs for desktop

* fix object selection and time updating

* i18n

* aspect fixes

* include tolerance for displaying of path and zone

some browsers (firefox and probably brave) intentionally reduce precision of seeking with currentTime for privacy reasons

* detail stream seeking fixes

* tracking details seeking fixes

* layout tweaks

* add download button back for now

* remove

* remove

* snapshot is now default tab
This commit is contained in:
Josh Hawkins 2025-11-01 09:19:30 -05:00 committed by GitHub
parent 9937a7cc3d
commit 36fb27ef56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 766 additions and 1387 deletions

View File

@ -33,6 +33,7 @@
"type": { "type": {
"details": "details", "details": "details",
"snapshot": "snapshot", "snapshot": "snapshot",
"thumbnail": "thumbnail",
"video": "video", "video": "video",
"object_lifecycle": "object lifecycle" "object_lifecycle": "object lifecycle"
}, },
@ -41,7 +42,7 @@
"noImageFound": "No image found for this timestamp.", "noImageFound": "No image found for this timestamp.",
"createObjectMask": "Create Object Mask", "createObjectMask": "Create Object Mask",
"adjustAnnotationSettings": "Adjust annotation settings", "adjustAnnotationSettings": "Adjust annotation settings",
"scrollViewTips": "Scroll to view the significant moments of this object's lifecycle.", "scrollViewTips": "Click to view the significant moments of this object's lifecycle.",
"autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.", "autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
"count": "{{first}} of {{second}}", "count": "{{first}} of {{second}}",
"trackedPoint": "Tracked Point", "trackedPoint": "Tracked Point",

View File

@ -13,6 +13,9 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
const TOLERANCE = 0.01;
type ObjectTrackOverlayProps = { type ObjectTrackOverlayProps = {
camera: string; camera: string;
showBoundingBoxes?: boolean; showBoundingBoxes?: boolean;
@ -166,41 +169,45 @@ export default function ObjectTrackOverlay({
}) || []; }) || [];
// show full path once current time has reached the object's start time // show full path once current time has reached the object's start time
const combinedPoints = [...savedPathPoints, ...eventSequencePoints] // event.start_time is in DETECT stream time, so convert it to record stream time for comparison
.sort((a, b) => a.timestamp - b.timestamp) const eventStartTimeRecord =
.filter( (eventData?.start_time ?? 0) + annotationOffset / 1000;
(point) =>
currentTime >= (eventData?.start_time ?? 0) && const allPoints = [...savedPathPoints, ...eventSequencePoints].sort(
point.timestamp >= (eventData?.start_time ?? 0) && (a, b) => a.timestamp - b.timestamp,
point.timestamp <= (eventData?.end_time ?? Infinity), );
); const combinedPoints = allPoints.filter(
(point) =>
currentTime >= eventStartTimeRecord - TOLERANCE &&
point.timestamp <= effectiveCurrentTime + TOLERANCE,
);
// Get color for this object // Get color for this object
const label = eventData?.label || "unknown"; const label = eventData?.label || "unknown";
const color = getObjectColor(label, objectId); const color = getObjectColor(label, objectId);
// Get current zones // zones (with tolerance for browsers with seek precision by-design issues)
const currentZones = const currentZones =
timelineData timelineData
?.filter( ?.filter(
(event: TrackingDetailsSequence) => (event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime, event.timestamp <= effectiveCurrentTime + TOLERANCE,
) )
.sort( .sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) => (a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp, b.timestamp - a.timestamp,
)[0]?.data?.zones || []; )[0]?.data?.zones || [];
// Get current bounding box // bounding box (with tolerance for browsers with seek precision by-design issues)
const currentBox = timelineData const boxCandidates = timelineData?.filter(
?.filter( (event: TrackingDetailsSequence) =>
(event: TrackingDetailsSequence) => event.timestamp <= effectiveCurrentTime + TOLERANCE &&
event.timestamp <= effectiveCurrentTime && event.data.box, event.data.box,
) );
.sort( const currentBox = boxCandidates?.sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) => (a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp, b.timestamp - a.timestamp,
)[0]?.data?.box; )[0]?.data?.box;
return { return {
objectId, objectId,
@ -221,6 +228,7 @@ export default function ObjectTrackOverlay({
getObjectColor, getObjectColor,
config, config,
camera, camera,
annotationOffset,
]); ]);
// Collect all zones across all objects // Collect all zones across all objects
@ -274,9 +282,10 @@ export default function ObjectTrackOverlay({
const handlePointClick = useCallback( const handlePointClick = useCallback(
(timestamp: number) => { (timestamp: number) => {
onSeekToTime?.(timestamp, false); // Convert detect stream timestamp to record stream timestamp before seeking
onSeekToTime?.(timestamp + annotationOffset / 1000, false);
}, },
[onSeekToTime], [onSeekToTime, annotationOffset],
); );
const zonePolygons = useMemo(() => { const zonePolygons = useMemo(() => {

View File

@ -91,8 +91,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
<div className="w-full flex-1 landscape:flex"> <div className="w-full flex-1 landscape:flex">
<Slider <Slider
value={[annotationOffset]} value={[annotationOffset]}
min={-1500} min={-2500}
max={1500} max={2500}
step={50} step={50}
onValueChange={handleChange} onValueChange={handleChange}
/> />

View File

@ -1,577 +0,0 @@
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "../../ui/sheet";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil";
import { useApiHost } from "@/api";
import {
ReviewDetailPaneType,
ReviewSegment,
ThreatLevel,
} from "@/types/review";
import { Event } from "@/types/event";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
import TrackingDetails from "./TrackingDetails";
import Chip from "@/components/indicators/Chip";
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { FaArrowsRotate } from "react-icons/fa6";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { baseUrl } from "@/api/baseUrl";
import { shareOrCopy } from "@/utils/browserUtil";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { LuSearch } from "react-icons/lu";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type ReviewDetailDialogProps = {
review?: ReviewSegment;
setReview: (review: ReviewSegment | undefined) => void;
};
export default function ReviewDetailDialog({
review,
setReview,
}: ReviewDetailDialogProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const navigate = useNavigate();
// upload
const [upload, setUpload] = useState<Event>();
// data
const { data: events } = useSWR<Event[]>(
review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
);
const aiAnalysis = useMemo(() => review?.data?.metadata, [review]);
const aiThreatLevel = useMemo(() => {
if (
!aiAnalysis ||
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
) {
return "None";
}
let concerns = "";
switch (aiAnalysis.potential_threat_level) {
case ThreatLevel.SUSPICIOUS:
concerns = `${t("suspiciousActivity", { ns: "views/events" })}\n`;
break;
case ThreatLevel.DANGER:
concerns = `${t("threateningActivity", { ns: "views/events" })}\n`;
break;
}
(aiAnalysis.other_concerns ?? []).forEach((c) => {
concerns += `${c}\n`;
});
return concerns || "None";
}, [aiAnalysis, t]);
const hasMismatch = useMemo(() => {
if (!review || !events) {
return false;
}
return events.length != review?.data.detections.length;
}, [review, events]);
const missingObjects = useMemo(() => {
if (!review || !events) {
return [];
}
const detectedIds = review.data.detections;
const missing = Array.from(
new Set(
events
.filter((event) => !detectedIds.includes(event.id))
.map((event) => event.label),
),
);
return missing;
}, [review, events]);
const formattedDate = useFormattedTimestamp(
review?.start_time ?? 0,
config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
ns: "common",
})
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
ns: "common",
}),
config?.ui.timezone,
);
// content
const [selectedEvent, setSelectedEvent] = useState<Event>();
const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
// dialog and mobile page
const [isOpen, setIsOpen] = useState(review != undefined);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (!open) {
// short timeout to allow the mobile page animation
// to complete before updating the state
setTimeout(() => {
setReview(undefined);
setSelectedEvent(undefined);
setPane("overview");
}, 300);
}
},
[setReview, setIsOpen],
);
useEffect(() => {
setIsOpen(review != undefined);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [review]);
// keyboard listener
useKeyboardListener(["Esc"], (key, modifiers) => {
if (key == "Esc" && modifiers.down && !modifiers.repeat) {
setIsOpen(false);
}
return true;
});
const Overlay = isDesktop ? Sheet : MobilePage;
const Content = isDesktop ? SheetContent : MobilePageContent;
const Header = isDesktop ? SheetHeader : MobilePageHeader;
const Title = isDesktop ? SheetTitle : MobilePageTitle;
const Description = isDesktop ? SheetDescription : MobilePageDescription;
if (!review) {
return;
}
return (
<>
<Overlay
open={isOpen ?? false}
onOpenChange={handleOpenChange}
enableHistoryBack={true}
>
<FrigatePlusDialog
upload={upload}
onClose={() => setUpload(undefined)}
onEventUploaded={() => {
if (upload) {
upload.plus_id = "new_upload";
}
}}
/>
<Content
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop && pane == "overview"
? "sm:max-w-xl"
: "pt-2 sm:max-w-4xl",
isMobile && "px-4",
)}
>
<span tabIndex={0} className="sr-only" />
{pane == "overview" && (
<Header className="justify-center">
<Title>{t("details.item.title")}</Title>
<Description className="sr-only">
{t("details.item.desc")}
</Description>
<div
className={cn(
"absolute flex gap-2 lg:flex-col",
isDesktop && "right-1 top-8",
isMobile && "right-0 top-3",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("details.item.button.share")}
size="sm"
onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`)
}
>
<FaShareAlt className="size-4 text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.share")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<DownloadVideoButton
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
camera={review.camera}
startTime={review.start_time}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</Header>
)}
{pane == "overview" && (
<div className="flex flex-col gap-5 md:mt-3">
{aiAnalysis != undefined && (
<div
className={cn(
"flex h-full w-full flex-col gap-2 rounded-md bg-card p-2",
isDesktop && "m-2 w-[90%]",
)}
>
{t("aiAnalysis.title")}
<div className="text-sm text-primary/40">
{t("details.description.label")}
</div>
<div className="text-sm">{aiAnalysis.scene}</div>
<div className="text-sm text-primary/40">
{t("details.score.label")}
</div>
<div className="text-sm">{aiAnalysis.confidence * 100}%</div>
<div className="text-sm text-primary/40">
{t("concerns.label")}
</div>
<div className="text-sm">{aiThreatLevel}</div>
</div>
)}
<div className="flex w-full flex-row">
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.camera")}
</div>
<div className="text-sm smart-capitalize">
<CameraNameLabel camera={review.camera} />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.timestamp")}
</div>
<div className="text-sm">{formattedDate}</div>
</div>
</div>
<div className="flex w-full flex-col items-center gap-2">
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
<div className="text-sm text-primary/40">
{t("details.objects")}
</div>
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm smart-capitalize">
{events?.map((event) => {
return (
<div
key={event.id}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{getIconForLabel(
event.label,
"size-3 text-primary",
)}
{event.sub_label ??
event.label.replaceAll("_", " ")}{" "}
({Math.round(event.data.top_score * 100)}%)
<Tooltip>
<TooltipTrigger>
<div
className="cursor-pointer"
onClick={() => {
navigate(`/explore?event_id=${event.id}`);
}}
>
<LuSearch className="size-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.viewInExplore")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
);
})}
</div>
</div>
{review.data.zones.length > 0 && (
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.zones")}
</div>
<div className="flex flex-col items-start gap-2 text-sm smart-capitalize">
{review.data.zones.map((zone) => {
return (
<div
key={zone}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{zone.replaceAll("_", " ")}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{hasMismatch && (
<div className="p-4 text-center text-sm">
{(() => {
const detectedCount = Math.abs(
(events?.length ?? 0) -
(review?.data.detections.length ?? 0),
);
return t("details.item.tips.mismatch", {
count: detectedCount,
});
})()}
{missingObjects.length > 0 && (
<div className="mt-2">
<Trans
ns="views/explore"
values={{
objects: missingObjects
.map((x) => getTranslatedLabel(x))
.join(", "),
}}
>
details.item.tips.hasMissingObjects
</Trans>
</div>
)}
</div>
)}
<div className="relative flex size-full flex-col gap-2">
{events?.map((event) => (
<EventItem
key={event.id}
event={event}
setPane={setPane}
setSelectedEvent={setSelectedEvent}
setUpload={setUpload}
/>
))}
</div>
</div>
)}
{pane == "details" && selectedEvent && (
<div className="mt-0 flex size-full flex-col gap-2">
<TrackingDetails event={selectedEvent} setPane={setPane} />
</div>
)}
</Content>
</Overlay>
</>
);
}
type EventItemProps = {
event: Event;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
setSelectedEvent: React.Dispatch<React.SetStateAction<Event | undefined>>;
setUpload?: React.Dispatch<React.SetStateAction<Event | undefined>>;
};
function EventItem({
event,
setPane,
setSelectedEvent,
setUpload,
}: EventItemProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const apiHost = useApiHost();
const imgRef = useRef(null);
const [hovered, setHovered] = useState(isMobile);
const navigate = useNavigate();
return (
<>
<div
className={cn(
"relative mr-auto",
!event.has_snapshot && "flex flex-row items-center justify-center",
)}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
key={event.id}
>
{event.has_snapshot && (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
<img
ref={imgRef}
className={cn(
"select-none rounded-lg object-contain transition-opacity",
)}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
/>
{hovered && (
<div>
<div
className={cn("absolute right-1 top-1 flex items-center gap-2")}
>
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
>
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
</TooltipTrigger>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</Tooltip>
{event.has_snapshot &&
event.plus_id == undefined &&
event.data.type == "object" &&
config?.plus.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setUpload?.(event);
}}
>
<FrigatePlusIcon className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.submitToPlus.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_clip && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setPane("details");
setSelectedEvent(event);
}}
>
<FaArrowsRotate className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.viewTrackingDetails.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_snapshot && config?.semantic_search.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
navigate(
`/explore?search_type=similarity&event_id=${event.id}`,
);
}}
>
<FaImages className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.findSimilar.label")}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
</div>
</>
);
}

View File

@ -31,10 +31,9 @@ import {
FaDownload, FaDownload,
FaHistory, FaHistory,
FaImage, FaImage,
FaRegListAlt,
FaVideo,
} from "react-icons/fa"; } from "react-icons/fa";
import TrackingDetails from "./TrackingDetails"; import { TrackingDetails } from "./TrackingDetails";
import { DetailStreamProvider } from "@/context/detail-stream-context";
import { import {
MobilePage, MobilePage,
MobilePageContent, MobilePageContent,
@ -80,13 +79,9 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { CgTranscript } from "react-icons/cg"; import { CgTranscript } from "react-icons/cg";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { PiPath } from "react-icons/pi"; import { PiPath } from "react-icons/pi";
import Heading from "@/components/ui/heading";
const SEARCH_TABS = [ const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
"details",
"snapshot",
"video",
"tracking_details",
] as const;
export type SearchTab = (typeof SEARCH_TABS)[number]; export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = { type SearchDetailDialogProps = {
@ -109,6 +104,7 @@ export default function SearchDetailDialog({
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const apiHost = useApiHost();
// tabs // tabs
@ -149,16 +145,6 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS]; const views = [...SEARCH_TABS];
if (!search.has_snapshot) {
const index = views.indexOf("snapshot");
views.splice(index, 1);
}
if (!search.has_clip) {
const index = views.indexOf("video");
views.splice(index, 1);
}
if (search.data.type != "object" || !search.has_clip) { if (search.data.type != "object" || !search.has_clip) {
const index = views.indexOf("tracking_details"); const index = views.indexOf("tracking_details");
views.splice(index, 1); views.splice(index, 1);
@ -173,10 +159,50 @@ export default function SearchDetailDialog({
} }
if (!searchTabs.includes(pageToggle)) { if (!searchTabs.includes(pageToggle)) {
setSearchPage("details"); setSearchPage("snapshot");
} }
}, [pageToggle, searchTabs, setSearchPage]); }, [pageToggle, searchTabs, setSearchPage]);
// Tabs component for reuse
const tabsComponent = (
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SearchTab) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
{item == "snapshot" && <FaImage className="size-4" />}
{item == "tracking_details" && <PiPath className="size-4" />}
<div className="smart-capitalize">
{item === "snapshot"
? search?.has_snapshot
? t("type.snapshot")
: t("type.thumbnail")
: t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
);
if (!search) { if (!search) {
return; return;
} }
@ -190,92 +216,188 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription; const Description = isDesktop ? DialogDescription : MobilePageDescription;
return ( return (
<Overlay <DetailStreamProvider
open={isOpen} isDetailMode={true}
onOpenChange={handleOpenChange} currentTime={(search as unknown as Event)?.start_time ?? 0}
enableHistoryBack={true} camera={(search as unknown as Event)?.camera ?? ""}
initialSelectedObjectIds={[(search as unknown as Event).id as string]}
> >
<Content <Overlay
className={cn( open={isOpen}
"scrollbar-container overflow-y-auto", onOpenChange={handleOpenChange}
isDesktop && enableHistoryBack={true}
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isMobile && "px-4",
)}
> >
<Header> <Content
<Title>{t("trackedObjectDetails")}</Title> className={cn(
<Description className="sr-only"> "scrollbar-container overflow-y-auto",
{t("trackedObjectDetails")} isDesktop &&
</Description> "max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
</Header> isDesktop &&
<ScrollArea page == "tracking_details" &&
className={cn("w-full whitespace-nowrap", isMobile && "my-2")} "lg:max-w-[75%] xl:max-w-[80%]",
isMobile && "px-4",
)}
> >
<div className="flex flex-row"> <Header>
<ToggleGroup <Title>{t("trackedObjectDetails")}</Title>
className="*:rounded-md *:px-3 *:py-4" <Description className="sr-only">
type="single" {t("trackedObjectDetails")}
size="sm" </Description>
value={pageToggle} </Header>
onValueChange={(value: SearchTab) => { {isDesktop ? (
if (value) { page === "tracking_details" ? (
setPageToggle(value); <TrackingDetails
} className="size-full"
}} event={search as unknown as Event}
> tabs={tabsComponent}
{Object.values(searchTabs).map((item) => ( />
<ToggleGroupItem ) : (
key={item} <div className="flex h-full gap-4 overflow-hidden">
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`} <div
value={item} className={cn(
data-nav-item={item} "scrollbar-container flex-[3] overflow-y-hidden",
aria-label={`Select ${item}`} page === "snapshot" && !search.has_snapshot && "flex-[2]",
)}
> >
{item == "details" && <FaRegListAlt className="size-4" />} {page === "snapshot" && search.has_snapshot && (
{item == "snapshot" && <FaImage className="size-4" />} <ObjectSnapshotTab
{item == "video" && <FaVideo className="size-4" />} search={
{item == "tracking_details" && <PiPath className="size-4" />} {
<div className="smart-capitalize">{t(`type.${item}`)}</div> ...search,
</ToggleGroupItem> plus_id: config?.plus?.enabled
))} ? search.plus_id
</ToggleGroup> : "not_enabled",
<ScrollBar orientation="horizontal" className="h-0" /> } as unknown as Event
</div> }
</ScrollArea> onEventUploaded={() => {
{page == "details" && ( search.plus_id = "new_upload";
<ObjectDetailsTab }}
search={search} />
config={config} )}
setSearch={setSearch} {page === "snapshot" && !search.has_snapshot && (
setSimilarity={setSimilarity} <img
setInputFocused={setInputFocused} className="size-full select-none rounded-lg object-contain transition-opacity"
/> style={
)} isIOS
{page == "snapshot" && ( ? {
<ObjectSnapshotTab WebkitUserSelect: "none",
search={ WebkitTouchCallout: "none",
{ }
...search, : undefined
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled", }
} as unknown as Event draggable={false}
} src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
onEventUploaded={() => { />
search.plus_id = "new_upload"; )}
}} </div>
/> <div className="flex flex-[2] flex-col gap-4 overflow-hidden">
)} {tabsComponent}
{page == "video" && <VideoTab search={search} />} <div className="scrollbar-container flex-1 overflow-y-auto">
{page == "tracking_details" && ( {page == "snapshot" && (
<TrackingDetails <ObjectDetailsTab
className="w-full overflow-x-hidden" search={search}
event={search as unknown as Event} config={config}
fullscreen={true} setSearch={setSearch}
setPane={() => {}} setSimilarity={setSimilarity}
/> setInputFocused={setInputFocused}
)} showThumbnail={false}
</Content> />
</Overlay> )}
</div>
</div>
</div>
)
) : (
<>
<ScrollArea
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
>
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SearchTab) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
{item == "snapshot" && <FaImage className="size-4" />}
{item == "tracking_details" && (
<PiPath className="size-4" />
)}
<div className="smart-capitalize">
{t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
{page == "snapshot" && (
<>
{search.has_snapshot && (
<ObjectSnapshotTab
search={
{
...search,
plus_id: config?.plus?.enabled
? search.plus_id
: "not_enabled",
} as unknown as Event
}
onEventUploaded={() => {
search.plus_id = "new_upload";
}}
/>
)}
{page == "snapshot" && !search.has_snapshot && (
<img
className="w-full select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
<Heading as="h3" className="mt-2 smart-capitalize">
{t("type.details")}
</Heading>
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
</>
)}
{page == "tracking_details" && (
<TrackingDetails event={search as unknown as Event} />
)}
</>
)}
</Content>
</Overlay>
</DetailStreamProvider>
); );
} }
@ -285,6 +407,7 @@ type ObjectDetailsTabProps = {
setSearch: (search: SearchResult | undefined) => void; setSearch: (search: SearchResult | undefined) => void;
setSimilarity?: () => void; setSimilarity?: () => void;
setInputFocused: React.Dispatch<React.SetStateAction<boolean>>; setInputFocused: React.Dispatch<React.SetStateAction<boolean>>;
showThumbnail?: boolean;
}; };
function ObjectDetailsTab({ function ObjectDetailsTab({
search, search,
@ -292,6 +415,7 @@ function ObjectDetailsTab({
setSearch, setSearch,
setSimilarity, setSimilarity,
setInputFocused, setInputFocused,
showThumbnail = true,
}: ObjectDetailsTabProps) { }: ObjectDetailsTabProps) {
const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
@ -873,66 +997,71 @@ function ObjectDetailsTab({
<div className="text-sm">{formattedDate}</div> <div className="text-sm">{formattedDate}</div>
</div> </div>
</div> </div>
<div className="flex w-full flex-col gap-2 pl-6"> {showThumbnail && (
<img <div className="flex w-full flex-col gap-2 pl-6">
className="aspect-video select-none rounded-lg object-contain transition-opacity" <img
style={ className="aspect-video select-none rounded-lg object-contain transition-opacity"
isIOS style={
? { isIOS
WebkitUserSelect: "none", ? {
WebkitTouchCallout: "none", WebkitUserSelect: "none",
} WebkitTouchCallout: "none",
: undefined }
} : undefined
draggable={false} }
src={`${apiHost}api/events/${search.id}/thumbnail.webp`} draggable={false}
/> src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
<div />
className={cn("flex w-full flex-row gap-2", isMobile && "flex-col")} <div
> className={cn(
{config?.semantic_search.enabled && "flex w-full flex-row gap-2",
setSimilarity != undefined && isMobile && "flex-col",
search.data.type == "object" && ( )}
<Button >
{config?.semantic_search.enabled &&
setSimilarity != undefined &&
search.data.type == "object" && (
<Button
className="w-full"
aria-label={t("itemMenu.findSimilar.aria")}
onClick={() => {
setSearch(undefined);
setSimilarity();
}}
>
<div className="flex gap-1">
<LuSearch />
{t("itemMenu.findSimilar.label")}
</div>
</Button>
)}
{hasFace && (
<FaceSelectionDialog
className="w-full" className="w-full"
aria-label={t("itemMenu.findSimilar.aria")} faceNames={faceNames}
onClick={() => { onTrainAttempt={onTrainFace}
setSearch(undefined);
setSimilarity();
}}
> >
<div className="flex gap-1"> <Button className="w-full">
<LuSearch /> <div className="flex gap-1">
{t("itemMenu.findSimilar.label")} <TbFaceId />
</div> {t("trainFace", { ns: "views/faceLibrary" })}
</Button> </div>
)} </Button>
{hasFace && ( </FaceSelectionDialog>
<FaceSelectionDialog
className="w-full"
faceNames={faceNames}
onTrainAttempt={onTrainFace}
>
<Button className="w-full">
<div className="flex gap-1">
<TbFaceId />
{t("trainFace", { ns: "views/faceLibrary" })}
</div>
</Button>
</FaceSelectionDialog>
)}
{config?.cameras[search?.camera].audio_transcription.enabled &&
search?.label == "speech" &&
search?.end_time && (
<Button className="w-full" onClick={onTranscribe}>
<div className="flex gap-1">
<CgTranscript />
{t("itemMenu.audioTranscription.label")}
</div>
</Button>
)} )}
{config?.cameras[search?.camera].audio_transcription.enabled &&
search?.label == "speech" &&
search?.end_time && (
<Button className="w-full" onClick={onTranscribe}>
<div className="flex gap-1">
<CgTranscript />
{t("itemMenu.audioTranscription.label")}
</div>
</Button>
)}
</div>
</div> </div>
</div> )}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{config?.cameras[search.camera].objects.genai.enabled && {config?.cameras[search.camera].objects.genai.enabled &&
@ -1167,7 +1296,7 @@ export function ObjectSnapshotTab({
search.label != "on_demand" && ( search.label != "on_demand" && (
<Card className="p-1 text-sm md:p-2"> <Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row"> <CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className={cn("flex flex-col space-y-3")}> <div className={cn("flex max-w-sm flex-col space-y-3")}>
<div className={"text-lg leading-none"}> <div className={"text-lg leading-none"}>
{t("explore.plus.submitToPlus.label")} {t("explore.plus.submitToPlus.label")}
</div> </div>
@ -1176,7 +1305,7 @@ export function ObjectSnapshotTab({
</div> </div>
</div> </div>
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end"> <div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:flex-1 md:justify-end">
{state == "reviewing" && ( {state == "reviewing" && (
<> <>
<div> <div>

File diff suppressed because it is too large Load Diff

View File

@ -57,7 +57,7 @@ export default function DetailStream({
elementRef: scrollRef, elementRef: scrollRef,
}); });
const effectiveTime = currentTime + annotationOffset / 1000; const effectiveTime = currentTime - annotationOffset / 1000;
const [upload, setUpload] = useState<Event | undefined>(undefined); const [upload, setUpload] = useState<Event | undefined>(undefined);
const [controlsExpanded, setControlsExpanded] = useState(false); const [controlsExpanded, setControlsExpanded] = useState(false);
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence( const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
@ -213,6 +213,7 @@ export default function DetailStream({
config={config} config={config}
onSeek={onSeekCheckPlaying} onSeek={onSeekCheckPlaying}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isActive={activeReviewId == id} isActive={activeReviewId == id}
onActivate={() => setActiveReviewId(id)} onActivate={() => setActiveReviewId(id)}
onOpenUpload={(e) => setUpload(e)} onOpenUpload={(e) => setUpload(e)}
@ -278,6 +279,7 @@ type ReviewGroupProps = {
onActivate?: () => void; onActivate?: () => void;
onOpenUpload?: (e: Event) => void; onOpenUpload?: (e: Event) => void;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
alwaysExpandActive?: boolean; alwaysExpandActive?: boolean;
}; };
@ -290,11 +292,14 @@ function ReviewGroup({
onActivate, onActivate,
onOpenUpload, onOpenUpload,
effectiveTime, effectiveTime,
annotationOffset,
alwaysExpandActive = false, alwaysExpandActive = false,
}: ReviewGroupProps) { }: ReviewGroupProps) {
const { t } = useTranslation("views/events"); const { t } = useTranslation("views/events");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const start = review.start_time ?? 0; const start = review.start_time ?? 0;
// review.start_time is in detect time, convert to record for seeking
const startRecord = start + annotationOffset / 1000;
// Auto-expand when this review becomes active and alwaysExpandActive is enabled // Auto-expand when this review becomes active and alwaysExpandActive is enabled
useEffect(() => { useEffect(() => {
@ -371,7 +376,7 @@ function ReviewGroup({
)} )}
onClick={() => { onClick={() => {
onActivate?.(); onActivate?.();
onSeek(start); onSeek(startRecord);
}} }}
> >
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start"> <div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
@ -450,6 +455,7 @@ function ReviewGroup({
key={event.id} key={event.id}
event={event} event={event}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
onSeek={onSeek} onSeek={onSeek}
onOpenUpload={onOpenUpload} onOpenUpload={onOpenUpload}
/> />
@ -483,12 +489,14 @@ function ReviewGroup({
type EventListProps = { type EventListProps = {
event: Event; event: Event;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
onSeek: (ts: number, play?: boolean) => void; onSeek: (ts: number, play?: boolean) => void;
onOpenUpload?: (e: Event) => void; onOpenUpload?: (e: Event) => void;
}; };
function EventList({ function EventList({
event, event,
effectiveTime, effectiveTime,
annotationOffset,
onSeek, onSeek,
onOpenUpload, onOpenUpload,
}: EventListProps) { }: EventListProps) {
@ -505,14 +513,17 @@ function EventList({
if (event) { if (event) {
setSelectedObjectIds([]); setSelectedObjectIds([]);
setSelectedObjectIds([event.id]); setSelectedObjectIds([event.id]);
onSeek(event.start_time); // event.start_time is detect time, convert to record
const recordTime = event.start_time + annotationOffset / 1000;
onSeek(recordTime);
} else { } else {
setSelectedObjectIds([]); setSelectedObjectIds([]);
} }
}; };
const handleTimelineClick = (ts: number, play?: boolean) => { const handleTimelineClick = (ts: number, play?: boolean) => {
handleObjectSelect(event); setSelectedObjectIds([]);
setSelectedObjectIds([event.id]);
onSeek(ts, play); onSeek(ts, play);
}; };
@ -554,7 +565,6 @@ function EventList({
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event); handleObjectSelect(event);
}} }}
role="button" role="button"
@ -568,7 +578,6 @@ function EventList({
className="flex flex-1 items-center gap-2" className="flex flex-1 items-center gap-2"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event); handleObjectSelect(event);
}} }}
role="button" role="button"
@ -607,6 +616,7 @@ function EventList({
eventId={event.id} eventId={event.id}
onSeek={handleTimelineClick} onSeek={handleTimelineClick}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
startTime={event.start_time} startTime={event.start_time}
endTime={event.end_time} endTime={event.end_time}
/> />
@ -621,6 +631,7 @@ type LifecycleItemProps = {
isActive?: boolean; isActive?: boolean;
onSeek?: (timestamp: number, play?: boolean) => void; onSeek?: (timestamp: number, play?: boolean) => void;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
isTimelineActive?: boolean; isTimelineActive?: boolean;
}; };
@ -629,6 +640,7 @@ function LifecycleItem({
isActive, isActive,
onSeek, onSeek,
effectiveTime, effectiveTime,
annotationOffset,
isTimelineActive = false, isTimelineActive = false,
}: LifecycleItemProps) { }: LifecycleItemProps) {
const { t } = useTranslation("views/events"); const { t } = useTranslation("views/events");
@ -682,7 +694,8 @@ function LifecycleItem({
<div <div
role="button" role="button"
onClick={() => { onClick={() => {
onSeek?.(item.timestamp, false); const recordTimestamp = item.timestamp + annotationOffset / 1000;
onSeek?.(recordTimestamp, false);
}} }}
className={cn( className={cn(
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant", "flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
@ -751,12 +764,14 @@ function ObjectTimeline({
eventId, eventId,
onSeek, onSeek,
effectiveTime, effectiveTime,
annotationOffset,
startTime, startTime,
endTime, endTime,
}: { }: {
eventId: string; eventId: string;
onSeek: (ts: number, play?: boolean) => void; onSeek: (ts: number, play?: boolean) => void;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
startTime?: number; startTime?: number;
endTime?: number; endTime?: number;
}) { }) {
@ -857,6 +872,7 @@ function ObjectTimeline({
onSeek={onSeek} onSeek={onSeek}
isActive={isActive} isActive={isActive}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isTimelineActive={isWithinEventRange} isTimelineActive={isWithinEventRange}
/> />
); );

View File

@ -22,6 +22,7 @@ interface DetailStreamProviderProps {
isDetailMode: boolean; isDetailMode: boolean;
currentTime: number; currentTime: number;
camera: string; camera: string;
initialSelectedObjectIds?: string[];
} }
export function DetailStreamProvider({ export function DetailStreamProvider({
@ -29,8 +30,11 @@ export function DetailStreamProvider({
isDetailMode, isDetailMode,
currentTime, currentTime,
camera, camera,
initialSelectedObjectIds,
}: DetailStreamProviderProps) { }: DetailStreamProviderProps) {
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>([]); const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>(
() => initialSelectedObjectIds ?? [],
);
const toggleObjectSelection = (id: string | undefined) => { const toggleObjectSelection = (id: string | undefined) => {
if (id === undefined) { if (id === undefined) {

View File

@ -893,7 +893,7 @@ function ObjectTrainGrid({
// selection // selection
const [selectedEvent, setSelectedEvent] = useState<Event>(); const [selectedEvent, setSelectedEvent] = useState<Event>();
const [dialogTab, setDialogTab] = useState<SearchTab>("details"); const [dialogTab, setDialogTab] = useState<SearchTab>("snapshot");
// handlers // handlers

View File

@ -214,7 +214,7 @@ export default function SearchView({
// detail // detail
const [searchDetail, setSearchDetail] = useState<SearchResult>(); const [searchDetail, setSearchDetail] = useState<SearchResult>();
const [page, setPage] = useState<SearchTab>("details"); const [page, setPage] = useState<SearchTab>("snapshot");
// search interaction // search interaction
@ -222,7 +222,7 @@ export default function SearchView({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback( const onSelectSearch = useCallback(
(item: SearchResult, ctrl: boolean, page: SearchTab = "details") => { (item: SearchResult, ctrl: boolean, page: SearchTab = "snapshot") => {
if (selectedObjects.length > 1 || ctrl) { if (selectedObjects.length > 1 || ctrl) {
const index = selectedObjects.indexOf(item.id); const index = selectedObjects.indexOf(item.id);