merge dev

This commit is contained in:
Josh Hawkins 2024-04-11 15:48:35 -05:00
parent 8d0385c991
commit bd64e6f873
13 changed files with 511 additions and 211 deletions

29
web/package-lock.json generated
View File

@ -21,6 +21,7 @@
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
@ -1650,6 +1651,29 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz",
"integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz",
@ -2568,11 +2592,6 @@
"@types/react": "*"
}
},
"node_modules/@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
},
"node_modules/@types/semver": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",

View File

@ -26,6 +26,7 @@
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",

View File

@ -6,8 +6,8 @@ import { FrigateStats } from "@/types/stats";
import { useFrigateStats } from "@/api/ws";
import { useMemo } from "react";
import useStats from "@/hooks/use-stats";
import GeneralSettings from "../settings/GeneralSettings";
import AccountSettings from "../settings/AccountSettings";
import GeneralSettings from "../menu/GeneralSettings";
import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
function Bottombar() {

View File

@ -2,8 +2,8 @@ import Logo from "../Logo";
import NavItem from "./NavItem";
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
import { useLocation } from "react-router-dom";
import GeneralSettings from "../settings/GeneralSettings";
import AccountSettings from "../settings/AccountSettings";
import GeneralSettings from "../menu/GeneralSettings";
import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
function Sidebar() {

View File

@ -0,0 +1,40 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
export default function General() {
return (
<>
<Heading as="h2">Settings</Heading>
<div className="flex items-center space-x-2 mt-5">
<Switch id="lowdata" checked={false} onCheckedChange={() => {}} />
<Label htmlFor="lowdata">Low Data Mode (this device only)</Label>
</div>
<div className="flex items-center space-x-2 mt-5">
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Another General Option" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Live Mode</SelectLabel>
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
<SelectItem value="mse">MSE</SelectItem>
<SelectItem value="webrtc">WebRTC</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</>
);
}

View File

@ -1,13 +1,4 @@
import Heading from "@/components/ui/heading";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
@ -23,12 +14,16 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PolygonCanvas } from "./PolygonCanvas";
import { Polygon } from "@/types/canvas";
import { interpolatePoints } from "@/utils/canvasUtil";
import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil";
import { isDesktop } from "react-device-detect";
import ZoneControls, { ZoneObjectSelector } from "./ZoneControls";
import ZoneControls, {
NewZoneButton,
ZoneObjectSelector,
} from "./NewZoneButton";
import { Skeleton } from "../ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer";
import { LuPencil } from "react-icons/lu";
import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu";
import { FaDrawPolygon } from "react-icons/fa";
const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(",");
@ -49,7 +44,15 @@ export type ZoneObjects = {
objects: string[];
};
export default function SettingsZones() {
type MasksAndZoneProps = {
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
};
export default function MasksAndZones({
selectedCamera,
setSelectedCamera,
}: MasksAndZoneProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const [zonePolygons, setZonePolygons] = useState<Polygon[]>([]);
const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]);
@ -68,8 +71,6 @@ export default function SettingsZones() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState(cameras[0].name);
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
@ -137,7 +138,7 @@ export default function SettingsZones() {
[setZoneObjects],
);
const grow = useMemo(() => {
const growe = useMemo(() => {
if (!cameraConfig) {
return;
}
@ -157,14 +158,51 @@ export default function SettingsZones() {
}
}, [cameraConfig]);
const handleCameraChange = useCallback(
(camera: string) => {
setSelectedCamera(camera);
setActivePolygonIndex(null);
const getCameraAspect = useCallback(
(cam: string) => {
if (!config) {
return undefined;
}
const camera = config.cameras[cam];
if (!camera) {
return undefined;
}
return camera.detect.width / camera.detect.height;
},
[setSelectedCamera, setActivePolygonIndex],
[config],
);
const mainCameraAspect = useMemo(() => {
const aspectRatio = getCameraAspect(selectedCamera);
if (!aspectRatio) {
return "normal";
} else if (aspectRatio > 2) {
return "wide";
} else if (aspectRatio < 16 / 9) {
return "tall";
} else {
return "normal";
}
}, [getCameraAspect, selectedCamera]);
const grow = useMemo(() => {
if (mainCameraAspect == "wide") {
return "w-full aspect-wide";
} else if (mainCameraAspect == "tall") {
if (isDesktop) {
return "size-full aspect-tall flex flex-col justify-center";
} else {
return "size-full";
}
} else {
return "w-full aspect-video";
}
}, [mainCameraAspect]);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
@ -173,13 +211,16 @@ export default function SettingsZones() {
: { width: 1, height: 1 };
const aspectRatio = width / height;
const stretch = false;
const fitAspect = 0.75;
const stretch = true;
const fitAspect = 16 / 9;
// console.log(containerRef.current?.clientHeight);
const scaledHeight = useMemo(() => {
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(containerHeight)
? Math.floor(
Math.min(containerHeight, containerRef.current?.clientHeight),
)
: Math.floor(containerWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
@ -244,57 +285,86 @@ export default function SettingsZones() {
console.log("component zone objects", zoneObjects);
}, [zoneObjects]);
useEffect(() => {
if (selectedCamera) {
setActivePolygonIndex(null);
}
}, [selectedCamera]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
}
return (
<div className="overflow-auto">
<Heading as="h2">Zones</Heading>
<div className="flex items-center space-x-2 mt-5">
<Select value={selectedCamera} onValueChange={handleCameraChange}>
<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-evenly">
<div
className={`flex flex-col justify-center items-center w-full md:w-[60%] ${grow}`}
>
<div ref={containerRef} className="size-full">
{cameraConfig ? (
<PolygonCanvas
<div className="flex flex-col md:flex-row size-full">
<div className="flex flex-col order-last w-full md:w-3/12 md:order-none md:mr-2">
<div className="flex mb-3">
<Separator />
</div>
<div className="flex flex-row justify-between items-center mb-3">
<div className="text-md">Zones</div>
<NewZoneButton
camera={cameraConfig.name}
width={scaledWidth}
height={scaledHeight}
polygons={zonePolygons}
setPolygons={setZonePolygons}
activePolygonIndex={activePolygonIndex}
setActivePolygonIndex={setActivePolygonIndex}
/>
</div>
{zonePolygons.map((polygon, index) => (
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 mb-1"
style={{
backgroundColor:
activePolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
<div
className={`flex items-center ${activePolygonIndex === index ? "text-primary" : "text-secondary-foreground"}`}
>
<FaDrawPolygon
className="size-4 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
{polygon.name}
</div>
<div className="flex flex-row gap-2">
<div
className="cursor-pointer"
onClick={() => setActivePolygonIndex(index)}
>
<LuPencil
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-secondary-foreground"}`}
/>
</div>
<LuCopy
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-secondary-foreground"}`}
/>
<div
className="cursor-pointer"
onClick={() => {
setZonePolygons((oldPolygons) => {
return oldPolygons.filter((_, i) => i !== index);
});
setActivePolygonIndex(null);
}}
>
<LuTrash
className={`size-4 ${activePolygonIndex === index ? "text-primary fill-primary" : "text-secondary-foreground fill-secondary-foreground"}`}
/>
) : (
<Skeleton className="w-full h-full" />
)}
</div>
</div>
<div className="w-full md:w-[30%]">
<Table>
</div>
))}
{/* <Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Name</TableHead>
@ -372,10 +442,29 @@ export default function SettingsZones() {
0,
)}
</pre>
</div> */}
</div>
<div
ref={containerRef}
className="flex md:w-7/12 md:grow md:h-dvh md:max-h-[90%]"
>
<div className="size-full">
{cameraConfig ? (
<PolygonCanvas
camera={cameraConfig.name}
width={scaledWidth}
height={scaledHeight}
polygons={zonePolygons}
setPolygons={setZonePolygons}
activePolygonIndex={activePolygonIndex}
/>
) : (
<Skeleton className="w-full h-full" />
)}
</div>
</div>
</div>
)}
</div>
</>
);
}

View File

@ -16,6 +16,7 @@ import { Button } from "../ui/button";
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { isMobile } from "react-device-detect";
import { LuPlusSquare } from "react-icons/lu";
type ZoneObjectSelectorProps = {
camera: string;
@ -126,7 +127,7 @@ export function ZoneObjectSelector({
);
}
type ZoneControlsProps = {
type NewZoneButtonProps = {
camera: string;
polygons: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
@ -134,13 +135,13 @@ type ZoneControlsProps = {
setActivePolygonIndex: React.Dispatch<React.SetStateAction<number | null>>;
};
export function ZoneControls({
export function NewZoneButton({
camera,
polygons,
setPolygons,
activePolygonIndex,
setActivePolygonIndex,
}: ZoneControlsProps) {
}: NewZoneButtonProps) {
const { data: config } = useSWR("config");
const [zoneName, setZoneName] = useState<string | null>();
const [invalidName, setInvalidName] = useState<boolean>();
@ -190,16 +191,20 @@ export function ZoneControls({
};
return (
<div className="flex flex-col">
<div className="flex justify-between items-center my-5">
<Button className="mr-5" variant="secondary" onClick={undo}>
<div className="flex">
{/* <Button className="mr-5" variant="secondary" onClick={undo}>
Undo
</Button>
<Button variant="secondary" onClick={reset}>
Reset
</Button>
<Button variant="secondary" onClick={() => setDialogOpen(true)}>
New Zone
</Button> */}
<Button
variant="ghost"
className="h-8 px-0"
onClick={() => setDialogOpen(true)}
>
<LuPlusSquare />
</Button>
<Dialog
open={dialogOpen}
@ -214,8 +219,8 @@ export function ZoneControls({
{isMobile && <span tabIndex={0} className="sr-only" />}
<DialogTitle>New Zone</DialogTitle>
<DialogDescription>
Enter a unique label for your zone. Do not include spaces, and
don't use the name of a camera.
Enter a unique label for your zone. Do not include spaces, and don't
use the name of a camera.
</DialogDescription>
<>
<Input
@ -226,9 +231,7 @@ export function ZoneControls({
setInvalidName(
Object.keys(config.cameras).includes(e.target.value) ||
e.target.value.includes(" ") ||
polygons
.map((item) => item.name)
.includes(e.target.value),
polygons.map((item) => item.name).includes(e.target.value),
);
setZoneName(e.target.value);
@ -257,8 +260,7 @@ export function ZoneControls({
</DialogContent>
</Dialog>
</div>
</div>
);
}
export default ZoneControls;
export default NewZoneButton;

View File

@ -1,6 +1,6 @@
import { useCallback, useState } from "react";
import { Line, Circle, Group } from "react-konva";
import { minMax, dragBoundFunc } from "@/utils/canvasUtil";
import { minMax, toRGBColorString, dragBoundFunc } from "@/utils/canvasUtil";
import type { KonvaEventObject } from "konva/lib/Node";
import Konva from "konva";
import { Vector2d } from "konva/lib/types";
@ -78,11 +78,7 @@ export default function PolygonDrawer({
const colorString = useCallback(
(darkened: boolean) => {
if (color.length !== 3) {
return "rgb(220,0,0,0.5)";
}
return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.8" : "0.5"})`;
return toRGBColorString(color, darkened);
},
[color],
);

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -1,75 +1,191 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import MotionTuner from "@/components/settings/MotionTuner";
import SettingsZones from "@/components/settings/Zones";
import MasksAndZones from "@/components/settings/MasksAndZones";
import { Button } from "@/components/ui/button";
import { useMemo, useState } from "react";
import useOptimisticState from "@/hooks/use-optimistic-state";
import Logo from "@/components/Logo";
import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import General from "@/components/settings/General";
import FilterCheckBox from "@/components/filter/FilterCheckBox";
function General() {
return (
<>
<Heading as="h2">Settings</Heading>
<div className="flex items-center space-x-2 mt-5">
<Switch id="detect" checked={false} onCheckedChange={() => {}} />
<Label htmlFor="detect">
Always show PTZ controls for ONVIF cameras
</Label>
type CameraSelectButtonProps = {
allCameras: CameraConfig[];
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
};
function CameraSelectButton({
allCameras,
selectedCamera,
setSelectedCamera,
}: CameraSelectButtonProps) {
const [open, setOpen] = useState(false);
const trigger = (
<Button
className="flex items-center gap-2 capitalize bg-selected hover:bg-selected"
size="sm"
>
<FaVideo className="text-background dark:text-primary" />
<div className="hidden md:block text-background dark:text-primary">
{selectedCamera == undefined ? "No Camera" : selectedCamera}
</div>
<div className="flex items-center space-x-2 mt-5">
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Default Live Mode" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Live Mode</SelectLabel>
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
<SelectItem value="mse">MSE</SelectItem>
<SelectItem value="webrtc">WebRTC</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Button>
);
const content = (
<>
{isMobile && (
<>
<DropdownMenuLabel className="flex justify-center">
Camera
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<div className="h-auto overflow-y-auto overflow-x-hidden pb-4 md:pb-0">
{allCameras.map((item) => (
<FilterCheckBox
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name}
onCheckedChange={(isChecked) => {
if (isChecked) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
/>
))}
</div>
</>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}
export default function Settings() {
const settingsViews = [
"general",
"objects",
"masks / zones",
"motion tuner",
] as const;
type SettingsType = (typeof settingsViews)[number];
const [page, setPage] = useState<SettingsType>("general");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const { data: config } = useSWR<FrigateConfig>("config");
const cameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState(cameras[0].name);
return (
<div className="w-full h-full">
<div className="flex h-full">
<div className="flex-1 content-start gap-2 overflow-y-auto no-scrollbar mt-4 mr-5">
<Tabs defaultValue="general" className="w-auto">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="objects">Objects</TabsTrigger>
<TabsTrigger value="zones">Zones</TabsTrigger>
<TabsTrigger value="masks">Masks</TabsTrigger>
<TabsTrigger value="motion">Motion</TabsTrigger>
</TabsList>
<TabsContent value="general">
<General />
</TabsContent>
<TabsContent value="objects">Objects</TabsContent>
<TabsContent value="zones">
<SettingsZones />
</TabsContent>
<TabsContent value="masks">Masks</TabsContent>
<TabsContent value="motion">
<MotionTuner />
</TabsContent>
</Tabs>
<div className="size-full p-2 flex flex-col">
<div className="w-full h-11 relative flex justify-between items-center">
{isMobile && (
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
)}
<ToggleGroup
className="*:px-3 *:py-4 *:rounded-md"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SettingsType) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(settingsViews).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-gray-500"}`}
value={item}
aria-label={`Select ${item}`}
>
<div className="capitalize">{item}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
{(page == "objects" ||
page == "masks / zones" ||
page == "motion tuner") && (
<div className="flex items-center gap-2">
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
/>
</div>
)}
</div>
<div className="mt-2 flex flex-col items-start">
{page == "general" && <General />}
{page == "objects" && <></>}
{page == "masks / zones" && (
<MasksAndZones
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
/>
)}
{page == "motion tuner" && <MotionTuner />}
</div>
</div>
);

View File

@ -63,3 +63,11 @@ export const interpolatePoints = (
return newPoints;
};
export const toRGBColorString = (color: number[], darkened: boolean) => {
if (color.length !== 3) {
return "rgb(220,0,0,0.5)";
}
return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.9" : "0.5"})`;
};