import Heading from "../ui/heading"; import { Separator } from "../ui/separator"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectGroup, SelectItem, SelectSeparator, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { useCallback, useEffect, useMemo } from "react"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { zodResolver } from "@hookform/resolvers/zod"; 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 } 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[]; setPolygons: React.Dispatch>; activePolygonIndex?: number; scaledWidth?: number; scaledHeight?: number; isLoading: boolean; setIsLoading: React.Dispatch>; onSave?: () => void; onCancel?: () => void; snapPoints: boolean; setSnapPoints: React.Dispatch>; }; export default function ObjectMaskEditPane({ polygons, setPolygons, activePolygonIndex, scaledWidth, scaledHeight, isLoading, setIsLoading, onSave, onCancel, snapPoints, setSnapPoints, }: ObjectMaskEditPaneProps) { const { t } = useTranslation(["views/settings"]); const { data: config, mutate: updateConfig } = useSWR("config"); const polygon = useMemo(() => { if (polygons && activePolygonIndex !== undefined) { return polygons[activePolygonIndex]; } else { return null; } }, [polygons, activePolygonIndex]); const cameraConfig = useMemo(() => { if (polygon?.camera && config) { return config.cameras[polygon.camera]; } }, [polygon, config]); const defaultName = useMemo(() => { if (!polygons) { return ""; } const count = polygons.filter((poly) => poly.type == "object_mask").length; return t("masksAndZones.objectMaskLabel", { number: count, }); }, [polygons, t]); const defaultId = useMemo(() => { if (!polygons) { return ""; } const count = polygons.filter((poly) => poly.type == "object_mask").length; return `object_mask_${count}`; }, [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"), }), }); 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", isFinished: polygon?.isFinished ?? false, }, }); const saveToConfig = useCallback( async ({ name: maskId, friendly_name, enabled, objects: form_objects, }: ObjectMaskFormValuesType) => { if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { return; } const coordinates = flattenPoints( interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), ).join(","); const editingMask = polygon.name.length > 0; const renamingMask = editingMask && maskId !== polygon.name; const globalMask = form_objects === "all_labels"; // 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 { 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", configBody) .then((res) => { if (res.status === 200) { toast.success( t("masksAndZones.objectMasks.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) { return; } setIsLoading(true); saveToConfig(values as ObjectMaskFormValuesType); if (onSave) { onSave(); } } useEffect(() => { document.title = t("masksAndZones.objectMasks.documentTitle"); }, [t]); if (!polygon) { return; } return ( <> {polygon.name.length ? t("masksAndZones.objectMasks.edit") : t("masksAndZones.objectMasks.add")}

{t("masksAndZones.objectMasks.context")}

{polygons && activePolygonIndex !== undefined && (
{t("masksAndZones.objectMasks.point", { count: polygons[activePolygonIndex].points.length, })} {polygons[activePolygonIndex].isFinished && ( )}
)}
{t("masksAndZones.objectMasks.clickDrawPolygon")}
0) ?? false} nameLabel={t("masksAndZones.objectMasks.name.title")} nameDescription={t( "masksAndZones.objectMasks.name.description", )} 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")} )} /> ( )} />
); } type ZoneObjectSelectorProps = { camera: string; }; export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) { const { t } = useTranslation(["views/settings"]); const { data: config } = useSWR("config"); const cameraConfig = useMemo(() => { if (config && camera) { return config.cameras[camera]; } }, [config, camera]); const allLabels = useMemo(() => { if (!config || !cameraConfig) { return []; } const labels = new Set(); Object.values(config.cameras).forEach((camera) => { camera.objects.track.forEach((label) => { labels.add(label); }); }); cameraConfig.objects.track.forEach((label) => { labels.add(label); }); return [...labels].sort(); }, [config, cameraConfig]); return ( <> {t("masksAndZones.objectMasks.objects.allObjectTypes")} {allLabels.map((item) => ( {getTranslatedLabel(item)} ))} ); }