Refactor motion search (#23378)

* refactor motion search

* cleanup dead code and tests

* tweaks

* fix multi-day seeking

* start playback a few seconds before the change so the motion is in view
This commit is contained in:
Josh Hawkins
2026-06-01 12:08:46 -05:00
committed by GitHub
parent 47a06c8b30
commit 8073174c20
14 changed files with 1502 additions and 506 deletions
@@ -7,6 +7,7 @@ import { LuHand, LuPencil } from "react-icons/lu";
import { FrigateConfig } from "@/types/frigateConfig";
import { TimeRange } from "@/types/timeline";
import { RecordingsSummary } from "@/types/review";
import { ASPECT_PORTRAIT_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Button } from "@/components/ui/button";
@@ -44,7 +45,11 @@ import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar";
import { useApiHost } from "@/api";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
import {
useFormattedTimestamp,
use24HourTime,
useTimezone,
} from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil";
import useSWR from "swr";
import { cn } from "@/lib/utils";
@@ -69,8 +74,6 @@ type MotionSearchDialogProps = {
setThreshold: React.Dispatch<React.SetStateAction<number>>;
minArea: number;
setMinArea: React.Dispatch<React.SetStateAction<number>>;
frameSkip: number;
setFrameSkip: React.Dispatch<React.SetStateAction<number>>;
maxResults: number;
setMaxResults: React.Dispatch<React.SetStateAction<number>>;
searchRange?: TimeRange;
@@ -100,8 +103,6 @@ export default function MotionSearchDialog({
setThreshold,
minArea,
setMinArea,
frameSkip,
setFrameSkip,
maxResults,
setMaxResults,
searchRange,
@@ -117,6 +118,16 @@ export default function MotionSearchDialog({
const [imageLoaded, setImageLoaded] = useState(false);
const [panMode, setPanMode] = useState(false);
const recordingsTimezone = useTimezone(config);
const { data: recordingsSummary } = useSWR<RecordingsSummary>(
selectedCamera
? [
"recordings/summary",
{ timezone: recordingsTimezone, cameras: selectedCamera },
]
: null,
);
const cameraConfig = useMemo(() => {
if (!selectedCamera) return undefined;
return config.cameras[selectedCamera];
@@ -437,23 +448,6 @@ export default function MotionSearchDialog({
{t("settings.minAreaDesc")}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="frameSkip">{t("settings.frameSkip")}</Label>
<div className="flex items-center gap-2">
<Slider
id="frameSkip"
min={1}
max={120}
step={1}
value={[frameSkip]}
onValueChange={([value]) => setFrameSkip(value)}
/>
<span className="w-12 text-sm">{frameSkip}</span>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.frameSkipDesc")}
</p>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="parallelMode">
@@ -496,6 +490,7 @@ export default function MotionSearchDialog({
setRange={setSearchRange}
defaultRange={defaultRange}
timezone={timezone}
recordingsSummary={recordingsSummary}
/>
<Button
@@ -519,6 +514,7 @@ type SearchRangeSelectorProps = {
setRange: React.Dispatch<React.SetStateAction<TimeRange | undefined>>;
defaultRange: TimeRange;
timezone?: string;
recordingsSummary?: RecordingsSummary;
};
function SearchRangeSelector({
@@ -526,6 +522,7 @@ function SearchRangeSelector({
setRange,
defaultRange,
timezone,
recordingsSummary,
}: SearchRangeSelectorProps) {
const { t } = useTranslation(["views/motionSearch", "common"]);
const [startOpen, setStartOpen] = useState(false);
@@ -630,6 +627,7 @@ function SearchRangeSelector({
<PopoverContent className="flex flex-col items-center">
<TimezoneAwareCalendar
timezone={timezone}
recordingsSummary={recordingsSummary}
selectedDay={new Date(startTime * 1000)}
onSelect={(day) => {
if (!day) {
@@ -696,6 +694,7 @@ function SearchRangeSelector({
<PopoverContent className="flex flex-col items-center">
<TimezoneAwareCalendar
timezone={timezone}
recordingsSummary={recordingsSummary}
selectedDay={new Date(endTime * 1000)}
onSelect={(day) => {
if (!day) {
+184 -114
View File
@@ -12,10 +12,12 @@ import { ExportMode } from "@/types/filter";
import {
MotionSearchRequest,
MotionSearchStartResponse,
MotionSearchStatusResponse,
MotionSearchResult,
MotionSearchMetrics,
MotionSearchJobResults,
MotionSearchJobPayload,
} from "@/types/motionSearch";
import { useJobStatus } from "@/api/ws";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
@@ -25,6 +27,11 @@ import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
@@ -44,7 +51,7 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { MotionData, ZoomLevel } from "@/types/review";
import { MotionData, REVIEW_PADDING, ZoomLevel } from "@/types/review";
import {
ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT,
@@ -52,14 +59,17 @@ import {
RecordingSegment,
} from "@/types/record";
import { VideoResolutionType } from "@/types/live";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
import {
useFormattedTimestamp,
useFormattedRange,
use24HourTime,
} from "@/hooks/use-date-utils";
import MotionSearchROICanvas from "./MotionSearchROICanvas";
import MotionSearchDialog from "./MotionSearchDialog";
import { IoMdArrowRoundBack } from "react-icons/io";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFire } from "react-icons/fa";
import { useNavigate } from "react-router-dom";
import { LuSearch } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { LuSearch, LuChevronRight, LuX } from "react-icons/lu";
type MotionSearchViewProps = {
config: FrigateConfig;
@@ -118,10 +128,11 @@ export default function MotionSearchView({
"actions" | "calendar"
>("actions");
// Recordings summary for calendar defer until dialog is closed
// so the preview image in the dialog loads without competing requests
// Recordings summary for the calendars (main view + search dialog). Fetched
// whenever a camera is selected so it is cached and ready by the time the
// dialog's date pickers render, regardless of open/closed state.
const { data: recordingsSummary } = useSWR<RecordingsSummary>(
selectedCamera && !isSearchDialogOpen
selectedCamera
? [
"recordings/summary",
{
@@ -146,17 +157,18 @@ export default function MotionSearchView({
const [parallelMode, setParallelMode] = useState(false);
const [threshold, setThreshold] = useState(30);
const [minArea, setMinArea] = useState(20);
const [frameSkip, setFrameSkip] = useState(30);
const [maxResults, setMaxResults] = useState(25);
// Job state
const [jobId, setJobId] = useState<string | null>(null);
const [jobCamera, setJobCamera] = useState<string | null>(null);
// Job polling with SWR
const { data: jobStatus } = useSWR<MotionSearchStatusResponse>(
jobId && jobCamera ? [`${jobCamera}/search/motion/${jobId}`] : null,
{ refreshInterval: 1000 },
const { payload: jobPayload } =
useJobStatus<MotionSearchJobResults>("motion_search");
const jobStatus = jobPayload as MotionSearchJobPayload | null;
const formattedScanningTimestamp = useFormattedTimestamp(
jobStatus?.scanning_timestamp ?? 0,
resultTimestampFormat,
timezone,
);
// Search state
@@ -170,6 +182,15 @@ export default function MotionSearchView({
undefined,
);
const [pendingSeekTime, setPendingSeekTime] = useState<number | null>(null);
const pendingSeekTimeRef = useRef<number | null>(null);
// Formatted search window shown above the results (same date+time convention).
const formattedSearchRange = useFormattedRange(
searchRange?.after ?? 0,
searchRange?.before ?? 0,
resultTimestampFormat,
timezone,
);
// Export state
const [exportMode, setExportMode] = useState<ExportMode>("none");
@@ -622,7 +643,7 @@ export default function MotionSearchView({
]);
useEffect(() => {
if (pendingSeekTime != null) {
if (pendingSeekTimeRef.current != null) {
return;
}
@@ -636,7 +657,7 @@ export default function MotionSearchView({
setPlaybackStart(nextTime);
setSelectedRangeIdx(index === -1 ? chunkedTimeRange.length - 1 : index);
mainControllerRef.current?.seekToTimestamp(nextTime, true);
}, [pendingSeekTime, timeRange, chunkedTimeRange]);
}, [timeRange, chunkedTimeRange]);
useEffect(() => {
if (!scrubbing) {
@@ -733,10 +754,9 @@ export default function MotionSearchView({
useEffect(() => {
return () => {
cancelMotionSearchJobViaBeacon(jobIdRef.current, jobCameraRef.current);
void cancelMotionSearchJob(jobIdRef.current, jobCameraRef.current);
};
}, [cancelMotionSearchJob, cancelMotionSearchJobViaBeacon]);
}, [cancelMotionSearchJob]);
useEffect(() => {
const handleBeforeUnload = () => {
@@ -802,7 +822,6 @@ export default function MotionSearchView({
parallel: parallelMode,
threshold,
min_area: minArea,
frame_skip: frameSkip,
max_results: maxResults,
};
@@ -877,7 +896,6 @@ export default function MotionSearchView({
parallelMode,
threshold,
minArea,
frameSkip,
maxResults,
t,
]);
@@ -888,23 +906,27 @@ export default function MotionSearchView({
return;
}
if (!jobId || jobStatus.id !== jobId) {
return;
}
const resultList = jobStatus.results?.results;
if (jobStatus.status === "success") {
setSearchResults(jobStatus.results ?? []);
setSearchResults(resultList ?? []);
setSearchMetrics(jobStatus.metrics ?? null);
setIsSearching(false);
setJobId(null);
setJobCamera(null);
toast.success(
t("changesFound", { count: jobStatus.results?.length ?? 0 }),
);
toast.success(t("changesFound", { count: resultList?.length ?? 0 }));
} else if (
jobStatus.status === "queued" ||
jobStatus.status === "running"
) {
setSearchMetrics(jobStatus.metrics ?? null);
// Stream partial results as they arrive
if (jobStatus.results && jobStatus.results.length > 0) {
setSearchResults(jobStatus.results);
if (resultList && resultList.length > 0) {
setSearchResults(resultList);
}
} else if (jobStatus.status === "failed") {
setIsSearching(false);
@@ -912,7 +934,7 @@ export default function MotionSearchView({
setJobCamera(null);
toast.error(
t("errors.searchFailed", {
message: jobStatus.error_message || jobStatus.message,
message: jobStatus.error_message || t("errors.unknown"),
}),
);
} else if (jobStatus.status === "cancelled") {
@@ -921,7 +943,7 @@ export default function MotionSearchView({
setJobCamera(null);
toast.message(t("searchCancelled"));
}
}, [jobStatus, t]);
}, [jobStatus, jobId, t]);
// Handle result click
const handleResultClick = useCallback(
@@ -930,12 +952,14 @@ export default function MotionSearchView({
result.timestamp < timeRange.after ||
result.timestamp > timeRange.before
) {
pendingSeekTimeRef.current = result.timestamp;
setPendingSeekTime(result.timestamp);
onDaySelect(new Date(result.timestamp * 1000));
return;
}
manuallySetCurrentTime(result.timestamp, true);
// start playback a few seconds before the change so the motion is in view
manuallySetCurrentTime(result.timestamp - REVIEW_PADDING, true);
},
[manuallySetCurrentTime, onDaySelect, timeRange],
);
@@ -949,8 +973,9 @@ export default function MotionSearchView({
pendingSeekTime >= timeRange.after &&
pendingSeekTime <= timeRange.before
) {
manuallySetCurrentTime(pendingSeekTime, true);
manuallySetCurrentTime(pendingSeekTime - REVIEW_PADDING, true);
setPendingSeekTime(null);
pendingSeekTimeRef.current = null;
}
}, [pendingSeekTime, timeRange, manuallySetCurrentTime]);
@@ -1028,6 +1053,9 @@ export default function MotionSearchView({
const progressMetrics = jobStatus?.metrics ?? searchMetrics;
const progressValue = (() => {
if (jobStatus?.progress != null) {
return Math.min(100, Math.max(0, jobStatus.progress * 100));
}
if (!progressMetrics || progressMetrics.segments_scanned <= 0) {
return 0;
}
@@ -1042,23 +1070,48 @@ export default function MotionSearchView({
return Math.min(100, Math.max(0, (doneWork / totalWork) * 100));
})();
const wallTimeLabel = searchMetrics
? searchMetrics.wall_time_seconds >= 60
? t("metrics.minutesSeconds", {
minutes: Math.floor(searchMetrics.wall_time_seconds / 60),
seconds: Math.round(searchMetrics.wall_time_seconds % 60),
})
: t("metrics.seconds", {
seconds: searchMetrics.wall_time_seconds.toFixed(1),
})
: "";
const resultsPanel = (
<>
<div className="p-2">
<h3 className="font-medium">{t("results")}</h3>
</div>
{(hasSearched || isSearching) && (
<div className="flex flex-col gap-1 px-3 py-2.5">
{searchRange && (
<div className="text-sm font-medium text-foreground">
{formattedSearchRange}
</div>
)}
{searchMetrics && (
<div className="text-xs text-muted-foreground">
{t("metrics.scanSummary", {
segments: searchMetrics.segments_scanned,
time: wallTimeLabel,
})}
</div>
)}
</div>
)}
<ScrollArea className="flex-1">
<ScrollArea className="flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block">
{isSearching && (
<div className="flex flex-col gap-2 border-b p-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1 text-wrap">
<ActivityIndicator className="mr-2 size-4" />
<div>{t("searching")}</div>
</div>
<div className="flex flex-col gap-1.5 border-b p-3">
<div className="flex items-center gap-2">
<Progress className="h-1.5 flex-1" value={progressValue} />
<Button
variant="destructive"
size="sm"
variant="ghost"
size="icon"
className="size-6 shrink-0 text-muted-foreground"
aria-label={t("cancelSearch")}
title={t("cancelSearch")}
onClick={() => {
void cancelMotionSearchJob(jobId, jobCamera);
setIsSearching(false);
@@ -1067,76 +1120,90 @@ export default function MotionSearchView({
toast.success(t("searchCancelled"));
}}
>
{t("cancelSearch")}
<LuX className="size-4" />
</Button>
</div>
<Progress className="h-1" value={progressValue} />
<div className="text-xs text-muted-foreground">
{jobStatus?.scanning_timestamp != null
? t("scanning", { time: formattedScanningTimestamp })
: t("searching")}
</div>
</div>
)}
{searchMetrics && (isSearching || searchResults.length > 0) && (
<div className="mx-2 my-3 rounded-lg border bg-secondary p-2">
<div className="space-y-0.5 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>{t("metrics.segmentsScanned")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_scanned}
</span>
</div>
{searchMetrics.segments_processed > 0 && (
<div className="flex justify-between font-medium">
<span>{t("metrics.segmentsProcessed")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_processed}
</span>
{searchMetrics &&
(isSearching || searchResults.length > 0 || hasSearched) && (
<Collapsible>
<CollapsibleTrigger className="group flex w-full items-center gap-1 px-3 py-2.5 text-left text-xs text-muted-foreground hover:bg-accent">
<LuChevronRight className="size-3 shrink-0 transition-transform group-data-[state=open]:rotate-90" />
{t("metrics.title")}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-0.5 px-3 pb-3 text-xs text-muted-foreground">
{searchMetrics.segments_processed > 0 && (
<div className="flex justify-between font-medium">
<span>{t("metrics.segmentsProcessed")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_processed}
</span>
</div>
)}
{searchMetrics.metadata_inactive_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedInactive")}</span>
<span className="text-primary-variant">
{searchMetrics.metadata_inactive_segments}
</span>
</div>
)}
{searchMetrics.heatmap_roi_skip_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedHeatmap")}</span>
<span className="text-primary-variant">
{searchMetrics.heatmap_roi_skip_segments}
</span>
</div>
)}
{searchMetrics.fallback_full_range_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.fallbackFullRange")}</span>
<span className="text-primary-variant">
{searchMetrics.fallback_full_range_segments}
</span>
</div>
)}
<div className="flex justify-between">
<span>{t("metrics.framesDecoded")}</span>
<span className="text-primary-variant">
{searchMetrics.frames_decoded}
</span>
</div>
<div className="flex justify-between">
<span>{t("metrics.wallTime")}</span>
<span className="text-primary-variant">
{wallTimeLabel}
</span>
</div>
{searchMetrics.segments_with_errors > 0 && (
<div className="flex justify-between text-destructive">
<span>{t("metrics.segmentErrors")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_with_errors}
</span>
</div>
)}
</div>
)}
{searchMetrics.metadata_inactive_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedInactive")}</span>
<span className="text-primary-variant">
{searchMetrics.metadata_inactive_segments}
</span>
</div>
)}
{searchMetrics.heatmap_roi_skip_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedHeatmap")}</span>
<span className="text-primary-variant">
{searchMetrics.heatmap_roi_skip_segments}
</span>
</div>
)}
{searchMetrics.fallback_full_range_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.fallbackFullRange")}</span>
<span className="text-primary-variant">
{searchMetrics.fallback_full_range_segments}
</span>
</div>
)}
<div className="flex justify-between">
<span>{t("metrics.framesDecoded")}</span>
<span className="text-primary-variant">
{searchMetrics.frames_decoded}
</span>
</div>
<div className="flex justify-between">
<span>{t("metrics.wallTime")}</span>
<span className="text-primary-variant">
{t("metrics.seconds", {
seconds: searchMetrics.wall_time_seconds.toFixed(1),
})}
</span>
</div>
{searchMetrics.segments_with_errors > 0 && (
<div className="flex justify-between text-destructive">
<span>{t("metrics.segmentErrors")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_with_errors}
</span>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
{(searchResults.length > 0 || (hasSearched && !isSearching)) && (
<div className="border-t px-1.5 pb-1.5 pt-3">
<h3 className="text-sm font-medium tracking-wide text-muted-foreground">
{searchResults.length > 0 && (
<span className="ml-1.5">{searchResults.length}</span>
)}{" "}
{t("results")}
</h3>
</div>
)}
@@ -1145,7 +1212,7 @@ export default function MotionSearchView({
{hasSearched ? t("noChangesFound") : t("noResultsYet")}
</div>
) : searchResults.length > 0 ? (
<div className="flex flex-col gap-1 p-2">
<div className="flex flex-col gap-1 px-1 pb-2">
{searchResults.map((result, index) => (
<SearchResultItem
key={index}
@@ -1187,8 +1254,6 @@ export default function MotionSearchView({
setThreshold={setThreshold}
minArea={minArea}
setMinArea={setMinArea}
frameSkip={frameSkip}
setFrameSkip={setFrameSkip}
maxResults={maxResults}
setMaxResults={setMaxResults}
searchRange={searchRange}
@@ -1516,15 +1581,20 @@ function SearchResultItem({
return (
<button
className="flex w-full flex-col rounded-md p-2 text-left hover:bg-accent"
className="flex w-full items-center justify-between gap-2 rounded-md p-2 text-left hover:bg-accent"
onClick={onClick}
title={t("jumpToTime")}
>
<span className="text-sm font-medium">{formattedTime}</span>
<span className="text-xs text-muted-foreground">
{t("changePercentage", {
<span className="min-w-0 truncate text-sm font-medium">
{formattedTime}
</span>
<span
className="shrink-0 text-xs tabular-nums text-muted-foreground"
title={t("changePercentage", {
percentage: result.change_percentage.toFixed(1),
})}
>
{result.change_percentage.toFixed(1)}%
</span>
</button>
);