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)", "restart_required": "Restart required (masks/zones changed)",
"disabledInConfig": "Item is disabled in the config file", "disabledInConfig": "Item is disabled in the config file",
"profileBase": "(base)",
"profileOverride": "(override)",
"toast": { "toast": {
"success": { "success": {
"copyCoordinates": "Copied coordinates for {{polyName}} to clipboard." "copyCoordinates": "Copied coordinates for {{polyName}} to clipboard."

View File

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

View File

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

View File

@ -35,6 +35,7 @@ import { Trans, useTranslation } from "react-i18next";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws"; import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
import { getProfileColor } from "@/utils/profileColors";
type PolygonItemProps = { type PolygonItemProps = {
polygon: Polygon; polygon: Polygon;
@ -48,6 +49,8 @@ type PolygonItemProps = {
setIsLoading: (loading: boolean) => void; setIsLoading: (loading: boolean) => void;
loadingPolygonIndex: number | undefined; loadingPolygonIndex: number | undefined;
setLoadingPolygonIndex: (index: number | undefined) => void; setLoadingPolygonIndex: (index: number | undefined) => void;
editingProfile?: string | null;
allProfileNames?: string[];
}; };
export default function PolygonItem({ export default function PolygonItem({
@ -62,6 +65,8 @@ export default function PolygonItem({
setIsLoading, setIsLoading,
loadingPolygonIndex, loadingPolygonIndex,
setLoadingPolygonIndex, setLoadingPolygonIndex,
editingProfile,
allProfileNames,
}: PolygonItemProps) { }: PolygonItemProps) {
const { t } = useTranslation("views/settings"); const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
@ -107,6 +112,8 @@ export default function PolygonItem({
const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined; const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined;
const isBasePolygon = !!editingProfile && polygon.polygonSource === "base";
const saveToConfig = useCallback( const saveToConfig = useCallback(
async (polygon: Polygon) => { async (polygon: Polygon) => {
if (!polygon || !cameraConfig) { if (!polygon || !cameraConfig) {
@ -122,25 +129,36 @@ export default function PolygonItem({
? "objects" ? "objects"
: polygon.type; : polygon.type;
const updateTopic = editingProfile
? undefined
: `config/cameras/${polygon.camera}/${updateTopicType}`;
setIsLoading(true); setIsLoading(true);
setLoadingPolygonIndex(index); setLoadingPolygonIndex(index);
if (polygon.type === "zone") { if (polygon.type === "zone") {
// Zones use query string format let url: string;
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name, if (editingProfile) {
false, // Profile mode: just delete the profile zone
false, url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
polygon.camera, } else {
cameraConfig?.review.alerts.required_zones || [], // Base mode: handle review queries
cameraConfig?.review.detections.required_zones || [], const { alertQueries, detectionQueries } = reviewQueries(
); polygon.name,
const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; 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 await axios
.put(`config/set?${url}`, { .put(`config/set?${url}`, {
requires_restart: 0, requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, update_topic: updateTopic,
}) })
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
@ -178,64 +196,34 @@ export default function PolygonItem({
} }
// Motion masks and object masks use JSON body format // Motion masks and object masks use JSON body format
// eslint-disable-next-line @typescript-eslint/no-explicit-any const deleteSection =
let configUpdate: any = {}; polygon.type === "motion_mask"
? { motion: { mask: { [polygon.name]: null } } }
if (polygon.type === "motion_mask") { : !polygon.objects.length
// Delete mask from motion.mask dict by setting it to undefined ? { objects: { mask: { [polygon.name]: null } } }
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: { objects: {
filters: { filters: {
[polygon.objects[0]]: { [polygon.objects[0]]: {
mask: { mask: { [polygon.name]: null },
[polygon.name]: null, // Setting to null will delete the key
},
}, },
}, },
}, },
}, };
},
}; const configUpdate = {
} cameras: {
} [polygon.camera]: editingProfile
? { profiles: { [editingProfile]: deleteSection } }
: deleteSection,
},
};
await axios await axios
.put("config/set", { .put("config/set", {
config_data: configUpdate, config_data: configUpdate,
requires_restart: 0, requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, update_topic: updateTopic,
}) })
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
@ -278,6 +266,7 @@ export default function PolygonItem({
setIsLoading, setIsLoading,
index, index,
setLoadingPolygonIndex, setLoadingPolygonIndex,
editingProfile,
], ],
); );
@ -289,14 +278,19 @@ export default function PolygonItem({
const handleToggleEnabled = useCallback( const handleToggleEnabled = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
// Prevent toggling if disabled in config // Prevent toggling if disabled in config or if this is a base polygon in profile mode
if (polygon.enabled_in_config === false) { if (polygon.enabled_in_config === false || isBasePolygon) {
return; return;
} }
if (!polygon) { if (!polygon) {
return; return;
} }
// Don't toggle via WS in profile mode
if (editingProfile) {
return;
}
const isEnabled = isPolygonEnabled; const isEnabled = isPolygonEnabled;
const nextState = isEnabled ? "OFF" : "ON"; const nextState = isEnabled ? "OFF" : "ON";
@ -320,6 +314,8 @@ export default function PolygonItem({
sendZoneState, sendZoneState,
sendMotionMaskState, sendMotionMaskState,
sendObjectMaskState, sendObjectMaskState,
isBasePolygon,
editingProfile,
], ],
); );
@ -358,7 +354,12 @@ export default function PolygonItem({
<button <button
type="button" type="button"
onClick={handleToggleEnabled} 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" 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 <PolygonItemIcon
@ -384,15 +385,37 @@ export default function PolygonItem({
</TooltipContent> </TooltipContent>
</Tooltip> </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 <p
className={cn( className={cn(
"cursor-default", "cursor-default",
!isPolygonEnabled && "opacity-60", !isPolygonEnabled && "opacity-60",
polygon.enabled_in_config === false && "line-through", polygon.enabled_in_config === false && "line-through",
isBasePolygon && "opacity-50",
)} )}
> >
{polygon.friendly_name ?? polygon.name} {polygon.friendly_name ?? polygon.name}
{!isPolygonEnabled && " (disabled)"} {!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> </p>
</div> </div>
<AlertDialog <AlertDialog
@ -459,7 +482,7 @@ export default function PolygonItem({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
aria-label={t("button.delete", { ns: "common" })} aria-label={t("button.delete", { ns: "common" })}
disabled={isLoading} disabled={isLoading || isBasePolygon}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
> >
{t("button.delete", { ns: "common" })} {t("button.delete", { ns: "common" })}
@ -531,9 +554,12 @@ export default function PolygonItem({
"size-[15px] cursor-pointer", "size-[15px] cursor-pointer",
hoveredPolygonIndex === index && hoveredPolygonIndex === index &&
"fill-primary-variant text-primary-variant", "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> </TooltipTrigger>
<TooltipContent> <TooltipContent>

View File

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

View File

@ -14,6 +14,7 @@ export type Polygon = {
friendly_name?: string; friendly_name?: string;
enabled?: boolean; enabled?: boolean;
enabled_in_config?: boolean; enabled_in_config?: boolean;
polygonSource?: "base" | "profile" | "override";
}; };
export type ZoneFormValuesType = { 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 useSWR from "swr";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { useDocDomain } from "@/hooks/use-doc-domain";
import { cn } from "@/lib/utils"; 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 = { type MasksAndZoneViewProps = {
selectedCamera: string; selectedCamera: string;
selectedZoneMask?: PolygonType[]; selectedZoneMask?: PolygonType[];
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>; setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
profileState?: ProfileState;
}; };
export default function MasksAndZonesView({ export default function MasksAndZonesView({
selectedCamera, selectedCamera,
selectedZoneMask, selectedZoneMask,
setUnsavedChanges, setUnsavedChanges,
profileState,
}: MasksAndZoneViewProps) { }: MasksAndZoneViewProps) {
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const { mutate } = useSWRConfig();
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]); const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]); const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -70,6 +77,63 @@ export default function MasksAndZonesView({
const [activeLine, setActiveLine] = useState<number | undefined>(); const [activeLine, setActiveLine] = useState<number | undefined>();
const [snapPoints, setSnapPoints] = useState(false); 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(() => { const cameraConfig = useMemo(() => {
if (config && selectedCamera) { if (config && selectedCamera) {
return config.cameras[selectedCamera]; return config.cameras[selectedCamera];
@ -228,18 +292,84 @@ export default function MasksAndZonesView({
[allPolygons, scaledHeight, scaledWidth, t], [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(() => { useEffect(() => {
if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) { if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) {
const zones = Object.entries(cameraConfig.zones).map( const profileData = currentEditingProfile
([name, zoneData], index) => ({ ? 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, type: "zone" as PolygonType,
typeIndex: index, typeIndex: zoneIndex,
camera: cameraConfig.name, camera: cameraConfig.name,
name, name,
friendly_name: zoneData.friendly_name, friendly_name: zoneData.friendly_name,
enabled: zoneData.enabled, enabled: zoneData.enabled,
enabled_in_config: zoneData.enabled_in_config, enabled_in_config: zoneData.enabled_in_config,
objects: zoneData.objects, objects: zoneData.objects ?? [],
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(zoneData.coordinates), parseCoordinates(zoneData.coordinates),
1, 1,
@ -248,21 +378,62 @@ export default function MasksAndZonesView({
scaledHeight, scaledHeight,
), ),
distances: distances:
zoneData.distances?.map((distance) => parseFloat(distance)) ?? [], zoneData.distances?.map((distance: string) =>
parseFloat(distance),
) ?? [],
isFinished: true, isFinished: true,
color: zoneData.color, color: isBase ? dimColor(baseColor) : baseColor,
}), polygonSource: currentEditingProfile ? source : undefined,
); });
zoneIndex++;
}
let motionMasks: Polygon[] = []; // Merge motion masks
let globalObjectMasks: Polygon[] = []; const mergedMotionMasks = new Map<
let objectMasks: Polygon[] = []; string,
{
data: CameraConfig["motion"]["mask"][string];
source: "base" | "profile" | "override";
}
>();
// Motion masks are a dict with mask_id as key for (const [maskId, maskData] of Object.entries(
motionMasks = Object.entries(cameraConfig.motion.mask || {}).map( cameraConfig.motion.mask || {},
([maskId, maskData], index) => ({ )) {
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, type: "motion_mask" as PolygonType,
typeIndex: index, typeIndex: motionMaskIndex,
camera: cameraConfig.name, camera: cameraConfig.name,
name: maskId, name: maskId,
friendly_name: maskData.friendly_name, friendly_name: maskData.friendly_name,
@ -278,15 +449,61 @@ export default function MasksAndZonesView({
), ),
distances: [], distances: [],
isFinished: true, 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 // Merge global object masks
globalObjectMasks = Object.entries(cameraConfig.objects.mask || {}).map( const mergedGlobalObjectMasks = new Map<
([maskId, maskData], index) => ({ 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, type: "object_mask" as PolygonType,
typeIndex: index, typeIndex: objectMaskIndex,
camera: cameraConfig.name, camera: cameraConfig.name,
name: maskId, name: maskId,
friendly_name: maskData.friendly_name, friendly_name: maskData.friendly_name,
@ -302,13 +519,43 @@ export default function MasksAndZonesView({
), ),
distances: [], distances: [],
isFinished: true, 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( .filter(
([, filterConfig]) => ([, filterConfig]) =>
filterConfig.mask && Object.keys(filterConfig.mask).length > 0, filterConfig.mask && Object.keys(filterConfig.mask).length > 0,
@ -316,22 +563,36 @@ export default function MasksAndZonesView({
.flatMap(([objectName, filterConfig]): Polygon[] => { .flatMap(([objectName, filterConfig]): Polygon[] => {
return Object.entries(filterConfig.mask || {}).flatMap( return Object.entries(filterConfig.mask || {}).flatMap(
([maskId, maskData]) => { ([maskId, maskData]) => {
// Skip if this mask is a global mask (prefixed with "global_")
if (maskId.startsWith("global_")) { if (maskId.startsWith("global_")) {
return []; 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, type: "object_mask" as PolygonType,
typeIndex: objectMaskIndex, typeIndex: objectMaskIndex,
camera: cameraConfig.name, camera: cameraConfig.name,
name: maskId, name: maskId,
friendly_name: maskData.friendly_name, friendly_name: finalData.friendly_name,
enabled: maskData.enabled, enabled: finalData.enabled,
enabled_in_config: maskData.enabled_in_config, enabled_in_config: finalData.enabled_in_config,
objects: [objectName], objects: [objectName],
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(maskData.coordinates), parseCoordinates(finalData.coordinates),
1, 1,
1, 1,
scaledWidth, scaledWidth,
@ -339,7 +600,8 @@ export default function MasksAndZonesView({
), ),
distances: [], distances: [],
isFinished: true, isFinished: true,
color: [128, 128, 128], color: isBase ? dimColor(baseColor) : baseColor,
polygonSource: currentEditingProfile ? source : undefined,
}; };
objectMaskIndex++; objectMaskIndex++;
return [newMask]; 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([ setAllPolygons([
...zones, ...zones,
...motionMasks, ...motionMasks,
@ -386,7 +687,14 @@ export default function MasksAndZonesView({
} }
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraConfig, containerRef, scaledHeight, scaledWidth]); }, [
cameraConfig,
containerRef,
scaledHeight,
scaledWidth,
currentEditingProfile,
dimColor,
]);
useEffect(() => { useEffect(() => {
if (editPane === undefined) { if (editPane === undefined) {
@ -403,6 +711,15 @@ export default function MasksAndZonesView({
} }
}, [selectedCamera]); }, [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) => { useSearchEffect("object_mask", (coordinates: string) => {
if (!scaledWidth || !scaledHeight || isLoading) { if (!scaledWidth || !scaledHeight || isLoading) {
return false; return false;
@ -473,6 +790,7 @@ export default function MasksAndZonesView({
setActiveLine={setActiveLine} setActiveLine={setActiveLine}
snapPoints={snapPoints} snapPoints={snapPoints}
setSnapPoints={setSnapPoints} setSnapPoints={setSnapPoints}
editingProfile={currentEditingProfile}
/> />
)} )}
{editPane == "motion_mask" && ( {editPane == "motion_mask" && (
@ -488,6 +806,7 @@ export default function MasksAndZonesView({
onSave={handleSave} onSave={handleSave}
snapPoints={snapPoints} snapPoints={snapPoints}
setSnapPoints={setSnapPoints} setSnapPoints={setSnapPoints}
editingProfile={currentEditingProfile}
/> />
)} )}
{editPane == "object_mask" && ( {editPane == "object_mask" && (
@ -503,13 +822,34 @@ export default function MasksAndZonesView({
onSave={handleSave} onSave={handleSave}
snapPoints={snapPoints} snapPoints={snapPoints}
setSnapPoints={setSnapPoints} setSnapPoints={setSnapPoints}
editingProfile={currentEditingProfile}
/> />
)} )}
{editPane === undefined && ( {editPane === undefined && (
<> <>
<Heading as="h4" className="mb-2"> <div className="mb-2 flex items-center justify-between">
{t("menu.masksAndZones")} <Heading as="h4">{t("menu.masksAndZones")}</Heading>
</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"> <div className="flex w-full flex-col">
{(selectedZoneMask === undefined || {(selectedZoneMask === undefined ||
selectedZoneMask.includes("zone" as PolygonType)) && ( selectedZoneMask.includes("zone" as PolygonType)) && (
@ -575,6 +915,8 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex} loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex} setLoadingPolygonIndex={setLoadingPolygonIndex}
editingProfile={currentEditingProfile}
allProfileNames={profileState?.allProfileNames}
/> />
))} ))}
</div> </div>
@ -649,6 +991,8 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex} loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex} setLoadingPolygonIndex={setLoadingPolygonIndex}
editingProfile={currentEditingProfile}
allProfileNames={profileState?.allProfileNames}
/> />
))} ))}
</div> </div>
@ -723,6 +1067,8 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex} loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex} setLoadingPolygonIndex={setLoadingPolygonIndex}
editingProfile={currentEditingProfile}
allProfileNames={profileState?.allProfileNames}
/> />
))} ))}
</div> </div>