mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-28 19:18:22 +03:00
profile support for mask and zone editor
This commit is contained in:
parent
6205a9d588
commit
379247dee6
@ -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."
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user