mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-30 23:59:02 +03:00
Motion search fixes (#23359)
* improve error parsing and increase skip default * improve motion search layout to match tracking details * implement draw and move mode on mobile * update motion search docs * language tweaks * improve tips * note actions menu
This commit is contained in:
@@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||
import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa";
|
||||
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
|
||||
import { LuHand, LuPencil } from "react-icons/lu";
|
||||
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { ASPECT_PORTRAIT_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -113,12 +115,34 @@ export default function MotionSearchDialog({
|
||||
const { t } = useTranslation(["views/motionSearch", "common"]);
|
||||
const apiHost = useApiHost();
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [panMode, setPanMode] = useState(false);
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (!selectedCamera) return undefined;
|
||||
return config.cameras[selectedCamera];
|
||||
}, [config, selectedCamera]);
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (!cameraConfig) {
|
||||
return 16 / 9;
|
||||
}
|
||||
|
||||
return cameraConfig.detect.width / cameraConfig.detect.height;
|
||||
}, [cameraConfig]);
|
||||
|
||||
// Determine camera aspect ratio category
|
||||
const cameraAspect = useMemo(() => {
|
||||
if (!aspectRatio) {
|
||||
return "normal";
|
||||
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||
return "wide";
|
||||
} else if (aspectRatio < ASPECT_PORTRAIT_LAYOUT) {
|
||||
return "tall";
|
||||
} else {
|
||||
return "normal";
|
||||
}
|
||||
}, [aspectRatio]);
|
||||
|
||||
const polygonClosed = useMemo(
|
||||
() => !isDrawingROI && polygonPoints.length >= 3,
|
||||
[isDrawingROI, polygonPoints.length],
|
||||
@@ -144,6 +168,7 @@ export default function MotionSearchDialog({
|
||||
|
||||
useEffect(() => {
|
||||
setImageLoaded(false);
|
||||
setPanMode(false);
|
||||
}, [selectedCamera]);
|
||||
|
||||
const Overlay = isDesktop ? Dialog : Drawer;
|
||||
@@ -218,7 +243,13 @@ export default function MotionSearchDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
||||
<TransformWrapper
|
||||
minScale={1.0}
|
||||
wheel={{ smoothStep: 0.005 }}
|
||||
panning={{ disabled: !isDesktop && !panMode }}
|
||||
pinch={{ disabled: !isDesktop && !panMode }}
|
||||
doubleClick={{ disabled: !isDesktop && !panMode }}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
@@ -231,7 +262,15 @@ export default function MotionSearchDialog({
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary">
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto flex items-center justify-center overflow-hidden rounded-lg border bg-secondary",
|
||||
cameraAspect === "tall"
|
||||
? "max-h-[50dvh] lg:max-h-[60dvh]"
|
||||
: "w-full",
|
||||
)}
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
{selectedCamera && cameraConfig ? (
|
||||
<div className="relative h-full w-full">
|
||||
<img
|
||||
@@ -261,6 +300,7 @@ export default function MotionSearchDialog({
|
||||
isDrawing={isDrawingROI}
|
||||
setIsDrawing={setIsDrawingROI}
|
||||
isInteractive={true}
|
||||
panMode={panMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -282,11 +322,41 @@ export default function MotionSearchDialog({
|
||||
{polygonClosed && <FaCheckCircle className="ml-2 size-5" />}
|
||||
</div>
|
||||
<div className="flex flex-row justify-center gap-2">
|
||||
{!isDesktop && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={panMode ? "select" : "default"}
|
||||
className="size-8 rounded-md p-1.5"
|
||||
aria-label={
|
||||
panMode
|
||||
? t("polygonControls.moveMode")
|
||||
: t("polygonControls.drawMode")
|
||||
}
|
||||
onClick={() => setPanMode((prev) => !prev)}
|
||||
>
|
||||
{panMode ? (
|
||||
<LuHand className="text-selected-foreground" />
|
||||
) : (
|
||||
<LuPencil className="text-secondary-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{panMode
|
||||
? t("polygonControls.moveMode")
|
||||
: t("polygonControls.drawMode")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="size-6 rounded-md p-1"
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
|
||||
)}
|
||||
aria-label={t("polygonControls.undo")}
|
||||
disabled={polygonPoints.length === 0 || isSearching}
|
||||
onClick={undoPolygonPoint}
|
||||
@@ -302,7 +372,10 @@ export default function MotionSearchDialog({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="size-6 rounded-md p-1"
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
|
||||
)}
|
||||
aria-label={t("polygonControls.reset")}
|
||||
disabled={polygonPoints.length === 0 || isSearching}
|
||||
onClick={resetPolygon}
|
||||
@@ -370,7 +443,7 @@ export default function MotionSearchDialog({
|
||||
<Slider
|
||||
id="frameSkip"
|
||||
min={1}
|
||||
max={60}
|
||||
max={120}
|
||||
step={1}
|
||||
value={[frameSkip]}
|
||||
onValueChange={([value]) => setFrameSkip(value)}
|
||||
|
||||
@@ -14,6 +14,7 @@ type MotionSearchROICanvasProps = {
|
||||
isDrawing: boolean;
|
||||
setIsDrawing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isInteractive?: boolean;
|
||||
panMode?: boolean;
|
||||
motionHeatmap?: Record<string, number> | null;
|
||||
showMotionHeatmap?: boolean;
|
||||
};
|
||||
@@ -26,6 +27,7 @@ export default function MotionSearchROICanvas({
|
||||
isDrawing,
|
||||
setIsDrawing,
|
||||
isInteractive = true,
|
||||
panMode = false,
|
||||
motionHeatmap,
|
||||
showMotionHeatmap = false,
|
||||
}: MotionSearchROICanvasProps) {
|
||||
@@ -341,7 +343,9 @@ export default function MotionSearchROICanvas({
|
||||
ref={setContainerNode}
|
||||
className={cn(
|
||||
"absolute inset-0 z-10",
|
||||
isInteractive ? "pointer-events-auto" : "pointer-events-none",
|
||||
isInteractive && !panMode
|
||||
? "pointer-events-auto"
|
||||
: "pointer-events-none",
|
||||
)}
|
||||
style={{ cursor: isDrawing ? "crosshair" : "default" }}
|
||||
>
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function MotionSearchView({
|
||||
const [parallelMode, setParallelMode] = useState(false);
|
||||
const [threshold, setThreshold] = useState(30);
|
||||
const [minArea, setMinArea] = useState(20);
|
||||
const [frameSkip, setFrameSkip] = useState(10);
|
||||
const [frameSkip, setFrameSkip] = useState(30);
|
||||
const [maxResults, setMaxResults] = useState(25);
|
||||
|
||||
// Job state
|
||||
@@ -846,7 +846,13 @@ export default function MotionSearchView({
|
||||
responseData.errors;
|
||||
|
||||
if (Array.isArray(apiMessage)) {
|
||||
errorMessage = apiMessage.join(", ");
|
||||
errorMessage = apiMessage
|
||||
.map((item) =>
|
||||
typeof item === "string"
|
||||
? item
|
||||
: ((item as { msg?: string })?.msg ?? JSON.stringify(item)),
|
||||
)
|
||||
.join(", ");
|
||||
} else if (typeof apiMessage === "string") {
|
||||
errorMessage = apiMessage;
|
||||
} else if (apiMessage) {
|
||||
|
||||
Reference in New Issue
Block a user