profile support for mask and zone editor

This commit is contained in:
Josh Hawkins 2026-03-11 07:16:44 -05:00
parent 6205a9d588
commit 379247dee6
7 changed files with 680 additions and 251 deletions

View File

@ -532,6 +532,8 @@
},
"restart_required": "Restart required (masks/zones changed)",
"disabledInConfig": "Item is disabled in the config file",
"profileBase": "(base)",
"profileOverride": "(override)",
"toast": {
"success": {
"copyCoordinates": "Copied coordinates for {{polyName}} to clipboard."

View File

@ -44,6 +44,7 @@ type MotionMaskEditPaneProps = {
onCancel?: () => void;
snapPoints: boolean;
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
editingProfile?: string | null;
};
export default function MotionMaskEditPane({
@ -58,6 +59,7 @@ export default function MotionMaskEditPane({
onCancel,
snapPoints,
setSnapPoints,
editingProfile,
}: MotionMaskEditPaneProps) {
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
@ -192,16 +194,28 @@ export default function MotionMaskEditPane({
coordinates: coordinates,
};
// Build config path based on profile mode
const motionMaskPath = editingProfile
? {
profiles: {
[editingProfile]: {
motion: { mask: { [maskId]: maskConfig } },
},
},
}
: { motion: { mask: { [maskId]: maskConfig } } };
// If renaming, we need to delete the old mask first
if (renamingMask) {
const deleteQueryPath = editingProfile
? `cameras.${polygon.camera}.profiles.${editingProfile}.motion.mask.${polygon.name}`
: `cameras.${polygon.camera}.motion.mask.${polygon.name}`;
try {
await axios.put(
`config/set?cameras.${polygon.camera}.motion.mask.${polygon.name}`,
{
requires_restart: 0,
},
);
} catch (error) {
await axios.put(`config/set?${deleteQueryPath}`, {
requires_restart: 0,
});
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
@ -210,22 +224,20 @@ export default function MotionMaskEditPane({
}
}
const updateTopic = editingProfile
? undefined
: `config/cameras/${polygon.camera}/motion`;
// Save the new/updated mask using JSON body
axios
.put("config/set", {
config_data: {
cameras: {
[polygon.camera]: {
motion: {
mask: {
[maskId]: maskConfig,
},
},
},
[polygon.camera]: motionMaskPath,
},
},
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/motion`,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
@ -238,8 +250,10 @@ export default function MotionMaskEditPane({
},
);
updateConfig();
// Publish the enabled state through websocket
sendMotionMaskState(enabled ? "ON" : "OFF");
// Only publish WS state for base config
if (!editingProfile) {
sendMotionMaskState(enabled ? "ON" : "OFF");
}
} else {
toast.error(
t("toast.save.error.title", {
@ -277,6 +291,7 @@ export default function MotionMaskEditPane({
cameraConfig,
t,
sendMotionMaskState,
editingProfile,
],
);

View File

@ -51,6 +51,7 @@ type ObjectMaskEditPaneProps = {
onCancel?: () => void;
snapPoints: boolean;
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
editingProfile?: string | null;
};
export default function ObjectMaskEditPane({
@ -65,6 +66,7 @@ export default function ObjectMaskEditPane({
onCancel,
snapPoints,
setSnapPoints,
editingProfile,
}: ObjectMaskEditPaneProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
@ -190,14 +192,22 @@ export default function ObjectMaskEditPane({
// 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}`;
let oldPath: string;
if (editingProfile) {
oldPath = wasGlobal
? `cameras.${polygon.camera}.profiles.${editingProfile}.objects.mask.${polygon.name}`
: `cameras.${polygon.camera}.profiles.${editingProfile}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`;
} else {
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) {
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
@ -206,45 +216,32 @@ export default function ObjectMaskEditPane({
}
}
// 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,
},
},
},
// Build config path based on profile mode
const objectsSection = globalMask
? { objects: { mask: { [maskId]: maskConfig } } }
: {
objects: {
filters: { [form_objects]: { mask: { [maskId]: maskConfig } } },
},
};
const cameraData = editingProfile
? { profiles: { [editingProfile]: objectsSection } }
: objectsSection;
const updateTopic = editingProfile
? undefined
: `config/cameras/${polygon.camera}/objects`;
const configBody = {
config_data: {
cameras: {
[polygon.camera]: cameraData,
},
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`,
};
}
},
requires_restart: 0,
update_topic: updateTopic,
};
axios
.put("config/set", configBody)
@ -259,8 +256,10 @@ export default function ObjectMaskEditPane({
},
);
updateConfig();
// Publish the enabled state through websocket
sendObjectMaskState(enabled ? "ON" : "OFF");
// Only publish WS state for base config
if (!editingProfile) {
sendObjectMaskState(enabled ? "ON" : "OFF");
}
} else {
toast.error(
t("toast.save.error.title", {
@ -301,6 +300,7 @@ export default function ObjectMaskEditPane({
cameraConfig,
t,
sendObjectMaskState,
editingProfile,
],
);

View File

@ -35,6 +35,7 @@ import { Trans, useTranslation } from "react-i18next";
import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
import { getProfileColor } from "@/utils/profileColors";
type PolygonItemProps = {
polygon: Polygon;
@ -48,6 +49,8 @@ type PolygonItemProps = {
setIsLoading: (loading: boolean) => void;
loadingPolygonIndex: number | undefined;
setLoadingPolygonIndex: (index: number | undefined) => void;
editingProfile?: string | null;
allProfileNames?: string[];
};
export default function PolygonItem({
@ -62,6 +65,8 @@ export default function PolygonItem({
setIsLoading,
loadingPolygonIndex,
setLoadingPolygonIndex,
editingProfile,
allProfileNames,
}: PolygonItemProps) {
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
@ -107,6 +112,8 @@ export default function PolygonItem({
const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined;
const isBasePolygon = !!editingProfile && polygon.polygonSource === "base";
const saveToConfig = useCallback(
async (polygon: Polygon) => {
if (!polygon || !cameraConfig) {
@ -122,25 +129,36 @@ export default function PolygonItem({
? "objects"
: polygon.type;
const updateTopic = editingProfile
? undefined
: `config/cameras/${polygon.camera}/${updateTopicType}`;
setIsLoading(true);
setLoadingPolygonIndex(index);
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}`;
let url: string;
if (editingProfile) {
// Profile mode: just delete the profile zone
url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
} else {
// Base mode: handle review queries
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}`;
}
await axios
.put(`config/set?${url}`, {
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
@ -178,64 +196,34 @@ export default function PolygonItem({
}
// 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]: {
const deleteSection =
polygon.type === "motion_mask"
? { motion: { mask: { [polygon.name]: null } } }
: !polygon.objects.length
? { objects: { mask: { [polygon.name]: null } } }
: {
objects: {
filters: {
[polygon.objects[0]]: {
mask: {
[polygon.name]: null, // Setting to null will delete the key
},
mask: { [polygon.name]: null },
},
},
},
},
},
};
}
}
};
const configUpdate = {
cameras: {
[polygon.camera]: editingProfile
? { profiles: { [editingProfile]: deleteSection } }
: deleteSection,
},
};
await axios
.put("config/set", {
config_data: configUpdate,
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
@ -278,6 +266,7 @@ export default function PolygonItem({
setIsLoading,
index,
setLoadingPolygonIndex,
editingProfile,
],
);
@ -289,14 +278,19 @@ export default function PolygonItem({
const handleToggleEnabled = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Prevent toggling if disabled in config
if (polygon.enabled_in_config === false) {
// Prevent toggling if disabled in config or if this is a base polygon in profile mode
if (polygon.enabled_in_config === false || isBasePolygon) {
return;
}
if (!polygon) {
return;
}
// Don't toggle via WS in profile mode
if (editingProfile) {
return;
}
const isEnabled = isPolygonEnabled;
const nextState = isEnabled ? "OFF" : "ON";
@ -320,6 +314,8 @@ export default function PolygonItem({
sendZoneState,
sendMotionMaskState,
sendObjectMaskState,
isBasePolygon,
editingProfile,
],
);
@ -358,7 +354,12 @@ export default function PolygonItem({
<button
type="button"
onClick={handleToggleEnabled}
disabled={isLoading || polygon.enabled_in_config === false}
disabled={
isLoading ||
polygon.enabled_in_config === false ||
isBasePolygon ||
!!editingProfile
}
className="mr-2 shrink-0 cursor-pointer border-none bg-transparent p-0 transition-opacity hover:opacity-70 disabled:cursor-not-allowed disabled:opacity-50"
>
<PolygonItemIcon
@ -384,15 +385,37 @@ export default function PolygonItem({
</TooltipContent>
</Tooltip>
))}
{editingProfile &&
(polygon.polygonSource === "profile" ||
polygon.polygonSource === "override") &&
allProfileNames && (
<span
className={cn(
"mr-1.5 inline-block h-2 w-2 shrink-0 rounded-full",
getProfileColor(editingProfile, allProfileNames).dot,
)}
/>
)}
<p
className={cn(
"cursor-default",
!isPolygonEnabled && "opacity-60",
polygon.enabled_in_config === false && "line-through",
isBasePolygon && "opacity-50",
)}
>
{polygon.friendly_name ?? polygon.name}
{!isPolygonEnabled && " (disabled)"}
{isBasePolygon && (
<span className="ml-1 text-xs text-muted-foreground">
{t("masksAndZones.profileBase", { ns: "views/settings" })}
</span>
)}
{polygon.polygonSource === "override" && (
<span className="ml-1 text-xs text-muted-foreground">
{t("masksAndZones.profileOverride", { ns: "views/settings" })}
</span>
)}
</p>
</div>
<AlertDialog
@ -459,7 +482,7 @@ export default function PolygonItem({
</DropdownMenuItem>
<DropdownMenuItem
aria-label={t("button.delete", { ns: "common" })}
disabled={isLoading}
disabled={isLoading || isBasePolygon}
onClick={() => setDeleteDialogOpen(true)}
>
{t("button.delete", { ns: "common" })}
@ -531,9 +554,12 @@ export default function PolygonItem({
"size-[15px] cursor-pointer",
hoveredPolygonIndex === index &&
"fill-primary-variant text-primary-variant",
isLoading && "cursor-not-allowed opacity-50",
(isLoading || isBasePolygon) &&
"cursor-not-allowed opacity-50",
)}
onClick={() => !isLoading && setDeleteDialogOpen(true)}
onClick={() =>
!isLoading && !isBasePolygon && setDeleteDialogOpen(true)
}
/>
</TooltipTrigger>
<TooltipContent>

View File

@ -50,6 +50,7 @@ type ZoneEditPaneProps = {
setActiveLine: React.Dispatch<React.SetStateAction<number | undefined>>;
snapPoints: boolean;
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
editingProfile?: string | null;
};
export default function ZoneEditPane({
@ -65,6 +66,7 @@ export default function ZoneEditPane({
setActiveLine,
snapPoints,
setSnapPoints,
editingProfile,
}: ZoneEditPaneProps) {
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
@ -101,15 +103,23 @@ export default function ZoneEditPane({
}, [polygon, config]);
const [lineA, lineB, lineC, lineD] = useMemo(() => {
const distances =
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.distances;
if (!polygon?.camera || !polygon?.name || !config) {
return [undefined, undefined, undefined, undefined];
}
// Check profile zone first, then base
const profileZone = editingProfile
? config.cameras[polygon.camera]?.profiles?.[editingProfile]?.zones?.[
polygon.name
]
: undefined;
const baseZone = config.cameras[polygon.camera]?.zones[polygon.name];
const distances = profileZone?.distances ?? baseZone?.distances;
return Array.isArray(distances)
? distances.map((value) => parseFloat(value) || 0)
: [undefined, undefined, undefined, undefined];
}, [polygon, config]);
}, [polygon, config, editingProfile]);
const formSchema = z
.object({
@ -272,6 +282,17 @@ export default function ZoneEditPane({
},
);
// Resolve zone data: profile zone takes priority over base
const resolvedZoneData = useMemo(() => {
if (!polygon?.camera || !polygon?.name || !config) return undefined;
const cam = config.cameras[polygon.camera];
if (!cam) return undefined;
const profileZone = editingProfile
? cam.profiles?.[editingProfile]?.zones?.[polygon.name]
: undefined;
return profileZone ?? cam.zones[polygon.name];
}, [polygon, config, editingProfile]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onBlur",
@ -279,20 +300,11 @@ export default function ZoneEditPane({
name: polygon?.name ?? "",
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
enabled:
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled !==
undefined
? config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled
resolvedZoneData?.enabled !== undefined
? resolvedZoneData.enabled
: (polygon?.enabled ?? true),
inertia:
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.inertia,
loitering_time:
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time,
inertia: resolvedZoneData?.inertia,
loitering_time: resolvedZoneData?.loitering_time,
isFinished: polygon?.isFinished ?? false,
objects: polygon?.objects ?? [],
speedEstimation: !!(lineA || lineB || lineC || lineD),
@ -300,10 +312,7 @@ export default function ZoneEditPane({
lineB,
lineC,
lineD,
speed_threshold:
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold,
speed_threshold: resolvedZoneData?.speed_threshold,
},
});
@ -341,6 +350,16 @@ export default function ZoneEditPane({
if (!scaledWidth || !scaledHeight || !polygon) {
return;
}
// Determine config path prefix based on profile mode
const pathPrefix = editingProfile
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${zoneName}`
: `cameras.${polygon.camera}.zones.${zoneName}`;
const oldPathPrefix = editingProfile
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`
: `cameras.${polygon.camera}.zones.${polygon.name}`;
let mutatedConfig = config;
let alertQueries = "";
let detectionQueries = "";
@ -349,55 +368,74 @@ export default function ZoneEditPane({
if (renamingZone) {
// rename - delete old zone and replace with new
const zoneInAlerts =
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ??
false;
const zoneInDetections =
cameraConfig?.review.detections.required_zones.includes(
let renameAlertQueries = "";
let renameDetectionQueries = "";
// Only handle review queries for base config (not profiles)
if (!editingProfile) {
const zoneInAlerts =
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ??
false;
const zoneInDetections =
cameraConfig?.review.detections.required_zones.includes(
polygon.name,
) ?? false;
({
alertQueries: renameAlertQueries,
detectionQueries: renameDetectionQueries,
} = reviewQueries(
polygon.name,
) ?? false;
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
));
const {
alertQueries: renameAlertQueries,
detectionQueries: renameDetectionQueries,
} = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
try {
await axios.put(
`config/set?${oldPathPrefix}${renameAlertQueries}${renameDetectionQueries}`,
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
},
);
try {
await axios.put(
`config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`,
{
// Wait for the config to be updated
mutatedConfig = await updateConfig();
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
return;
}
// make sure new zone name is readded to review
({ alertQueries, detectionQueries } = reviewQueries(
zoneName,
zoneInAlerts,
zoneInDetections,
polygon.camera,
mutatedConfig?.cameras[polygon.camera]?.review.alerts
.required_zones || [],
mutatedConfig?.cameras[polygon.camera]?.review.detections
.required_zones || [],
));
} else {
// Profile mode: just delete the old profile zone path
try {
await axios.put(`config/set?${oldPathPrefix}`, {
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
},
);
// Wait for the config to be updated
mutatedConfig = await updateConfig();
} catch (error) {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
return;
});
mutatedConfig = await updateConfig();
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
return;
}
}
// make sure new zone name is readded to review
({ alertQueries, detectionQueries } = reviewQueries(
zoneName,
zoneInAlerts,
zoneInDetections,
polygon.camera,
mutatedConfig?.cameras[polygon.camera]?.review.alerts
.required_zones || [],
mutatedConfig?.cameras[polygon.camera]?.review.detections
.required_zones || [],
));
}
const coordinates = flattenPoints(
@ -405,10 +443,7 @@ export default function ZoneEditPane({
).join(",");
let objectQueries = objects
.map(
(object) =>
`&cameras.${polygon?.camera}.zones.${zoneName}.objects=${object}`,
)
.map((object) => `&${pathPrefix}.objects=${object}`)
.join("");
const same_objects =
@ -419,55 +454,55 @@ export default function ZoneEditPane({
// deleting objects
if (!objectQueries && !same_objects && !renamingZone) {
objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`;
objectQueries = `&${pathPrefix}.objects`;
}
let inertiaQuery = "";
if (inertia) {
inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`;
inertiaQuery = `&${pathPrefix}.inertia=${inertia}`;
}
let loiteringTimeQuery = "";
if (loitering_time >= 0) {
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
loiteringTimeQuery = `&${pathPrefix}.loitering_time=${loitering_time}`;
}
let distancesQuery = "";
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
if (speedEstimation) {
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`;
distancesQuery = `&${pathPrefix}.distances=${distances}`;
} else {
if (distances != "") {
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`;
distancesQuery = `&${pathPrefix}.distances`;
}
}
let speedThresholdQuery = "";
if (speed_threshold >= 0 && speedEstimation) {
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold=${speed_threshold}`;
speedThresholdQuery = `&${pathPrefix}.speed_threshold=${speed_threshold}`;
} else {
if (
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold
) {
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold`;
if (resolvedZoneData?.speed_threshold) {
speedThresholdQuery = `&${pathPrefix}.speed_threshold`;
}
}
let friendlyNameQuery = "";
if (friendly_name && friendly_name !== zoneName) {
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`;
friendlyNameQuery = `&${pathPrefix}.friendly_name=${encodeURIComponent(friendly_name)}`;
}
const enabledQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.enabled=${enabled ? "True" : "False"}`;
const enabledQuery = `&${pathPrefix}.enabled=${enabled ? "True" : "False"}`;
const updateTopic = editingProfile
? undefined
: `config/cameras/${polygon.camera}/zones`;
axios
.put(
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
`config/set?${pathPrefix}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
update_topic: updateTopic,
},
)
.then((res) => {
@ -481,8 +516,10 @@ export default function ZoneEditPane({
},
);
updateConfig();
// Publish the enabled state through websocket
sendZoneState(enabled ? "ON" : "OFF");
// Only publish WS state for base config (not profiles)
if (!editingProfile) {
sendZoneState(enabled ? "ON" : "OFF");
}
} else {
toast.error(
t("toast.save.error.title", {
@ -524,6 +561,8 @@ export default function ZoneEditPane({
cameraConfig,
t,
sendZoneState,
editingProfile,
resolvedZoneData,
],
);

View File

@ -14,6 +14,7 @@ export type Polygon = {
friendly_name?: string;
enabled?: boolean;
enabled_in_config?: boolean;
polygonSource?: "base" | "profile" | "override";
};
export type ZoneFormValuesType = {

View File

@ -1,4 +1,4 @@
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -35,21 +35,28 @@ import { useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { cn } from "@/lib/utils";
import { ProfileState } from "@/types/profile";
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
import axios from "axios";
import { useSWRConfig } from "swr";
type MasksAndZoneViewProps = {
selectedCamera: string;
selectedZoneMask?: PolygonType[];
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
profileState?: ProfileState;
};
export default function MasksAndZonesView({
selectedCamera,
selectedZoneMask,
setUnsavedChanges,
profileState,
}: MasksAndZoneViewProps) {
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
const { mutate } = useSWRConfig();
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [isLoading, setIsLoading] = useState(false);
@ -70,6 +77,63 @@ export default function MasksAndZonesView({
const [activeLine, setActiveLine] = useState<number | undefined>();
const [snapPoints, setSnapPoints] = useState(false);
// Profile state
const profileSectionKey = `${selectedCamera}::masksAndZones`;
const currentEditingProfile =
profileState?.editingProfile[profileSectionKey] ?? null;
const hasProfileData = useCallback(
(profileName: string) => {
if (!config || !selectedCamera) return false;
const profileData =
config.cameras[selectedCamera]?.profiles?.[profileName];
if (!profileData) return false;
const hasZones =
profileData.zones && Object.keys(profileData.zones).length > 0;
const hasMotionMasks =
profileData.motion?.mask &&
Object.keys(profileData.motion.mask).length > 0;
const hasObjectMasks =
(profileData.objects?.mask &&
Object.keys(profileData.objects.mask).length > 0) ||
(profileData.objects?.filters &&
Object.values(profileData.objects.filters).some(
(f) => f.mask && Object.keys(f.mask).length > 0,
));
return !!(hasZones || hasMotionMasks || hasObjectMasks);
},
[config, selectedCamera],
);
const handleDeleteProfileMasksAndZones = useCallback(
async (profileName: string) => {
try {
// Delete zones, motion masks, and object masks from the profile
await axios.put("config/set", {
config_data: {
cameras: {
[selectedCamera]: {
profiles: {
[profileName]: {
zones: "",
motion: { mask: "" },
objects: { mask: "", filters: "" },
},
},
},
},
},
});
await mutate("config");
profileState?.onSelectProfile(selectedCamera, "masksAndZones", null);
toast.success(t("toast.save.success", { ns: "common" }));
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }));
}
},
[selectedCamera, mutate, profileState, t],
);
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
@ -228,18 +292,84 @@ export default function MasksAndZonesView({
[allPolygons, scaledHeight, scaledWidth, t],
);
// Helper to dim colors for base polygons in profile mode
const dimColor = useCallback(
(color: number[]): number[] => {
if (!currentEditingProfile) return color;
return color.map((c) => Math.round(c * 0.4 + 153 * 0.6));
},
[currentEditingProfile],
);
useEffect(() => {
if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) {
const zones = Object.entries(cameraConfig.zones).map(
([name, zoneData], index) => ({
const profileData = currentEditingProfile
? cameraConfig.profiles?.[currentEditingProfile]
: undefined;
// Build base zone names set for source tracking
const baseZoneNames = new Set(Object.keys(cameraConfig.zones));
const profileZoneNames = new Set(Object.keys(profileData?.zones ?? {}));
const baseMotionMaskNames = new Set(
Object.keys(cameraConfig.motion.mask || {}),
);
const profileMotionMaskNames = new Set(
Object.keys(profileData?.motion?.mask ?? {}),
);
const baseGlobalObjectMaskNames = new Set(
Object.keys(cameraConfig.objects.mask || {}),
);
const profileGlobalObjectMaskNames = new Set(
Object.keys(profileData?.objects?.mask ?? {}),
);
// Merge zones: profile zones override base zones with same name
const mergedZones = new Map<
string,
{
data: CameraConfig["zones"][string];
source: "base" | "profile" | "override";
}
>();
for (const [name, zoneData] of Object.entries(cameraConfig.zones)) {
if (currentEditingProfile && profileZoneNames.has(name)) {
// Profile overrides this base zone
mergedZones.set(name, {
data: profileData!.zones![name]!,
source: "override",
});
} else {
mergedZones.set(name, {
data: zoneData,
source: currentEditingProfile ? "base" : "base",
});
}
}
// Add profile-only zones
if (profileData?.zones) {
for (const [name, zoneData] of Object.entries(profileData.zones)) {
if (!baseZoneNames.has(name)) {
mergedZones.set(name, { data: zoneData!, source: "profile" });
}
}
}
let zoneIndex = 0;
const zones: Polygon[] = [];
for (const [name, { data: zoneData, source }] of mergedZones) {
const isBase = source === "base" && !!currentEditingProfile;
const baseColor = zoneData.color ?? [128, 128, 0];
zones.push({
type: "zone" as PolygonType,
typeIndex: index,
typeIndex: zoneIndex,
camera: cameraConfig.name,
name,
friendly_name: zoneData.friendly_name,
enabled: zoneData.enabled,
enabled_in_config: zoneData.enabled_in_config,
objects: zoneData.objects,
objects: zoneData.objects ?? [],
points: interpolatePoints(
parseCoordinates(zoneData.coordinates),
1,
@ -248,21 +378,62 @@ export default function MasksAndZonesView({
scaledHeight,
),
distances:
zoneData.distances?.map((distance) => parseFloat(distance)) ?? [],
zoneData.distances?.map((distance: string) =>
parseFloat(distance),
) ?? [],
isFinished: true,
color: zoneData.color,
}),
);
color: isBase ? dimColor(baseColor) : baseColor,
polygonSource: currentEditingProfile ? source : undefined,
});
zoneIndex++;
}
let motionMasks: Polygon[] = [];
let globalObjectMasks: Polygon[] = [];
let objectMasks: Polygon[] = [];
// Merge motion masks
const mergedMotionMasks = new Map<
string,
{
data: CameraConfig["motion"]["mask"][string];
source: "base" | "profile" | "override";
}
>();
// Motion masks are a dict with mask_id as key
motionMasks = Object.entries(cameraConfig.motion.mask || {}).map(
([maskId, maskData], index) => ({
for (const [maskId, maskData] of Object.entries(
cameraConfig.motion.mask || {},
)) {
if (currentEditingProfile && profileMotionMaskNames.has(maskId)) {
mergedMotionMasks.set(maskId, {
data: profileData!.motion!.mask![maskId],
source: "override",
});
} else {
mergedMotionMasks.set(maskId, {
data: maskData,
source: currentEditingProfile ? "base" : "base",
});
}
}
if (profileData?.motion?.mask) {
for (const [maskId, maskData] of Object.entries(
profileData.motion.mask,
)) {
if (!baseMotionMaskNames.has(maskId)) {
mergedMotionMasks.set(maskId, {
data: maskData,
source: "profile",
});
}
}
}
let motionMaskIndex = 0;
const motionMasks: Polygon[] = [];
for (const [maskId, { data: maskData, source }] of mergedMotionMasks) {
const isBase = source === "base" && !!currentEditingProfile;
const baseColor = [0, 0, 255];
motionMasks.push({
type: "motion_mask" as PolygonType,
typeIndex: index,
typeIndex: motionMaskIndex,
camera: cameraConfig.name,
name: maskId,
friendly_name: maskData.friendly_name,
@ -278,15 +449,61 @@ export default function MasksAndZonesView({
),
distances: [],
isFinished: true,
color: [0, 0, 255],
}),
);
color: isBase ? dimColor(baseColor) : baseColor,
polygonSource: currentEditingProfile ? source : undefined,
});
motionMaskIndex++;
}
// Global object masks are a dict with mask_id as key
globalObjectMasks = Object.entries(cameraConfig.objects.mask || {}).map(
([maskId, maskData], index) => ({
// Merge global object masks
const mergedGlobalObjectMasks = new Map<
string,
{
data: CameraConfig["objects"]["mask"][string];
source: "base" | "profile" | "override";
}
>();
for (const [maskId, maskData] of Object.entries(
cameraConfig.objects.mask || {},
)) {
if (currentEditingProfile && profileGlobalObjectMaskNames.has(maskId)) {
mergedGlobalObjectMasks.set(maskId, {
data: profileData!.objects!.mask![maskId],
source: "override",
});
} else {
mergedGlobalObjectMasks.set(maskId, {
data: maskData,
source: currentEditingProfile ? "base" : "base",
});
}
}
if (profileData?.objects?.mask) {
for (const [maskId, maskData] of Object.entries(
profileData.objects.mask,
)) {
if (!baseGlobalObjectMaskNames.has(maskId)) {
mergedGlobalObjectMasks.set(maskId, {
data: maskData,
source: "profile",
});
}
}
}
let objectMaskIndex = 0;
const globalObjectMasks: Polygon[] = [];
for (const [
maskId,
{ data: maskData, source },
] of mergedGlobalObjectMasks) {
const isBase = source === "base" && !!currentEditingProfile;
const baseColor = [128, 128, 128];
globalObjectMasks.push({
type: "object_mask" as PolygonType,
typeIndex: index,
typeIndex: objectMaskIndex,
camera: cameraConfig.name,
name: maskId,
friendly_name: maskData.friendly_name,
@ -302,13 +519,43 @@ export default function MasksAndZonesView({
),
distances: [],
isFinished: true,
color: [128, 128, 128],
}),
);
color: isBase ? dimColor(baseColor) : baseColor,
polygonSource: currentEditingProfile ? source : undefined,
});
objectMaskIndex++;
}
let objectMaskIndex = globalObjectMasks.length;
objectMaskIndex = globalObjectMasks.length;
objectMasks = Object.entries(cameraConfig.objects.filters)
// Build per-object filter mask names for profile tracking
const baseFilterMaskNames = new Set<string>();
for (const [, filterConfig] of Object.entries(
cameraConfig.objects.filters,
)) {
for (const maskId of Object.keys(filterConfig.mask || {})) {
if (!maskId.startsWith("global_")) {
baseFilterMaskNames.add(maskId);
}
}
}
const profileFilterMaskNames = new Set<string>();
if (profileData?.objects?.filters) {
for (const [, filterConfig] of Object.entries(
profileData.objects.filters,
)) {
if (filterConfig?.mask) {
for (const maskId of Object.keys(filterConfig.mask)) {
profileFilterMaskNames.add(maskId);
}
}
}
}
// Per-object filter masks (base)
const objectMasks: Polygon[] = Object.entries(
cameraConfig.objects.filters,
)
.filter(
([, filterConfig]) =>
filterConfig.mask && Object.keys(filterConfig.mask).length > 0,
@ -316,22 +563,36 @@ export default function MasksAndZonesView({
.flatMap(([objectName, filterConfig]): Polygon[] => {
return Object.entries(filterConfig.mask || {}).flatMap(
([maskId, maskData]) => {
// Skip if this mask is a global mask (prefixed with "global_")
if (maskId.startsWith("global_")) {
return [];
}
const newMask = {
const source: "base" | "override" = currentEditingProfile
? profileFilterMaskNames.has(maskId)
? "override"
: "base"
: "base";
const isBase = source === "base" && !!currentEditingProfile;
// If override, use profile data
const finalData =
source === "override" && profileData?.objects?.filters
? (profileData.objects.filters[objectName]?.mask?.[maskId] ??
maskData)
: maskData;
const baseColor = [128, 128, 128];
const newMask: Polygon = {
type: "object_mask" as PolygonType,
typeIndex: objectMaskIndex,
camera: cameraConfig.name,
name: maskId,
friendly_name: maskData.friendly_name,
enabled: maskData.enabled,
enabled_in_config: maskData.enabled_in_config,
friendly_name: finalData.friendly_name,
enabled: finalData.enabled,
enabled_in_config: finalData.enabled_in_config,
objects: [objectName],
points: interpolatePoints(
parseCoordinates(maskData.coordinates),
parseCoordinates(finalData.coordinates),
1,
1,
scaledWidth,
@ -339,7 +600,8 @@ export default function MasksAndZonesView({
),
distances: [],
isFinished: true,
color: [128, 128, 128],
color: isBase ? dimColor(baseColor) : baseColor,
polygonSource: currentEditingProfile ? source : undefined,
};
objectMaskIndex++;
return [newMask];
@ -347,6 +609,45 @@ export default function MasksAndZonesView({
);
});
// Add profile-only per-object filter masks
if (profileData?.objects?.filters) {
for (const [objectName, filterConfig] of Object.entries(
profileData.objects.filters,
)) {
if (filterConfig?.mask) {
for (const [maskId, maskData] of Object.entries(
filterConfig.mask,
)) {
if (!baseFilterMaskNames.has(maskId) && maskData) {
const baseColor = [128, 128, 128];
objectMasks.push({
type: "object_mask" as PolygonType,
typeIndex: objectMaskIndex,
camera: cameraConfig.name,
name: maskId,
friendly_name: maskData.friendly_name,
enabled: maskData.enabled,
enabled_in_config: maskData.enabled_in_config,
objects: [objectName],
points: interpolatePoints(
parseCoordinates(maskData.coordinates),
1,
1,
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: baseColor,
polygonSource: "profile",
});
objectMaskIndex++;
}
}
}
}
}
setAllPolygons([
...zones,
...motionMasks,
@ -386,7 +687,14 @@ export default function MasksAndZonesView({
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraConfig, containerRef, scaledHeight, scaledWidth]);
}, [
cameraConfig,
containerRef,
scaledHeight,
scaledWidth,
currentEditingProfile,
dimColor,
]);
useEffect(() => {
if (editPane === undefined) {
@ -403,6 +711,15 @@ export default function MasksAndZonesView({
}
}, [selectedCamera]);
// Cancel editing when profile selection changes
useEffect(() => {
if (editPaneRef.current !== undefined) {
handleCancel();
}
// we only want to react to profile changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentEditingProfile]);
useSearchEffect("object_mask", (coordinates: string) => {
if (!scaledWidth || !scaledHeight || isLoading) {
return false;
@ -473,6 +790,7 @@ export default function MasksAndZonesView({
setActiveLine={setActiveLine}
snapPoints={snapPoints}
setSnapPoints={setSnapPoints}
editingProfile={currentEditingProfile}
/>
)}
{editPane == "motion_mask" && (
@ -488,6 +806,7 @@ export default function MasksAndZonesView({
onSave={handleSave}
snapPoints={snapPoints}
setSnapPoints={setSnapPoints}
editingProfile={currentEditingProfile}
/>
)}
{editPane == "object_mask" && (
@ -503,13 +822,34 @@ export default function MasksAndZonesView({
onSave={handleSave}
snapPoints={snapPoints}
setSnapPoints={setSnapPoints}
editingProfile={currentEditingProfile}
/>
)}
{editPane === undefined && (
<>
<Heading as="h4" className="mb-2">
{t("menu.masksAndZones")}
</Heading>
<div className="mb-2 flex items-center justify-between">
<Heading as="h4">{t("menu.masksAndZones")}</Heading>
{profileState && selectedCamera && (
<ProfileSectionDropdown
cameraName={selectedCamera}
sectionKey="masksAndZones"
allProfileNames={profileState.allProfileNames}
editingProfile={currentEditingProfile}
hasProfileData={hasProfileData}
onSelectProfile={(profile) =>
profileState.onSelectProfile(
selectedCamera,
"masksAndZones",
profile,
)
}
onAddProfile={profileState.onAddProfile}
onDeleteProfileSection={(profileName) =>
handleDeleteProfileMasksAndZones(profileName)
}
/>
)}
</div>
<div className="flex w-full flex-col">
{(selectedZoneMask === undefined ||
selectedZoneMask.includes("zone" as PolygonType)) && (
@ -575,6 +915,8 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
editingProfile={currentEditingProfile}
allProfileNames={profileState?.allProfileNames}
/>
))}
</div>
@ -649,6 +991,8 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
editingProfile={currentEditingProfile}
allProfileNames={profileState?.allProfileNames}
/>
))}
</div>
@ -723,6 +1067,8 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
editingProfile={currentEditingProfile}
allProfileNames={profileState?.allProfileNames}
/>
))}
</div>