From a823a184964902b0ec377b3895c8a4070e161214 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 16 Apr 2024 14:21:52 -0600 Subject: [PATCH 01/19] Fix camera switching and loading position (#10982) * Fix alignment * Set loading when switching cameras --- .../components/player/dynamic/DynamicVideoPlayer.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index d3cf4ee66..7576a90cb 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -89,7 +89,13 @@ export default function DynamicVideoPlayer({ if (!isScrubbing) { setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000)); } - }, [isScrubbing]); + + return () => { + if (loadingTimeout) { + clearTimeout(loadingTimeout) + } + } + }, [camera, isScrubbing]); const onPlayerLoaded = useCallback(() => { if (!controller || !startTimestamp) { @@ -179,7 +185,7 @@ export default function DynamicVideoPlayer({ }} /> {isLoading && ( - + )} ); From 9be595107673d45792d8dba2eae261f80dfcc2e0 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 16 Apr 2024 14:55:24 -0600 Subject: [PATCH 02/19] UI tweaks (#10998) * Make buttons consistent and have hover state * Use switch for camera to be consistent * Use everywhere and remove unused * Use green for normal stats color * Fix logs copy icon * Remove warnings from pydantic serialization * Ignore warnings * Fix wsdl resolution * Fix loading on switch --- frigate/api/app.py | 2 +- frigate/config.py | 17 +++-- frigate/ptz/onvif.py | 4 +- .../components/filter/CameraGroupSelector.tsx | 4 +- web/src/components/filter/FilterCheckBox.tsx | 34 --------- web/src/components/filter/FilterSwitch.tsx | 29 +++++++ .../components/filter/ReviewFilterGroup.tsx | 75 ++++++++++--------- web/src/components/graph/SystemGraph.tsx | 2 +- web/src/components/navigation/NavItem.tsx | 4 +- web/src/components/navigation/Sidebar.tsx | 2 +- .../player/dynamic/DynamicVideoPlayer.tsx | 6 +- .../components/settings/AccountSettings.tsx | 10 ++- .../components/settings/GeneralSettings.tsx | 8 +- web/src/pages/Logs.tsx | 4 +- 14 files changed, 106 insertions(+), 95 deletions(-) delete mode 100644 web/src/components/filter/FilterCheckBox.tsx create mode 100644 web/src/components/filter/FilterSwitch.tsx diff --git a/frigate/api/app.py b/frigate/api/app.py index a324b6a05..b56c9c229 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -139,7 +139,7 @@ def stats_history(): def config(): config_obj: FrigateConfig = current_app.frigate_config config: dict[str, dict[str, any]] = config_obj.model_dump( - mode="json", exclude_none=True + mode="json", warnings="none", exclude_none=True ) # remove the mqtt password diff --git a/frigate/config.py b/frigate/config.py index ebb471028..d3a89e7a1 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -1351,11 +1351,12 @@ class FrigateConfig(FrigateBaseModel): "timestamp_style": ..., }, exclude_unset=True, + warnings="none", ) for name, camera in config.cameras.items(): merged_config = deep_merge( - camera.model_dump(exclude_unset=True), global_config + camera.model_dump(exclude_unset=True, warnings="none"), global_config ) camera_config: CameraConfig = CameraConfig.model_validate( {"name": name, **merged_config} @@ -1466,7 +1467,7 @@ class FrigateConfig(FrigateBaseModel): # Set runtime filter to create masks camera_config.objects.filters[object] = RuntimeFilterConfig( frame_shape=camera_config.frame_shape, - **filter.model_dump(exclude_unset=True), + **filter.model_dump(exclude_unset=True, warnings="none"), ) # Convert motion configuration @@ -1478,7 +1479,9 @@ class FrigateConfig(FrigateBaseModel): camera_config.motion = RuntimeMotionConfig( frame_shape=camera_config.frame_shape, raw_mask=camera_config.motion.mask, - **camera_config.motion.model_dump(exclude_unset=True), + **camera_config.motion.model_dump( + exclude_unset=True, warnings="none" + ), ) camera_config.motion.enabled_in_config = camera_config.motion.enabled @@ -1515,7 +1518,9 @@ class FrigateConfig(FrigateBaseModel): for key, detector in config.detectors.items(): adapter = TypeAdapter(DetectorConfig) model_dict = ( - detector if isinstance(detector, dict) else detector.model_dump() + detector + if isinstance(detector, dict) + else detector.model_dump(warnings="none") ) detector_config: DetectorConfig = adapter.validate_python(model_dict) if detector_config.model is None: @@ -1536,8 +1541,8 @@ class FrigateConfig(FrigateBaseModel): "Customizing more than a detector model path is unsupported." ) merged_model = deep_merge( - detector_config.model.model_dump(exclude_unset=True), - config.model.model_dump(exclude_unset=True), + detector_config.model.model_dump(exclude_unset=True, warnings="none"), + config.model.model_dump(exclude_unset=True, warnings="none"), ) if "path" not in merged_model: diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 2cd903709..4dbad973f 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -51,7 +51,9 @@ class OnvifController: cam.onvif.port, cam.onvif.user, cam.onvif.password, - wsdl_dir=Path(find_spec("onvif").origin).parent / "../wsdl", + wsdl_dir=str( + Path(find_spec("onvif").origin).parent / "wsdl" + ).replace("dist-packages/onvif", "site-packages"), ), "init": False, "active": False, diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index e514667ed..c147ab638 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -22,8 +22,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import FilterCheckBox from "./FilterCheckBox"; import axios from "axios"; +import FilterSwitch from "./FilterSwitch"; type CameraGroupSelectorProps = { className?: string; @@ -305,7 +305,7 @@ function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) { ...(birdseyeConfig?.enabled ? ["birdseye"] : []), ...Object.keys(config?.cameras ?? {}), ].map((camera) => ( - void; -}; - -export default function FilterCheckBox({ - label, - CheckIcon = LuCheck, - iconClassName = "size-6", - isChecked, - onCheckedChange, -}: FilterCheckBoxProps) { - return ( - - ); -} diff --git a/web/src/components/filter/FilterSwitch.tsx b/web/src/components/filter/FilterSwitch.tsx new file mode 100644 index 000000000..8af6d6ce3 --- /dev/null +++ b/web/src/components/filter/FilterSwitch.tsx @@ -0,0 +1,29 @@ +import { Switch } from "../ui/switch"; +import { Label } from "../ui/label"; + +type FilterSwitchProps = { + label: string; + isChecked: boolean; + onCheckedChange: (checked: boolean) => void; +}; +export default function FilterSwitch({ + label, + isChecked, + onCheckedChange, +}: FilterSwitchProps) { + return ( +
+ + +
+ ); +} diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index a92399539..2a36b2a60 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -24,12 +24,12 @@ import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; -import FilterCheckBox from "./FilterCheckBox"; import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar"; import MobileReviewSettingsDrawer, { DrawerFeatures, } from "../overlay/MobileReviewSettingsDrawer"; import useOptimisticState from "@/hooks/use-optimistic-state"; +import FilterSwitch from "./FilterSwitch"; const REVIEW_FILTERS = [ "cameras", @@ -248,8 +248,8 @@ export function CamerasFilterButton({ )} -
- + { @@ -260,51 +260,52 @@ export function CamerasFilterButton({ /> {groups.length > 0 && ( <> - + {groups.map(([name, conf]) => { return ( - { - setCurrentCameras([...conf.cameras]); - }} - /> + className="w-full px-2 py-1.5 text-sm text-primary capitalize cursor-pointer rounded-lg hover:bg-muted" + onClick={() => setCurrentCameras([...conf.cameras])} + > + {name} +
); })} )} - - {allCameras.map((item) => ( - { - if (isChecked) { - const updatedCameras = currentCameras - ? [...currentCameras] - : []; + +
+ {allCameras.map((item) => ( + { + if (isChecked) { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; - updatedCameras.push(item); - setCurrentCameras(updatedCameras); - } else { - const updatedCameras = currentCameras - ? [...currentCameras] - : []; - - // can not deselect the last item - if (updatedCameras.length > 1) { - updatedCameras.splice(updatedCameras.indexOf(item), 1); + updatedCameras.push(item); setCurrentCameras(updatedCameras); + } else { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + // can not deselect the last item + if (updatedCameras.length > 1) { + updatedCameras.splice(updatedCameras.indexOf(item), 1); + setCurrentCameras(updatedCameras); + } } - } - }} - /> - ))} + }} + /> + ))} +
- +
-
+
diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 7576a90cb..f923fbd43 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -111,9 +111,13 @@ export default function DynamicVideoPlayer({ return; } + if (isLoading) { + setIsLoading(false); + } + onTimestampUpdate(controller.getProgress(time)); }, - [controller, onTimestampUpdate, isScrubbing], + [controller, onTimestampUpdate, isScrubbing, isLoading], ); // state of playback player diff --git a/web/src/components/settings/AccountSettings.tsx b/web/src/components/settings/AccountSettings.tsx index e5881465e..afa63ae4c 100644 --- a/web/src/components/settings/AccountSettings.tsx +++ b/web/src/components/settings/AccountSettings.tsx @@ -3,16 +3,18 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { isDesktop } from "react-device-detect"; import { VscAccount } from "react-icons/vsc"; -import { Button } from "../ui/button"; export default function AccountSettings() { return ( - +
+ +

Account

diff --git a/web/src/components/settings/GeneralSettings.tsx b/web/src/components/settings/GeneralSettings.tsx index 3ffcc540d..bc45f650f 100644 --- a/web/src/components/settings/GeneralSettings.tsx +++ b/web/src/components/settings/GeneralSettings.tsx @@ -118,9 +118,11 @@ export default function GeneralSettings({ className }: GeneralSettings) { - +
+ +

Settings

diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index e735d9a6f..a12f2d162 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -31,7 +31,7 @@ function Logs() { const [logService, setLogService] = useState("frigate"); useEffect(() => { - document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Stats - Frigate`; + document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`; }, [logService]); // log data handling @@ -366,7 +366,7 @@ function Logs() { size="sm" onClick={handleCopyLogs} > - +
Copy to Clipboard
From ff823b87c8af09b18d315e1a07932d831dbd5665 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 16 Apr 2024 14:56:28 -0600 Subject: [PATCH 03/19] Add support for arbitrary sub labels in reviews (#10990) * store arbitrary sub labels * Include sub labels in tooltip * Update tooltips on filmstrip * Fix item display * Fix bug with creating review segment --- frigate/review/maintainer.py | 16 ++++++++++++---- web/src/components/card/AnimatedEventCard.tsx | 13 ++++++++++++- .../components/player/PreviewThumbnailPlayer.tsx | 16 +++++++++++++--- web/src/types/review.ts | 1 + 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index ebc03a35e..86cac58a8 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -46,8 +46,9 @@ class PendingReviewSegment: frame_time: float, severity: SeverityEnum, detections: dict[str, str], - zones: set[str] = set(), - audio: set[str] = set(), + sub_labels: set[str], + zones: set[str], + audio: set[str], ): rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) self.id = f"{frame_time}-{rand_id}" @@ -55,6 +56,7 @@ class PendingReviewSegment: self.start_time = frame_time self.severity = severity self.detections = detections + self.sub_labels = sub_labels self.zones = zones self.audio = audio self.last_update = frame_time @@ -111,6 +113,7 @@ class PendingReviewSegment: ReviewSegment.data: { "detections": list(set(self.detections.keys())), "objects": list(set(self.detections.values())), + "sub_labels": list(self.sub_labels), "zones": list(self.zones), "audio": list(self.audio), }, @@ -181,6 +184,7 @@ class ReviewSegmentMaintainer(threading.Thread): segment.detections[object["id"]] = object["sub_label"][0] else: segment.detections[object["id"]] = f'{object["label"]}-verified' + segment.sub_labels.add(object["sub_label"][0]) # if object is alert label # and has entered required zones or required zones is not set @@ -233,8 +237,8 @@ class ReviewSegmentMaintainer(threading.Thread): active_objects = get_active_objects(frame_time, camera_config, objects) if len(active_objects) > 0: - has_sig_object = False detections: dict[str, str] = {} + sub_labels = set() zones: set = set() severity = None @@ -245,6 +249,7 @@ class ReviewSegmentMaintainer(threading.Thread): detections[object["id"]] = object["sub_label"][0] else: detections[object["id"]] = f'{object["label"]}-verified' + sub_labels.add(object["sub_label"][0]) # if object is alert label # and has entered required zones or required zones is not set @@ -290,8 +295,9 @@ class ReviewSegmentMaintainer(threading.Thread): self.active_review_segments[camera] = PendingReviewSegment( camera, frame_time, - SeverityEnum.alert if has_sig_object else SeverityEnum.detection, + severity, detections, + sub_labels=sub_labels, audio=set(), zones=zones, ) @@ -435,6 +441,7 @@ class ReviewSegmentMaintainer(threading.Thread): severity, {}, set(), + set(), detections, ) elif topic == DetectionTypeEnum.api: @@ -445,6 +452,7 @@ class ReviewSegmentMaintainer(threading.Thread): {manual_info["event_id"]: manual_info["label"]}, set(), set(), + set(), ) if manual_info["state"] == ManualEventState.start: diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index 85cfe3c3c..93ecbe919 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -83,7 +83,18 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
- {`${[...event.data.objects, ...event.data.audio].join(", ").replaceAll("-verified", "")} detected`} + {`${[ + ...new Set([ + ...(event.data.objects || []), + ...(event.data.sub_labels || []), + ...(event.data.audio || []), + ]), + ] + .filter((item) => item !== undefined && !item.includes("-verified")) + .map((text) => text.charAt(0).toUpperCase() + text.substring(1)) + .sort() + .join(", ") + .replaceAll("-verified", "")} detected`} ); diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index e640cfbb7..5d7cc6a15 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -239,7 +239,7 @@ export default function PreviewThumbnailPlayer({ - {review.data.objects.map((object) => { + {review.data.objects.sort().map((object) => { return getIconForLabel(object, "size-3 text-white"); })} {review.data.audio.map((audio) => { @@ -252,8 +252,18 @@ export default function PreviewThumbnailPlayer({ - {[...(review.data.objects || []), ...(review.data.audio || [])] - .filter((item) => item !== undefined) + {[ + ...new Set([ + ...(review.data.objects || []), + ...(review.data.sub_labels || []), + ...(review.data.audio || []), + ]), + ] + .filter( + (item) => item !== undefined && !item.includes("-verified"), + ) + .map((text) => text.charAt(0).toUpperCase() + text.substring(1)) + .sort() .join(", ") .replaceAll("-verified", "")} diff --git a/web/src/types/review.ts b/web/src/types/review.ts index ea5bbb250..b8e5254d9 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -15,6 +15,7 @@ export type ReviewData = { audio: string[]; detections: string[]; objects: string[]; + sub_labels?: string[]; significant_motion_areas: number[]; zones: string[]; }; From a87cca23eacd16f23375530facf2c2c1eef9788d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 17 Apr 2024 06:02:03 -0600 Subject: [PATCH 04/19] Add ability to link to review items directly (#11002) * Fix action group icon colors * Add ability to query specific review item * Pull id search key and open recordings to review item --- frigate/api/review.py | 9 ++++++ .../components/filter/ReviewActionGroup.tsx | 8 ++--- web/src/hooks/use-overlay-state.tsx | 29 ++++++++++++++++++- web/src/pages/Events.tsx | 20 ++++++++++++- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 2ad36962e..fa1dee73c 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -8,6 +8,7 @@ from pathlib import Path import pandas as pd from flask import Blueprint, jsonify, make_response, request from peewee import Case, DoesNotExist, fn, operator +from playhouse.shortcuts import model_to_dict from frigate.models import Recordings, ReviewSegment from frigate.util.builtin import get_tz_modifiers @@ -78,6 +79,14 @@ def review(): return jsonify([r for r in review]) +@ReviewBp.route("/review/") +def get_review(id: str): + try: + return model_to_dict(ReviewSegment.get(ReviewSegment.id == id)) + except DoesNotExist: + return "Review item not found", 404 + + @ReviewBp.route("/review/summary") def review_summary(): tz_name = request.args.get("timezone", default="utc", type=str) diff --git a/web/src/components/filter/ReviewActionGroup.tsx b/web/src/components/filter/ReviewActionGroup.tsx index cd31112af..fa32c92a6 100644 --- a/web/src/components/filter/ReviewActionGroup.tsx +++ b/web/src/components/filter/ReviewActionGroup.tsx @@ -56,7 +56,7 @@ export default function ReviewActionGroup({ onClearSelected(); }} > - + {isDesktop &&
Export
} )} @@ -65,15 +65,15 @@ export default function ReviewActionGroup({ size="sm" onClick={onMarkAsReviewed} > - + {isDesktop &&
Mark as reviewed
} diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index c2f2a0f85..656b61bda 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { usePersistence } from "./use-persistence"; @@ -91,3 +91,30 @@ export function useHashState(): [ return [hash, setHash]; } + +export function useSearchEffect( + key: string, + callback: (value: string) => void, +) { + const location = useLocation(); + + const param = useMemo(() => { + if (!location || !location.search || location.search.length == 0) { + return undefined; + } + + const params = location.search.substring(1).split("&"); + + return params + .find((p) => p.includes("=") && p.split("=")[0] == key) + ?.split("="); + }, [location, key]); + + useEffect(() => { + if (!param) { + return; + } + + callback(param[1]); + }, [param, callback]); +} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 6277c6994..0cc3cd6b5 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,7 +1,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator"; import useApiFilter from "@/hooks/use-api-filter"; import { useTimezone } from "@/hooks/use-date-utils"; -import { useOverlayState } from "@/hooks/use-overlay-state"; +import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { RecordingStartingPoint } from "@/types/record"; @@ -33,6 +33,24 @@ export default function Events() { const [recording, setRecording] = useOverlayState("recording"); + useSearchEffect("id", (reviewId: string) => { + axios + .get(`review/${reviewId}`) + .then((resp) => { + if (resp.status == 200 && resp.data) { + setRecording( + { + camera: resp.data.camera, + startTime: resp.data.start_time, + severity: resp.data.severity, + }, + true, + ); + } + }) + .catch(() => {}); + }); + const [startTime, setStartTime] = useState(); useEffect(() => { From 392ff1319d87a7ff6d736678c05b0daecaba5ecb Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 17 Apr 2024 06:02:59 -0600 Subject: [PATCH 05/19] Remove use_experimental config as part of config migration (#11003) * Remove experimental config as part of config migration * Remove from config * remove config from docs --- docs/docs/configuration/reference.md | 2 -- frigate/config.py | 18 ++++++------------ frigate/util/config.py | 6 ++++++ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index f603d33fb..5f89eddb7 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -607,8 +607,6 @@ ui: live_mode: mse # Optional: Set a timezone to use in the UI (default: use browser local time) # timezone: America/Denver - # Optional: Use an experimental recordings / camera view UI (default: shown below) - use_experimental: False # Optional: Set the time format used. # Options are browser, 12hour, or 24hour (default: shown below) time_format: browser diff --git a/frigate/config.py b/frigate/config.py index d3a89e7a1..1ac7ac886 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -101,7 +101,6 @@ class UIConfig(FrigateBaseModel): default=LiveModeEnum.mse, title="Default Live Mode." ) timezone: Optional[str] = Field(default=None, title="Override UI timezone.") - use_experimental: bool = Field(default=False, title="Experimental UI") time_format: TimeFormatEnum = Field( default=TimeFormatEnum.browser, title="Override UI time format." ) @@ -1351,12 +1350,11 @@ class FrigateConfig(FrigateBaseModel): "timestamp_style": ..., }, exclude_unset=True, - warnings="none", ) for name, camera in config.cameras.items(): merged_config = deep_merge( - camera.model_dump(exclude_unset=True, warnings="none"), global_config + camera.model_dump(exclude_unset=True), global_config ) camera_config: CameraConfig = CameraConfig.model_validate( {"name": name, **merged_config} @@ -1467,7 +1465,7 @@ class FrigateConfig(FrigateBaseModel): # Set runtime filter to create masks camera_config.objects.filters[object] = RuntimeFilterConfig( frame_shape=camera_config.frame_shape, - **filter.model_dump(exclude_unset=True, warnings="none"), + **filter.model_dump(exclude_unset=True), ) # Convert motion configuration @@ -1479,9 +1477,7 @@ class FrigateConfig(FrigateBaseModel): camera_config.motion = RuntimeMotionConfig( frame_shape=camera_config.frame_shape, raw_mask=camera_config.motion.mask, - **camera_config.motion.model_dump( - exclude_unset=True, warnings="none" - ), + **camera_config.motion.model_dump(exclude_unset=True), ) camera_config.motion.enabled_in_config = camera_config.motion.enabled @@ -1518,9 +1514,7 @@ class FrigateConfig(FrigateBaseModel): for key, detector in config.detectors.items(): adapter = TypeAdapter(DetectorConfig) model_dict = ( - detector - if isinstance(detector, dict) - else detector.model_dump(warnings="none") + detector if isinstance(detector, dict) else detector.model_dump() ) detector_config: DetectorConfig = adapter.validate_python(model_dict) if detector_config.model is None: @@ -1541,8 +1535,8 @@ class FrigateConfig(FrigateBaseModel): "Customizing more than a detector model path is unsupported." ) merged_model = deep_merge( - detector_config.model.model_dump(exclude_unset=True, warnings="none"), - config.model.model_dump(exclude_unset=True, warnings="none"), + detector_config.model.model_dump(exclude_unset=True), + config.model.model_dump(exclude_unset=True), ) if "path" not in merged_model: diff --git a/frigate/util/config.py b/frigate/util/config.py index 46a1ea941..fb7aa3ef5 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -81,6 +81,12 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]: if not new_config["record"]: del new_config["record"] + if new_config.get("ui", {}).get("use_experimental"): + del new_config["ui"]["experimental"] + + if not new_config["ui"]: + del new_config["ui"] + # remove rtmp if new_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"): del new_config["ffmpeg"]["output_args"]["rtmp"] From 8230813b795c1b3d3669a463111e10e02d005299 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 17 Apr 2024 15:26:16 -0600 Subject: [PATCH 06/19] Migrate export filenames (#11005) * Migrate export filenames * formatting * Remove test.yaml saving --- frigate/util/builtin.py | 2 -- frigate/util/config.py | 12 +++++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index be91421f9..1536d9799 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -223,8 +223,6 @@ def update_yaml_file(file_path, key_path, new_value): data = yaml.load(f) data = update_yaml(data, key_path, new_value) - with open("/config/test.yaml", "w") as f: - yaml.dump(data, f) with open(file_path, "w") as f: yaml.dump(data, f) diff --git a/frigate/util/config.py b/frigate/util/config.py index fb7aa3ef5..1192ac9de 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -6,7 +6,7 @@ import shutil from ruamel.yaml import YAML -from frigate.const import CONFIG_DIR +from frigate.const import CONFIG_DIR, EXPORT_DIR logger = logging.getLogger(__name__) @@ -46,6 +46,16 @@ def migrate_frigate_config(config_file: str): yaml.dump(new_config, f) previous_version = 0.14 + logger.info("Migrating export file names...") + for file in os.listdir(EXPORT_DIR): + if "@" not in file: + continue + + new_name = file.replace("@", "_") + os.rename( + os.path.join(EXPORT_DIR, file), os.path.join(EXPORT_DIR, new_name) + ) + with open(version_file, "w") as f: f.write(str(CURRENT_CONFIG_VERSION)) From fb721ad031dc2d31e9e4d7ea9983528a403302cc Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 18 Apr 2024 10:34:18 -0600 Subject: [PATCH 07/19] UI fixes (#11012) * Get pip working correctly * Fix system graphs click and hover states --- web/src/components/graph/SystemGraph.tsx | 19 +++++++++++++++++++ web/src/views/live/LiveCameraView.tsx | 12 ++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/web/src/components/graph/SystemGraph.tsx b/web/src/components/graph/SystemGraph.tsx index 31d12ddf8..e96ce8235 100644 --- a/web/src/components/graph/SystemGraph.tsx +++ b/web/src/components/graph/SystemGraph.tsx @@ -90,6 +90,13 @@ export function ThresholdBarGraph({ distributed: true, }, }, + states: { + active: { + filter: { + type: "none", + }, + }, + }, tooltip: { theme: systemTheme || theme, y: { @@ -192,6 +199,18 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) { horizontal: true, }, }, + states: { + active: { + filter: { + type: "none", + }, + }, + hover: { + filter: { + type: "none", + }, + }, + }, tooltip: { enabled: false, }, diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index ee71a687c..3bf140d15 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -127,22 +127,22 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { if (mainRef.current == null) { return; } - const fsListener = () => { setFullscreen(document.fullscreenElement != null); }; - const pipListener = () => { - setPip(document.pictureInPictureElement != null); - }; document.addEventListener("fullscreenchange", fsListener); - document.addEventListener("focusin", pipListener); return () => { document.removeEventListener("fullscreenchange", fsListener); - document.removeEventListener("focusin", pipListener); }; }, [mainRef]); + useEffect(() => { + setPip(document.pictureInPictureElement != null); + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [document.pictureInPictureElement]); + // playback state const [audio, setAudio] = useState(false); From 03e25b3f9444db24adb2d836e8966909d4dd55b0 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 18 Apr 2024 10:35:16 -0600 Subject: [PATCH 08/19] Improve config validation for zones and object masks (#11022) * Add verification for required zone names * Make global object masks use relative coordinates as well * Ensure event image cleanup doesn't fail * Return passed value --- frigate/config.py | 87 ++++++++++++--------------------------- frigate/events/cleanup.py | 53 ++++++++++++++---------- frigate/util/config.py | 36 ++++++++++++++++ 3 files changed, 95 insertions(+), 81 deletions(-) diff --git a/frigate/config.py b/frigate/config.py index 1ac7ac886..2894fa42a 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -46,6 +46,7 @@ from frigate.util.builtin import ( get_ffmpeg_arg_list, load_config_with_no_duplicates, ) +from frigate.util.config import get_relative_coordinates from frigate.util.image import create_mask from frigate.util.services import auto_detect_hwaccel, get_video_properties @@ -348,35 +349,7 @@ class RuntimeMotionConfig(MotionConfig): def __init__(self, **config): frame_shape = config.get("frame_shape", (1, 1)) - mask = config.get("mask", "") - - # masks and zones are saved as relative coordinates - # we know if any points are > 1 then it is using the - # old native resolution coordinates - if mask: - if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")): - relative_masks = [] - for m in mask: - points = m.split(",") - relative_masks.append( - ",".join( - [ - f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" - for i in range(0, len(points), 2) - ] - ) - ) - - mask = relative_masks - elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")): - points = mask.split(",") - mask = ",".join( - [ - f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" - for i in range(0, len(points), 2) - ] - ) - + mask = get_relative_coordinates(config.get("mask", ""), frame_shape) config["raw_mask"] = mask if mask: @@ -508,34 +481,7 @@ class RuntimeFilterConfig(FilterConfig): def __init__(self, **config): frame_shape = config.get("frame_shape", (1, 1)) - mask = config.get("mask") - - # masks and zones are saved as relative coordinates - # we know if any points are > 1 then it is using the - # old native resolution coordinates - if mask: - if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")): - relative_masks = [] - for m in mask: - points = m.split(",") - relative_masks.append( - ",".join( - [ - f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" - for i in range(0, len(points), 2) - ] - ) - ) - - mask = relative_masks - elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")): - points = mask.split(",") - mask = ",".join( - [ - f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" - for i in range(0, len(points), 2) - ] - ) + mask = get_relative_coordinates(config.get("mask"), frame_shape) config["raw_mask"] = mask @@ -1231,6 +1177,20 @@ def verify_zone_objects_are_tracked(camera_config: CameraConfig) -> None: ) +def verify_required_zones_exist(camera_config: CameraConfig) -> None: + for det_zone in camera_config.review.detections.required_zones: + if det_zone not in camera_config.zones.keys(): + raise ValueError( + f"Camera {camera_config.name} has a required zone for detections {det_zone} that is not defined." + ) + + for det_zone in camera_config.review.alerts.required_zones: + if det_zone not in camera_config.zones.keys(): + raise ValueError( + f"Camera {camera_config.name} has a required zone for alerts {det_zone} that is not defined." + ) + + def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None: """Verify that required_zones are specified when autotracking is enabled.""" if ( @@ -1456,9 +1416,15 @@ class FrigateConfig(FrigateBaseModel): else [filter.mask] ) object_mask = ( - camera_config.objects.mask - if isinstance(camera_config.objects.mask, list) - else [camera_config.objects.mask] + get_relative_coordinates( + ( + camera_config.objects.mask + if isinstance(camera_config.objects.mask, list) + else [camera_config.objects.mask] + ), + camera_config.frame_shape, + ) + or [] ) filter.mask = filter_mask + object_mask @@ -1495,6 +1461,7 @@ class FrigateConfig(FrigateBaseModel): verify_recording_retention(camera_config) verify_recording_segments_setup_with_reasonable_time(camera_config) verify_zone_objects_are_tracked(camera_config) + verify_required_zones_exist(camera_config) verify_autotrack_zones(camera_config) verify_motion_and_detect(camera_config) diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index 98da72f6c..29ea54ed7 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -83,7 +83,7 @@ class EventCleanup(threading.Thread): datetime.datetime.now() - datetime.timedelta(days=expire_days) ).timestamp() # grab all events after specific time - expired_events = ( + expired_events: list[Event] = ( Event.select( Event.id, Event.camera, @@ -103,12 +103,16 @@ class EventCleanup(threading.Thread): media_path = Path( f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}" ) - media_path.unlink(missing_ok=True) - if file_extension == "jpg": - media_path = Path( - f"{os.path.join(CLIPS_DIR, media_name)}-clean.png" - ) + + try: media_path.unlink(missing_ok=True) + if file_extension == "jpg": + media_path = Path( + f"{os.path.join(CLIPS_DIR, media_name)}-clean.png" + ) + media_path.unlink(missing_ok=True) + except OSError as e: + logger.warning(f"Unable to delete event images: {e}") # update the clips attribute for the db entry update_query = Event.update(update_params).where( @@ -163,15 +167,18 @@ class EventCleanup(threading.Thread): events_to_update.append(event.id) if media_type == EventCleanupType.snapshots: - media_name = f"{event.camera}-{event.id}" - media_path = Path( - f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}" - ) - media_path.unlink(missing_ok=True) - media_path = Path( - f"{os.path.join(CLIPS_DIR, media_name)}-clean.png" - ) - media_path.unlink(missing_ok=True) + try: + media_name = f"{event.camera}-{event.id}" + media_path = Path( + f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}" + ) + media_path.unlink(missing_ok=True) + media_path = Path( + f"{os.path.join(CLIPS_DIR, media_name)}-clean.png" + ) + media_path.unlink(missing_ok=True) + except OSError as e: + logger.warning(f"Unable to delete event images: {e}") # update the clips attribute for the db entry Event.update(update_params).where(Event.id << events_to_update).execute() @@ -195,14 +202,18 @@ class EventCleanup(threading.Thread): select distinct id, camera, has_snapshot, has_clip from grouped_events where copy_number > 1 and end_time not null;""" - duplicate_events = Event.raw(duplicate_query) + duplicate_events: list[Event] = Event.raw(duplicate_query) for event in duplicate_events: logger.debug(f"Removing duplicate: {event.id}") - media_name = f"{event.camera}-{event.id}" - media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") - media_path.unlink(missing_ok=True) - media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png") - media_path.unlink(missing_ok=True) + + try: + media_name = f"{event.camera}-{event.id}" + media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") + media_path.unlink(missing_ok=True) + media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png") + media_path.unlink(missing_ok=True) + except OSError as e: + logger.warning(f"Unable to delete event images: {e}") ( Event.delete() diff --git a/frigate/util/config.py b/frigate/util/config.py index 1192ac9de..d720df067 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -3,6 +3,7 @@ import logging import os import shutil +from typing import Optional, Union from ruamel.yaml import YAML @@ -141,3 +142,38 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]: new_config["cameras"][name] = camera_config return new_config + + +def get_relative_coordinates( + mask: Optional[Union[str, list]], frame_shape: tuple[int, int] +) -> Union[str, list]: + # masks and zones are saved as relative coordinates + # we know if any points are > 1 then it is using the + # old native resolution coordinates + if mask: + if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")): + relative_masks = [] + for m in mask: + points = m.split(",") + relative_masks.append( + ",".join( + [ + f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" + for i in range(0, len(points), 2) + ] + ) + ) + + mask = relative_masks + elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")): + points = mask.split(",") + mask = ",".join( + [ + f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" + for i in range(0, len(points), 2) + ] + ) + + return mask + + return mask From 0bad001ac94cfa2d69a2f824508468864195028e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 18 Apr 2024 21:34:16 -0600 Subject: [PATCH 09/19] Show coral temps on system page if available (#11026) --- web/src/types/graph.ts | 5 ++ web/src/views/system/GeneralMetrics.tsx | 65 ++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/web/src/types/graph.ts b/web/src/types/graph.ts index 4963142e8..6a3eb7663 100644 --- a/web/src/types/graph.ts +++ b/web/src/types/graph.ts @@ -18,6 +18,11 @@ export const InferenceThreshold = { error: 100, } as Threshold; +export const DetectorTempThreshold = { + warning: 72, + error: 80, +} as Threshold; + export const DetectorCpuThreshold = { warning: 25, error: 50, diff --git a/web/src/views/system/GeneralMetrics.tsx b/web/src/views/system/GeneralMetrics.tsx index fe5e4dffe..7d2d5fc5a 100644 --- a/web/src/views/system/GeneralMetrics.tsx +++ b/web/src/views/system/GeneralMetrics.tsx @@ -5,6 +5,7 @@ import { useFrigateStats } from "@/api/ws"; import { DetectorCpuThreshold, DetectorMemThreshold, + DetectorTempThreshold, GPUMemThreshold, GPUUsageThreshold, InferenceThreshold, @@ -105,6 +106,44 @@ export default function GeneralMetrics({ return Object.values(series); }, [statsHistory]); + const detTempSeries = useMemo(() => { + if (!statsHistory) { + return []; + } + + if ( + statsHistory.length > 0 && + Object.keys(statsHistory[0].service.temperatures).length == 0 + ) { + return undefined; + } + + const series: { + [key: string]: { name: string; data: { x: number; y: number }[] }; + } = {}; + + statsHistory.forEach((stats, statsIdx) => { + if (!stats) { + return; + } + + Object.entries(stats.detectors).forEach(([key], cIdx) => { + if (cIdx <= Object.keys(stats.service.temperatures).length) { + if (!(key in series)) { + series[key] = { + name: key, + data: [], + }; + } + + const temp = Object.values(stats.service.temperatures)[cIdx]; + series[key].data.push({ x: statsIdx + 1, y: Math.round(temp) }); + } + }); + }); + return Object.values(series); + }, [statsHistory]); + const detCpuSeries = useMemo(() => { if (!statsHistory) { return []; @@ -291,7 +330,9 @@ export default function GeneralMetrics({
Detectors
-
+
{statsHistory.length != 0 ? (
Detector Inference Speed
@@ -310,6 +351,28 @@ export default function GeneralMetrics({ ) : ( )} + {statsHistory.length != 0 ? ( + <> + {detTempSeries && ( +
+
Detector Temperature
+ {detTempSeries.map((series) => ( + + ))} +
+ )} + + ) : ( + + )} {statsHistory.length != 0 ? (
Detector CPU Usage
From a1905f560475cd6d5d0c26beadf1f58c3bb2a399 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 18 Apr 2024 21:34:57 -0600 Subject: [PATCH 10/19] Remove gifs and use existing views (#11027) * Use existing components for preview filmstrip instead of gif * Allow setting format --- frigate/api/media.py | 164 ++++++++++++++++-- web/src/components/card/AnimatedEventCard.tsx | 65 ++++--- .../player/PreviewThumbnailPlayer.tsx | 70 +++++--- 3 files changed, 238 insertions(+), 61 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 345636e25..5387b2866 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -14,14 +14,7 @@ from urllib.parse import unquote import cv2 import numpy as np import pytz -from flask import ( - Blueprint, - Response, - current_app, - jsonify, - make_response, - request, -) +from flask import Blueprint, Response, current_app, jsonify, make_response, request from peewee import DoesNotExist, fn from tzlocal import get_localzone_name from werkzeug.utils import secure_filename @@ -36,9 +29,7 @@ from frigate.const import ( ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment from frigate.record.export import PlaybackFactorEnum, RecordingExporter -from frigate.util.builtin import ( - get_tz_modifiers, -) +from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) @@ -1322,8 +1313,151 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000): return response -@MediaBp.route("/review//preview.gif") +@MediaBp.route("//start//end//preview.mp4") +@MediaBp.route("//start//end//preview.mp4") +def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000): + if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0): + # has preview mp4 + preview: Previews = ( + Previews.select( + Previews.camera, + Previews.path, + Previews.duration, + Previews.start_time, + Previews.end_time, + ) + .where( + Previews.start_time.between(start_ts, end_ts) + | Previews.end_time.between(start_ts, end_ts) + | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) + ) + .where(Previews.camera == camera_name) + .limit(1) + .get() + ) + + if not preview: + return make_response( + jsonify({"success": False, "message": "Preview not found"}), 404 + ) + + diff = start_ts - preview.start_time + minutes = int(diff / 60) + seconds = int(diff % 60) + ffmpeg_cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "warning", + "-ss", + f"00:{minutes}:{seconds}", + "-t", + f"{end_ts - start_ts}", + "-i", + preview.path, + "-r", + "8", + "-vf", + "setpts=0.12*PTS", + "-loop", + "0", + "-c:v", + "copy", + "-f", + "mp4", + "-", + ] + + process = sp.run( + ffmpeg_cmd, + capture_output=True, + ) + + if process.returncode != 0: + logger.error(process.stderr) + return make_response( + jsonify({"success": False, "message": "Unable to create preview gif"}), + 500, + ) + + gif_bytes = process.stdout + else: + # need to generate from existing images + preview_dir = os.path.join(CACHE_DIR, "preview_frames") + file_start = f"preview_{camera_name}" + start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" + selected_previews = [] + + for file in sorted(os.listdir(preview_dir)): + if not file.startswith(file_start): + continue + + if file < start_file: + continue + + if file > end_file: + break + + selected_previews.append(f"file '{os.path.join(preview_dir, file)}'") + selected_previews.append("duration 0.12") + + if not selected_previews: + return make_response( + jsonify({"success": False, "message": "Preview not found"}), 404 + ) + + last_file = selected_previews[-2] + selected_previews.append(last_file) + + ffmpeg_cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "warning", + "-f", + "concat", + "-y", + "-protocol_whitelist", + "pipe,file", + "-safe", + "0", + "-i", + "/dev/stdin", + "-loop", + "0", + "-c:v", + "libx264", + "-f", + "gif", + "-", + ] + + process = sp.run( + ffmpeg_cmd, + input=str.encode("\n".join(selected_previews)), + capture_output=True, + ) + + if process.returncode != 0: + logger.error(process.stderr) + return make_response( + jsonify({"success": False, "message": "Unable to create preview gif"}), + 500, + ) + + gif_bytes = process.stdout + + response = make_response(gif_bytes) + response.headers["Content-Type"] = "image/gif" + response.headers["Cache-Control"] = f"private, max-age={max_cache_age}" + return response + + +@MediaBp.route("/review//preview") def review_preview(id: str): + format = request.args.get("format", default="gif") + try: review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id) except DoesNotExist: @@ -1336,7 +1470,11 @@ def review_preview(id: str): end_ts = ( review.end_time + padding if review.end_time else datetime.now().timestamp() ) - return preview_gif(review.camera, start_ts, end_ts) + + if format == "gif": + return preview_gif(review.camera, start_ts, end_ts) + else: + return preview_mp4(review.camera, start_ts, end_ts) @MediaBp.route("/preview//thumbnail.jpg") diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index 93ecbe919..08a2670bd 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -1,14 +1,17 @@ -import { baseUrl } from "@/api/baseUrl"; import TimeAgo from "../dynamic/TimeAgo"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment } from "@/types/review"; import { useNavigate } from "react-router-dom"; -import { Skeleton } from "../ui/skeleton"; import { RecordingStartingPoint } from "@/types/record"; import axios from "axios"; +import { Preview } from "@/types/preview"; +import { + InProgressPreview, + VideoPreview, +} from "../player/PreviewThumbnailPlayer"; type AnimatedEventCardProps = { event: ReviewSegment; @@ -16,6 +19,12 @@ type AnimatedEventCardProps = { export function AnimatedEventCard({ event }: AnimatedEventCardProps) { const { data: config } = useSWR("config"); + // preview + + const { data: previews } = useSWR( + `/preview/${event.camera}/start/${Math.round(event.start_time)}/end/${Math.round(event.end_time || event.start_time + 20)}`, + ); + // interaction const navigate = useNavigate(); @@ -35,16 +44,6 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) { // image behavior - const [loaded, setLoaded] = useState(false); - const [error, setError] = useState(0); - const imageUrl = useMemo(() => { - if (error > 0) { - return `${baseUrl}api/review/${event.id}/preview.gif?key=${error}`; - } - - return `${baseUrl}api/review/${event.id}/preview.gif`; - }, [error, event]); - const aspectRatio = useMemo(() => { if (!config) { return 1; @@ -63,18 +62,36 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) { aspectRatio: aspectRatio, }} > - setLoaded(true)} - onError={() => { - if (error < 2) { - setError(error + 1); - } - }} - /> - {!loaded && } + > + {previews ? ( + {}} + setIgnoreClick={() => {}} + isPlayingBack={() => {}} + /> + ) : ( + {}} + setIgnoreClick={() => {}} + isPlayingBack={() => {}} + /> + )} +
diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 5d7cc6a15..a7968075d 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -342,15 +342,19 @@ type VideoPreviewProps = { relevantPreview: Preview; startTime: number; endTime?: number; + showProgress?: boolean; + loop?: boolean; setReviewed: () => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; onTimeUpdate?: (time: number | undefined) => void; }; -function VideoPreview({ +export function VideoPreview({ relevantPreview, startTime, endTime, + showProgress = true, + loop = false, setReviewed, setIgnoreClick, isPlayingBack, @@ -425,6 +429,11 @@ function VideoPreview({ if (playerPercent > 100) { setReviewed(); + if (loop && playerRef.current) { + playerRef.current.currentTime = playerStartTime; + return; + } + if (isMobile) { isPlayingBack(false); @@ -553,17 +562,19 @@ function VideoPreview({ > - + {showProgress && ( + + )}
); } @@ -572,14 +583,18 @@ const MIN_LOAD_TIMEOUT_MS = 200; type InProgressPreviewProps = { review: ReviewSegment; timeRange: TimeRange; + showProgress?: boolean; + loop?: boolean; setReviewed: (reviewId: string) => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; onTimeUpdate?: (time: number | undefined) => void; }; -function InProgressPreview({ +export function InProgressPreview({ review, timeRange, + showProgress = true, + loop = false, setReviewed, setIgnoreClick, isPlayingBack, @@ -615,6 +630,11 @@ function InProgressPreview({ setReviewed(review.id); } + if (loop) { + setKey(0); + return; + } + if (isMobile) { isPlayingBack(false); @@ -717,17 +737,19 @@ function InProgressPreview({ src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.webp`} onLoad={handleLoad} /> - + {showProgress && ( + + )}
); } From 5f15641b1bfb26aa2d6808b8736ba0cc06e29836 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 19 Apr 2024 06:34:07 -0500 Subject: [PATCH 11/19] New mask/zone editor and motion tuner (#11020) * initial working konva * working multi polygons * multi zones * clean up * new zone dialog * clean up * relative coordinates and colors * fix color order * better motion tuner * objects for zones * progress * merge dev * edit pane * motion and object masks * filtering * add objects and unsaved to type * motion tuner, edit controls, tooltips * object and motion edit panes * polygon item component, switch color, object form, hover cards * working zone edit pane * working motion masks * object masks and deletion of all types * use FilterSwitch * motion tuner fixes and tweaks * clean up * tweaks * spaces in camera name * tweaks * allow dragging of points while drawing polygon * turn off editing mode when switching camera * limit interpolated coordinates and use crosshair cursor * padding * fix tooltip trigger for icons * konva tweaks * consolidate * fix top menu items on mobile --- frigate/config.py | 24 + web/package-lock.json | 114 ++- web/package.json | 5 +- web/src/api/ws.tsx | 42 ++ .../camera/AutoUpdatingCameraImage.tsx | 3 + web/src/components/camera/CameraImage.tsx | 7 +- .../components/camera/DebugCameraImage.tsx | 1 + .../dynamic/CameraFeatureToggle.tsx | 2 +- web/src/components/filter/LogLevelFilter.tsx | 1 - .../components/filter/ReviewFilterGroup.tsx | 2 +- web/src/components/filter/ZoneMaskFilter.tsx | 139 ++++ .../{settings => menu}/AccountSettings.tsx | 0 .../{settings => menu}/GeneralSettings.tsx | 0 web/src/components/navigation/Bottombar.tsx | 4 +- web/src/components/navigation/Sidebar.tsx | 4 +- web/src/components/player/LivePlayer.tsx | 1 + web/src/components/settings/General.tsx | 40 ++ web/src/components/settings/MasksAndZones.tsx | 634 +++++++++++++++++ .../settings/MotionMaskEditPane.tsx | 268 ++++++++ web/src/components/settings/MotionTuner.tsx | 303 ++++++++ .../settings/ObjectMaskEditPane.tsx | 409 +++++++++++ .../components/settings/ObjectSettings.tsx | 31 + web/src/components/settings/PolygonCanvas.tsx | 384 +++++++++++ web/src/components/settings/PolygonDrawer.tsx | 172 +++++ .../settings/PolygonEditControls.tsx | 81 +++ web/src/components/settings/PolygonItem.tsx | 334 +++++++++ web/src/components/settings/ZoneEditPane.tsx | 649 ++++++++++++++++++ web/src/components/ui/icon-wrapper.tsx | 21 + web/src/components/ui/separator.tsx | 29 + web/src/components/ui/slider.tsx | 2 +- web/src/components/ui/switch.tsx | 1 + web/src/pages/Settings.tsx | 300 +++++++- web/src/pages/SubmitPlus.tsx | 6 +- web/src/types/canvas.ts | 31 + web/src/types/frigateConfig.ts | 24 +- web/src/utils/canvasUtil.ts | 102 +++ web/src/utils/zoneEdutUtil.ts | 50 ++ web/tailwind.config.js | 1 + web/themes/theme-default.css | 14 +- 39 files changed, 4170 insertions(+), 65 deletions(-) create mode 100644 web/src/components/filter/ZoneMaskFilter.tsx rename web/src/components/{settings => menu}/AccountSettings.tsx (100%) rename web/src/components/{settings => menu}/GeneralSettings.tsx (100%) create mode 100644 web/src/components/settings/General.tsx create mode 100644 web/src/components/settings/MasksAndZones.tsx create mode 100644 web/src/components/settings/MotionMaskEditPane.tsx create mode 100644 web/src/components/settings/MotionTuner.tsx create mode 100644 web/src/components/settings/ObjectMaskEditPane.tsx create mode 100644 web/src/components/settings/ObjectSettings.tsx create mode 100644 web/src/components/settings/PolygonCanvas.tsx create mode 100644 web/src/components/settings/PolygonDrawer.tsx create mode 100644 web/src/components/settings/PolygonEditControls.tsx create mode 100644 web/src/components/settings/PolygonItem.tsx create mode 100644 web/src/components/settings/ZoneEditPane.tsx create mode 100644 web/src/components/ui/icon-wrapper.tsx create mode 100644 web/src/components/ui/separator.tsx create mode 100644 web/src/types/canvas.ts create mode 100644 web/src/utils/canvasUtil.ts create mode 100644 web/src/utils/zoneEdutUtil.ts diff --git a/frigate/config.py b/frigate/config.py index 2894fa42a..d7ee147f3 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -533,6 +533,14 @@ class ZoneConfig(BaseModel): def contour(self) -> np.ndarray: return self._contour + @field_validator("objects", mode="before") + @classmethod + def validate_objects(cls, v): + if isinstance(v, str) and "," not in v: + return [v] + + return v + def __init__(self, **config): super().__init__(**config) @@ -613,6 +621,14 @@ class AlertsConfig(FrigateBaseModel): title="List of required zones to be entered in order to save the event as an alert.", ) + @field_validator("required_zones", mode="before") + @classmethod + def validate_required_zones(cls, v): + if isinstance(v, str) and "," not in v: + return [v] + + return v + class DetectionsConfig(FrigateBaseModel): """Configure detections""" @@ -625,6 +641,14 @@ class DetectionsConfig(FrigateBaseModel): title="List of required zones to be entered in order to save the event as a detection.", ) + @field_validator("required_zones", mode="before") + @classmethod + def validate_required_zones(cls, v): + if isinstance(v, str) and "," not in v: + return [v] + + return v + class ReviewConfig(FrigateBaseModel): """Configure reviews""" diff --git a/web/package-lock.json b/web/package-lock.json index 262c11421..168a00acd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@cycjimmy/jsmpeg-player": "^6.0.5", - "@hookform/resolvers": "^3.3.2", + "@hookform/resolvers": "^3.3.4", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-context-menu": "^2.1.5", @@ -21,6 +21,7 @@ "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", @@ -37,6 +38,7 @@ "hls.js": "^1.5.8", "idb-keyval": "^6.2.1", "immer": "^10.0.4", + "konva": "^9.3.6", "lucide-react": "^0.368.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", @@ -47,6 +49,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.51.3", "react-icons": "^5.0.1", + "react-konva": "^18.2.10", "react-router-dom": "^6.22.3", "react-swipeable": "^7.0.1", "react-tracked": "^1.7.14", @@ -1648,6 +1651,29 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", + "integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slider": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz", @@ -2518,8 +2544,7 @@ "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "devOptional": true + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { "version": "18.2.78", @@ -2550,6 +2575,14 @@ "react-icons": "*" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.8", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.8.tgz", + "integrity": "sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -5046,6 +5079,17 @@ "node": ">=8" } }, + "node_modules/its-fine": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.1.3.tgz", + "integrity": "sha512-mncCA+yb6tuh5zK26cHqKlsSyxm4zdm4YgJpxycyx6p9fgxgK5PLu3iDVpKhzTn57Yrv3jk/r0aK0RFTT1OjFw==", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -5177,6 +5221,25 @@ "json-buffer": "3.0.1" } }, + "node_modules/konva": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.6.tgz", + "integrity": "sha512-dqR8EbcM0hjuilZCBP6xauQ5V3kH3m9kBcsDkqPypQuRgsXbcXUrxqYxhNbdvKZpYNW8Amq94jAD/C0NY3qfBQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ] + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6289,6 +6352,51 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/react-konva": { + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz", + "integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "dependencies": { + "@types/react-reconciler": "^0.28.2", + "its-fine": "^1.1.1", + "react-reconciler": "~0.29.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz", + "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", diff --git a/web/package.json b/web/package.json index 626b9a409..b01cadc54 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@cycjimmy/jsmpeg-player": "^6.0.5", - "@hookform/resolvers": "^3.3.2", + "@hookform/resolvers": "^3.3.4", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-context-menu": "^2.1.5", @@ -26,6 +26,7 @@ "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", @@ -42,6 +43,7 @@ "hls.js": "^1.5.8", "idb-keyval": "^6.2.1", "immer": "^10.0.4", + "konva": "^9.3.6", "lucide-react": "^0.368.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", @@ -52,6 +54,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.51.3", "react-icons": "^5.0.1", + "react-konva": "^18.2.10", "react-router-dom": "^6.22.3", "react-swipeable": "^7.0.1", "react-tracked": "^1.7.14", diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index a6638a94d..2439dbb33 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -206,3 +206,45 @@ export function useAudioActivity(camera: string): { payload: number } { } = useWs(`${camera}/audio/rms`, ""); return { payload: payload as number }; } + +export function useMotionThreshold(camera: string): { + payload: string; + send: (payload: number, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/motion_threshold/state`, + `${camera}/motion_threshold/set`, + ); + return { payload: payload as string, send }; +} + +export function useMotionContourArea(camera: string): { + payload: string; + send: (payload: number, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/motion_contour_area/state`, + `${camera}/motion_contour_area/set`, + ); + return { payload: payload as string, send }; +} + +export function useImproveContrast(camera: string): { + payload: ToggleableSetting; + send: (payload: string, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/improve_contrast/state`, + `${camera}/improve_contrast/set`, + ); + return { payload: payload as ToggleableSetting, send }; +} diff --git a/web/src/components/camera/AutoUpdatingCameraImage.tsx b/web/src/components/camera/AutoUpdatingCameraImage.tsx index 2f2005d9c..28ad3b883 100644 --- a/web/src/components/camera/AutoUpdatingCameraImage.tsx +++ b/web/src/components/camera/AutoUpdatingCameraImage.tsx @@ -6,6 +6,7 @@ type AutoUpdatingCameraImageProps = { searchParams?: URLSearchParams; showFps?: boolean; className?: string; + cameraClasses?: string; reloadInterval?: number; }; @@ -16,6 +17,7 @@ export default function AutoUpdatingCameraImage({ searchParams = undefined, showFps = true, className, + cameraClasses, reloadInterval = MIN_LOAD_TIMEOUT_MS, }: AutoUpdatingCameraImageProps) { const [key, setKey] = useState(Date.now()); @@ -68,6 +70,7 @@ export default function AutoUpdatingCameraImage({ camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} + className={cameraClasses} /> {showFps ? Displaying at {fps}fps : null}
diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index be978f7a5..1f2c28ade 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -36,12 +36,7 @@ export default function CameraImage({ }, [apiHost, name, imgRef, searchParams, config]); return ( -
+
{enabled ? (
- ); } diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 2a36b2a60..e4eafb8df 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -248,7 +248,7 @@ export function CamerasFilterButton({ )} -
+
void; +}; +export function ZoneMaskFilterButton({ + selectedZoneMask, + updateZoneMaskFilter, +}: ZoneMaskFilterButtonProps) { + const trigger = ( + + ); + const content = ( + + ); + + if (isMobile) { + return ( + + {trigger} + + {content} + + + ); + } + + return ( + + {trigger} + {content} + + ); +} + +type GeneralFilterContentProps = { + selectedZoneMask: PolygonType[] | undefined; + updateZoneMaskFilter: (labels: PolygonType[] | undefined) => void; +}; +export function GeneralFilterContent({ + selectedZoneMask, + updateZoneMaskFilter, +}: GeneralFilterContentProps) { + return ( + <> +
+
+ + { + if (isChecked) { + updateZoneMaskFilter(undefined); + } + }} + /> +
+ +
+ {["zone", "motion_mask", "object_mask"].map((item) => ( +
+ + { + if (isChecked) { + const updatedLabels = selectedZoneMask + ? [...selectedZoneMask] + : []; + + updatedLabels.push(item as PolygonType); + updateZoneMaskFilter(updatedLabels); + } else { + const updatedLabels = selectedZoneMask + ? [...selectedZoneMask] + : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice( + updatedLabels.indexOf(item as PolygonType), + 1, + ); + updateZoneMaskFilter(updatedLabels); + } + } + }} + /> +
+ ))} +
+
+ + ); +} diff --git a/web/src/components/settings/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx similarity index 100% rename from web/src/components/settings/AccountSettings.tsx rename to web/src/components/menu/AccountSettings.tsx diff --git a/web/src/components/settings/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx similarity index 100% rename from web/src/components/settings/GeneralSettings.tsx rename to web/src/components/menu/GeneralSettings.tsx diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx index 2ef8c2e8d..e21556aaa 100644 --- a/web/src/components/navigation/Bottombar.tsx +++ b/web/src/components/navigation/Bottombar.tsx @@ -6,8 +6,8 @@ import { FrigateStats } from "@/types/stats"; import { useFrigateStats } from "@/api/ws"; import { useMemo } from "react"; import useStats from "@/hooks/use-stats"; -import GeneralSettings from "../settings/GeneralSettings"; -import AccountSettings from "../settings/AccountSettings"; +import GeneralSettings from "../menu/GeneralSettings"; +import AccountSettings from "../menu/AccountSettings"; import useNavigation from "@/hooks/use-navigation"; function Bottombar() { diff --git a/web/src/components/navigation/Sidebar.tsx b/web/src/components/navigation/Sidebar.tsx index e4b4d9c81..ea895a12f 100644 --- a/web/src/components/navigation/Sidebar.tsx +++ b/web/src/components/navigation/Sidebar.tsx @@ -2,8 +2,8 @@ import Logo from "../Logo"; import NavItem from "./NavItem"; import { CameraGroupSelector } from "../filter/CameraGroupSelector"; import { useLocation } from "react-router-dom"; -import GeneralSettings from "../settings/GeneralSettings"; -import AccountSettings from "../settings/AccountSettings"; +import GeneralSettings from "../menu/GeneralSettings"; +import AccountSettings from "../menu/AccountSettings"; import useNavigation from "@/hooks/use-navigation"; function Sidebar() { diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 925d7c88f..26c8c6477 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -163,6 +163,7 @@ export default function LivePlayer({ camera={cameraConfig.name} showFps={false} reloadInterval={stillReloadInterval} + cameraClasses="relative w-full h-full flex justify-center" />
diff --git a/web/src/components/settings/General.tsx b/web/src/components/settings/General.tsx new file mode 100644 index 000000000..4d8465299 --- /dev/null +++ b/web/src/components/settings/General.tsx @@ -0,0 +1,40 @@ +import Heading from "@/components/ui/heading"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; + +export default function General() { + return ( + <> + Settings +
+ {}} /> + +
+ +
+ +
+ + ); +} diff --git a/web/src/components/settings/MasksAndZones.tsx b/web/src/components/settings/MasksAndZones.tsx new file mode 100644 index 000000000..381b1d952 --- /dev/null +++ b/web/src/components/settings/MasksAndZones.tsx @@ -0,0 +1,634 @@ +import { FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { PolygonCanvas } from "./PolygonCanvas"; +import { Polygon, PolygonType } from "@/types/canvas"; +import { interpolatePoints, parseCoordinates } from "@/utils/canvasUtil"; +import { Skeleton } from "../ui/skeleton"; +import { useResizeObserver } from "@/hooks/resize-observer"; +import { LuExternalLink, LuPlus } from "react-icons/lu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import copy from "copy-to-clipboard"; +import { toast } from "sonner"; +import { Toaster } from "../ui/sonner"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import Heading from "../ui/heading"; +import ZoneEditPane from "./ZoneEditPane"; +import MotionMaskEditPane from "./MotionMaskEditPane"; +import ObjectMaskEditPane from "./ObjectMaskEditPane"; +import PolygonItem from "./PolygonItem"; +import { Link } from "react-router-dom"; +import { isDesktop } from "react-device-detect"; + +type MasksAndZoneProps = { + selectedCamera: string; + selectedZoneMask?: PolygonType[]; + setUnsavedChanges: React.Dispatch>; +}; + +export default function MasksAndZones({ + selectedCamera, + selectedZoneMask, + setUnsavedChanges, +}: MasksAndZoneProps) { + const { data: config } = useSWR("config"); + const [allPolygons, setAllPolygons] = useState([]); + const [editingPolygons, setEditingPolygons] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [activePolygonIndex, setActivePolygonIndex] = useState< + number | undefined + >(undefined); + const [hoveredPolygonIndex, setHoveredPolygonIndex] = useState( + null, + ); + const containerRef = useRef(null); + const [editPane, setEditPane] = useState(undefined); + + const cameraConfig = useMemo(() => { + if (config && selectedCamera) { + return config.cameras[selectedCamera]; + } + }, [config, selectedCamera]); + + const [{ width: containerWidth, height: containerHeight }] = + useResizeObserver(containerRef); + + const aspectRatio = useMemo(() => { + if (!config) { + return undefined; + } + + const camera = config.cameras[selectedCamera]; + + if (!camera) { + return undefined; + } + + return camera.detect.width / camera.detect.height; + }, [config, selectedCamera]); + + const detectHeight = useMemo(() => { + if (!config) { + return undefined; + } + + const camera = config.cameras[selectedCamera]; + + if (!camera) { + return undefined; + } + + return camera.detect.height; + }, [config, selectedCamera]); + + const stretch = true; + // may need tweaking for mobile + const fitAspect = isDesktop ? 16 / 9 : 3 / 4; + + const scaledHeight = useMemo(() => { + if (containerRef.current && aspectRatio && detectHeight) { + const scaledHeight = + aspectRatio < (fitAspect ?? 0) + ? Math.floor( + Math.min(containerHeight, containerRef.current?.clientHeight), + ) + : isDesktop || aspectRatio > fitAspect + ? Math.floor(containerWidth / aspectRatio) + : Math.floor(containerWidth / aspectRatio) / 1.5; + const finalHeight = stretch + ? scaledHeight + : Math.min(scaledHeight, detectHeight); + + if (finalHeight > 0) { + return finalHeight; + } + } + }, [ + aspectRatio, + containerWidth, + containerHeight, + fitAspect, + detectHeight, + stretch, + ]); + + const scaledWidth = useMemo(() => { + if (aspectRatio && scaledHeight) { + return Math.ceil(scaledHeight * aspectRatio); + } + }, [scaledHeight, aspectRatio]); + + const handleNewPolygon = (type: PolygonType) => { + if (!cameraConfig) { + return; + } + + setActivePolygonIndex(allPolygons.length); + + let polygonColor = [128, 128, 0]; + + if (type == "motion_mask") { + polygonColor = [0, 0, 220]; + } + if (type == "object_mask") { + polygonColor = [128, 128, 128]; + } + + setEditingPolygons([ + ...(allPolygons || []), + { + points: [], + isFinished: false, + type, + typeIndex: 9999, + name: "", + objects: [], + camera: selectedCamera, + color: polygonColor, + }, + ]); + }; + + const handleCancel = useCallback(() => { + setEditPane(undefined); + setEditingPolygons([...allPolygons]); + setActivePolygonIndex(undefined); + setHoveredPolygonIndex(null); + setUnsavedChanges(false); + }, [allPolygons, setUnsavedChanges]); + + const handleSave = useCallback(() => { + setAllPolygons([...(editingPolygons ?? [])]); + setHoveredPolygonIndex(null); + setUnsavedChanges(false); + }, [editingPolygons, setUnsavedChanges]); + + useEffect(() => { + if (isLoading) { + return; + } + if (!isLoading && editPane !== undefined) { + setActivePolygonIndex(undefined); + setEditPane(undefined); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading]); + + const handleCopyCoordinates = useCallback( + (index: number) => { + if (allPolygons && scaledWidth && scaledHeight) { + const poly = allPolygons[index]; + copy( + interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1) + .map((point) => `${point[0]},${point[1]}`) + .join(","), + ); + toast.success(`Copied coordinates for ${poly.name} to clipboard.`); + } else { + toast.error("Could not copy coordinates to clipboard."); + } + }, + [allPolygons, scaledHeight, scaledWidth], + ); + + useEffect(() => { + if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) { + const zones = Object.entries(cameraConfig.zones).map( + ([name, zoneData], index) => ({ + type: "zone" as PolygonType, + typeIndex: index, + camera: cameraConfig.name, + name, + objects: zoneData.objects, + points: interpolatePoints( + parseCoordinates(zoneData.coordinates), + 1, + 1, + scaledWidth, + scaledHeight, + ), + isFinished: true, + color: zoneData.color, + }), + ); + + let motionMasks: Polygon[] = []; + let globalObjectMasks: Polygon[] = []; + let objectMasks: Polygon[] = []; + + // this can be an array or a string + motionMasks = ( + Array.isArray(cameraConfig.motion.mask) + ? cameraConfig.motion.mask + : cameraConfig.motion.mask + ? [cameraConfig.motion.mask] + : [] + ).map((maskData, index) => ({ + type: "motion_mask" as PolygonType, + typeIndex: index, + camera: cameraConfig.name, + name: `Motion Mask ${index + 1}`, + objects: [], + points: interpolatePoints( + parseCoordinates(maskData), + 1, + 1, + scaledWidth, + scaledHeight, + ), + isFinished: true, + color: [0, 0, 255], + })); + + const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask) + ? cameraConfig.objects.mask + : cameraConfig.objects.mask + ? [cameraConfig.objects.mask] + : []; + + globalObjectMasks = globalObjectMasksArray.map((maskData, index) => ({ + type: "object_mask" as PolygonType, + typeIndex: index, + camera: cameraConfig.name, + name: `Object Mask ${index + 1} (all objects)`, + objects: [], + points: interpolatePoints( + parseCoordinates(maskData), + 1, + 1, + scaledWidth, + scaledHeight, + ), + isFinished: true, + color: [128, 128, 128], + })); + + const globalObjectMasksCount = globalObjectMasks.length; + let index = 0; + + objectMasks = Object.entries(cameraConfig.objects.filters) + .filter(([, { mask }]) => mask || Array.isArray(mask)) + .flatMap(([objectName, { mask }]): Polygon[] => { + const maskArray = Array.isArray(mask) ? mask : mask ? [mask] : []; + return maskArray.flatMap((maskItem, subIndex) => { + const maskItemString = maskItem; + const newMask = { + type: "object_mask" as PolygonType, + typeIndex: subIndex, + camera: cameraConfig.name, + name: `Object Mask ${globalObjectMasksCount + index + 1} (${objectName})`, + objects: [objectName], + points: interpolatePoints( + parseCoordinates(maskItem), + 1, + 1, + scaledWidth, + scaledHeight, + ), + isFinished: true, + color: [128, 128, 128], + }; + index++; + + if ( + globalObjectMasksArray.some( + (globalMask) => globalMask === maskItemString, + ) + ) { + index--; + return []; + } else { + return [newMask]; + } + }); + }); + + setAllPolygons([ + ...zones, + ...motionMasks, + ...globalObjectMasks, + ...objectMasks, + ]); + setEditingPolygons([ + ...zones, + ...motionMasks, + ...globalObjectMasks, + ...objectMasks, + ]); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cameraConfig, containerRef, scaledHeight, scaledWidth]); + + useEffect(() => { + if (editPane === undefined) { + setEditingPolygons([...allPolygons]); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setEditingPolygons, allPolygons]); + + useEffect(() => { + if (selectedCamera) { + setActivePolygonIndex(undefined); + setEditPane(undefined); + } + }, [selectedCamera]); + + if (!cameraConfig && !selectedCamera) { + return ; + } + + return ( + <> + {cameraConfig && editingPolygons && ( +
+ +
+ {editPane == "zone" && ( + + )} + {editPane == "motion_mask" && ( + + )} + {editPane == "object_mask" && ( + + )} + {editPane === undefined && ( + <> + + Masks / Zones + +
+ {(selectedZoneMask === undefined || + selectedZoneMask.includes("zone" as PolygonType)) && ( +
+
+ + +
Zones
+
+ +
+

+ Zones allow you to define a specific area of the + frame so you can determine whether or not an + object is within a particular area. +

+
+ + Documentation{" "} + + +
+
+
+
+ + + + + Add Zone + +
+ {allPolygons + .flatMap((polygon, index) => + polygon.type === "zone" ? [{ polygon, index }] : [], + ) + .map(({ polygon, index }) => ( + + ))} +
+ )} + {(selectedZoneMask === undefined || + selectedZoneMask.includes( + "motion_mask" as PolygonType, + )) && ( +
+
+ + +
+ Motion Masks +
+
+ +
+

+ Motion masks are used to prevent unwanted types + of motion from triggering detection. Over + masking will make it more difficult for objects + to be tracked. +

+
+ + Documentation{" "} + + +
+
+
+
+ + + + + Add Motion Mask + +
+ {allPolygons + .flatMap((polygon, index) => + polygon.type === "motion_mask" + ? [{ polygon, index }] + : [], + ) + .map(({ polygon, index }) => ( + + ))} +
+ )} + {(selectedZoneMask === undefined || + selectedZoneMask.includes( + "object_mask" as PolygonType, + )) && ( +
+
+ + +
+ Object Masks +
+
+ +
+

+ Object filter masks are used to filter out false + positives for a given object type based on + location. +

+
+ + Documentation{" "} + + +
+
+
+
+ + + + + Add Object Mask + +
+ {allPolygons + .flatMap((polygon, index) => + polygon.type === "object_mask" + ? [{ polygon, index }] + : [], + ) + .map(({ polygon, index }) => ( + + ))} +
+ )} +
+ + )} +
+
+
+ {cameraConfig && + scaledWidth && + scaledHeight && + editingPolygons ? ( + + ) : ( + + )} +
+
+
+ )} + + ); +} diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx new file mode 100644 index 000000000..5b54a1122 --- /dev/null +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -0,0 +1,268 @@ +import Heading from "../ui/heading"; +import { Separator } from "../ui/separator"; +import { Button } from "@/components/ui/button"; +import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { useCallback, useMemo } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import PolygonEditControls from "./PolygonEditControls"; +import { FaCheckCircle } from "react-icons/fa"; +import { Polygon } from "@/types/canvas"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { + flattenPoints, + interpolatePoints, + parseCoordinates, +} from "@/utils/canvasUtil"; +import axios from "axios"; +import { toast } from "sonner"; +import { Toaster } from "../ui/sonner"; +import ActivityIndicator from "../indicators/activity-indicator"; + +type MotionMaskEditPaneProps = { + polygons?: Polygon[]; + setPolygons: React.Dispatch>; + activePolygonIndex?: number; + scaledWidth?: number; + scaledHeight?: number; + isLoading: boolean; + setIsLoading: React.Dispatch>; + onSave?: () => void; + onCancel?: () => void; +}; + +export default function MotionMaskEditPane({ + polygons, + setPolygons, + activePolygonIndex, + scaledWidth, + scaledHeight, + isLoading, + setIsLoading, + onSave, + onCancel, +}: MotionMaskEditPaneProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + + const polygon = useMemo(() => { + if (polygons && activePolygonIndex !== undefined) { + return polygons[activePolygonIndex]; + } else { + return null; + } + }, [polygons, activePolygonIndex]); + + const cameraConfig = useMemo(() => { + if (polygon?.camera && config) { + return config.cameras[polygon.camera]; + } + }, [polygon, config]); + + const defaultName = useMemo(() => { + if (!polygons) { + return; + } + + const count = polygons.filter((poly) => poly.type == "motion_mask").length; + + return `Motion Mask ${count + 1}`; + }, [polygons]); + + const formSchema = z + .object({ + polygon: z.object({ name: z.string(), isFinished: z.boolean() }), + }) + .refine(() => polygon?.isFinished === true, { + message: "The polygon drawing must be finished before saving.", + path: ["polygon.isFinished"], + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, + }, + }); + + const saveToConfig = useCallback(async () => { + if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { + return; + } + + const coordinates = flattenPoints( + interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), + ).join(","); + + let index = Array.isArray(cameraConfig.motion.mask) + ? cameraConfig.motion.mask.length + : cameraConfig.motion.mask + ? 1 + : 0; + + const editingMask = polygon.name.length > 0; + + // editing existing mask, not creating a new one + if (editingMask) { + index = polygon.typeIndex; + } + + const filteredMask = ( + Array.isArray(cameraConfig.motion.mask) + ? cameraConfig.motion.mask + : [cameraConfig.motion.mask] + ).filter((_, currentIndex) => currentIndex !== index); + + filteredMask.splice(index, 0, coordinates); + + const queryString = filteredMask + .map((pointsArray) => { + const coordinates = flattenPoints(parseCoordinates(pointsArray)).join( + ",", + ); + return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`; + }) + .join(""); + + axios + .put(`config/set?${queryString}`, { + requires_restart: 0, + }) + .then((res) => { + if (res.status === 200) { + toast.success(`${polygon.name || "Motion Mask"} has been saved.`, { + position: "top-center", + }); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, [ + updateConfig, + polygon, + scaledWidth, + scaledHeight, + setIsLoading, + cameraConfig, + ]); + + function onSubmit(values: z.infer) { + if (activePolygonIndex === undefined || !values || !polygons) { + return; + } + setIsLoading(true); + + saveToConfig(); + if (onSave) { + onSave(); + } + } + + if (!polygon) { + return; + } + + return ( + <> + + + {polygon.name.length ? "Edit" : "New"} Motion Mask + +
+

+ Motion masks are used to prevent unwanted types of motion from + triggering detection. Over masking will make it more difficult for + objects to be tracked. +

+
+ + {polygons && activePolygonIndex !== undefined && ( +
+
+ {polygons[activePolygonIndex].points.length}{" "} + {polygons[activePolygonIndex].points.length > 1 || + polygons[activePolygonIndex].points.length == 0 + ? "points" + : "point"} + {polygons[activePolygonIndex].isFinished && ( + + )} +
+ +
+ )} +
+ Click to draw a polygon on the image. +
+ + + +
+ + ( + + + + )} + /> + ( + + + + )} + /> +
+
+ + +
+
+ + + + ); +} diff --git a/web/src/components/settings/MotionTuner.tsx b/web/src/components/settings/MotionTuner.tsx new file mode 100644 index 000000000..fce762df5 --- /dev/null +++ b/web/src/components/settings/MotionTuner.tsx @@ -0,0 +1,303 @@ +import Heading from "@/components/ui/heading"; +import { FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import axios from "axios"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Slider } from "@/components/ui/slider"; +import { Label } from "@/components/ui/label"; +import { + useImproveContrast, + useMotionContourArea, + useMotionThreshold, +} from "@/api/ws"; +import { Skeleton } from "../ui/skeleton"; +import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; +import { Separator } from "../ui/separator"; +import { Link } from "react-router-dom"; +import { LuExternalLink } from "react-icons/lu"; + +type MotionTunerProps = { + selectedCamera: string; + setUnsavedChanges: React.Dispatch>; +}; + +type MotionSettings = { + threshold?: number; + contour_area?: number; + improve_contrast?: boolean; +}; + +export default function MotionTuner({ + selectedCamera, + setUnsavedChanges, +}: MotionTunerProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + const [changedValue, setChangedValue] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { send: sendMotionThreshold } = useMotionThreshold(selectedCamera); + const { send: sendMotionContourArea } = useMotionContourArea(selectedCamera); + const { send: sendImproveContrast } = useImproveContrast(selectedCamera); + + const [motionSettings, setMotionSettings] = useState({ + threshold: undefined, + contour_area: undefined, + improve_contrast: undefined, + }); + + const [origMotionSettings, setOrigMotionSettings] = useState({ + threshold: undefined, + contour_area: undefined, + improve_contrast: undefined, + }); + + const cameraConfig = useMemo(() => { + if (config && selectedCamera) { + return config.cameras[selectedCamera]; + } + }, [config, selectedCamera]); + + useEffect(() => { + if (cameraConfig) { + setMotionSettings({ + threshold: cameraConfig.motion.threshold, + contour_area: cameraConfig.motion.contour_area, + improve_contrast: cameraConfig.motion.improve_contrast, + }); + setOrigMotionSettings({ + threshold: cameraConfig.motion.threshold, + contour_area: cameraConfig.motion.contour_area, + improve_contrast: cameraConfig.motion.improve_contrast, + }); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCamera]); + + useEffect(() => { + if (!motionSettings.threshold) return; + + sendMotionThreshold(motionSettings.threshold); + }, [motionSettings.threshold, sendMotionThreshold]); + + useEffect(() => { + if (!motionSettings.contour_area) return; + + sendMotionContourArea(motionSettings.contour_area); + }, [motionSettings.contour_area, sendMotionContourArea]); + + useEffect(() => { + if (motionSettings.improve_contrast === undefined) return; + + sendImproveContrast(motionSettings.improve_contrast ? "ON" : "OFF"); + }, [motionSettings.improve_contrast, sendImproveContrast]); + + const handleMotionConfigChange = (newConfig: Partial) => { + setMotionSettings((prevConfig) => ({ ...prevConfig, ...newConfig })); + setUnsavedChanges(true); + setChangedValue(true); + }; + + const saveToConfig = useCallback(async () => { + setIsLoading(true); + + axios + .put( + `config/set?cameras.${selectedCamera}.motion.threshold=${motionSettings.threshold}&cameras.${selectedCamera}.motion.contour_area=${motionSettings.contour_area}&cameras.${selectedCamera}.motion.improve_contrast=${motionSettings.improve_contrast}`, + { requires_restart: 0 }, + ) + .then((res) => { + if (res.status === 200) { + toast.success("Motion settings have been saved.", { + position: "top-center", + }); + setChangedValue(false); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, [ + updateConfig, + motionSettings.threshold, + motionSettings.contour_area, + motionSettings.improve_contrast, + selectedCamera, + ]); + + const onCancel = useCallback(() => { + setMotionSettings(origMotionSettings); + setChangedValue(false); + }, [origMotionSettings]); + + if (!cameraConfig && !selectedCamera) { + return ; + } + + return ( +
+ +
+ + Motion Detection Tuner + +
+

+ Frigate uses motion detection as a first line check to see if there + is anything happening in the frame worth checking with object + detection. +

+ +
+ + Read the Motion Tuning Guide{" "} + + +
+
+ +
+
+
+ +
+

+ The threshold value dictates how much of a change in a pixel's + luminance is required to be considered motion.{" "} + Default: 30 +

+
+
+
+ { + handleMotionConfigChange({ threshold: value[0] }); + }} + /> +
+ {motionSettings.threshold} +
+
+
+
+
+ +
+

+ The contour area value is used to decide which groups of + changed pixels qualify as motion. Default: 10 +

+
+
+
+ { + handleMotionConfigChange({ contour_area: value[0] }); + }} + /> +
+ {motionSettings.contour_area} +
+
+
+ +
+
+ +
+ Improve contrast for darker scenes. Default: ON +
+
+ { + handleMotionConfigChange({ improve_contrast: isChecked }); + }} + /> +
+
+
+
+ + +
+
+
+ + {cameraConfig ? ( +
+
+ +
+
+ ) : ( + + )} +
+ ); +} diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx new file mode 100644 index 000000000..ae755c48f --- /dev/null +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -0,0 +1,409 @@ +import Heading from "../ui/heading"; +import { Separator } from "../ui/separator"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useCallback, useMemo } from "react"; +import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas"; +import PolygonEditControls from "./PolygonEditControls"; +import { FaCheckCircle } from "react-icons/fa"; +import { + flattenPoints, + interpolatePoints, + parseCoordinates, +} from "@/utils/canvasUtil"; +import axios from "axios"; +import { toast } from "sonner"; +import { Toaster } from "../ui/sonner"; +import ActivityIndicator from "../indicators/activity-indicator"; + +type ObjectMaskEditPaneProps = { + polygons?: Polygon[]; + setPolygons: React.Dispatch>; + activePolygonIndex?: number; + scaledWidth?: number; + scaledHeight?: number; + isLoading: boolean; + setIsLoading: React.Dispatch>; + onSave?: () => void; + onCancel?: () => void; +}; + +export default function ObjectMaskEditPane({ + polygons, + setPolygons, + activePolygonIndex, + scaledWidth, + scaledHeight, + isLoading, + setIsLoading, + onSave, + onCancel, +}: ObjectMaskEditPaneProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + + const polygon = useMemo(() => { + if (polygons && activePolygonIndex !== undefined) { + return polygons[activePolygonIndex]; + } else { + return null; + } + }, [polygons, activePolygonIndex]); + + const cameraConfig = useMemo(() => { + if (polygon?.camera && config) { + return config.cameras[polygon.camera]; + } + }, [polygon, config]); + + const defaultName = useMemo(() => { + if (!polygons) { + return; + } + + const count = polygons.filter((poly) => poly.type == "object_mask").length; + + let objectType = ""; + const objects = polygon?.objects[0]; + if (objects === undefined) { + objectType = "all objects"; + } else { + objectType = objects; + } + + return `Object Mask ${count + 1} (${objectType})`; + }, [polygons, polygon]); + + const formSchema = z + .object({ + objects: z.string(), + polygon: z.object({ isFinished: z.boolean(), name: z.string() }), + }) + .refine(() => polygon?.isFinished === true, { + message: "The polygon drawing must be finished before saving.", + path: ["polygon.isFinished"], + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + objects: polygon?.objects[0] ?? "all_labels", + polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, + }, + }); + + const saveToConfig = useCallback( + async ( + { objects: form_objects }: ObjectMaskFormValuesType, // values submitted via the form + ) => { + if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { + return; + } + + const coordinates = flattenPoints( + interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), + ).join(","); + + let queryString = ""; + let configObject; + let createFilter = false; + let globalMask = false; + let filteredMask = [coordinates]; + const editingMask = polygon.name.length > 0; + + // global mask on camera for all objects + if (form_objects == "all_labels") { + configObject = cameraConfig.objects.mask; + globalMask = true; + } else { + if ( + cameraConfig.objects.filters[form_objects] && + cameraConfig.objects.filters[form_objects].mask !== null + ) { + configObject = cameraConfig.objects.filters[form_objects].mask; + } else { + createFilter = true; + } + } + + if (!createFilter) { + let index = Array.isArray(configObject) + ? configObject.length + : configObject + ? 1 + : 0; + + if (editingMask) { + index = polygon.typeIndex; + } + + // editing existing mask, not creating a new one + if (editingMask) { + index = polygon.typeIndex; + } + + filteredMask = ( + Array.isArray(configObject) ? configObject : [configObject as string] + ).filter((_, currentIndex) => currentIndex !== index); + + filteredMask.splice(index, 0, coordinates); + } + + queryString = filteredMask + .map((pointsArray) => { + const coordinates = flattenPoints(parseCoordinates(pointsArray)).join( + ",", + ); + return globalMask + ? `cameras.${polygon?.camera}.objects.mask=${coordinates}&` + : `cameras.${polygon?.camera}.objects.filters.${form_objects}.mask=${coordinates}&`; + }) + .join(""); + + if (!queryString) { + return; + } + + axios + .put(`config/set?${queryString}`, { + requires_restart: 0, + }) + .then((res) => { + if (res.status === 200) { + toast.success(`${polygon.name || "Object Mask"} has been saved.`, { + position: "top-center", + }); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [ + updateConfig, + polygon, + scaledWidth, + scaledHeight, + setIsLoading, + cameraConfig, + ], + ); + + function onSubmit(values: z.infer) { + if (activePolygonIndex === undefined || !values || !polygons) { + return; + } + setIsLoading(true); + + saveToConfig(values as ObjectMaskFormValuesType); + if (onSave) { + onSave(); + } + } + + if (!polygon) { + return; + } + + return ( + <> + + + {polygon.name.length ? "Edit" : "New"} Object Mask + +
+

+ Object filter masks are used to filter out false positives for a given + object type based on location. +

+
+ + {polygons && activePolygonIndex !== undefined && ( +
+
+ {polygons[activePolygonIndex].points.length}{" "} + {polygons[activePolygonIndex].points.length > 1 || + polygons[activePolygonIndex].points.length == 0 + ? "points" + : "point"} + {polygons[activePolygonIndex].isFinished && ( + + )} +
+ +
+ )} +
+ Click to draw a polygon on the image. +
+ + + +
+ +
+ ( + + + + )} + /> + ( + + Objects + + + The object type that that applies to this object mask. + + + + )} + /> + ( + + + + )} + /> +
+
+
+ + +
+
+
+ + + ); +} + +type ZoneObjectSelectorProps = { + camera: string; +}; + +export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) { + const { data: config } = useSWR("config"); + + const cameraConfig = useMemo(() => { + if (config && camera) { + return config.cameras[camera]; + } + }, [config, camera]); + + const allLabels = useMemo(() => { + if (!config || !cameraConfig) { + return []; + } + + const labels = new Set(); + + Object.values(config.cameras).forEach((camera) => { + camera.objects.track.forEach((label) => { + if (!ATTRIBUTE_LABELS.includes(label)) { + labels.add(label); + } + }); + }); + + cameraConfig.objects.track.forEach((label) => { + if (!ATTRIBUTE_LABELS.includes(label)) { + labels.add(label); + } + }); + + return [...labels].sort(); + }, [config, cameraConfig]); + + return ( + <> + + All object types + + {allLabels.map((item) => ( + + {item.replaceAll("_", " ").charAt(0).toUpperCase() + item.slice(1)} + + ))} + + + ); +} diff --git a/web/src/components/settings/ObjectSettings.tsx b/web/src/components/settings/ObjectSettings.tsx new file mode 100644 index 000000000..4b45c1fa4 --- /dev/null +++ b/web/src/components/settings/ObjectSettings.tsx @@ -0,0 +1,31 @@ +import { useMemo } from "react"; +import DebugCameraImage from "../camera/DebugCameraImage"; +import { FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import ActivityIndicator from "../indicators/activity-indicator"; + +type ObjectSettingsProps = { + selectedCamera?: string; +}; + +export default function ObjectSettings({ + selectedCamera, +}: ObjectSettingsProps) { + const { data: config } = useSWR("config"); + + const cameraConfig = useMemo(() => { + if (config && selectedCamera) { + return config.cameras[selectedCamera]; + } + }, [config, selectedCamera]); + + if (!cameraConfig) { + return ; + } + + return ( +
+ +
+ ); +} diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx new file mode 100644 index 000000000..22c23a226 --- /dev/null +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -0,0 +1,384 @@ +import React, { useMemo, useRef, useState, useEffect } from "react"; +import PolygonDrawer from "./PolygonDrawer"; +import { Stage, Layer, Image } from "react-konva"; +import Konva from "konva"; +import type { KonvaEventObject } from "konva/lib/Node"; +import { Polygon, PolygonType } from "@/types/canvas"; +import { useApiHost } from "@/api"; + +type PolygonCanvasProps = { + camera: string; + width: number; + height: number; + polygons: Polygon[]; + setPolygons: React.Dispatch>; + activePolygonIndex: number | undefined; + hoveredPolygonIndex: number | null; + selectedZoneMask: PolygonType[] | undefined; +}; + +export function PolygonCanvas({ + camera, + width, + height, + polygons, + setPolygons, + activePolygonIndex, + hoveredPolygonIndex, + selectedZoneMask, +}: PolygonCanvasProps) { + const [image, setImage] = useState(); + const imageRef = useRef(null); + const stageRef = useRef(null); + const apiHost = useApiHost(); + + const videoElement = useMemo(() => { + if (camera && width && height) { + const element = new window.Image(); + element.width = width; + element.height = height; + element.src = `${apiHost}api/${camera}/latest.jpg`; + return element; + } + }, [camera, width, height, apiHost]); + + useEffect(() => { + if (!videoElement) { + return; + } + const onload = function () { + setImage(videoElement); + }; + videoElement.addEventListener("load", onload); + return () => { + videoElement.removeEventListener("load", onload); + }; + }, [videoElement]); + + const getMousePos = (stage: Konva.Stage) => { + return [stage.getPointerPosition()!.x, stage.getPointerPosition()!.y]; + }; + + const addPointToPolygon = (polygon: Polygon, newPoint: number[]) => { + const points = polygon.points; + const [newPointX, newPointY] = newPoint; + const updatedPoints = [...points]; + + for (let i = 0; i < points.length; i++) { + const [x1, y1] = points[i]; + const [x2, y2] = i === points.length - 1 ? points[0] : points[i + 1]; + + if ( + (x1 <= newPointX && newPointX <= x2) || + (x2 <= newPointX && newPointX <= x1) + ) { + if ( + (y1 <= newPointY && newPointY <= y2) || + (y2 <= newPointY && newPointY <= y1) + ) { + const insertIndex = i + 1; + updatedPoints.splice(insertIndex, 0, [newPointX, newPointY]); + break; + } + } + } + + return updatedPoints; + }; + + const isPointNearLineSegment = ( + polygon: Polygon, + mousePos: number[], + radius = 10, + ) => { + const points = polygon.points; + const [x, y] = mousePos; + + for (let i = 0; i < points.length; i++) { + const [x1, y1] = points[i]; + const [x2, y2] = i === points.length - 1 ? points[0] : points[i + 1]; + + const crossProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1); + if (crossProduct > 0) { + const lengthSquared = (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1); + const dot = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1); + if (dot < 0 || dot > lengthSquared) { + continue; + } + const lineSegmentDistance = Math.abs( + ((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / + Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2)), + ); + if (lineSegmentDistance <= radius) { + const midPointX = (x1 + x2) / 2; + const midPointY = (y1 + y2) / 2; + return [midPointX, midPointY]; + } + } + } + + return null; + }; + + const isMouseOverFirstPoint = (polygon: Polygon, mousePos: number[]) => { + if (!polygon || !polygon.points || polygon.points.length < 1) { + return false; + } + const [firstPoint] = polygon.points; + const distance = Math.hypot( + mousePos[0] - firstPoint[0], + mousePos[1] - firstPoint[1], + ); + return distance < 10; + }; + + const isMouseOverAnyPoint = (polygon: Polygon, mousePos: number[]) => { + if (!polygon || !polygon.points || polygon.points.length === 0) { + return false; + } + + for (let i = 1; i < polygon.points.length; i++) { + const point = polygon.points[i]; + const distance = Math.hypot( + mousePos[0] - point[0], + mousePos[1] - point[1], + ); + if (distance < 10) { + return true; + } + } + + return false; + }; + + const handleMouseDown = (e: KonvaEventObject) => { + if (activePolygonIndex === undefined || !polygons) { + return; + } + + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + const stage = e.target.getStage()!; + const mousePos = getMousePos(stage); + + if ( + activePolygon.points.length >= 3 && + isMouseOverFirstPoint(activePolygon, mousePos) + ) { + // Close the polygon + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + isFinished: true, + }; + setPolygons(updatedPolygons); + } else { + if ( + !activePolygon.isFinished && + !isMouseOverAnyPoint(activePolygon, mousePos) + ) { + let updatedPoints; + + if (isPointNearLineSegment(activePolygon, mousePos)) { + // we've clicked near a line segment, so add a new point in the right position + updatedPoints = addPointToPolygon(activePolygon, mousePos); + } else { + // Add a new point to the active polygon + updatedPoints = [...activePolygon.points, mousePos]; + } + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + points: updatedPoints, + }; + setPolygons(updatedPolygons); + } + } + // } + }; + + const handleMouseOverStartPoint = ( + e: KonvaEventObject, + ) => { + if (activePolygonIndex === undefined || !polygons) { + return; + } + + const activePolygon = polygons[activePolygonIndex]; + if (!activePolygon.isFinished && activePolygon.points.length >= 3) { + e.target.getStage()!.container().style.cursor = "default"; + e.currentTarget.scale({ x: 2, y: 2 }); + } + }; + + const handleMouseOutStartPoint = ( + e: KonvaEventObject, + ) => { + e.currentTarget.scale({ x: 1, y: 1 }); + + if (activePolygonIndex === undefined || !polygons) { + return; + } + + const activePolygon = polygons[activePolygonIndex]; + if ( + (!activePolygon.isFinished && activePolygon.points.length >= 3) || + activePolygon.isFinished + ) { + e.currentTarget.scale({ x: 1, y: 1 }); + } + }; + + const handleMouseOverAnyPoint = ( + e: KonvaEventObject, + ) => { + if (!polygons) { + return; + } + e.target.getStage()!.container().style.cursor = "move"; + }; + + const handleMouseOutAnyPoint = ( + e: KonvaEventObject, + ) => { + if (activePolygonIndex === undefined || !polygons) { + return; + } + const activePolygon = polygons[activePolygonIndex]; + if (activePolygon.isFinished) { + e.target.getStage()!.container().style.cursor = "default"; + } else { + e.target.getStage()!.container().style.cursor = "crosshair"; + } + }; + + const handlePointDragMove = ( + e: KonvaEventObject, + ) => { + if (activePolygonIndex === undefined || !polygons) { + return; + } + + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + const stage = e.target.getStage(); + if (stage) { + const index = e.target.index - 1; + const pos = [e.target._lastPos!.x, e.target._lastPos!.y]; + if (pos[0] < 0) pos[0] = 0; + if (pos[1] < 0) pos[1] = 0; + if (pos[0] > stage.width()) pos[0] = stage.width(); + if (pos[1] > stage.height()) pos[1] = stage.height(); + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + points: [ + ...activePolygon.points.slice(0, index), + pos, + ...activePolygon.points.slice(index + 1), + ], + }; + setPolygons(updatedPolygons); + } + }; + + const handleGroupDragEnd = (e: KonvaEventObject) => { + if (activePolygonIndex !== undefined && e.target.name() === "polygon") { + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + const result: number[][] = []; + activePolygon.points.map((point: number[]) => + result.push([point[0] + e.target.x(), point[1] + e.target.y()]), + ); + e.target.position({ x: 0, y: 0 }); + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + points: result, + }; + setPolygons(updatedPolygons); + } + }; + + const handleStageMouseOver = ( + e: Konva.KonvaEventObject, + ) => { + if (activePolygonIndex === undefined || !polygons) { + return; + } + + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + const stage = e.target.getStage()!; + const mousePos = getMousePos(stage); + + if ( + activePolygon.isFinished || + isMouseOverAnyPoint(activePolygon, mousePos) || + isMouseOverFirstPoint(activePolygon, mousePos) + ) + return; + + e.target.getStage()!.container().style.cursor = "crosshair"; + }; + + return ( + + + + {polygons?.map( + (polygon, index) => + (selectedZoneMask === undefined || + selectedZoneMask.includes(polygon.type)) && + index !== activePolygonIndex && ( + + ), + )} + {activePolygonIndex !== undefined && + polygons?.[activePolygonIndex] && + (selectedZoneMask === undefined || + selectedZoneMask.includes(polygons[activePolygonIndex].type)) && ( + + )} + + + ); +} + +export default PolygonCanvas; diff --git a/web/src/components/settings/PolygonDrawer.tsx b/web/src/components/settings/PolygonDrawer.tsx new file mode 100644 index 000000000..99d05888e --- /dev/null +++ b/web/src/components/settings/PolygonDrawer.tsx @@ -0,0 +1,172 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { Line, Circle, Group } from "react-konva"; +import { + minMax, + toRGBColorString, + dragBoundFunc, + flattenPoints, +} from "@/utils/canvasUtil"; +import type { KonvaEventObject } from "konva/lib/Node"; +import Konva from "konva"; +import { Vector2d } from "konva/lib/types"; + +type PolygonDrawerProps = { + points: number[][]; + isActive: boolean; + isHovered: boolean; + isFinished: boolean; + color: number[]; + handlePointDragMove: (e: KonvaEventObject) => void; + handleGroupDragEnd: (e: KonvaEventObject) => void; + handleMouseOverStartPoint: ( + e: KonvaEventObject, + ) => void; + handleMouseOutStartPoint: ( + e: KonvaEventObject, + ) => void; + handleMouseOverAnyPoint: ( + e: KonvaEventObject, + ) => void; + handleMouseOutAnyPoint: ( + e: KonvaEventObject, + ) => void; +}; + +export default function PolygonDrawer({ + points, + isActive, + isHovered, + isFinished, + color, + handlePointDragMove, + handleGroupDragEnd, + handleMouseOverStartPoint, + handleMouseOutStartPoint, + handleMouseOverAnyPoint, + handleMouseOutAnyPoint, +}: PolygonDrawerProps) { + const vertexRadius = 6; + const flattenedPoints = useMemo(() => flattenPoints(points), [points]); + const [stage, setStage] = useState(); + const [minMaxX, setMinMaxX] = useState([0, 0]); + const [minMaxY, setMinMaxY] = useState([0, 0]); + const groupRef = useRef(null); + + const handleGroupMouseOver = ( + e: Konva.KonvaEventObject, + ) => { + if (!isFinished) return; + e.target.getStage()!.container().style.cursor = "move"; + setStage(e.target.getStage()!); + }; + + const handleGroupMouseOut = ( + e: Konva.KonvaEventObject, + ) => { + if (!e.target || !isFinished) return; + e.target.getStage()!.container().style.cursor = "default"; + }; + + const handleGroupDragStart = () => { + const arrX = points.map((p) => p[0]); + const arrY = points.map((p) => p[1]); + setMinMaxX(minMax(arrX)); + setMinMaxY(minMax(arrY)); + }; + + const groupDragBound = (pos: Vector2d) => { + if (!stage) { + return pos; + } + + let { x, y } = pos; + const sw = stage.width(); + const sh = stage.height(); + + if (minMaxY[0] + y < 0) y = -1 * minMaxY[0]; + if (minMaxX[0] + x < 0) x = -1 * minMaxX[0]; + if (minMaxY[1] + y > sh) y = sh - minMaxY[1]; + if (minMaxX[1] + x > sw) x = sw - minMaxX[1]; + + return { x, y }; + }; + + const colorString = useCallback( + (darkened: boolean) => { + return toRGBColorString(color, darkened); + }, + [color], + ); + + return ( + + + {points.map((point, index) => { + if (!isActive) { + return; + } + const x = point[0]; + const y = point[1]; + const startPointAttr = + index === 0 + ? { + hitStrokeWidth: 12, + onMouseOver: handleMouseOverStartPoint, + onMouseOut: handleMouseOutStartPoint, + } + : null; + const otherPointsAttr = + index !== 0 + ? { + onMouseOver: handleMouseOverAnyPoint, + onMouseOut: handleMouseOutAnyPoint, + } + : null; + + return ( + { + if (stage) { + return dragBoundFunc( + stage.width(), + stage.height(), + vertexRadius, + pos, + ); + } else { + return pos; + } + }} + {...startPointAttr} + {...otherPointsAttr} + /> + ); + })} + + ); +} diff --git a/web/src/components/settings/PolygonEditControls.tsx b/web/src/components/settings/PolygonEditControls.tsx new file mode 100644 index 000000000..ba979fc82 --- /dev/null +++ b/web/src/components/settings/PolygonEditControls.tsx @@ -0,0 +1,81 @@ +import { Polygon } from "@/types/canvas"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { MdOutlineRestartAlt, MdUndo } from "react-icons/md"; +import { Button } from "../ui/button"; + +type PolygonEditControlsProps = { + polygons: Polygon[]; + setPolygons: React.Dispatch>; + activePolygonIndex: number | undefined; +}; + +export default function PolygonEditControls({ + polygons, + setPolygons, + activePolygonIndex, +}: PolygonEditControlsProps) { + const undo = () => { + if (activePolygonIndex === undefined || !polygons) { + return; + } + + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + points: [...activePolygon.points.slice(0, -1)], + isFinished: false, + }; + setPolygons(updatedPolygons); + }; + + const reset = () => { + if (activePolygonIndex === undefined || !polygons) { + return; + } + + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + points: [], + isFinished: false, + }; + setPolygons(updatedPolygons); + }; + + if (activePolygonIndex === undefined || !polygons) { + return; + } + + return ( +
+ + + + + Undo + + + + + + Reset + +
+ ); +} diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx new file mode 100644 index 000000000..9d8972341 --- /dev/null +++ b/web/src/components/settings/PolygonItem.tsx @@ -0,0 +1,334 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { LuCopy, LuPencil } from "react-icons/lu"; +import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa"; +import { BsPersonBoundingBox } from "react-icons/bs"; +import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; +import { isMobile } from "react-device-detect"; +import { + flattenPoints, + parseCoordinates, + toRGBColorString, +} from "@/utils/canvasUtil"; +import { Polygon, PolygonType } from "@/types/canvas"; +import { useCallback, useMemo, useState } from "react"; +import axios from "axios"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { reviewQueries } from "@/utils/zoneEdutUtil"; +import IconWrapper from "../ui/icon-wrapper"; + +type PolygonItemProps = { + polygon: Polygon; + index: number; + hoveredPolygonIndex: number | null; + setHoveredPolygonIndex: (index: number | null) => void; + setActivePolygonIndex: (index: number | undefined) => void; + setEditPane: (type: PolygonType) => void; + handleCopyCoordinates: (index: number) => void; +}; + +export default function PolygonItem({ + polygon, + index, + hoveredPolygonIndex, + setHoveredPolygonIndex, + setActivePolygonIndex, + setEditPane, + handleCopyCoordinates, +}: PolygonItemProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const cameraConfig = useMemo(() => { + if (polygon?.camera && config) { + return config.cameras[polygon.camera]; + } + }, [polygon, config]); + + const polygonTypeIcons = { + zone: FaDrawPolygon, + motion_mask: FaObjectGroup, + object_mask: BsPersonBoundingBox, + }; + + const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined; + + const saveToConfig = useCallback( + async (polygon: Polygon) => { + if (!polygon || !cameraConfig) { + return; + } + let url = ""; + if (polygon.type == "zone") { + const { alertQueries, detectionQueries } = reviewQueries( + polygon.name, + false, + false, + polygon.camera, + cameraConfig?.review.alerts.required_zones || [], + cameraConfig?.review.detections.required_zones || [], + ); + url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; + } + if (polygon.type == "motion_mask") { + const filteredMask = ( + Array.isArray(cameraConfig.motion.mask) + ? cameraConfig.motion.mask + : [cameraConfig.motion.mask] + ).filter((_, currentIndex) => currentIndex !== polygon.typeIndex); + + url = filteredMask + .map((pointsArray) => { + const coordinates = flattenPoints( + parseCoordinates(pointsArray), + ).join(","); + return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`; + }) + .join(""); + + if (!url) { + // deleting last mask + url = `cameras.${polygon?.camera}.motion.mask&`; + } + } + + if (polygon.type == "object_mask") { + let configObject; + let globalMask = false; + + // global mask on camera for all objects + if (!polygon.objects.length) { + configObject = cameraConfig.objects.mask; + globalMask = true; + } else { + configObject = cameraConfig.objects.filters[polygon.objects[0]].mask; + } + + if (!configObject) { + return; + } + + const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask) + ? cameraConfig.objects.mask + : cameraConfig.objects.mask + ? [cameraConfig.objects.mask] + : []; + + let filteredMask; + if (globalMask) { + filteredMask = ( + Array.isArray(configObject) ? configObject : [configObject] + ).filter((_, currentIndex) => currentIndex !== polygon.typeIndex); + } else { + filteredMask = ( + Array.isArray(configObject) ? configObject : [configObject] + ) + .filter((mask) => !globalObjectMasksArray.includes(mask)) + .filter((_, currentIndex) => currentIndex !== polygon.typeIndex); + } + + url = filteredMask + .map((pointsArray) => { + const coordinates = flattenPoints( + parseCoordinates(pointsArray), + ).join(","); + return globalMask + ? `cameras.${polygon?.camera}.objects.mask=${coordinates}&` + : `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask=${coordinates}&`; + }) + .join(""); + + if (!url) { + // deleting last mask + url = globalMask + ? `cameras.${polygon?.camera}.objects.mask&` + : `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask`; + } + } + + setIsLoading(true); + + await axios + .put(`config/set?${url}`, { requires_restart: 0 }) + .then((res) => { + if (res.status === 200) { + toast.success(`${polygon?.name} has been deleted.`, { + position: "top-center", + }); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [updateConfig, cameraConfig], + ); + + const handleDelete = () => { + setActivePolygonIndex(undefined); + saveToConfig(polygon); + }; + + return ( + <> + + +
setHoveredPolygonIndex(index)} + onMouseLeave={() => setHoveredPolygonIndex(null)} + style={{ + backgroundColor: + hoveredPolygonIndex === index + ? toRGBColorString(polygon.color, false) + : "", + }} + > +
+ {PolygonItemIcon && ( + + )} +

{polygon.name}

+
+ setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Are you sure you want to delete the{" "} + {polygon.type.replace("_", " ")} {polygon.name}? + + + Cancel + + Delete + + + + + + {isMobile && ( + <> + + + + + + { + setActivePolygonIndex(index); + setEditPane(polygon.type); + }} + > + Edit + + handleCopyCoordinates(index)}> + Copy + + setDeleteDialogOpen(true)} + > + Delete + + + + + )} + {!isMobile && hoveredPolygonIndex === index && ( +
+ + + { + setActivePolygonIndex(index); + setEditPane(polygon.type); + }} + /> + + Edit + + + + + handleCopyCoordinates(index)} + /> + + Copy coordinates + + + + + !isLoading && setDeleteDialogOpen(true)} + /> + + Delete + +
+ )} +
+ + ); +} diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx new file mode 100644 index 000000000..803d172d8 --- /dev/null +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -0,0 +1,649 @@ +import Heading from "../ui/heading"; +import { Separator } from "../ui/separator"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ZoneFormValuesType, Polygon } from "@/types/canvas"; +import { reviewQueries } from "@/utils/zoneEdutUtil"; +import { Switch } from "../ui/switch"; +import { Label } from "../ui/label"; +import PolygonEditControls from "./PolygonEditControls"; +import { FaCheckCircle } from "react-icons/fa"; +import axios from "axios"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; +import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil"; +import ActivityIndicator from "../indicators/activity-indicator"; + +type ZoneEditPaneProps = { + polygons?: Polygon[]; + setPolygons: React.Dispatch>; + activePolygonIndex?: number; + scaledWidth?: number; + scaledHeight?: number; + isLoading: boolean; + setIsLoading: React.Dispatch>; + onSave?: () => void; + onCancel?: () => void; +}; + +export default function ZoneEditPane({ + polygons, + setPolygons, + activePolygonIndex, + scaledWidth, + scaledHeight, + isLoading, + setIsLoading, + onSave, + onCancel, +}: ZoneEditPaneProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + + const cameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .filter((conf) => conf.ui.dashboard && conf.enabled) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); + + const polygon = useMemo(() => { + if (polygons && activePolygonIndex !== undefined) { + return polygons[activePolygonIndex]; + } else { + return null; + } + }, [polygons, activePolygonIndex]); + + const cameraConfig = useMemo(() => { + if (polygon?.camera && config) { + return config.cameras[polygon.camera]; + } + }, [polygon, config]); + + const formSchema = z.object({ + name: z + .string() + .min(2, { + message: "Zone name must be at least 2 characters.", + }) + .transform((val: string) => val.trim().replace(/\s+/g, "_")) + .refine( + (value: string) => { + return !cameras.map((cam) => cam.name).includes(value); + }, + { + message: "Zone name must not be the name of a camera.", + }, + ) + .refine( + (value: string) => { + const otherPolygonNames = + polygons + ?.filter((_, index) => index !== activePolygonIndex) + .map((polygon) => polygon.name) || []; + + return !otherPolygonNames.includes(value); + }, + { + message: "Zone name already exists on this camera.", + }, + ), + inertia: z.coerce + .number() + .min(1, { + message: "Inertia must be above 0.", + }) + .or(z.literal("")), + loitering_time: z.coerce + .number() + .min(0, { + message: "Loitering time must be greater than or equal to 0.", + }) + .optional() + .or(z.literal("")), + isFinished: z.boolean().refine(() => polygon?.isFinished === true, { + message: "The polygon drawing must be finished before saving.", + }), + objects: z.array(z.string()).optional(), + review_alerts: z.boolean().default(false).optional(), + review_detections: z.boolean().default(false).optional(), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + name: polygon?.name ?? "", + inertia: + polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name]?.inertia, + loitering_time: + polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time, + isFinished: polygon?.isFinished ?? false, + objects: polygon?.objects ?? [], + review_alerts: + (polygon?.camera && + polygon?.name && + config?.cameras[ + polygon.camera + ]?.review.alerts.required_zones.includes(polygon.name)) || + false, + review_detections: + (polygon?.camera && + polygon?.name && + config?.cameras[ + polygon.camera + ]?.review.detections.required_zones.includes(polygon.name)) || + false, + }, + }); + + const saveToConfig = useCallback( + async ( + { + name: zoneName, + inertia, + loitering_time, + objects: form_objects, + review_alerts, + review_detections, + }: ZoneFormValuesType, // values submitted via the form + objects: string[], + ) => { + if (!scaledWidth || !scaledHeight || !polygon) { + return; + } + let mutatedConfig = config; + + const renamingZone = zoneName != polygon.name && polygon.name != ""; + + if (renamingZone) { + // rename - delete old zone and replace with new + const { + alertQueries: renameAlertQueries, + detectionQueries: renameDetectionQueries, + } = reviewQueries( + polygon.name, + false, + false, + polygon.camera, + cameraConfig?.review.alerts.required_zones || [], + cameraConfig?.review.detections.required_zones || [], + ); + + try { + await axios.put( + `config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`, + { + requires_restart: 0, + }, + ); + + // Wait for the config to be updated + mutatedConfig = await updateConfig(); + } catch (error) { + toast.error(`Failed to save config changes.`, { + position: "top-center", + }); + return; + } + } + + const coordinates = flattenPoints( + interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), + ).join(","); + + let objectQueries = objects + .map( + (object) => + `&cameras.${polygon?.camera}.zones.${zoneName}.objects=${object}`, + ) + .join(""); + + const same_objects = + form_objects.length == objects.length && + form_objects.every(function (element, index) { + return element === objects[index]; + }); + + // deleting objects + if (!objectQueries && !same_objects && !renamingZone) { + objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`; + } + + const { alertQueries, detectionQueries } = reviewQueries( + zoneName, + review_alerts, + review_detections, + polygon.camera, + mutatedConfig?.cameras[polygon.camera]?.review.alerts.required_zones || + [], + mutatedConfig?.cameras[polygon.camera]?.review.detections + .required_zones || [], + ); + + let inertiaQuery = ""; + if (inertia) { + inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`; + } + + let loiteringTimeQuery = ""; + if (loitering_time) { + loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`; + } + + axios + .put( + `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${objectQueries}${alertQueries}${detectionQueries}`, + { requires_restart: 0 }, + ) + .then((res) => { + if (res.status === 200) { + toast.success(`Zone (${zoneName}) has been saved.`, { + position: "top-center", + }); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [ + config, + updateConfig, + polygon, + scaledWidth, + scaledHeight, + setIsLoading, + cameraConfig, + ], + ); + + function onSubmit(values: z.infer) { + if (activePolygonIndex === undefined || !values || !polygons) { + return; + } + setIsLoading(true); + + saveToConfig( + values as ZoneFormValuesType, + polygons[activePolygonIndex].objects, + ); + + if (onSave) { + onSave(); + } + } + + if (!polygon) { + return; + } + + return ( + <> + + + {polygon.name.length ? "Edit" : "New"} Zone + +
+

+ Zones allow you to define a specific area of the frame so you can + determine whether or not an object is within a particular area. +

+
+ + {polygons && activePolygonIndex !== undefined && ( +
+
+ {polygons[activePolygonIndex].points.length}{" "} + {polygons[activePolygonIndex].points.length > 1 || + polygons[activePolygonIndex].points.length == 0 + ? "points" + : "point"} + {polygons[activePolygonIndex].isFinished && ( + + )} +
+ +
+ )} +
+ Click to draw a polygon on the image. +
+ + + +
+ + ( + + Name + + + + + Name must be at least 2 characters and must not be the name of + a camera or another zone. + + + + )} + /> + + ( + + Inertia + + + + + Specifies how many frames that an object must be in a zone + before they are considered in the zone. Default: 3 + + + + )} + /> + + ( + + Loitering Time + + + + + Sets a minimum amount of time in seconds that the object must + be in the zone for it to activate. Default: 0 + + + + )} + /> + + + Objects + + List of objects that apply to this zone. + + { + if (activePolygonIndex === undefined || !polygons) { + return; + } + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + objects: objects ?? [], + }; + setPolygons(updatedPolygons); + }} + /> + + + + + ( + +
+ Alerts + + When an object enters this zone, ensure it is marked as an + alert. + +
+ + + +
+ )} + /> + ( + +
+ Detections + + When an object enters this zone, ensure it is marked as a + detection. + +
+ + + +
+ )} + /> + ( + + + + )} + /> +
+ + +
+ + + + ); +} + +type ZoneObjectSelectorProps = { + camera: string; + zoneName: string; + selectedLabels: string[]; + updateLabelFilter: (labels: string[] | undefined) => void; +}; + +export function ZoneObjectSelector({ + camera, + zoneName, + selectedLabels, + updateLabelFilter, +}: ZoneObjectSelectorProps) { + const { data: config } = useSWR("config"); + + const cameraConfig = useMemo(() => { + if (config && camera) { + return config.cameras[camera]; + } + }, [config, camera]); + + const allLabels = useMemo(() => { + if (!cameraConfig || !config) { + return []; + } + + const labels = new Set(); + + // Object.values(config.cameras).forEach((camera) => { + // camera.objects.track.forEach((label) => { + // if (!ATTRIBUTE_LABELS.includes(label)) { + // labels.add(label); + // } + // }); + // }); + + cameraConfig.objects.track.forEach((label) => { + if (!ATTRIBUTE_LABELS.includes(label)) { + labels.add(label); + } + }); + + if (zoneName) { + if (cameraConfig.zones[zoneName]) { + cameraConfig.zones[zoneName].objects.forEach((label) => { + if (!ATTRIBUTE_LABELS.includes(label)) { + labels.add(label); + } + }); + } + } + + return [...labels].sort() || []; + }, [config, cameraConfig, zoneName]); + + const [currentLabels, setCurrentLabels] = useState( + selectedLabels, + ); + + useEffect(() => { + updateLabelFilter(currentLabels); + }, [currentLabels, updateLabelFilter]); + + return ( + <> +
+
+ + { + if (isChecked) { + setCurrentLabels([]); + } + }} + /> +
+ +
+ {allLabels.map((item) => ( +
+ + { + if (isChecked) { + const updatedLabels = currentLabels + ? [...currentLabels] + : []; + + updatedLabels.push(item); + setCurrentLabels(updatedLabels); + } else { + const updatedLabels = currentLabels + ? [...currentLabels] + : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setCurrentLabels(updatedLabels); + } + } + }} + /> +
+ ))} +
+
+ + ); +} diff --git a/web/src/components/ui/icon-wrapper.tsx b/web/src/components/ui/icon-wrapper.tsx new file mode 100644 index 000000000..f87d18c62 --- /dev/null +++ b/web/src/components/ui/icon-wrapper.tsx @@ -0,0 +1,21 @@ +import { ForwardedRef, forwardRef } from "react"; +import { IconType } from "react-icons"; + +interface IconWrapperProps { + icon: IconType; + className?: string; + [key: string]: any; +} + +const IconWrapper = forwardRef( + ( + { icon: Icon, className, ...props }: IconWrapperProps, + ref: ForwardedRef, + ) => ( +
+ +
+ ), +); + +export default IconWrapper; diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx new file mode 100644 index 000000000..6d7f12265 --- /dev/null +++ b/web/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/web/src/components/ui/slider.tsx b/web/src/components/ui/slider.tsx index 0f57209d8..8a3e93747 100644 --- a/web/src/components/ui/slider.tsx +++ b/web/src/components/ui/slider.tsx @@ -18,7 +18,7 @@ const Slider = React.forwardRef< - + )); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx index 162260f87..1cb6c7ffa 100644 --- a/web/src/components/ui/switch.tsx +++ b/web/src/components/ui/switch.tsx @@ -18,6 +18,7 @@ const Switch = React.forwardRef< diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index b80040203..c6a509f90 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1,44 +1,272 @@ -import Heading from "@/components/ui/heading"; -import { Label } from "@/components/ui/label"; import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; +import MotionTuner from "@/components/settings/MotionTuner"; +import MasksAndZones from "@/components/settings/MasksAndZones"; +import { Button } from "@/components/ui/button"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import useOptimisticState from "@/hooks/use-optimistic-state"; +import { isMobile } from "react-device-detect"; +import { FaVideo } from "react-icons/fa"; +import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import General from "@/components/settings/General"; +import FilterSwitch from "@/components/filter/FilterSwitch"; +import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; +import { PolygonType } from "@/types/canvas"; +import ObjectSettings from "@/components/settings/ObjectSettings"; + +export default function Settings() { + const settingsViews = [ + "general", + "objects", + "masks / zones", + "motion tuner", + ] as const; + + type SettingsType = (typeof settingsViews)[number]; + const [page, setPage] = useState("general"); + const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + + const { data: config } = useSWR("config"); + + // TODO: confirm leave page + const [unsavedChanges, setUnsavedChanges] = useState(false); + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + + const cameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .filter((conf) => conf.ui.dashboard && conf.enabled) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); + + const [selectedCamera, setSelectedCamera] = useState(""); + + const [filterZoneMask, setFilterZoneMask] = useState(); + + const handleDialog = useCallback( + (save: boolean) => { + if (unsavedChanges && save) { + // TODO + } + setConfirmationDialogOpen(false); + setUnsavedChanges(false); + }, + [unsavedChanges], + ); + + useEffect(() => { + if (cameras.length) { + setSelectedCamera(cameras[0].name); + } + // only run once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); -function Settings() { return ( - <> - Settings -
- {}} /> - +
+
+
+ { + if (value) { + setPageToggle(value); + } + }} + > + {Object.values(settingsViews).map((item) => ( + +
{item}
+
+ ))} +
+
+ {(page == "objects" || + page == "masks / zones" || + page == "motion tuner") && ( +
+ {page == "masks / zones" && ( + + )} + +
+ )}
- -
- +
+ {page == "general" && } + {page == "objects" && ( + + )} + {page == "masks / zones" && ( + + )} + {page == "motion tuner" && ( + + )}
- + {confirmationDialogOpen && ( + setConfirmationDialogOpen(false)} + > + + + You have unsaved changes. + + Do you want to save your changes before continuing? + + + + handleDialog(false)}> + Cancel + + handleDialog(true)}> + Save + + + + + )} +
); } -export default Settings; +type CameraSelectButtonProps = { + allCameras: CameraConfig[]; + selectedCamera: string; + setSelectedCamera: React.Dispatch>; +}; + +function CameraSelectButton({ + allCameras, + selectedCamera, + setSelectedCamera, +}: CameraSelectButtonProps) { + const [open, setOpen] = useState(false); + + if (!allCameras.length) { + return; + } + + const trigger = ( + + ); + const content = ( + <> + {isMobile && ( + <> + + Camera + + + + )} +
+
+ {allCameras.map((item) => ( + { + if (isChecked) { + setSelectedCamera(item.name); + setOpen(false); + } + }} + /> + ))} +
+
+ + ); + + if (isMobile) { + return ( + { + if (!open) { + setSelectedCamera(selectedCamera); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setSelectedCamera(selectedCamera); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx index 88c449362..098f0d5ff 100644 --- a/web/src/pages/SubmitPlus.tsx +++ b/web/src/pages/SubmitPlus.tsx @@ -24,7 +24,7 @@ import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { DualThumbSlider } from "@/components/ui/slider"; import { Event } from "@/types/event"; -import { FrigateConfig } from "@/types/frigateConfig"; +import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; import axios from "axios"; import { useCallback, useEffect, useMemo, useState } from "react"; import { isMobile } from "react-device-detect"; @@ -199,8 +199,6 @@ export default function SubmitPlus() { ); } -const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; - type PlusFilterGroupProps = { selectedCameras: string[] | undefined; selectedLabels: string[] | undefined; @@ -237,7 +235,7 @@ function PlusFilterGroup({ cameras.forEach((camera) => { const cameraConfig = config.cameras[camera]; cameraConfig.objects.track.forEach((label) => { - if (!ATTRIBUTES.includes(label)) { + if (!ATTRIBUTE_LABELS.includes(label)) { labels.add(label); } }); diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts new file mode 100644 index 000000000..5eb04e98f --- /dev/null +++ b/web/src/types/canvas.ts @@ -0,0 +1,31 @@ +export type PolygonType = "zone" | "motion_mask" | "object_mask"; + +export type Polygon = { + typeIndex: number; + camera: string; + name: string; + type: PolygonType; + objects: string[]; + points: number[][]; + isFinished: boolean; + // isUnsaved: boolean; + color: number[]; +}; + +export type ZoneFormValuesType = { + name: string; + inertia: number; + loitering_time: number; + isFinished: boolean; + objects: string[]; + review_alerts: boolean; + review_detections: boolean; +}; + +export type ObjectMaskFormValuesType = { + objects: string; + polygon: { + isFinished: boolean; + name: string; + }; +}; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index a6c6b3864..b5c89f5db 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -21,6 +21,14 @@ export interface BirdseyeConfig { width: number; } +export const ATTRIBUTE_LABELS = [ + "amazon", + "face", + "fedex", + "license_plate", + "ups", +]; + export interface CameraConfig { audio: { enabled: boolean; @@ -106,7 +114,7 @@ export interface CameraConfig { objects: { filters: { [objectName: string]: { - mask: string | null; + mask: string[] | null; max_area: number; max_ratio: number; min_area: number; @@ -163,6 +171,14 @@ export interface CameraConfig { }; sync_recordings: boolean; }; + review: { + alerts: { + required_zones: string[]; + }; + detections: { + required_zones: string[]; + }; + }; rtmp: { enabled: boolean; }; @@ -199,7 +215,9 @@ export interface CameraConfig { coordinates: string; filters: Record; inertia: number; + loitering_time: number; objects: string[]; + color: number[]; }; }; } @@ -327,7 +345,7 @@ export interface FrigateConfig { objects: { filters: { [objectName: string]: { - mask: string | null; + mask: string[] | null; max_area: number; max_ratio: number; min_area: number; @@ -336,7 +354,7 @@ export interface FrigateConfig { threshold: number; }; }; - mask: string; + mask: string[]; track: string[]; }; diff --git a/web/src/utils/canvasUtil.ts b/web/src/utils/canvasUtil.ts new file mode 100644 index 000000000..12bd6b167 --- /dev/null +++ b/web/src/utils/canvasUtil.ts @@ -0,0 +1,102 @@ +import { Vector2d } from "konva/lib/types"; + +export const getAveragePoint = (points: number[]): Vector2d => { + let totalX = 0; + let totalY = 0; + for (let i = 0; i < points.length; i += 2) { + totalX += points[i]; + totalY += points[i + 1]; + } + return { + x: totalX / (points.length / 2), + y: totalY / (points.length / 2), + }; +}; + +export const getDistance = (node1: number[], node2: number[]): string => { + const diffX = Math.abs(node1[0] - node2[0]); + const diffY = Math.abs(node1[1] - node2[1]); + const distanceInPixel = Math.sqrt(diffX * diffX + diffY * diffY); + return distanceInPixel.toFixed(2); +}; + +export const dragBoundFunc = ( + stageWidth: number, + stageHeight: number, + vertexRadius: number, + pos: Vector2d, +): Vector2d => { + let x = pos.x; + let y = pos.y; + if (pos.x + vertexRadius > stageWidth) x = stageWidth; + if (pos.x - vertexRadius < 0) x = 0; + if (pos.y + vertexRadius > stageHeight) y = stageHeight; + if (pos.y - vertexRadius < 0) y = 0; + return { x, y }; +}; + +export const minMax = (points: number[]): [number, number] => { + return points.reduce( + (acc: [number | undefined, number | undefined], val) => { + acc[0] = acc[0] === undefined || val < acc[0] ? val : acc[0]; + acc[1] = acc[1] === undefined || val > acc[1] ? val : acc[1]; + return acc; + }, + [undefined, undefined], + ) as [number, number]; +}; + +export const interpolatePoints = ( + points: number[][], + width: number, + height: number, + newWidth: number, + newHeight: number, +): number[][] => { + const newPoints: number[][] = []; + + for (const [x, y] of points) { + const newX = Math.min(+((x * newWidth) / width).toFixed(3), newWidth); + const newY = Math.min(+((y * newHeight) / height).toFixed(3), newHeight); + newPoints.push([newX, newY]); + } + + return newPoints; +}; + +export const parseCoordinates = (coordinatesString: string) => { + const coordinates = coordinatesString.split(","); + const points = []; + + for (let i = 0; i < coordinates.length; i += 2) { + const x = parseFloat(coordinates[i]); + const y = parseFloat(coordinates[i + 1]); + points.push([x, y]); + } + + return points; +}; + +export const flattenPoints = (points: number[][]): number[] => { + return points.reduce((acc, point) => [...acc, ...point], []); +}; + +export const toRGBColorString = (color: number[], darkened: boolean) => { + if (color.length !== 3) { + return "rgb(220,0,0,0.5)"; + } + + return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.7" : "0.3"})`; +}; + +export const masksAreIdentical = (arr1: string[], arr2: string[]): boolean => { + if (arr1.length !== arr2.length) { + return false; + } + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) { + return false; + } + } + return true; +}; diff --git a/web/src/utils/zoneEdutUtil.ts b/web/src/utils/zoneEdutUtil.ts new file mode 100644 index 000000000..ce5ed3d96 --- /dev/null +++ b/web/src/utils/zoneEdutUtil.ts @@ -0,0 +1,50 @@ +export const reviewQueries = ( + name: string, + review_alerts: boolean, + review_detections: boolean, + camera: string, + alertsZones: string[], + detectionsZones: string[], +) => { + let alertQueries = ""; + let detectionQueries = ""; + let same_alerts = false; + let same_detections = false; + + const alerts = new Set(alertsZones || []); + + if (review_alerts) { + alerts.add(name); + } else { + same_alerts = !alerts.has(name); + alerts.delete(name); + } + + alertQueries = [...alerts] + .map((zone) => `&cameras.${camera}.review.alerts.required_zones=${zone}`) + .join(""); + + const detections = new Set(detectionsZones || []); + + if (review_detections) { + detections.add(name); + } else { + same_detections = !detections.has(name); + detections.delete(name); + } + + detectionQueries = [...detections] + .map( + (zone) => `&cameras.${camera}.review.detections.required_zones=${zone}`, + ) + .join(""); + + if (!alertQueries && !same_alerts) { + alertQueries = `&cameras.${camera}.review.alerts`; + } + if (!detectionQueries && !same_detections) { + detectionQueries = `&cameras.${camera}.review.detections`; + } + + return { alertQueries, detectionQueries }; +}; diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 80cbc4a12..93faa87ca 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -53,6 +53,7 @@ module.exports = { primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))", + variant: "hsl(var(--primary-variant))", }, secondary: { DEFAULT: "hsl(var(--secondary))", diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index 07b924956..3d2619c72 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -24,6 +24,9 @@ --primary: hsl(222.2, 37.4%, 11.2%); --primary: 222.2 47.4% 11.2%; + --primary-variant: hsl(222.2, 37.4%, 24.2%); + --primary-variant: 222.2 47.4% 24.2%; + --primary-foreground: hsl(210, 40%, 98%); --primary-foreground: 210 40% 98%; @@ -115,12 +118,15 @@ --popover: hsl(0, 0%, 15%); --popover: 0, 0%, 15%; - --popover-foreground: hsl(0, 0%, 100%); - --popover-foreground: 210 40% 98%; + --popover-foreground: hsl(0, 0%, 98%); + --popover-foreground: 0 0% 98%; --primary: hsl(0, 0%, 91%); --primary: 0 0% 91%; + --primary-variant: hsl(0, 0%, 64%); + --primary-variant: 0 0% 64%; + --primary-foreground: hsl(0, 0%, 9%); --primary-foreground: 0 0% 9%; @@ -133,8 +139,8 @@ --secondary-highlight: hsl(0, 0%, 25%); --secondary-highlight: 0 0% 25%; - --muted: hsl(0, 0%, 8%); - --muted: 0 0% 8%; + --muted: hsl(0, 0%, 12%); + --muted: 0 0% 12%; --muted-foreground: hsl(0, 0%, 32%); --muted-foreground: 0 0% 32%; From d6dfa596de22518b493a29fd6bbf90a948c546a2 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 19 Apr 2024 08:59:28 -0500 Subject: [PATCH 12/19] undo points based on order added to polygon (#11035) --- web/src/components/settings/PolygonCanvas.tsx | 82 +++++++++---------- .../settings/PolygonEditControls.tsx | 31 +++++-- web/src/types/canvas.ts | 2 +- 3 files changed, 64 insertions(+), 51 deletions(-) diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index 22c23a226..d21ef01bf 100644 --- a/web/src/components/settings/PolygonCanvas.tsx +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -61,9 +61,20 @@ export function PolygonCanvas({ const addPointToPolygon = (polygon: Polygon, newPoint: number[]) => { const points = polygon.points; + const pointsOrder = polygon.pointsOrder; + const [newPointX, newPointY] = newPoint; const updatedPoints = [...points]; + let updatedPointsOrder: number[]; + if (!pointsOrder) { + updatedPointsOrder = []; + } else { + updatedPointsOrder = [...pointsOrder]; + } + + let insertIndex = points.length; + for (let i = 0; i < points.length; i++) { const [x1, y1] = points[i]; const [x2, y2] = i === points.length - 1 ? points[0] : points[i + 1]; @@ -76,48 +87,16 @@ export function PolygonCanvas({ (y1 <= newPointY && newPointY <= y2) || (y2 <= newPointY && newPointY <= y1) ) { - const insertIndex = i + 1; - updatedPoints.splice(insertIndex, 0, [newPointX, newPointY]); + insertIndex = i + 1; break; } } } - return updatedPoints; - }; + updatedPoints.splice(insertIndex, 0, [newPointX, newPointY]); + updatedPointsOrder.splice(insertIndex, 0, updatedPoints.length); - const isPointNearLineSegment = ( - polygon: Polygon, - mousePos: number[], - radius = 10, - ) => { - const points = polygon.points; - const [x, y] = mousePos; - - for (let i = 0; i < points.length; i++) { - const [x1, y1] = points[i]; - const [x2, y2] = i === points.length - 1 ? points[0] : points[i + 1]; - - const crossProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1); - if (crossProduct > 0) { - const lengthSquared = (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1); - const dot = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1); - if (dot < 0 || dot > lengthSquared) { - continue; - } - const lineSegmentDistance = Math.abs( - ((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / - Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2)), - ); - if (lineSegmentDistance <= radius) { - const midPointX = (x1 + x2) / 2; - const midPointY = (y1 + y2) / 2; - return [midPointX, midPointY]; - } - } - } - - return null; + return { updatedPoints, updatedPointsOrder }; }; const isMouseOverFirstPoint = (polygon: Polygon, mousePos: number[]) => { @@ -176,18 +155,15 @@ export function PolygonCanvas({ !activePolygon.isFinished && !isMouseOverAnyPoint(activePolygon, mousePos) ) { - let updatedPoints; + const { updatedPoints, updatedPointsOrder } = addPointToPolygon( + activePolygon, + mousePos, + ); - if (isPointNearLineSegment(activePolygon, mousePos)) { - // we've clicked near a line segment, so add a new point in the right position - updatedPoints = addPointToPolygon(activePolygon, mousePos); - } else { - // Add a new point to the active polygon - updatedPoints = [...activePolygon.points, mousePos]; - } updatedPolygons[activePolygonIndex] = { ...activePolygon, points: updatedPoints, + pointsOrder: updatedPointsOrder, }; setPolygons(updatedPolygons); } @@ -318,6 +294,24 @@ export function PolygonCanvas({ e.target.getStage()!.container().style.cursor = "crosshair"; }; + useEffect(() => { + if (activePolygonIndex === undefined || !polygons) { + return; + } + + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + + // add default points order for already completed polygons + if (!activePolygon.pointsOrder && activePolygon.isFinished) { + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + pointsOrder: activePolygon.points.map((_, index) => index), + }; + setPolygons(updatedPolygons); + } + }, [activePolygonIndex, polygons, setPolygons]); + return ( 0 && + activePolygon.pointsOrder && + activePolygon.pointsOrder.length > 0 + ) { + const lastPointOrderIndex = activePolygon.pointsOrder.indexOf( + Math.max(...activePolygon.pointsOrder), + ); + + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + points: [ + ...activePolygon.points.slice(0, lastPointOrderIndex), + ...activePolygon.points.slice(lastPointOrderIndex + 1), + ], + pointsOrder: [ + ...activePolygon.pointsOrder.slice(0, lastPointOrderIndex), + ...activePolygon.pointsOrder.slice(lastPointOrderIndex + 1), + ], + isFinished: false, + }; + + setPolygons(updatedPolygons); + } }; const reset = () => { diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 5eb04e98f..4fcf8a46a 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -7,8 +7,8 @@ export type Polygon = { type: PolygonType; objects: string[]; points: number[][]; + pointsOrder?: number[]; isFinished: boolean; - // isUnsaved: boolean; color: number[]; }; From 3b0f9988df225ada4498e360fb1f24268e79b8a8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 19 Apr 2024 12:17:23 -0500 Subject: [PATCH 13/19] UI tweaks (#11036) * spacing, mobile navbar, and minor color updates * tab scrolling behavior --- web/src/components/settings/MotionTuner.tsx | 2 +- web/src/pages/Logs.tsx | 2 +- web/src/pages/Settings.tsx | 71 +++++++++++++-------- web/src/pages/System.tsx | 2 +- web/src/views/events/EventView.tsx | 8 ++- web/src/views/events/RecordingView.tsx | 4 +- 6 files changed, 56 insertions(+), 33 deletions(-) diff --git a/web/src/components/settings/MotionTuner.tsx b/web/src/components/settings/MotionTuner.tsx index fce762df5..584eab83e 100644 --- a/web/src/components/settings/MotionTuner.tsx +++ b/web/src/components/settings/MotionTuner.tsx @@ -154,7 +154,7 @@ export default function MotionTuner({ return (
-
+
Motion Detection Tuner diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index a12f2d162..867f8b512 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -352,7 +352,7 @@ function Logs() { {Object.values(logTypes).map((item) => ( diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index c6a509f90..9d22e534d 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -20,7 +20,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import MotionTuner from "@/components/settings/MotionTuner"; import MasksAndZones from "@/components/settings/MasksAndZones"; import { Button } from "@/components/ui/button"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; @@ -31,6 +31,8 @@ import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { PolygonType } from "@/types/canvas"; import ObjectSettings from "@/components/settings/ObjectSettings"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import scrollIntoView from "scroll-into-view-if-needed"; export default function Settings() { const settingsViews = [ @@ -43,6 +45,7 @@ export default function Settings() { type SettingsType = (typeof settingsViews)[number]; const [page, setPage] = useState("general"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + const tabsRef = useRef(null); const { data: config } = useSWR("config"); @@ -83,33 +86,51 @@ export default function Settings() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (tabsRef.current) { + const element = tabsRef.current.querySelector( + `[data-nav-item="${pageToggle}"]`, + ); + if (element instanceof HTMLElement) { + scrollIntoView(element, { + behavior: "smooth", + inline: "start", + }); + } + } + }, [tabsRef, pageToggle]); + return (
-
- { - if (value) { - setPageToggle(value); - } - }} - > - {Object.values(settingsViews).map((item) => ( - -
{item}
-
- ))} -
-
+ +
+ { + if (value) { + setPageToggle(value); + } + }} + > + {Object.values(settingsViews).map((item) => ( + +
{item}
+
+ ))} +
+ +
+
{(page == "objects" || page == "masks / zones" || page == "motion tuner") && ( diff --git a/web/src/pages/System.tsx b/web/src/pages/System.tsx index 8693a30b1..5d296f053 100644 --- a/web/src/pages/System.tsx +++ b/web/src/pages/System.tsx @@ -52,7 +52,7 @@ function System() { {Object.values(metrics).map((item) => ( diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index c6736e3a4..2540efc79 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -228,7 +228,7 @@ export default function EventView({ } // don't allow the severity to be unselected > @@ -238,7 +238,7 @@ export default function EventView({
@@ -250,7 +250,9 @@ export default function EventView({
Timeline
From fe4fb645d38b83ad6e129f74992641eac2b5b1fe Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 19 Apr 2024 16:11:41 -0600 Subject: [PATCH 14/19] Save exports to database (#11040) * Save review thumbs in dedicated folder * Create exports table * Save exports to DB and save thumbnail for export * Save full frame always * Fix rounded corners * Save exports that are in progress * No need to remove spaces * Reorganize apis to use IDs * Use new apis for frontend * Get video playback working * Fix deleting and renaming * Import existing exports to DB * Implement downloading * Formatting --- frigate/api/app.py | 2 + frigate/api/export.py | 157 +++++++++++++ frigate/api/media.py | 222 +++--------------- frigate/app.py | 15 ++ frigate/models.py | 10 + frigate/record/export.py | 199 +++++++++++++++- frigate/review/maintainer.py | 8 +- migrations/024_create_export_table.py | 37 +++ web/src/App.tsx | 4 +- web/src/components/card/AnimatedEventCard.tsx | 2 +- web/src/components/card/ExportCard.tsx | 124 +++++----- web/src/pages/{Export.tsx => Exports.tsx} | 79 +++++-- web/src/types/export.ts | 9 + 13 files changed, 584 insertions(+), 284 deletions(-) create mode 100644 frigate/api/export.py create mode 100644 migrations/024_create_export_table.py rename web/src/pages/{Export.tsx => Exports.tsx} (59%) create mode 100644 web/src/types/export.ts diff --git a/frigate/api/app.py b/frigate/api/app.py index b56c9c229..5d0bce78b 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -15,6 +15,7 @@ from peewee import operator from playhouse.sqliteq import SqliteQueueDatabase from frigate.api.event import EventBp +from frigate.api.export import ExportBp from frigate.api.media import MediaBp from frigate.api.preview import PreviewBp from frigate.api.review import ReviewBp @@ -39,6 +40,7 @@ logger = logging.getLogger(__name__) bp = Blueprint("frigate", __name__) bp.register_blueprint(EventBp) +bp.register_blueprint(ExportBp) bp.register_blueprint(MediaBp) bp.register_blueprint(PreviewBp) bp.register_blueprint(ReviewBp) diff --git a/frigate/api/export.py b/frigate/api/export.py new file mode 100644 index 000000000..c88e0146a --- /dev/null +++ b/frigate/api/export.py @@ -0,0 +1,157 @@ +"""Export apis.""" + +import logging +from pathlib import Path +from typing import Optional + +from flask import ( + Blueprint, + current_app, + jsonify, + make_response, + request, +) +from peewee import DoesNotExist +from werkzeug.utils import secure_filename + +from frigate.models import Export, Recordings +from frigate.record.export import PlaybackFactorEnum, RecordingExporter + +logger = logging.getLogger(__name__) + +ExportBp = Blueprint("exports", __name__) + + +@ExportBp.route("/exports") +def get_exports(): + exports = Export.select().order_by(Export.date.desc()).dicts().iterator() + return jsonify([e for e in exports]) + + +@ExportBp.route( + "/export//start//end/", methods=["POST"] +) +@ExportBp.route( + "/export//start//end/", + methods=["POST"], +) +def export_recording(camera_name: str, start_time, end_time): + if not camera_name or not current_app.frigate_config.cameras.get(camera_name): + return make_response( + jsonify( + {"success": False, "message": f"{camera_name} is not a valid camera."} + ), + 404, + ) + + json: dict[str, any] = request.get_json(silent=True) or {} + playback_factor = json.get("playback", "realtime") + name: Optional[str] = json.get("name") + + if len(name or "") > 256: + return make_response( + jsonify({"success": False, "message": "File name is too long."}), + 401, + ) + + recordings_count = ( + Recordings.select() + .where( + Recordings.start_time.between(start_time, end_time) + | Recordings.end_time.between(start_time, end_time) + | ((start_time > Recordings.start_time) & (end_time < Recordings.end_time)) + ) + .where(Recordings.camera == camera_name) + .count() + ) + + if recordings_count <= 0: + return make_response( + jsonify( + {"success": False, "message": "No recordings found for time range"} + ), + 400, + ) + + exporter = RecordingExporter( + current_app.frigate_config, + camera_name, + secure_filename(name) if name else None, + int(start_time), + int(end_time), + ( + PlaybackFactorEnum[playback_factor] + if playback_factor in PlaybackFactorEnum.__members__.values() + else PlaybackFactorEnum.realtime + ), + ) + exporter.start() + return make_response( + jsonify( + { + "success": True, + "message": "Starting export of recording.", + } + ), + 200, + ) + + +@ExportBp.route("/export//", methods=["PATCH"]) +def export_rename(id, new_name: str): + try: + export: Export = Export.get(Export.id == id) + except DoesNotExist: + return make_response( + jsonify( + { + "success": False, + "message": "Export not found.", + } + ), + 404, + ) + + export.name = new_name + export.save() + return make_response( + jsonify( + { + "success": True, + "message": "Successfully renamed export.", + } + ), + 200, + ) + + +@ExportBp.route("/export/", methods=["DELETE"]) +def export_delete(id: str): + try: + export: Export = Export.get(Export.id == id) + except DoesNotExist: + return make_response( + jsonify( + { + "success": False, + "message": "Export not found.", + } + ), + 404, + ) + + Path(export.video_path).unlink(missing_ok=True) + + if export.thumb_path: + Path(export.thumb_path).unlink(missing_ok=True) + + export.delete_instance() + return make_response( + jsonify( + { + "success": True, + "message": "Successfully deleted export.", + } + ), + 200, + ) diff --git a/frigate/api/media.py b/frigate/api/media.py index 5387b2866..9770de157 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -4,11 +4,9 @@ import base64 import glob import logging import os -import re import subprocess as sp import time from datetime import datetime, timedelta, timezone -from typing import Optional from urllib.parse import unquote import cv2 @@ -22,13 +20,11 @@ from werkzeug.utils import secure_filename from frigate.const import ( CACHE_DIR, CLIPS_DIR, - EXPORT_DIR, MAX_SEGMENT_DURATION, PREVIEW_FRAME_TYPE, RECORD_DIR, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment -from frigate.record.export import PlaybackFactorEnum, RecordingExporter from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) @@ -592,151 +588,6 @@ def vod_event(id): ) -@MediaBp.route( - "/export//start//end/", methods=["POST"] -) -@MediaBp.route( - "/export//start//end/", - methods=["POST"], -) -def export_recording(camera_name: str, start_time, end_time): - if not camera_name or not current_app.frigate_config.cameras.get(camera_name): - return make_response( - jsonify( - {"success": False, "message": f"{camera_name} is not a valid camera."} - ), - 404, - ) - - json: dict[str, any] = request.get_json(silent=True) or {} - playback_factor = json.get("playback", "realtime") - name: Optional[str] = json.get("name") - - recordings_count = ( - Recordings.select() - .where( - Recordings.start_time.between(start_time, end_time) - | Recordings.end_time.between(start_time, end_time) - | ((start_time > Recordings.start_time) & (end_time < Recordings.end_time)) - ) - .where(Recordings.camera == camera_name) - .count() - ) - - if recordings_count <= 0: - return make_response( - jsonify( - {"success": False, "message": "No recordings found for time range"} - ), - 400, - ) - - exporter = RecordingExporter( - current_app.frigate_config, - camera_name, - secure_filename(name.replace(" ", "_")) if name else None, - int(start_time), - int(end_time), - ( - PlaybackFactorEnum[playback_factor] - if playback_factor in PlaybackFactorEnum.__members__.values() - else PlaybackFactorEnum.realtime - ), - ) - exporter.start() - return make_response( - jsonify( - { - "success": True, - "message": "Starting export of recording.", - } - ), - 200, - ) - - -def export_filename_check_extension(filename: str): - if filename.endswith(".mp4"): - return filename - else: - return filename + ".mp4" - - -def export_filename_is_valid(filename: str): - if re.search(r"[^:_A-Za-z0-9]", filename) or filename.startswith("in_progress."): - return False - else: - return True - - -@MediaBp.route("/export//", methods=["PATCH"]) -def export_rename(file_name_current, file_name_new: str): - safe_file_name_current = secure_filename( - export_filename_check_extension(file_name_current) - ) - file_current = os.path.join(EXPORT_DIR, safe_file_name_current) - - if not os.path.exists(file_current): - return make_response( - jsonify({"success": False, "message": f"{file_name_current} not found."}), - 404, - ) - - if not export_filename_is_valid(file_name_new): - return make_response( - jsonify( - { - "success": False, - "message": f"{file_name_new} contains illegal characters.", - } - ), - 400, - ) - - safe_file_name_new = secure_filename(export_filename_check_extension(file_name_new)) - file_new = os.path.join(EXPORT_DIR, safe_file_name_new) - - if os.path.exists(file_new): - return make_response( - jsonify({"success": False, "message": f"{file_name_new} already exists."}), - 400, - ) - - os.rename(file_current, file_new) - return make_response( - jsonify( - { - "success": True, - "message": "Successfully renamed file.", - } - ), - 200, - ) - - -@MediaBp.route("/export/", methods=["DELETE"]) -def export_delete(file_name: str): - safe_file_name = secure_filename(export_filename_check_extension(file_name)) - file = os.path.join(EXPORT_DIR, safe_file_name) - - if not os.path.exists(file): - return make_response( - jsonify({"success": False, "message": f"{file_name} not found."}), - 404, - ) - - os.unlink(file) - return make_response( - jsonify( - { - "success": True, - "message": "Successfully deleted file.", - } - ), - 200, - ) - - @MediaBp.route("//