diff --git a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx index d74909e86..665a04553 100644 --- a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx +++ b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx @@ -137,42 +137,35 @@ export default function AnnotationOffsetSlider({ className }: Props) { -
-
- - trackingDetails.annotationSettings.offset.millisecondsToOffset - - - - - - - {t("trackingDetails.annotationSettings.offset.tips")} - - -
-
- + + + {t("trackingDetails.annotationSettings.offset.tips")} + + +
+
+ + {isAdmin && ( + - {isAdmin && ( - - )} -
+ )}
); diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index ef9cd6364..baa665f50 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TrackingDetailsSequence } from "@/types/timeline"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { useDetailStream } from "@/context/detail-stream-context"; @@ -33,6 +33,7 @@ import { MdAutoAwesome } from "react-icons/md"; import { isPWA } from "@/utils/isPWA"; import { isInIframe } from "@/utils/isIFrame"; import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip"; +import { Separator } from "../ui/separator"; type DetailStreamProps = { reviewItems?: ReviewSegment[]; @@ -49,7 +50,8 @@ export default function DetailStream({ }: DetailStreamProps) { const { data: config } = useSWR("config"); const { t } = useTranslation("views/events"); - const { annotationOffset } = useDetailStream(); + const { annotationOffset, selectedObjectIds, setSelectedObjectIds } = + useDetailStream(); const scrollRef = useRef(null); const [activeReviewId, setActiveReviewId] = useState( @@ -67,9 +69,69 @@ export default function DetailStream({ true, ); - const onSeekCheckPlaying = (timestamp: number) => { - onSeek(timestamp, isPlaying); - }; + // When the settings panel opens, pin to the nearest review with detections + // so the user can visually align the bounding box using the offset slider + const pinnedDetectTimestampRef = useRef(null); + const wasControlsExpandedRef = useRef(false); + const selectedBeforeExpandRef = useRef([]); + + const onSeekCheckPlaying = useCallback( + (timestamp: number) => { + onSeek(timestamp, isPlaying); + }, + [onSeek, isPlaying], + ); + + useEffect(() => { + if (controlsExpanded && !wasControlsExpandedRef.current) { + selectedBeforeExpandRef.current = selectedObjectIds; + + const items = (reviewItems ?? []).filter( + (r) => r.data?.detections?.length > 0, + ); + if (items.length > 0) { + // Pick the nearest review to current effective time + let nearest = items[0]; + let minDiff = Math.abs(effectiveTime - nearest.start_time); + for (const r of items) { + const diff = Math.abs(effectiveTime - r.start_time); + if (diff < minDiff) { + nearest = r; + minDiff = diff; + } + } + + const nearestId = `review-${nearest.id ?? nearest.start_time ?? Math.floor(nearest.start_time ?? 0)}`; + setActiveReviewId(nearestId); + + const detectionId = nearest.data.detections[0]; + setSelectedObjectIds([detectionId]); + + // Use the detection's actual start timestamp (parsed from its ID) + // rather than review.start_time, which can be >10ms away from any + // lifecycle event and would fail the bounding-box TOLERANCE check. + const detectTimestamp = parseFloat(detectionId); + pinnedDetectTimestampRef.current = detectTimestamp; + const recordTime = detectTimestamp + annotationOffset / 1000; + onSeek(recordTime, false); + } + } + if (!controlsExpanded && wasControlsExpandedRef.current) { + pinnedDetectTimestampRef.current = null; + setSelectedObjectIds(selectedBeforeExpandRef.current); + } + wasControlsExpandedRef.current = controlsExpanded; + // Only trigger on expand/collapse transition + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controlsExpanded]); + + // Re-seek on annotation offset change while settings panel is open + useEffect(() => { + const pinned = pinnedDetectTimestampRef.current; + if (!controlsExpanded || pinned == null) return; + const recordTime = pinned + annotationOffset / 1000; + onSeek(recordTime, false); + }, [controlsExpanded, annotationOffset, onSeek]); // Ensure we initialize the active review when reviewItems first arrive. // This helps when the component mounts while the video is already @@ -214,6 +276,12 @@ export default function DetailStream({ />
+ {controlsExpanded && ( +
setControlsExpanded(false)} + /> + )}
{controlsExpanded && ( -
+
+