motion tuner, edit controls, tooltips

This commit is contained in:
Josh Hawkins 2024-04-14 18:36:39 -05:00
parent 8deec1c9b6
commit 6ae3e24157
12 changed files with 507 additions and 362 deletions

View File

@ -6,6 +6,7 @@ type AutoUpdatingCameraImageProps = {
searchParams?: URLSearchParams; searchParams?: URLSearchParams;
showFps?: boolean; showFps?: boolean;
className?: string; className?: string;
cameraClasses?: string;
reloadInterval?: number; reloadInterval?: number;
}; };
@ -16,6 +17,7 @@ export default function AutoUpdatingCameraImage({
searchParams = undefined, searchParams = undefined,
showFps = true, showFps = true,
className, className,
cameraClasses,
reloadInterval = MIN_LOAD_TIMEOUT_MS, reloadInterval = MIN_LOAD_TIMEOUT_MS,
}: AutoUpdatingCameraImageProps) { }: AutoUpdatingCameraImageProps) {
const [key, setKey] = useState(Date.now()); const [key, setKey] = useState(Date.now());
@ -68,6 +70,7 @@ export default function AutoUpdatingCameraImage({
camera={camera} camera={camera}
onload={handleLoad} onload={handleLoad}
searchParams={`cache=${key}&${searchParams}`} searchParams={`cache=${key}&${searchParams}`}
className={cameraClasses}
/> />
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null} {showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
</div> </div>

View File

@ -36,12 +36,7 @@ export default function CameraImage({
}, [apiHost, name, imgRef, searchParams, config]); }, [apiHost, name, imgRef, searchParams, config]);
return ( return (
<div <div className={className} ref={containerRef}>
className={`relative w-full h-full flex justify-center ${
className || ""
}`}
ref={containerRef}
>
{enabled ? ( {enabled ? (
<img <img
ref={imgRef} ref={imgRef}

View File

@ -53,6 +53,7 @@ export default function DebugCameraImage({
<AutoUpdatingCameraImage <AutoUpdatingCameraImage
camera={cameraConfig.name} camera={cameraConfig.name}
searchParams={searchParams} searchParams={searchParams}
cameraClasses="relative w-full h-full flex justify-center"
/> />
<Button onClick={handleToggleSettings} variant="link" size="sm"> <Button onClick={handleToggleSettings} variant="link" size="sm">
<span className="w-5 h-5"> <span className="w-5 h-5">

View File

@ -163,6 +163,7 @@ export default function LivePlayer({
camera={cameraConfig.name} camera={cameraConfig.name}
showFps={false} showFps={false}
reloadInterval={stillReloadInterval} reloadInterval={stillReloadInterval}
cameraClasses="relative w-full h-full flex justify-center"
/> />
</div> </div>

View File

@ -5,16 +5,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PolygonCanvas } from "./PolygonCanvas"; import { PolygonCanvas } from "./PolygonCanvas";
import { Polygon, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil"; import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil";
import { isDesktop, isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu"; import { LuCopy, LuPencil, LuPlus } from "react-icons/lu";
import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa"; import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
import { BsPersonBoundingBox } from "react-icons/bs";
import { HiTrash } from "react-icons/hi";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "../ui/sonner"; import { Toaster } from "../ui/sonner";
import { ZoneEditPane } from "./ZoneEditPane"; import { ZoneEditPane } from "./ZoneEditPane";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -25,8 +28,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "../ui/alert-dialog"; } from "../ui/alert-dialog";
import { Separator } from "../ui/separator"; import Heading from "../ui/heading";
import { BsPersonBoundingBox } from "react-icons/bs";
const parseCoordinates = (coordinatesString: string) => { const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(","); const coordinates = coordinatesString.split(",");
@ -196,7 +198,7 @@ export default function MasksAndZones({
}, [config, selectedCamera]); }, [config, selectedCamera]);
const stretch = true; const stretch = true;
const fitAspect = 1; const fitAspect = 16 / 9;
const scaledHeight = useMemo(() => { const scaledHeight = useMemo(() => {
if (containerRef.current && aspectRatio && detectHeight) { if (containerRef.current && aspectRatio && detectHeight) {
@ -247,13 +249,18 @@ export default function MasksAndZones({
}; };
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
console.log("handling cancel");
setEditPane(undefined); setEditPane(undefined);
setAllPolygons(allPolygons.filter((poly) => !poly.isUnsaved)); console.log("all", allPolygons);
console.log("editing", editingPolygons);
// setAllPolygons(allPolygons.filter((poly) => !poly.isUnsaved));
setEditingPolygons([...allPolygons]);
setActivePolygonIndex(undefined); setActivePolygonIndex(undefined);
setHoveredPolygonIndex(null); setHoveredPolygonIndex(null);
}, [allPolygons]); }, [allPolygons, editingPolygons]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
console.log("handling save");
setAllPolygons([...(editingPolygons ?? [])]); setAllPolygons([...(editingPolygons ?? [])]);
setActivePolygonIndex(undefined); setActivePolygonIndex(undefined);
setEditPane(undefined); setEditPane(undefined);
@ -368,20 +375,27 @@ export default function MasksAndZones({
: [], : [],
); );
console.log("setting all and editing");
setAllPolygons([ setAllPolygons([
...zones, ...zones,
...motionMasks, ...motionMasks,
...globalObjectMasks, ...globalObjectMasks,
...objectMasks, ...objectMasks,
]); ]);
setEditingPolygons([
...zones,
...motionMasks,
...globalObjectMasks,
...objectMasks,
]);
setZoneObjects( // setZoneObjects(
Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ // Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
camera: cameraConfig.name, // camera: cameraConfig.name,
zoneName: name, // zoneName: name,
objects: Object.keys(zoneData.filters), // objects: Object.keys(zoneData.filters),
})), // })),
); // );
} }
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -391,23 +405,25 @@ export default function MasksAndZones({
if (editPane === undefined) { if (editPane === undefined) {
setEditingPolygons([...allPolygons]); setEditingPolygons([...allPolygons]);
setIsEditing(false); setIsEditing(false);
console.log(allPolygons); console.log("edit pane undefined, all", allPolygons);
} else { } else {
setIsEditing(true); setIsEditing(true);
} }
}, [setEditingPolygons, setIsEditing, allPolygons, editPane]); // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditingPolygons, setIsEditing, allPolygons]);
useEffect(() => { // useEffect(() => {
console.log( // console.log(
"config zone objects", // "config zone objects",
Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ // Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
camera: cameraConfig.name, // camera: cameraConfig.name,
zoneName: name, // zoneName: name,
objects: Object.keys(zoneData.filters), // objects: Object.keys(zoneData.filters),
})), // })),
); // );
console.log("component zone objects", zoneObjects); // console.log("component zone objects", zoneObjects);
}, [zoneObjects]); // }, [zoneObjects]);
useEffect(() => { useEffect(() => {
if (selectedCamera) { if (selectedCamera) {
@ -421,13 +437,14 @@ export default function MasksAndZones({
return ( return (
<> <>
{cameraConfig && allPolygons && ( {cameraConfig && editingPolygons && (
<div className="flex flex-col md:flex-row size-full"> <div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" /> <Toaster position="top-center" />
<div className="flex flex-col w-full overflow-y-auto md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt"> <div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
{editPane == "zone" && ( {editPane == "zone" && (
<ZoneEditPane <ZoneEditPane
polygons={editingPolygons} polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
onCancel={handleCancel} onCancel={handleCancel}
onSave={handleSave} onSave={handleSave}
@ -436,6 +453,7 @@ export default function MasksAndZones({
{editPane == "motion_mask" && ( {editPane == "motion_mask" && (
<ZoneEditPane <ZoneEditPane
polygons={editingPolygons} polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
onCancel={handleCancel} onCancel={handleCancel}
onSave={handleSave} onSave={handleSave}
@ -444,6 +462,7 @@ export default function MasksAndZones({
{editPane == "object_mask" && ( {editPane == "object_mask" && (
<ZoneEditPane <ZoneEditPane
polygons={editingPolygons} polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
onCancel={handleCancel} onCancel={handleCancel}
onSave={handleSave} onSave={handleSave}
@ -451,130 +470,148 @@ export default function MasksAndZones({
)} )}
{editPane === undefined && ( {editPane === undefined && (
<> <>
{(selectedZoneMask === undefined || <Heading as="h3" className="my-2">
selectedZoneMask.includes("zone" as PolygonType)) && ( Masks / Zones
<> </Heading>
<div className="flex flex-row justify-between items-center my-2"> <div className="flex flex-col w-full">
<div className="text-md">Zones</div> {(selectedZoneMask === undefined ||
<Button selectedZoneMask.includes("zone" as PolygonType)) && (
variant="ghost" <div className="mt-0 pt-0">
className="h-8 px-0" <div className="flex flex-row justify-between items-center my-3">
onClick={() => { <div className="text-md">Zones</div>
setEditPane("zone"); <Tooltip>
handleNewPolygon("zone"); <TooltipTrigger asChild>
}} <Button
> variant="secondary"
<LuPlusSquare /> className="size-6 p-1 rounded-md text-background bg-secondary-foreground"
</Button> onClick={() => {
setEditPane("zone");
handleNewPolygon("zone");
}}
>
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Zone</TooltipContent>
</Tooltip>
</div>
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "zone" ? [{ polygon, index }] : [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
setAllPolygons={setAllPolygons}
handleCopyCoordinates={handleCopyCoordinates}
/>
))}
</div> </div>
{allPolygons )}
.flatMap((polygon, index) => {(selectedZoneMask === undefined ||
polygon.type === "zone" ? [{ polygon, index }] : [], selectedZoneMask.includes(
) "motion_mask" as PolygonType,
.map(({ polygon, index }) => ( )) && (
<PolygonItem <div className="first:mt-0 mt-3 first:pt-0 pt-3 border-t-[1px] first:border-transparent border-secondary">
key={index} <div className="flex flex-row justify-between items-center my-3">
polygon={polygon} <div className="text-md">Motion Masks</div>
index={index} <Tooltip>
activePolygonIndex={activePolygonIndex} <TooltipTrigger asChild>
hoveredPolygonIndex={hoveredPolygonIndex} <Button
setHoveredPolygonIndex={setHoveredPolygonIndex} variant="secondary"
deleteDialogOpen={deleteDialogOpen} className="size-6 p-1 rounded-md text-background bg-secondary-foreground"
setDeleteDialogOpen={setDeleteDialogOpen} onClick={() => {
setActivePolygonIndex={setActivePolygonIndex} setEditPane("motion_mask");
setEditPane={setEditPane} handleNewPolygon("motion_mask");
setAllPolygons={setAllPolygons} }}
handleCopyCoordinates={handleCopyCoordinates} >
/> <LuPlus />
))} </Button>
</> </TooltipTrigger>
)} <TooltipContent>Add Motion Mask</TooltipContent>
<div className="flex my-2"> </Tooltip>
<Separator className="bg-secondary" /> </div>
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "motion_mask"
? [{ polygon, index }]
: [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
setAllPolygons={setAllPolygons}
handleCopyCoordinates={handleCopyCoordinates}
/>
))}
</div>
)}
{(selectedZoneMask === undefined ||
selectedZoneMask.includes(
"object_mask" as PolygonType,
)) && (
<div className="first:mt-0 mt-3 first:pt-0 pt-3 border-t-[1px] first:border-transparent border-secondary">
<div className="flex flex-row justify-between items-center my-3">
<div className="text-md">Object Masks</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
className="size-6 p-1 rounded-md text-background bg-secondary-foreground"
onClick={() => {
setEditPane("object_mask");
handleNewPolygon("object_mask");
}}
>
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Object Mask</TooltipContent>
</Tooltip>
</div>
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "object_mask"
? [{ polygon, index }]
: [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
setAllPolygons={setAllPolygons}
handleCopyCoordinates={handleCopyCoordinates}
/>
))}
</div>
)}
</div> </div>
{(selectedZoneMask === undefined ||
selectedZoneMask.includes("motion_mask" as PolygonType)) && (
<>
<div className="flex flex-row justify-between items-center my-2">
<div className="text-md">Motion Masks</div>
<Button
variant="ghost"
className="h-8 px-0"
onClick={() => {
setEditPane("motion_mask");
handleNewPolygon("motion_mask");
}}
>
<LuPlusSquare />
</Button>
</div>
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "motion_mask"
? [{ polygon, index }]
: [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
setAllPolygons={setAllPolygons}
handleCopyCoordinates={handleCopyCoordinates}
/>
))}
</>
)}
<div className="flex my-2">
<Separator className="bg-secondary" />
</div>
{(selectedZoneMask === undefined ||
selectedZoneMask.includes("object_mask" as PolygonType)) && (
<>
<div className="flex flex-row justify-between items-center my-2">
<div className="text-md">Object Masks</div>
<Button
variant="ghost"
className="h-8 px-0"
onClick={() => {
setEditPane("object_mask");
handleNewPolygon("object_mask");
}}
>
<LuPlusSquare />
</Button>
</div>
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "object_mask"
? [{ polygon, index }]
: [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
setAllPolygons={setAllPolygons}
handleCopyCoordinates={handleCopyCoordinates}
/>
))}
</>
)}
</> </>
)} )}
{/* <Table> {/* <Table>
@ -661,7 +698,7 @@ export default function MasksAndZones({
ref={containerRef} ref={containerRef}
className="flex md:w-7/12 md:grow md:h-dvh md:max-h-full" className="flex md:w-7/12 md:grow md:h-dvh md:max-h-full"
> >
<div className="size-full"> <div className="flex flex-row justify-center mx-auto size-full">
{cameraConfig && {cameraConfig &&
scaledWidth && scaledWidth &&
scaledHeight && scaledHeight &&
@ -677,7 +714,7 @@ export default function MasksAndZones({
selectedZoneMask={selectedZoneMask} selectedZoneMask={selectedZoneMask}
/> />
) : ( ) : (
<Skeleton className="w-full h-full" /> <Skeleton className="size-full" />
)} )}
</div> </div>
</div> </div>
@ -725,7 +762,7 @@ function PolygonItem({
{isMobile && <></>} {isMobile && <></>}
<div <div
className={`flex items-center ${ className={`flex items-center ${
activePolygonIndex === index hoveredPolygonIndex === index
? "text-primary" ? "text-primary"
: "text-muted-foreground" : "text-muted-foreground"
}`} }`}
@ -779,37 +816,52 @@ function PolygonItem({
setEditPane(polygon.type); setEditPane(polygon.type);
}} }}
> >
<LuPencil <Tooltip>
className={`size-4 ${ <TooltipTrigger>
activePolygonIndex === index <LuPencil
? "text-primary" className={`size-4 ${
: "text-muted-foreground" hoveredPolygonIndex === index
}`} ? "text-primary"
/> : "text-muted-foreground"
}`}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</div> </div>
<div <div
className="cursor-pointer" className="cursor-pointer"
onClick={() => handleCopyCoordinates(index)} onClick={() => handleCopyCoordinates(index)}
> >
<LuCopy <Tooltip>
className={`size-4 ${ <TooltipTrigger>
activePolygonIndex === index <LuCopy
? "text-primary" className={`size-4 ${
: "text-muted-foreground" hoveredPolygonIndex === index
}`} ? "text-primary"
/> : "text-muted-foreground"
}`}
/>
</TooltipTrigger>
<TooltipContent>Copy coordinates</TooltipContent>
</Tooltip>
</div> </div>
<div <div
className="cursor-pointer" className="cursor-pointer"
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
> >
<LuTrash <Tooltip>
className={`size-4 ${ <TooltipTrigger>
activePolygonIndex === index <HiTrash
? "text-primary fill-primary" className={`size-4 ${
: "text-muted-foreground fill-muted-foreground" hoveredPolygonIndex === index
}`} ? "text-primary fill-primary"
/> : "text-muted-foreground fill-muted-foreground"
}`}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
)} )}

View File

@ -1,13 +1,4 @@
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -37,13 +28,17 @@ import { Switch } from "../ui/switch";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner"; import { toast } from "sonner";
type MotionTunerProps = {
selectedCamera: string;
};
type MotionSettings = { type MotionSettings = {
threshold?: number; threshold?: number;
contour_area?: number; contour_area?: number;
improve_contrast?: boolean; improve_contrast?: boolean;
}; };
export default function MotionTuner() { export default function MotionTuner({ selectedCamera }: MotionTunerProps) {
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false); const [changedValue, setChangedValue] = useState(false);
@ -60,7 +55,7 @@ export default function MotionTuner() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]); }, [config]);
const [selectedCamera, setSelectedCamera] = useState(cameras[0]?.name); // const [selectedCamera, setSelectedCamera] = useState(cameras[0]?.name);
const [nextSelectedCamera, setNextSelectedCamera] = useState(""); const [nextSelectedCamera, setNextSelectedCamera] = useState("");
const { send: sendMotionThreshold } = useMotionThreshold(selectedCamera); const { send: sendMotionThreshold } = useMotionThreshold(selectedCamera);
@ -169,11 +164,11 @@ export default function MotionTuner() {
setNextSelectedCamera(camera); setNextSelectedCamera(camera);
setConfirmationDialogOpen(true); setConfirmationDialogOpen(true);
} else { } else {
setSelectedCamera(camera); // setSelectedCamera(camera);
setNextSelectedCamera(""); setNextSelectedCamera("");
} }
}, },
[setSelectedCamera, changedValue], [changedValue],
); );
const handleDialog = useCallback( const handleDialog = useCallback(
@ -181,12 +176,12 @@ export default function MotionTuner() {
if (save) { if (save) {
saveToConfig(); saveToConfig();
} }
setSelectedCamera(nextSelectedCamera); // setSelectedCamera(nextSelectedCamera);
setNextSelectedCamera(""); setNextSelectedCamera("");
setConfirmationDialogOpen(false); setConfirmationDialogOpen(false);
setChangedValue(false); setChangedValue(false);
}, },
[saveToConfig, setSelectedCamera, nextSelectedCamera], [saveToConfig],
); );
if (!cameraConfig && !selectedCamera) { if (!cameraConfig && !selectedCamera) {
@ -194,127 +189,113 @@ export default function MotionTuner() {
} }
return ( return (
<> <div className="flex flex-col md:flex-row size-full">
<Heading as="h2">Motion Detection Tuner</Heading> <Toaster position="top-center" />
<Toaster /> <div className="flex flex-col w-full overflow-y-auto mt-2 md:mt-0 md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
<div className="flex items-center space-x-2 mt-5"> <Heading as="h3" className="my-2">
<Select Motion Detection Tuner
value={selectedCamera} </Heading>
onValueChange={handleSelectedCameraChange}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Camera" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Choose a camera</SelectLabel>
{cameras.map((camera) => (
<SelectItem
key={camera.name}
value={camera.name}
className="capitalize"
>
{camera.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{cameraConfig ? (
<div className="flex flex-col justify-start">
<AutoUpdatingCameraImage
camera={cameraConfig.name}
searchParams={new URLSearchParams([["motion", "1"]])}
className="w-[50%]"
/>
<div className="flex flex-row justify-evenly w-full">
<div className="flex flex-row mb-5">
<Slider
id="motion-threshold"
className="w-[300px]"
disabled={motionSettings.threshold === undefined}
value={[motionSettings.threshold ?? 0]}
min={10}
max={80}
step={1}
onValueChange={(value) => {
handleMotionConfigChange({ threshold: value[0] });
}}
/>
<Label htmlFor="motion-threshold" className="px-2">
Threshold: {motionSettings.threshold}
</Label>
</div>
<div className="flex flex-row">
<Slider
id="motion-contour-area"
className="w-[300px]"
disabled={motionSettings.contour_area === undefined}
value={[motionSettings.contour_area ?? 0]}
min={10}
max={200}
step={5}
onValueChange={(value) => {
handleMotionConfigChange({ contour_area: value[0] });
}}
/>
<Label htmlFor="motion-contour-area" className="px-2">
Contour Area: {motionSettings.contour_area}
</Label>
</div>
<div className="flex flex-row">
<Switch
id="improve-contrast"
disabled={motionSettings.improve_contrast === undefined}
checked={motionSettings.improve_contrast === true}
onCheckedChange={(isChecked) => {
handleMotionConfigChange({ improve_contrast: isChecked });
}}
/>
<Label htmlFor="improve-contrast">Improve Contrast</Label>
</div>
<div className="flex"> <div className="flex flex-col w-full space-y-10">
<Button <div className="flex flex-row mb-5">
size="sm" <Slider
variant={isLoading ? "ghost" : "select"} id="motion-threshold"
disabled={!changedValue || isLoading} className="w-[300px]"
onClick={saveToConfig} disabled={motionSettings.threshold === undefined}
> value={[motionSettings.threshold ?? 0]}
{isLoading ? "Saving..." : "Save to Config"} min={10}
</Button> max={80}
</div> step={1}
onValueChange={(value) => {
handleMotionConfigChange({ threshold: value[0] });
}}
/>
<Label htmlFor="motion-threshold" className="px-2">
Threshold: {motionSettings.threshold}
</Label>
</div> </div>
{confirmationDialogOpen && ( <div className="flex flex-row">
<AlertDialog <Slider
open={confirmationDialogOpen} id="motion-contour-area"
onOpenChange={() => setConfirmationDialogOpen(false)} className="w-[300px]"
disabled={motionSettings.contour_area === undefined}
value={[motionSettings.contour_area ?? 0]}
min={10}
max={200}
step={5}
onValueChange={(value) => {
handleMotionConfigChange({ contour_area: value[0] });
}}
/>
<Label htmlFor="motion-contour-area" className="px-2">
Contour Area: {motionSettings.contour_area}
</Label>
</div>
<div className="flex flex-row">
<Switch
id="improve-contrast"
disabled={motionSettings.improve_contrast === undefined}
checked={motionSettings.improve_contrast === true}
onCheckedChange={(isChecked) => {
handleMotionConfigChange({ improve_contrast: isChecked });
}}
/>
<Label htmlFor="improve-contrast">Improve Contrast</Label>
</div>
<div className="flex">
<Button
size="sm"
variant={isLoading ? "ghost" : "select"}
disabled={!changedValue || isLoading}
onClick={saveToConfig}
> >
<AlertDialogContent> {isLoading ? "Saving..." : "Save to Config"}
<AlertDialogHeader> </Button>
<AlertDialogTitle> </div>
You have unsaved changes on this camera. </div>
</AlertDialogTitle> {confirmationDialogOpen && (
<AlertDialogDescription> <AlertDialog
Do you want to save your changes before continuing? open={confirmationDialogOpen}
</AlertDialogDescription> onOpenChange={() => setConfirmationDialogOpen(false)}
</AlertDialogHeader> >
<AlertDialogFooter> <AlertDialogContent>
<AlertDialogCancel onClick={() => handleDialog(false)}> <AlertDialogHeader>
Cancel <AlertDialogTitle>
</AlertDialogCancel> You have unsaved changes on this camera.
<AlertDialogAction onClick={() => handleDialog(true)}> </AlertDialogTitle>
Save <AlertDialogDescription>
</AlertDialogAction> Do you want to save your changes before continuing?
</AlertDialogFooter> </AlertDialogDescription>
</AlertDialogContent> </AlertDialogHeader>
</AlertDialog> <AlertDialogFooter>
)} <AlertDialogCancel onClick={() => handleDialog(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDialog(true)}>
Save
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{cameraConfig ? (
<div className="flex md:w-7/12 md:grow md:h-dvh md:max-h-full">
<div className="size-full min-h-10">
<AutoUpdatingCameraImage
camera={cameraConfig.name}
searchParams={new URLSearchParams([["motion", "1"]])}
showFps={false}
className="size-full"
cameraClasses="relative w-full h-full flex flex-col justify-start"
/>
</div>
</div> </div>
) : ( ) : (
<Skeleton className="size-full rounded-2xl" /> <Skeleton className="size-full rounded-2xl" />
)} )}
</> </div>
); );
} }

View File

@ -1,6 +1,6 @@
import React, { useMemo, useRef, useState, useEffect } from "react"; import React, { useMemo, useRef, useState, useEffect } from "react";
import PolygonDrawer from "./PolygonDrawer"; import PolygonDrawer from "./PolygonDrawer";
import { Stage, Layer, Image, Text } from "react-konva"; import { Stage, Layer, Image, Text, Circle } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node"; import type { KonvaEventObject } from "konva/lib/Node";
import { Polygon, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
@ -216,23 +216,43 @@ export function PolygonCanvas({
isHovered={index === hoveredPolygonIndex} isHovered={index === hoveredPolygonIndex}
isFinished={polygon.isFinished} isFinished={polygon.isFinished}
color={polygon.color} color={polygon.color}
name={polygon.name}
handlePointDragMove={handlePointDragMove} handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd} handleGroupDragEnd={handleGroupDragEnd}
handleMouseOverStartPoint={handleMouseOverStartPoint} handleMouseOverStartPoint={handleMouseOverStartPoint}
handleMouseOutStartPoint={handleMouseOutStartPoint} handleMouseOutStartPoint={handleMouseOutStartPoint}
/> />
{index === hoveredPolygonIndex && ( {index === hoveredPolygonIndex && (
<Text <>
text={polygon.name} <Circle
align="left" x={
verticalAlign="top" getAveragePoint(flattenPoints(polygon.points)).x //-
x={ //(polygon.name.length * 16 * 0.6) / 2
getAveragePoint(flattenPoints(polygon.points)).x }
// - (polygon.name.length * 16 * 0.6) / 2 y={
} getAveragePoint(flattenPoints(polygon.points)).y //-
y={getAveragePoint(flattenPoints(polygon.points)).y} //- 16 / 2} //16 / 2
fontSize={16} }
/> radius={2}
fill="red"
/>
<Text
text={polygon.name}
// align="left"
// verticalAlign="top"
align="center"
verticalAlign="middle"
x={
getAveragePoint(flattenPoints(polygon.points)).x //-
//(polygon.name.length * 16 * 0.6) / 2
}
y={
getAveragePoint(flattenPoints(polygon.points)).y //-
//16 / 2
}
fontSize={16}
/>
</>
)} )}
</React.Fragment> </React.Fragment>
), ),

View File

@ -1,6 +1,11 @@
import { useCallback, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { Line, Circle, Group } from "react-konva"; import { Line, Circle, Group, Text } from "react-konva";
import { minMax, toRGBColorString, dragBoundFunc } from "@/utils/canvasUtil"; import {
minMax,
toRGBColorString,
dragBoundFunc,
getAveragePoint,
} from "@/utils/canvasUtil";
import type { KonvaEventObject } from "konva/lib/Node"; import type { KonvaEventObject } from "konva/lib/Node";
import Konva from "konva"; import Konva from "konva";
import { Vector2d } from "konva/lib/types"; import { Vector2d } from "konva/lib/types";
@ -12,6 +17,7 @@ type PolygonDrawerProps = {
isHovered: boolean; isHovered: boolean;
isFinished: boolean; isFinished: boolean;
color: number[]; color: number[];
name: string;
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void; handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void; handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleMouseOverStartPoint: ( handleMouseOverStartPoint: (
@ -28,6 +34,7 @@ export default function PolygonDrawer({
isActive, isActive,
isHovered, isHovered,
isFinished, isFinished,
name,
color, color,
handlePointDragMove, handlePointDragMove,
handleGroupDragEnd, handleGroupDragEnd,
@ -38,6 +45,7 @@ export default function PolygonDrawer({
const [stage, setStage] = useState<Konva.Stage>(); const [stage, setStage] = useState<Konva.Stage>();
const [minMaxX, setMinMaxX] = useState([0, 0]); const [minMaxX, setMinMaxX] = useState([0, 0]);
const [minMaxY, setMinMaxY] = useState([0, 0]); const [minMaxY, setMinMaxY] = useState([0, 0]);
const groupRef = useRef<Konva.Group>(null);
const handleGroupMouseOver = ( const handleGroupMouseOver = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>, e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
@ -85,9 +93,12 @@ export default function PolygonDrawer({
[color], [color],
); );
// console.log(groupRef.current?.height());
return ( return (
<Group <Group
name="polygon" name="polygon"
ref={groupRef}
draggable={isActive && isFinished} draggable={isActive && isFinished}
onDragStart={isActive ? handleGroupDragStart : undefined} onDragStart={isActive ? handleGroupDragStart : undefined}
onDragEnd={isActive ? handleGroupDragEnd : undefined} onDragEnd={isActive ? handleGroupDragEnd : undefined}
@ -145,6 +156,26 @@ export default function PolygonDrawer({
/> />
); );
})} })}
{groupRef.current && (
<Text
text={name}
// align="left"
// verticalAlign="top"
width={groupRef.current.width()}
height={groupRef.current.height()}
align="center"
verticalAlign="middle"
x={
getAveragePoint(flattenedPoints).x //-
//(polygon.name.length * 16 * 0.6) / 2
}
y={
getAveragePoint(flattenedPoints).y //-
//16 / 2
}
fontSize={16}
/>
)}
</Group> </Group>
); );
} }

View File

@ -1,10 +1,12 @@
import { Polygon } from "@/types/canvas"; import { Polygon } from "@/types/canvas";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
type PolygonEditControlsProps = { type PolygonEditControlsProps = {
polygons: Polygon[]; polygons: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>; setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex: number | null; activePolygonIndex: number | undefined;
}; };
export default function PolygonEditControls({ export default function PolygonEditControls({
@ -13,39 +15,61 @@ export default function PolygonEditControls({
activePolygonIndex, activePolygonIndex,
}: PolygonEditControlsProps) { }: PolygonEditControlsProps) {
const undo = () => { const undo = () => {
if (activePolygonIndex !== null && polygons) { if (activePolygonIndex === undefined || !polygons) {
const updatedPolygons = [...polygons]; return;
const activePolygon = updatedPolygons[activePolygonIndex];
if (activePolygon.points.length > 0) {
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: activePolygon.points.slice(0, -1),
isFinished: false,
};
setPolygons(updatedPolygons);
}
} }
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: [...activePolygon.points.slice(0, -1)],
isFinished: false,
};
setPolygons(updatedPolygons);
}; };
const reset = () => { const reset = () => {
if (activePolygonIndex !== null) { if (activePolygonIndex === undefined || !polygons) {
const updatedPolygons = [...polygons]; return;
updatedPolygons[activePolygonIndex] = {
...updatedPolygons[activePolygonIndex],
points: [],
};
setPolygons(updatedPolygons);
} }
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: [],
isFinished: false,
};
setPolygons(updatedPolygons);
}; };
return ( return (
<div className="flex"> <div className="flex flex-row justify-center gap-2">
<Button className="mr-5" variant="secondary" onClick={undo}> <Tooltip>
Undo <TooltipTrigger asChild>
</Button> <Button
<Button variant="secondary" onClick={reset}> variant="secondary"
Reset className="size-6 p-1 rounded-md text-background bg-secondary-foreground"
</Button> onClick={undo}
>
<MdUndo />
</Button>
</TooltipTrigger>
<TooltipContent>Undo</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
className="size-6 p-1 rounded-md text-background bg-secondary-foreground"
onClick={reset}
>
<MdOutlineRestartAlt />
</Button>
</TooltipTrigger>
<TooltipContent>Reset</TooltipContent>
</Tooltip>
</div> </div>
); );
} }

View File

@ -21,9 +21,11 @@ import { z } from "zod";
import { Polygon } from "@/types/canvas"; import { Polygon } from "@/types/canvas";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import PolygonEditControls from "./PolygonEditControls";
type ZoneEditPaneProps = { type ZoneEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex?: number; activePolygonIndex?: number;
onSave?: () => void; onSave?: () => void;
onCancel?: () => void; onCancel?: () => void;
@ -31,6 +33,7 @@ type ZoneEditPaneProps = {
export function ZoneEditPane({ export function ZoneEditPane({
polygons, polygons,
setPolygons,
activePolygonIndex, activePolygonIndex,
onSave, onSave,
onCancel, onCancel,
@ -133,10 +136,26 @@ export function ZoneEditPane({
<Heading as="h3" className="my-2"> <Heading as="h3" className="my-2">
Zone Zone
</Heading> </Heading>
<div className="flex my-3"> <Separator className="my-3 bg-secondary" />
<Separator className="bg-secondary" /> {polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1">
{polygons[activePolygonIndex].points.length} points
</div>
{polygons[activePolygonIndex].isFinished ? <></> : <></>}
<PolygonEditControls
polygons={polygons}
setPolygons={setPolygons}
activePolygonIndex={activePolygonIndex}
/>
</div>
)}
<div className="mb-3 text-sm text-muted-foreground">
Click to draw a polygon on the image.
</div> </div>
<Separator className="my-3 bg-secondary" />
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField <FormField
@ -146,8 +165,16 @@ export function ZoneEditPane({
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Enter a name..." {...field} /> <Input
className="w-full p-2 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="Enter a name..."
{...field}
/>
</FormControl> </FormControl>
<FormDescription>
Name must be at least 2 characters and must not be the name of
a camera or another zone.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -162,7 +189,11 @@ export function ZoneEditPane({
<FormItem> <FormItem>
<FormLabel>Inertia</FormLabel> <FormLabel>Inertia</FormLabel>
<FormControl> <FormControl>
<Input placeholder="3" {...field} /> <Input
className="w-full p-2 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="3"
{...field}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Specifies how many frames that an object must be in a zone Specifies how many frames that an object must be in a zone
@ -182,7 +213,11 @@ export function ZoneEditPane({
<FormItem> <FormItem>
<FormLabel>Loitering Time</FormLabel> <FormLabel>Loitering Time</FormLabel>
<FormControl> <FormControl>
<Input placeholder="0" {...field} /> <Input
className="w-full p-2 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0"
{...field}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Sets a minimum amount of time in seconds that the object must Sets a minimum amount of time in seconds that the object must

View File

@ -86,7 +86,7 @@ export default function Settings() {
page == "masks / zones" || page == "masks / zones" ||
page == "motion tuner") && ( page == "motion tuner") && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isEditing && ( {page == "masks / zones" && (
<ZoneMaskFilterButton <ZoneMaskFilterButton
selectedZoneMask={filterZoneMask} selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask} updateZoneMaskFilter={setFilterZoneMask}
@ -101,7 +101,7 @@ export default function Settings() {
</div> </div>
)} )}
</div> </div>
<div className="mt-2 flex flex-col items-start w-full h-dvh md:pb-24"> <div className="mt-2 flex flex-col items-start w-full h-full md:h-dvh pb-9 md:pb-24">
{page == "general" && <General />} {page == "general" && <General />}
{page == "objects" && <></>} {page == "objects" && <></>}
{page == "masks / zones" && ( {page == "masks / zones" && (
@ -114,7 +114,9 @@ export default function Settings() {
setUnsavedChanges={setUnsavedChanges} setUnsavedChanges={setUnsavedChanges}
/> />
)} )}
{page == "motion tuner" && <MotionTuner />} {page == "motion tuner" && (
<MotionTuner selectedCamera={selectedCamera} />
)}
</div> </div>
</div> </div>
); );

View File

@ -133,8 +133,8 @@
--secondary-highlight: hsl(0, 0%, 25%); --secondary-highlight: hsl(0, 0%, 25%);
--secondary-highlight: 0 0% 25%; --secondary-highlight: 0 0% 25%;
--muted: hsl(0, 0%, 8%); --muted: hsl(0, 0%, 15%);
--muted: 0 0% 8%; --muted: 0 0% 15%;
--muted-foreground: hsl(0, 0%, 32%); --muted-foreground: hsl(0, 0%, 32%);
--muted-foreground: 0 0% 32%; --muted-foreground: 0 0% 32%;