diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index ddb78c877..bc5fb61af 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -1,21 +1,25 @@ import Heading from "../ui/heading"; import { Separator } from "../ui/separator"; import { Button } from "@/components/ui/button"; -import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { useCallback, useEffect, useMemo } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { z } from "zod"; import PolygonEditControls from "./PolygonEditControls"; import { FaCheckCircle } from "react-icons/fa"; -import { Polygon } from "@/types/canvas"; +import { MotionMaskFormValuesType, Polygon } from "@/types/canvas"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; -import { - flattenPoints, - interpolatePoints, - parseCoordinates, -} from "@/utils/canvasUtil"; +import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil"; import axios from "axios"; import { toast } from "sonner"; import { Toaster } from "../ui/sonner"; @@ -24,6 +28,8 @@ import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; import { Trans, useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; +import NameAndIdFields from "../input/NameAndIdFields"; +import { Switch } from "../ui/switch"; type MotionMaskEditPaneProps = { polygons?: Polygon[]; @@ -73,12 +79,24 @@ export default function MotionMaskEditPane({ const defaultName = useMemo(() => { if (!polygons) { - return; + return ""; } const count = polygons.filter((poly) => poly.type == "motion_mask").length; - return `Motion Mask ${count + 1}`; + return t("masksAndZones.motionMasks.defaultName", { + number: count + 1, + }); + }, [polygons, t]); + + const defaultId = useMemo(() => { + if (!polygons) { + return ""; + } + + const count = polygons.filter((poly) => poly.type == "motion_mask").length; + + return `motion_mask_${count + 1}`; }, [polygons]); const polygonArea = useMemo(() => { @@ -104,116 +122,154 @@ export default function MotionMaskEditPane({ } }, [polygon, scaledWidth, scaledHeight]); - const formSchema = z - .object({ - polygon: z.object({ name: z.string(), isFinished: z.boolean() }), - }) - .refine(() => polygon?.isFinished === true, { + const formSchema = z.object({ + name: z + .string() + .min(1, { + message: t("masksAndZones.form.id.error.mustNotBeEmpty"), + }) + .refine( + (value: string) => { + // When editing, allow the same name + if (polygon?.name && value === polygon.name) { + return true; + } + // Check if mask ID already exists + const existingMaskIds = Object.keys(cameraConfig?.motion.mask || {}); + return !existingMaskIds.includes(value); + }, + { + message: t("masksAndZones.form.id.error.alreadyExists"), + }, + ), + friendly_name: z.string().min(1, { + message: t("masksAndZones.form.name.error.mustNotBeEmpty"), + }), + enabled: z.boolean(), + isFinished: z.boolean().refine(() => polygon?.isFinished === true, { message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"), - path: ["polygon.isFinished"], - }); + }), + }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { - polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, + name: polygon?.name || defaultId, + friendly_name: polygon?.friendly_name || defaultName, + enabled: polygon?.enabled ?? true, + isFinished: polygon?.isFinished ?? false, }, }); - const saveToConfig = useCallback(async () => { - if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { - return; - } + const saveToConfig = useCallback( + async ({ + name: maskId, + friendly_name, + enabled, + }: MotionMaskFormValuesType) => { + if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { + return; + } - const coordinates = flattenPoints( - interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), - ).join(","); + const coordinates = flattenPoints( + interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), + ).join(","); - let index = Array.isArray(cameraConfig.motion.mask) - ? cameraConfig.motion.mask.length - : cameraConfig.motion.mask - ? 1 - : 0; + const editingMask = polygon.name.length > 0; + const renamingMask = editingMask && maskId !== polygon.name; - const editingMask = polygon.name.length > 0; + // Build the new mask configuration + const maskConfig = { + friendly_name: friendly_name, + enabled: enabled, + coordinates: coordinates, + }; - // editing existing mask, not creating a new one - if (editingMask) { - index = polygon.typeIndex; - } - - const filteredMask = ( - Array.isArray(cameraConfig.motion.mask) - ? cameraConfig.motion.mask - : [cameraConfig.motion.mask] - ).filter((_, currentIndex) => currentIndex !== index); - - filteredMask.splice(index, 0, coordinates); - - const queryString = filteredMask - .map((pointsArray) => { - const coordinates = flattenPoints(parseCoordinates(pointsArray)).join( - ",", - ); - return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`; - }) - .join(""); - - axios - .put(`config/set?${queryString}`, { - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/motion`, - }) - .then((res) => { - if (res.status === 200) { - toast.success( - polygon.name - ? t("masksAndZones.motionMasks.toast.success.title", { - polygonName: polygon.name, - }) - : t("masksAndZones.motionMasks.toast.success.noName"), + // If renaming, we need to delete the old mask first + if (renamingMask) { + try { + await axios.put( + `config/set?cameras.${polygon.camera}.motion.mask.${polygon.name}`, { - position: "top-center", + requires_restart: 0, }, ); - updateConfig(); - } else { - toast.error( - t("toast.save.error.title", { - errorMessage: res.statusText, - ns: "common", - }), - { - position: "top-center", - }, - ); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error( - t("toast.save.error.title", { errorMessage, ns: "common" }), - { + } catch (error) { + toast.error(t("toast.save.error.noMessage", { ns: "common" }), { position: "top-center", + }); + setIsLoading(false); + return; + } + } + + // Save the new/updated mask using JSON body + axios + .put("config/set", { + config_data: { + cameras: { + [polygon.camera]: { + motion: { + mask: { + [maskId]: maskConfig, + }, + }, + }, + }, }, - ); - }) - .finally(() => { - setIsLoading(false); - }); - }, [ - updateConfig, - polygon, - scaledWidth, - scaledHeight, - setIsLoading, - cameraConfig, - t, - ]); + requires_restart: 0, + update_topic: `config/cameras/${polygon.camera}/motion`, + }) + .then((res) => { + if (res.status === 200) { + toast.success( + t("masksAndZones.motionMasks.toast.success.title", { + polygonName: friendly_name || maskId, + }), + { + position: "top-center", + }, + ); + updateConfig(); + } else { + toast.error( + t("toast.save.error.title", { + errorMessage: res.statusText, + ns: "common", + }), + { + position: "top-center", + }, + ); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { + position: "top-center", + }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [ + updateConfig, + polygon, + scaledWidth, + scaledHeight, + setIsLoading, + cameraConfig, + t, + ], + ); function onSubmit(values: z.infer) { if (activePolygonIndex === undefined || !values || !polygons) { @@ -221,7 +277,7 @@ export default function MotionMaskEditPane({ } setIsLoading(true); - saveToConfig(); + saveToConfig(values as MotionMaskFormValuesType); if (onSave) { onSave(); } @@ -310,58 +366,83 @@ export default function MotionMaskEditPane({ )} -
- - ( - - - - )} - /> - ( - - - - )} - /> -
-
- - + + + + + )} + /> + ( + + + + )} + /> +
+
+ + +
-
- - + + + ); } diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 2874c8b92..ff9336d9f 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -23,22 +23,20 @@ import { useCallback, useEffect, useMemo } from "react"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { z } from "zod"; import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas"; import PolygonEditControls from "./PolygonEditControls"; import { FaCheckCircle } from "react-icons/fa"; -import { - flattenPoints, - interpolatePoints, - parseCoordinates, -} from "@/utils/canvasUtil"; +import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil"; import axios from "axios"; import { toast } from "sonner"; import { Toaster } from "../ui/sonner"; import ActivityIndicator from "../indicators/activity-indicator"; import { useTranslation } from "react-i18next"; import { getTranslatedLabel } from "@/utils/i18n"; +import NameAndIdFields from "../input/NameAndIdFields"; +import { Switch } from "../ui/switch"; type ObjectMaskEditPaneProps = { polygons?: Polygon[]; @@ -87,7 +85,7 @@ export default function ObjectMaskEditPane({ const defaultName = useMemo(() => { if (!polygons) { - return; + return ""; } const count = polygons.filter((poly) => poly.type == "object_mask").length; @@ -95,40 +93,81 @@ export default function ObjectMaskEditPane({ let objectType = ""; const objects = polygon?.objects[0]; if (objects === undefined) { - objectType = "all objects"; + objectType = t("masksAndZones.zones.allObjects"); } else { - objectType = objects; + objectType = getTranslatedLabel(objects); } return t("masksAndZones.objectMaskLabel", { number: count + 1, - label: getTranslatedLabel(objectType), + label: objectType, }); }, [polygons, polygon, t]); - const formSchema = z - .object({ - objects: z.string(), - polygon: z.object({ isFinished: z.boolean(), name: z.string() }), - }) - .refine(() => polygon?.isFinished === true, { + const defaultId = useMemo(() => { + if (!polygons) { + return ""; + } + + const count = polygons.filter((poly) => poly.type == "object_mask").length; + + return `object_mask_${count + 1}`; + }, [polygons]); + + const formSchema = z.object({ + name: z + .string() + .min(1, { + message: t("masksAndZones.form.id.error.mustNotBeEmpty"), + }) + .refine( + (value: string) => { + // When editing, allow the same name + if (polygon?.name && value === polygon.name) { + return true; + } + // Check if mask ID already exists in global masks or filter masks + const globalMaskIds = Object.keys(cameraConfig?.objects.mask || {}); + const filterMaskIds = Object.values( + cameraConfig?.objects.filters || {}, + ).flatMap((filter) => Object.keys(filter.mask || {})); + return ( + !globalMaskIds.includes(value) && !filterMaskIds.includes(value) + ); + }, + { + message: t("masksAndZones.form.id.error.alreadyExists"), + }, + ), + friendly_name: z.string().min(1, { + message: t("masksAndZones.form.name.error.mustNotBeEmpty"), + }), + enabled: z.boolean(), + objects: z.string(), + isFinished: z.boolean().refine(() => polygon?.isFinished === true, { message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"), - path: ["polygon.isFinished"], - }); + }), + }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { + name: polygon?.name || defaultId, + friendly_name: polygon?.friendly_name || defaultName, + enabled: polygon?.enabled ?? true, objects: polygon?.objects[0] ?? "all_labels", - polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, + isFinished: polygon?.isFinished ?? false, }, }); const saveToConfig = useCallback( - async ( - { objects: form_objects }: ObjectMaskFormValuesType, // values submitted via the form - ) => { + async ({ + name: maskId, + friendly_name, + enabled, + objects: form_objects, + }: ObjectMaskFormValuesType) => { if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { return; } @@ -137,88 +176,87 @@ export default function ObjectMaskEditPane({ interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), ).join(","); - let queryString = ""; - let configObject; - let createFilter = false; - let globalMask = false; - let filteredMask = [coordinates]; const editingMask = polygon.name.length > 0; + const renamingMask = editingMask && maskId !== polygon.name; + const globalMask = form_objects === "all_labels"; - // global mask on camera for all objects - if (form_objects == "all_labels") { - configObject = cameraConfig.objects.mask; - globalMask = true; + // Build the mask configuration + const maskConfig = { + friendly_name: friendly_name, + enabled: enabled, + coordinates: coordinates, + }; + + // If renaming, delete the old mask first + if (renamingMask) { + try { + // Determine if old mask was global or per-object + const wasGlobal = + polygon.objects.length === 0 || polygon.objects[0] === "all_labels"; + const oldPath = wasGlobal + ? `cameras.${polygon.camera}.objects.mask.${polygon.name}` + : `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`; + + await axios.put(`config/set?${oldPath}`, { + requires_restart: 0, + }); + } catch (error) { + toast.error(t("toast.save.error.noMessage", { ns: "common" }), { + position: "top-center", + }); + setIsLoading(false); + return; + } + } + + // Build the config structure based on whether it's global or per-object + let configBody; + if (globalMask) { + configBody = { + config_data: { + cameras: { + [polygon.camera]: { + objects: { + mask: { + [maskId]: maskConfig, + }, + }, + }, + }, + }, + requires_restart: 0, + update_topic: `config/cameras/${polygon.camera}/objects`, + }; } else { - if ( - cameraConfig.objects.filters[form_objects] && - cameraConfig.objects.filters[form_objects].mask !== null - ) { - configObject = cameraConfig.objects.filters[form_objects].mask; - } else { - createFilter = true; - } - } - - if (!createFilter) { - let index = Array.isArray(configObject) - ? configObject.length - : configObject - ? 1 - : 0; - - // editing existing mask, not creating a new one - if (editingMask) { - index = polygon.typeIndex; - } - - filteredMask = ( - Array.isArray(configObject) ? configObject : [configObject as string] - ).filter((_, currentIndex) => currentIndex !== index); - - filteredMask.splice(index, 0, coordinates); - } - - // prevent duplicating global masks under specific object filters - if (!globalMask) { - const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask) - ? cameraConfig.objects.mask - : cameraConfig.objects.mask - ? [cameraConfig.objects.mask] - : []; - - filteredMask = filteredMask.filter( - (mask) => !globalObjectMasksArray.includes(mask), - ); - } - - queryString = filteredMask - .map((pointsArray) => { - const coordinates = flattenPoints(parseCoordinates(pointsArray)).join( - ",", - ); - return globalMask - ? `cameras.${polygon?.camera}.objects.mask=${coordinates}&` - : `cameras.${polygon?.camera}.objects.filters.${form_objects}.mask=${coordinates}&`; - }) - .join(""); - - if (!queryString) { - return; + configBody = { + config_data: { + cameras: { + [polygon.camera]: { + objects: { + filters: { + [form_objects]: { + mask: { + [maskId]: maskConfig, + }, + }, + }, + }, + }, + }, + }, + requires_restart: 0, + update_topic: `config/cameras/${polygon.camera}/objects`, + }; } axios - .put(`config/set?${queryString}`, { - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/objects`, - }) + .put("config/set", configBody) .then((res) => { if (res.status === 200) { toast.success( - polygon.name - ? t("masksAndZones.objectMasks.toast.success.title", { - polygonName: polygon.name, - }) - : t("masksAndZones.objectMasks.toast.success.noName"), + t("masksAndZones.objectMasks.toast.success.title", { + polygonName: friendly_name || maskId, + }), { position: "top-center", }, @@ -323,89 +361,118 @@ export default function ObjectMaskEditPane({ -
- -
- ( - - - - )} - /> - ( - - - {t("masksAndZones.objectMasks.objects.title")} - - - - {t("masksAndZones.objectMasks.objects.desc")} - - - - )} - /> - ( - - - - )} - /> -
-
-
- - + placeholderName={t( + "masksAndZones.objectMasks.name.placeholder", + )} + /> + ( + +
+ + {t("masksAndZones.masks.enabled.title")} + + + {t("masksAndZones.masks.enabled.description")} + +
+ + + +
+ )} + /> + ( + + + {t("masksAndZones.objectMasks.objects.title")} + + + + {t("masksAndZones.objectMasks.objects.desc")} + + + + )} + /> + ( + + + + )} + />
-
-
- +
+
+ + +
+
+ + + ); } diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index 015bf9bd2..98c69c4ef 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -20,11 +20,7 @@ import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa"; import { BsPersonBoundingBox } from "react-icons/bs"; import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; import { isDesktop, isMobile } from "react-device-detect"; -import { - flattenPoints, - parseCoordinates, - toRGBColorString, -} from "@/utils/canvasUtil"; +import { toRGBColorString } from "@/utils/canvasUtil"; import { Polygon, PolygonType } from "@/types/canvas"; import { useCallback, useMemo, useState } from "react"; import axios from "axios"; @@ -81,93 +77,6 @@ export default function PolygonItem({ if (!polygon || !cameraConfig) { return; } - let url = ""; - if (polygon.type == "zone") { - const { alertQueries, detectionQueries } = reviewQueries( - polygon.name, - false, - false, - polygon.camera, - cameraConfig?.review.alerts.required_zones || [], - cameraConfig?.review.detections.required_zones || [], - ); - url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; - } - if (polygon.type == "motion_mask") { - const filteredMask = ( - Array.isArray(cameraConfig.motion.mask) - ? cameraConfig.motion.mask - : [cameraConfig.motion.mask] - ).filter((_, currentIndex) => currentIndex !== polygon.typeIndex); - - url = filteredMask - .map((pointsArray) => { - const coordinates = flattenPoints( - parseCoordinates(pointsArray), - ).join(","); - return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`; - }) - .join(""); - - if (!url) { - // deleting last mask - url = `cameras.${polygon?.camera}.motion.mask&`; - } - } - - if (polygon.type == "object_mask") { - let configObject; - let globalMask = false; - - // global mask on camera for all objects - if (!polygon.objects.length) { - configObject = cameraConfig.objects.mask; - globalMask = true; - } else { - configObject = cameraConfig.objects.filters[polygon.objects[0]].mask; - } - - if (!configObject) { - return; - } - - const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask) - ? cameraConfig.objects.mask - : cameraConfig.objects.mask - ? [cameraConfig.objects.mask] - : []; - - let filteredMask; - if (globalMask) { - filteredMask = ( - Array.isArray(configObject) ? configObject : [configObject] - ).filter((_, currentIndex) => currentIndex !== polygon.typeIndex); - } else { - filteredMask = ( - Array.isArray(configObject) ? configObject : [configObject] - ) - .filter((mask) => !globalObjectMasksArray.includes(mask)) - .filter((_, currentIndex) => currentIndex !== polygon.typeIndex); - } - - url = filteredMask - .map((pointsArray) => { - const coordinates = flattenPoints( - parseCoordinates(pointsArray), - ).join(","); - return globalMask - ? `cameras.${polygon?.camera}.objects.mask=${coordinates}&` - : `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask=${coordinates}&`; - }) - .join(""); - - if (!url) { - // deleting last mask - url = globalMask - ? `cameras.${polygon?.camera}.objects.mask&` - : `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask`; - } - } const updateTopicType = polygon.type === "zone" @@ -180,8 +89,115 @@ export default function PolygonItem({ setIsLoading(true); + if (polygon.type === "zone") { + // Zones use query string format + const { alertQueries, detectionQueries } = reviewQueries( + polygon.name, + false, + false, + polygon.camera, + cameraConfig?.review.alerts.required_zones || [], + cameraConfig?.review.detections.required_zones || [], + ); + const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; + + await axios + .put(`config/set?${url}`, { + requires_restart: 0, + update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, + }) + .then((res) => { + if (res.status === 200) { + toast.success( + t("masksAndZones.form.polygonDrawing.delete.success", { + name: polygon?.friendly_name ?? polygon?.name, + }), + { position: "top-center" }, + ); + updateConfig(); + } else { + toast.error( + t("toast.save.error.title", { + ns: "common", + errorMessage: res.statusText, + }), + { position: "top-center" }, + ); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + return; + } + + // Motion masks and object masks use JSON body format + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let configUpdate: any = {}; + + if (polygon.type === "motion_mask") { + // Delete mask from motion.mask dict by setting it to undefined + configUpdate = { + cameras: { + [polygon.camera]: { + motion: { + mask: { + [polygon.name]: null, // Setting to null will delete the key + }, + }, + }, + }, + }; + } + + if (polygon.type === "object_mask") { + // Determine if this is a global mask or object-specific mask + const isGlobalMask = !polygon.objects.length; + + if (isGlobalMask) { + configUpdate = { + cameras: { + [polygon.camera]: { + objects: { + mask: { + [polygon.name]: null, // Setting to null will delete the key + }, + }, + }, + }, + }; + } else { + configUpdate = { + cameras: { + [polygon.camera]: { + objects: { + filters: { + [polygon.objects[0]]: { + mask: { + [polygon.name]: null, // Setting to null will delete the key + }, + }, + }, + }, + }, + }, + }; + } + } + await axios - .put(`config/set?${url}`, { + .put("config/set", { + config_data: configUpdate, requires_restart: 0, update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, }) @@ -191,9 +207,7 @@ export default function PolygonItem({ t("masksAndZones.form.polygonDrawing.delete.success", { name: polygon?.friendly_name ?? polygon?.name, }), - { - position: "top-center", - }, + { position: "top-center" }, ); updateConfig(); } else { @@ -202,9 +216,7 @@ export default function PolygonItem({ ns: "common", errorMessage: res.statusText, }), - { - position: "top-center", - }, + { position: "top-center" }, ); } }) @@ -215,9 +227,7 @@ export default function PolygonItem({ "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), - { - position: "top-center", - }, + { position: "top-center" }, ); }) .finally(() => { @@ -238,7 +248,9 @@ export default function PolygonItem({
setHoveredPolygonIndex(index)} onMouseLeave={() => setHoveredPolygonIndex(null)} @@ -265,8 +277,11 @@ export default function PolygonItem({ }} /> )} -

+

{polygon.friendly_name ?? polygon.name} + {polygon.enabled === false && " (disabled)"}

({ - type: "motion_mask" as PolygonType, - typeIndex: index, - camera: cameraConfig.name, - name: t("masksAndZones.motionMaskLabel", { - number: index + 1, + // Motion masks are a dict with mask_id as key + motionMasks = Object.entries(cameraConfig.motion.mask || {}).map( + ([maskId, maskData], index) => ({ + type: "motion_mask" as PolygonType, + typeIndex: index, + camera: cameraConfig.name, + name: maskId, + friendly_name: maskData.friendly_name, + enabled: maskData.enabled, + objects: [], + points: interpolatePoints( + parseCoordinates(maskData.coordinates), + 1, + 1, + scaledWidth, + scaledHeight, + ), + distances: [], + isFinished: true, + color: maskData.enabled ? [0, 0, 255] : [100, 100, 100], }), - objects: [], - points: interpolatePoints( - parseCoordinates(maskData), - 1, - 1, - scaledWidth, - scaledHeight, - ), - distances: [], - isFinished: true, - color: [0, 0, 255], - })); + ); - const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask) - ? cameraConfig.objects.mask - : cameraConfig.objects.mask - ? [cameraConfig.objects.mask] - : []; - - globalObjectMasks = globalObjectMasksArray.map((maskData, index) => ({ - type: "object_mask" as PolygonType, - typeIndex: index, - camera: cameraConfig.name, - name: t("masksAndZones.objectMaskLabel", { - number: index + 1, - label: t("masksAndZones.zones.allObjects"), + // Global object masks are a dict with mask_id as key + globalObjectMasks = Object.entries(cameraConfig.objects.mask || {}).map( + ([maskId, maskData], index) => ({ + type: "object_mask" as PolygonType, + typeIndex: index, + camera: cameraConfig.name, + name: maskId, + friendly_name: maskData.friendly_name, + enabled: maskData.enabled, + objects: [], + points: interpolatePoints( + parseCoordinates(maskData.coordinates), + 1, + 1, + scaledWidth, + scaledHeight, + ), + distances: [], + isFinished: true, + color: maskData.enabled ? [128, 128, 128] : [80, 80, 80], }), - objects: [], - points: interpolatePoints( - parseCoordinates(maskData), - 1, - 1, - scaledWidth, - scaledHeight, - ), - distances: [], - isFinished: true, - color: [128, 128, 128], - })); + ); - const globalObjectMasksCount = globalObjectMasks.length; - let index = 0; + const globalObjectMaskIds = Object.keys(cameraConfig.objects.mask || {}); + let objectMaskIndex = globalObjectMasks.length; objectMasks = Object.entries(cameraConfig.objects.filters) - .filter(([, { mask }]) => mask || Array.isArray(mask)) - .flatMap(([objectName, { mask }]): Polygon[] => { - const maskArray = Array.isArray(mask) ? mask : mask ? [mask] : []; - return maskArray.flatMap((maskItem, subIndex) => { - const maskItemString = maskItem; - const newMask = { - type: "object_mask" as PolygonType, - typeIndex: subIndex, - camera: cameraConfig.name, - name: t("masksAndZones.objectMaskLabel", { - number: globalObjectMasksCount + index + 1, - label: getTranslatedLabel(objectName), - }), - objects: [objectName], - points: interpolatePoints( - parseCoordinates(maskItem), - 1, - 1, - scaledWidth, - scaledHeight, - ), - distances: [], - isFinished: true, - color: [128, 128, 128], - }; - index++; + .filter( + ([, filterConfig]) => + filterConfig.mask && Object.keys(filterConfig.mask).length > 0, + ) + .flatMap(([objectName, filterConfig]): Polygon[] => { + return Object.entries(filterConfig.mask || {}).flatMap( + ([maskId, maskData]) => { + // Skip if this mask is already included in global masks + if (globalObjectMaskIds.includes(maskId)) { + return []; + } - if ( - globalObjectMasksArray.some( - (globalMask) => globalMask === maskItemString, - ) - ) { - index--; - return []; - } else { + const newMask = { + type: "object_mask" as PolygonType, + typeIndex: objectMaskIndex, + camera: cameraConfig.name, + name: maskId, + friendly_name: maskData.friendly_name, + enabled: maskData.enabled, + objects: [objectName], + points: interpolatePoints( + parseCoordinates(maskData.coordinates), + 1, + 1, + scaledWidth, + scaledHeight, + ), + distances: [], + isFinished: true, + color: maskData.enabled ? [128, 128, 128] : [80, 80, 80], + }; + objectMaskIndex++; return [newMask]; - } - }); + }, + ); }); setAllPolygons([