From 7abf28bcbce5bb7cefbcec8ab37203299c1da51e Mon Sep 17 00:00:00 2001 From: KastB Date: Wed, 19 Feb 2025 04:51:08 +0100 Subject: [PATCH 01/63] fix syntax error: missing space (#15954) --- .../rootfs/etc/s6-overlay/s6-rc.d/trt-model-prepare/run | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/tensorrt/detector/rootfs/etc/s6-overlay/s6-rc.d/trt-model-prepare/run b/docker/tensorrt/detector/rootfs/etc/s6-overlay/s6-rc.d/trt-model-prepare/run index a88da89d6..419cf4a89 100755 --- a/docker/tensorrt/detector/rootfs/etc/s6-overlay/s6-rc.d/trt-model-prepare/run +++ b/docker/tensorrt/detector/rootfs/etc/s6-overlay/s6-rc.d/trt-model-prepare/run @@ -20,7 +20,7 @@ FIRST_MODEL=true MODEL_DOWNLOAD="" MODEL_CONVERT="" -if [ -z "$YOLO_MODELS"]; then +if [ -z "$YOLO_MODELS" ]; then echo "tensorrt model preparation disabled" exit 0 fi From 2b3ab02ebf1fed7aee7ed36c13def884c497f781 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 18 Feb 2025 21:55:16 -0600 Subject: [PATCH 02/63] object path plotter per camera with time selection dropdown (#16676) --- web/package-lock.json | 169 ++++++++++- web/package.json | 2 +- .../components/overlay/detail/ObjectPath.tsx | 4 +- .../overlay/detail/ObjectPathPlotter.tsx | 281 ++++++++++++++++++ web/src/components/ui/pagination.tsx | 117 ++++++++ web/src/pages/UIPlayground.tsx | 3 + web/src/types/search.ts | 1 + 7 files changed, 570 insertions(+), 7 deletions(-) create mode 100644 web/src/components/overlay/detail/ObjectPathPlotter.tsx create mode 100644 web/src/components/ui/pagination.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 119fc79ea..f2b186312 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,7 +25,7 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slider": "^1.2.1", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toggle": "^1.1.0", @@ -1176,6 +1176,24 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", @@ -1293,6 +1311,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", @@ -1417,6 +1453,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -1685,6 +1739,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", @@ -1737,6 +1809,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", @@ -1840,6 +1930,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-radio-group": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", @@ -2022,6 +2130,24 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", @@ -2094,12 +2220,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -2111,6 +2237,21 @@ } } }, + "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-switch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz", @@ -2303,6 +2444,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index d0bdd01d4..700fd12d7 100644 --- a/web/package.json +++ b/web/package.json @@ -31,7 +31,7 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slider": "^1.2.1", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toggle": "^1.1.0", diff --git a/web/src/components/overlay/detail/ObjectPath.tsx b/web/src/components/overlay/detail/ObjectPath.tsx index d85750ee7..80f454470 100644 --- a/web/src/components/overlay/detail/ObjectPath.tsx +++ b/web/src/components/overlay/detail/ObjectPath.tsx @@ -15,6 +15,7 @@ type ObjectPathProps = { pointRadius?: number; imgRef: React.RefObject; onPointClick?: (index: number) => void; + visible?: boolean; }; const typeColorMap: Partial< @@ -37,6 +38,7 @@ export function ObjectPath({ pointRadius = 4, imgRef, onPointClick, + visible = true, }: ObjectPathProps) { const getAbsolutePositions = useCallback(() => { if (!imgRef.current || !positions) return []; @@ -69,7 +71,7 @@ export function ObjectPath({ return `rgb(${baseColor.map((c) => Math.max(0, c - 10)).join(",")})`; }; - if (!imgRef.current) return null; + if (!imgRef.current || !visible) return null; const absolutePositions = getAbsolutePositions(); const lineColor = `rgb(${color.join(",")})`; diff --git a/web/src/components/overlay/detail/ObjectPathPlotter.tsx b/web/src/components/overlay/detail/ObjectPathPlotter.tsx new file mode 100644 index 000000000..40cf1728e --- /dev/null +++ b/web/src/components/overlay/detail/ObjectPathPlotter.tsx @@ -0,0 +1,281 @@ +import { useState, useEffect, useMemo, useRef } from "react"; +import useSWR from "swr"; +import { useApiHost } from "@/api"; +import type { SearchResult } from "@/types/search"; +import { ObjectPath } from "./ObjectPath"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent } from "@/components/ui/card"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { useTimezone } from "@/hooks/use-date-utils"; +import { Button } from "@/components/ui/button"; +import { LuX } from "react-icons/lu"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +export default function ObjectPathPlotter() { + const apiHost = useApiHost(); + const [timeRange, setTimeRange] = useState("1d"); + const { data: config } = useSWR("config"); + const imgRef = useRef(null); + const timezone = useTimezone(config); + const [selectedCamera, setSelectedCamera] = useState(""); + const [selectedEvent, setSelectedEvent] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const eventsPerPage = 20; + + useEffect(() => { + if (config && !selectedCamera) { + setSelectedCamera(Object.keys(config.cameras)[0]); + } + }, [config, selectedCamera]); + + const searchQuery = useMemo(() => { + if (!selectedCamera) return null; + return [ + "events", + { + cameras: selectedCamera, + after: Math.floor(Date.now() / 1000) - getTimeRangeInSeconds(timeRange), + before: Math.floor(Date.now() / 1000), + has_clip: 1, + include_thumbnails: 0, + limit: 1000, + timezone, + }, + ]; + }, [selectedCamera, timeRange, timezone]); + + const { data: events } = useSWR(searchQuery); + + const aspectRatio = useMemo(() => { + if (!config || !selectedCamera) return 16 / 9; + return ( + config.cameras[selectedCamera].detect.width / + config.cameras[selectedCamera].detect.height + ); + }, [config, selectedCamera]); + + const pathPoints = useMemo(() => { + if (!events) return []; + return events.flatMap( + (event) => + event.data.path_data?.map( + ([coords, timestamp]: [number[], number]) => ({ + x: coords[0], + y: coords[1], + timestamp, + event, + }), + ) || [], + ); + }, [events]); + + const getRandomColor = () => { + return [ + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256), + ]; + }; + + const eventColors = useMemo(() => { + if (!events) return {}; + return events.reduce( + (acc, event) => { + acc[event.id] = getRandomColor(); + return acc; + }, + {} as Record, + ); + }, [events]); + + const [imageLoaded, setImageLoaded] = useState(false); + + useEffect(() => { + if (!selectedCamera) return; + const img = new Image(); + img.src = selectedEvent + ? `${apiHost}api/${selectedCamera}/recordings/${selectedEvent.start_time}/snapshot.jpg` + : `${apiHost}api/${selectedCamera}/latest.jpg?h=500`; + img.onload = () => { + if (imgRef.current) { + imgRef.current.src = img.src; + setImageLoaded(true); + } + }; + }, [apiHost, selectedCamera, selectedEvent]); + + const handleEventClick = (event: SearchResult) => { + setSelectedEvent(event.id === selectedEvent?.id ? null : event); + }; + + const clearSelectedEvent = () => { + setSelectedEvent(null); + }; + + const totalPages = Math.ceil((events?.length || 0) / eventsPerPage); + const paginatedEvents = events?.slice( + (currentPage - 1) * eventsPerPage, + currentPage * eventsPerPage, + ); + + return ( + + +
+

Tracked Object Paths

+
+ + +
+
+
+ {`Latest + {imgRef.current && imageLoaded && ( + + {events?.map((event) => ( + point.event.id === event.id, + )} + color={eventColors[event.id]} + width={2} + imgRef={imgRef} + visible={ + selectedEvent === null || selectedEvent.id === event.id + } + /> + ))} + + )} +
+
+
+

Legend

+ {selectedEvent && ( + + )} +
+
+ {paginatedEvents?.map((event) => ( +
handleEventClick(event)} + > +
+ + {event.label} + {formatUnixTimestampToDateTime(event.start_time, { + timezone: config?.ui.timezone, + })} + +
+ ))} +
+ + + + + setCurrentPage((prev) => Math.max(prev - 1, 1)) + } + /> + + {[...Array(totalPages)].map((_, index) => ( + + setCurrentPage(index + 1)} + isActive={currentPage === index + 1} + > + {index + 1} + + + ))} + + + setCurrentPage((prev) => Math.min(prev + 1, totalPages)) + } + /> + + + +
+ + + ); +} + +function getTimeRangeInSeconds(range: string): number { + switch (range) { + case "1h": + return 60 * 60; + case "6h": + return 6 * 60 * 60; + case "12h": + return 12 * 60 * 60; + case "1d": + return 24 * 60 * 60; + default: + return 24 * 60 * 60; + } +} diff --git a/web/src/components/ui/pagination.tsx b/web/src/components/ui/pagination.tsx new file mode 100644 index 000000000..ea40d196d --- /dev/null +++ b/web/src/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +
diff --git a/web/src/components/overlay/dialog/TextEntryDialog.tsx b/web/src/components/overlay/dialog/TextEntryDialog.tsx index 1b0655078..d7b90aabb 100644 --- a/web/src/components/overlay/dialog/TextEntryDialog.tsx +++ b/web/src/components/overlay/dialog/TextEntryDialog.tsx @@ -10,7 +10,7 @@ import { import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -20,13 +20,18 @@ type TextEntryDialogProps = { description?: string; setOpen: (open: boolean) => void; onSave: (text: string) => void; + defaultValue?: string; + allowEmpty?: boolean; }; + export default function TextEntryDialog({ open, title, description, setOpen, onSave, + defaultValue = "", + allowEmpty = false, }: TextEntryDialogProps) { const formSchema = z.object({ text: z.string(), @@ -34,6 +39,7 @@ export default function TextEntryDialog({ const form = useForm>({ resolver: zodResolver(formSchema), + defaultValues: { text: defaultValue }, }); const fileRef = form.register("text"); @@ -41,15 +47,20 @@ export default function TextEntryDialog({ const onSubmit = useCallback( (data: z.infer) => { - if (!data["text"]) { + if (!allowEmpty && !data["text"]) { return; } - onSave(data["text"]); }, - [onSave], + [onSave, allowEmpty], ); + useEffect(() => { + if (open) { + form.reset({ text: defaultValue }); + } + }, [open, defaultValue, form]); + return ( From 1d8f1bd7ae7b7c379ccf4a2566cc22ad7ba081db Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 24 Feb 2025 08:02:36 -0600 Subject: [PATCH 22/63] Ensure sub label is null when submitting an empty string (#16779) * null sub_label when submitting an empty string * prevent cancel from submitting form * fix test --- frigate/api/event.py | 24 ++++++++++--------- frigate/test/test_http.py | 2 +- .../overlay/dialog/TextEntryDialog.tsx | 4 +++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/frigate/api/event.py b/frigate/api/event.py index 2df32471e..bb1bf7395 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -991,6 +991,10 @@ def set_sub_label( new_sub_label = body.subLabel new_score = body.subLabelScore + if new_sub_label == "": + new_sub_label = None + new_score = None + if tracked_obj: tracked_obj.obj_data["sub_label"] = (new_sub_label, new_score) @@ -1001,21 +1005,19 @@ def set_sub_label( if event: event.sub_label = new_sub_label - - if new_score: - data = event.data + data = event.data + if new_sub_label is None: + data["sub_label_score"] = None + elif new_score is not None: data["sub_label_score"] = new_score - event.data = data - + event.data = data event.save() return JSONResponse( - content=( - { - "success": True, - "message": "Event " + event_id + " sub label set to " + new_sub_label, - } - ), + content={ + "success": True, + "message": f"Event {event_id} sub label set to {new_sub_label if new_sub_label is not None else 'None'}", + }, status_code=200, ) diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 8c89e0433..46de1307f 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -275,7 +275,7 @@ class TestHttp(unittest.TestCase): event = client.get(f"/events/{id}").json() assert event assert event["id"] == id - assert event["sub_label"] == "" + assert event["sub_label"] == None def test_sub_label_list(self): app = create_fastapi_app( diff --git a/web/src/components/overlay/dialog/TextEntryDialog.tsx b/web/src/components/overlay/dialog/TextEntryDialog.tsx index d7b90aabb..c11a84ae7 100644 --- a/web/src/components/overlay/dialog/TextEntryDialog.tsx +++ b/web/src/components/overlay/dialog/TextEntryDialog.tsx @@ -86,7 +86,9 @@ export default function TextEntryDialog({ )} /> - + From 0de928703facc46fd2ed947dbb2be486b41ae4dc Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Mon, 24 Feb 2025 10:56:01 -0500 Subject: [PATCH 23/63] Initial implementation of D-FINE model via ONNX (#16772) * initial implementation of D-FINE model * revert docker-compose * add docs for D-FINE * remove weird auto-format issue --- .devcontainer/devcontainer.json | 47 +++++++++++++--- .../onnxruntime-gpu/devcontainer-feature.json | 22 ++++++++ .../features/onnxruntime-gpu/install.sh | 15 +++++ docs/docs/configuration/object_detectors.md | 55 ++++++++++++++++++- frigate/detectors/detector_config.py | 1 + frigate/detectors/plugins/onnx.py | 17 +++++- frigate/util/model.py | 27 +++++++++ 7 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 .devcontainer/features/onnxruntime-gpu/devcontainer-feature.json create mode 100644 .devcontainer/features/onnxruntime-gpu/install.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 63adae73d..c782fb32f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,9 +8,25 @@ "overrideCommand": false, "remoteUser": "vscode", "features": { - "ghcr.io/devcontainers/features/common-utils:1": {} + "ghcr.io/devcontainers/features/common-utils:2": {} + // Uncomment the following lines to use ONNX Runtime with CUDA support + // "ghcr.io/devcontainers/features/nvidia-cuda:1": { + // "installCudnn": true, + // "installNvtx": true, + // "installToolkit": true, + // "cudaVersion": "12.5", + // "cudnnVersion": "9.4.0.58" + // }, + // "./features/onnxruntime-gpu": {} }, - "forwardPorts": [8971, 5000, 5001, 5173, 8554, 8555], + "forwardPorts": [ + 8971, + 5000, + 5001, + 5173, + 8554, + 8555 + ], "portsAttributes": { "8971": { "label": "External NGINX", @@ -64,10 +80,18 @@ "editor.formatOnType": true, "python.testing.pytestEnabled": false, "python.testing.unittestEnabled": true, - "python.testing.unittestArgs": ["-v", "-s", "./frigate/test"], + "python.testing.unittestArgs": [ + "-v", + "-s", + "./frigate/test" + ], "files.trimTrailingWhitespace": true, - "eslint.workingDirectories": ["./web"], - "isort.args": ["--settings-path=./pyproject.toml"], + "eslint.workingDirectories": [ + "./web" + ], + "isort.args": [ + "--settings-path=./pyproject.toml" + ], "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true, @@ -86,9 +110,16 @@ ], "editor.tabSize": 2 }, - "cSpell.ignoreWords": ["rtmp"], - "cSpell.words": ["preact", "astype", "hwaccel", "mqtt"] + "cSpell.ignoreWords": [ + "rtmp" + ], + "cSpell.words": [ + "preact", + "astype", + "hwaccel", + "mqtt" + ] } } } -} +} \ No newline at end of file diff --git a/.devcontainer/features/onnxruntime-gpu/devcontainer-feature.json b/.devcontainer/features/onnxruntime-gpu/devcontainer-feature.json new file mode 100644 index 000000000..30514442b --- /dev/null +++ b/.devcontainer/features/onnxruntime-gpu/devcontainer-feature.json @@ -0,0 +1,22 @@ +{ + "id": "onnxruntime-gpu", + "version": "0.0.1", + "name": "ONNX Runtime GPU (Nvidia)", + "description": "Installs ONNX Runtime for Nvidia GPUs.", + "documentationURL": "", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "1.20.1", + "1.20.0" + ], + "default": "latest", + "description": "Version of ONNX Runtime to install" + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/nvidia-cuda" + ] +} \ No newline at end of file diff --git a/.devcontainer/features/onnxruntime-gpu/install.sh b/.devcontainer/features/onnxruntime-gpu/install.sh new file mode 100644 index 000000000..0c090beec --- /dev/null +++ b/.devcontainer/features/onnxruntime-gpu/install.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e + +VERSION=${VERSION} + +python3 -m pip config set global.break-system-packages true +# if VERSION == "latest" or VERSION is empty, install the latest version +if [ "$VERSION" == "latest" ] || [ -z "$VERSION" ]; then + python3 -m pip install onnxruntime-gpu +else + python3 -m pip install onnxruntime-gpu==$VERSION +fi + +echo "Done!" \ No newline at end of file diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 21ba46c2d..bc76779cb 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -10,25 +10,31 @@ title: Object Detectors Frigate supports multiple different detectors that work on different types of hardware: **Most Hardware** + - [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices. - [Hailo](#hailo-8l): The Hailo8 AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices. **AMD** + - [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection. - [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured. **Intel** + - [OpenVino](#openvino-detector): OpenVino can run on Intel Arc GPUs, Intel integrated GPUs, and Intel CPUs to provide efficient object detection. - [ONNX](#onnx): OpenVINO will automatically be detected and used as a detector in the default Frigate image when a supported ONNX model is configured. **Nvidia** + - [TensortRT](#nvidia-tensorrt-detector): TensorRT can run on Nvidia GPUs and Jetson devices, using one of many default models. - [ONNX](#onnx): TensorRT will automatically be detected and used as a detector in the `-tensorrt` or `-tensorrt-jp(4/5)` Frigate images when a supported ONNX model is configured. **Rockchip** + - [RKNN](#rockchip-platform): RKNN models can run on Rockchip devices with included NPUs. **For Testing** + - [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results. ::: @@ -147,7 +153,6 @@ model: path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef ``` - ## OpenVINO Detector The OpenVINO detector type runs an OpenVINO IR model on AMD and Intel CPUs, Intel GPUs and Intel VPU hardware. To configure an OpenVINO detector, set the `"type"` attribute to `"openvino"`. @@ -412,7 +417,7 @@ When using docker compose: ```yaml services: frigate: -... + environment: HSA_OVERRIDE_GFX_VERSION: "9.0.0" ``` @@ -555,6 +560,50 @@ model: Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. +#### D-FINE + +[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default. + +To export as ONNX: + +1. Clone: https://github.com/Peterande/D-FINE and install all dependencies. +2. Select and download a checkpoint from the [readme](https://github.com/Peterande/D-FINE). +3. Modify line 58 of `tools/deployment/export_onnx.py` and change batch size to 1: `data = torch.rand(1, 3, 640, 640)` +4. Run the export, making sure you select the right config, for your checkpoint. + +Example: + +``` +python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_m_obj2coco.yml -r output/dfine_m_obj2coco.pth +``` + +:::tip + +Model export has only been tested on Linux (or WSL2). Not all dependencies are in `requirements.txt`. Some live in the deployment folder, and some are still missing entirely and must be installed manually. + +Make sure you change the batch size to 1 before exporting. + +::: + +After placing the downloaded onnx model in your config folder, you can use the following configuration: + +```yaml +detectors: + onnx: + type: onnx + +model: + model_type: dfine + width: 640 + height: 640 + input_tensor: nchw + input_dtype: float + path: /config/model_cache/dfine_m_obj2coco.onnx + labelmap_path: /labelmap/coco-80.txt +``` + +Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. + ## CPU Detector (not recommended) The CPU detector type runs a TensorFlow Lite model utilizing the CPU without hardware acceleration. It is recommended to use a hardware accelerated detector type instead for better performance. To configure a CPU based detector, set the `"type"` attribute to `"cpu"`. @@ -704,7 +753,7 @@ To convert a onnx model to the rknn format using the [rknn-toolkit2](https://git This is an example configuration file that you need to adjust to your specific onnx model: ```yaml -soc: ["rk3562","rk3566", "rk3568", "rk3576", "rk3588"] +soc: ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"] quantization: false output_name: "{input_basename}" diff --git a/frigate/detectors/detector_config.py b/frigate/detectors/detector_config.py index c8aea0a1d..16599b141 100644 --- a/frigate/detectors/detector_config.py +++ b/frigate/detectors/detector_config.py @@ -37,6 +37,7 @@ class ModelTypeEnum(str, Enum): yolox = "yolox" yolov9 = "yolov9" yolonas = "yolonas" + dfine = "dfine" class ModelConfig(BaseModel): diff --git a/frigate/detectors/plugins/onnx.py b/frigate/detectors/plugins/onnx.py index c8589145a..13a948de9 100644 --- a/frigate/detectors/plugins/onnx.py +++ b/frigate/detectors/plugins/onnx.py @@ -9,7 +9,11 @@ from frigate.detectors.detector_config import ( BaseDetectorConfig, ModelTypeEnum, ) -from frigate.util.model import get_ort_providers, post_process_yolov9 +from frigate.util.model import ( + get_ort_providers, + post_process_dfine, + post_process_yolov9, +) logger = logging.getLogger(__name__) @@ -41,6 +45,7 @@ class ONNXDetector(DetectionApi): providers, options = get_ort_providers( detector_config.device == "CPU", detector_config.device ) + self.model = ort.InferenceSession( path, providers=providers, provider_options=options ) @@ -55,6 +60,16 @@ class ONNXDetector(DetectionApi): logger.info(f"ONNX: {path} loaded") def detect_raw(self, tensor_input: np.ndarray): + if self.onnx_model_type == ModelTypeEnum.dfine: + tensor_output = self.model.run( + None, + { + "images": tensor_input, + "orig_target_sizes": np.array([[self.h, self.w]], dtype=np.int64), + }, + ) + return post_process_dfine(tensor_output, self.w, self.h) + model_input_name = self.model.get_inputs()[0].name tensor_output = self.model.run(None, {model_input_name: tensor_input}) diff --git a/frigate/util/model.py b/frigate/util/model.py index da7b1a50a..0428a42ff 100644 --- a/frigate/util/model.py +++ b/frigate/util/model.py @@ -9,7 +9,34 @@ import onnxruntime as ort logger = logging.getLogger(__name__) + ### Post Processing +def post_process_dfine(tensor_output: np.ndarray, width, height) -> np.ndarray: + class_ids = tensor_output[0][tensor_output[2] > 0.4] + boxes = tensor_output[1][tensor_output[2] > 0.4] + scores = tensor_output[2][tensor_output[2] > 0.4] + + input_shape = np.array([height, width, height, width]) + boxes = np.divide(boxes, input_shape, dtype=np.float32) + indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4) + detections = np.zeros((20, 6), np.float32) + + for i, (bbox, confidence, class_id) in enumerate( + zip(boxes[indices], scores[indices], class_ids[indices]) + ): + if i == 20: + break + + detections[i] = [ + class_id, + confidence, + bbox[1], + bbox[0], + bbox[3], + bbox[2], + ] + + return detections def post_process_yolov9(predictions: np.ndarray, width, height) -> np.ndarray: From 7ce1b354cc8fc0811d8ca21d400af16fdd9e916f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 25 Feb 2025 10:02:56 -0700 Subject: [PATCH 24/63] Use native arm runner for arm docker builds (#16804) * Try building jetpack on latest ubuntu version * Update ci.yml * run natively on arm * Run all arm builds using arm runner * Update ci.yml --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398d7fc8c..9a666b897 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: tags: ${{ steps.setup.outputs.image-name }}-amd64 cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64 arm64_build: - runs-on: ubuntu-22.04 + runs-on: ubuntu-22.04-arm name: ARM Build steps: - name: Check out code @@ -107,7 +107,7 @@ jobs: *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5 *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5,mode=max jetson_jp6_build: - runs-on: ubuntu-22.04 + runs-on: ubuntu-22.04-arm name: Jetson Jetpack 6 steps: - name: Check out code @@ -177,7 +177,7 @@ jobs: rocm.tags=${{ steps.setup.outputs.image-name }}-rocm *.cache-from=type=gha arm64_extra_builds: - runs-on: ubuntu-22.04 + runs-on: ubuntu-22.04-arm name: ARM Extra Build needs: - arm64_build From 7eb3c87fa0d8a5737d866867bdbaea7b690b4642 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 25 Feb 2025 18:17:39 -0700 Subject: [PATCH 25/63] UI tweaks (#16813) * Add escape to close review details * Refresh review page automatically if there are currently no items to review --- .../components/overlay/detail/ReviewDetailDialog.tsx | 9 +++++++++ web/src/views/events/EventView.tsx | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 76234193c..2570fd033 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -41,6 +41,7 @@ import { useOverlayState } from "@/hooks/use-overlay-state"; import { DownloadVideoButton } from "@/components/button/DownloadVideoButton"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { LuSearch } from "react-icons/lu"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; type ReviewDetailDialogProps = { review?: ReviewSegment; @@ -133,6 +134,14 @@ export default function ReviewDetailDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [review]); + // keyboard listener + + useKeyboardListener(["Esc"], (key, modifiers) => { + if (key == "Esc" && modifiers.down && !modifiers.repeat) { + setIsOpen(false); + } + }); + const Overlay = isDesktop ? Sheet : MobilePage; const Content = isDesktop ? SheetContent : MobilePageContent; const Header = isDesktop ? SheetHeader : MobilePageHeader; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index e8e864e32..583b47fe9 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -621,6 +621,16 @@ function DetectionReview({ // existing review item + useEffect(() => { + if (loading || currentItems == null || itemsToReview == undefined) { + return; + } + + if (currentItems.length == 0 && itemsToReview > 0) { + pullLatestData(); + } + }, [loading, currentItems, itemsToReview, pullLatestData]); + useEffect(() => { if (!startTime || !currentItems || currentItems.length == 0) { return; From 447f26e1b97db60dec0a225744428dbe77a1a658 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:29:34 -0600 Subject: [PATCH 26/63] Fix lpr metrics and add yolov9 plate detection metric (#16827) --- .../common/license_plate/mixin.py | 22 +++++++++++++++++++ frigate/data_processing/post/license_plate.py | 15 ++++--------- .../real_time/license_plate.py | 9 -------- frigate/data_processing/types.py | 2 ++ frigate/stats/util.py | 4 ++++ 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index 1723d213e..aa03bc985 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -816,6 +816,20 @@ class LicensePlateProcessingMixin: # 5. Return True if we should keep the previous plate (i.e., if it scores higher) return prev_score > curr_score + def __update_yolov9_metrics(self, duration: float) -> None: + """ + Update inference metrics. + """ + self.metrics.yolov9_lpr_fps.value = ( + self.metrics.yolov9_lpr_fps.value * 9 + duration + ) / 10 + + def __update_lpr_metrics(self, duration: float) -> None: + """ + Update inference metrics. + """ + self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10 + def lpr_process(self, obj_data: dict[str, any], frame: np.ndarray): """Look for license plates in image.""" @@ -843,6 +857,7 @@ class LicensePlateProcessingMixin: if self.requires_license_plate_detection: logger.debug("Running manual license_plate detection.") + car_box = obj_data.get("box") if not car_box: @@ -867,6 +882,9 @@ class LicensePlateProcessingMixin: logger.debug( f"YOLOv9 LPD inference time: {(datetime.datetime.now().timestamp() - yolov9_start) * 1000:.2f} ms" ) + self.__update_yolov9_metrics( + datetime.datetime.now().timestamp() - yolov9_start + ) if not license_plate: logger.debug("Detected no license plates for car object.") @@ -945,11 +963,15 @@ class LicensePlateProcessingMixin: license_plate_frame, ) + start = datetime.datetime.now().timestamp() + # run detection, returns results sorted by confidence, best first license_plates, confidences, areas = self._process_license_plate( license_plate_frame ) + self.__update_lpr_metrics(datetime.datetime.now().timestamp() - start) + logger.debug(f"Text boxes: {license_plates}") logger.debug(f"Confidences: {confidences}") logger.debug(f"Areas: {areas}") diff --git a/frigate/data_processing/post/license_plate.py b/frigate/data_processing/post/license_plate.py index 9a9974bc7..2c80418c7 100644 --- a/frigate/data_processing/post/license_plate.py +++ b/frigate/data_processing/post/license_plate.py @@ -40,12 +40,6 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): self.config = config super().__init__(config, metrics, model_runner) - def __update_metrics(self, duration: float) -> None: - """ - Update inference metrics. - """ - self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10 - def process_data( self, data: dict[str, any], data_type: PostProcessDataEnum ) -> None: @@ -57,8 +51,6 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): Returns: None. """ - start = datetime.datetime.now().timestamp() - event_id = data["event_id"] camera_name = data["camera"] @@ -128,7 +120,10 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): return if WRITE_DEBUG_IMAGES: - cv2.imwrite(f"debug/frames/lpr_post_{start}.jpg", image) + cv2.imwrite( + f"debug/frames/lpr_post_{datetime.datetime.now().timestamp()}.jpg", + image, + ) # convert to yuv for processing frame = cv2.cvtColor(image, cv2.COLOR_BGR2YUV_I420) @@ -210,8 +205,6 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): logger.debug(f"Post processing plate: {event_id}, {frame_time}") self.lpr_process(keyframe_obj_data, frame) - self.__update_metrics(datetime.datetime.now().timestamp() - start) - def handle_request(self, topic, request_data) -> dict[str, any] | None: if topic == EmbeddingsRequestEnum.reprocess_plate.value: event = request_data["event"] diff --git a/frigate/data_processing/real_time/license_plate.py b/frigate/data_processing/real_time/license_plate.py index 2809e861f..c8f0efa11 100644 --- a/frigate/data_processing/real_time/license_plate.py +++ b/frigate/data_processing/real_time/license_plate.py @@ -1,6 +1,5 @@ """Handle processing images for face detection and recognition.""" -import datetime import logging import numpy as np @@ -33,17 +32,9 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess self.config = config super().__init__(config, metrics) - def __update_metrics(self, duration: float) -> None: - """ - Update inference metrics. - """ - self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10 - def process_frame(self, obj_data: dict[str, any], frame: np.ndarray): """Look for license plates in image.""" - start = datetime.datetime.now().timestamp() self.lpr_process(obj_data, frame) - self.__update_metrics(datetime.datetime.now().timestamp() - start) def handle_request(self, topic, request_data) -> dict[str, any] | None: return diff --git a/frigate/data_processing/types.py b/frigate/data_processing/types.py index 6f87f77f9..29abb22d1 100644 --- a/frigate/data_processing/types.py +++ b/frigate/data_processing/types.py @@ -10,12 +10,14 @@ class DataProcessorMetrics: text_embeddings_sps: Synchronized face_rec_fps: Synchronized alpr_pps: Synchronized + yolov9_lpr_fps: Synchronized def __init__(self): self.image_embeddings_fps = mp.Value("d", 0.01) self.text_embeddings_sps = mp.Value("d", 0.01) self.face_rec_fps = mp.Value("d", 0.01) self.alpr_pps = mp.Value("d", 0.01) + self.yolov9_lpr_fps = mp.Value("d", 0.01) class DataProcessorModelRunner: diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 262cec3d2..3d836868e 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -302,6 +302,10 @@ def stats_snapshot( stats["embeddings"]["plate_recognition_speed"] = round( embeddings_metrics.alpr_pps.value * 1000, 2 ) + if "license_plate" not in config.objects.all_objects: + stats["embeddings"]["yolov9_plate_detection_speed"] = round( + embeddings_metrics.yolov9_lpr_fps.value * 1000, 2 + ) get_processing_stats(config, stats, hwaccel_errors) From d0e9bcbfdcffdcbe0c7eb45971bd16ef41ddce8b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:58:25 -0600 Subject: [PATCH 27/63] Add ability to use Jina CLIP V2 for semantic search (#16826) * add wheels * move extra index url to bottom * config model option * add postprocess * fix config * jina v2 embedding class * use jina v2 in embeddings * fix ov inference * frontend * update reference config * revert device * fix truncation * return np tensors * use correct embeddings from inference * manual preprocess * clean up * docs * lower batch size for v2 only * docs clarity * wording --- docker/main/requirements-wheels.txt | 1 - docs/docs/configuration/reference.md | 2 + docs/docs/configuration/semantic_search.md | 34 ++- frigate/config/classification.py | 12 +- frigate/embeddings/embeddings.py | 86 +++++-- frigate/embeddings/onnx/base_embedding.py | 7 +- frigate/embeddings/onnx/jina_v2_embedding.py | 231 +++++++++++++++++++ frigate/embeddings/onnx/runner.py | 9 +- web/src/pages/Explore.tsx | 45 +++- web/src/types/frigateConfig.ts | 2 + 10 files changed, 380 insertions(+), 49 deletions(-) create mode 100644 frigate/embeddings/onnx/jina_v2_embedding.py diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 320ce3334..25286617e 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -54,7 +54,6 @@ pywebpush == 2.0.* pyclipper == 1.3.* shapely == 2.0.* Levenshtein==0.26.* -prometheus-client == 0.21.* # HailoRT Wheels appdirs==1.4.* argcomplete==2.0.* diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index b791e708a..c64272214 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -536,6 +536,8 @@ semantic_search: enabled: False # Optional: Re-index embeddings database from historical tracked objects (default: shown below) reindex: False + # Optional: Set the model used for embeddings. (default: shown below) + model: "jinav1" # Optional: Set the model size used for embeddings. (default: shown below) # NOTE: small model runs on CPU and large model runs on GPU model_size: "small" diff --git a/docs/docs/configuration/semantic_search.md b/docs/docs/configuration/semantic_search.md index bd3d79cae..07e2cbfb2 100644 --- a/docs/docs/configuration/semantic_search.md +++ b/docs/docs/configuration/semantic_search.md @@ -5,7 +5,7 @@ title: Semantic Search Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one. This feature works by creating _embeddings_ — numerical vector representations — for both the images and text descriptions of your tracked objects. By comparing these embeddings, Frigate assesses their similarities to deliver relevant search results. -Frigate uses [Jina AI's CLIP model](https://huggingface.co/jinaai/jina-clip-v1) to create and save embeddings to Frigate's database. All of this runs locally. +Frigate uses models from [Jina AI](https://huggingface.co/jinaai) to create and save embeddings to Frigate's database. All of this runs locally. Semantic Search is accessed via the _Explore_ view in the Frigate UI. @@ -35,23 +35,47 @@ If you are enabling Semantic Search for the first time, be advised that Frigate ::: -### Jina AI CLIP +### Jina AI CLIP (version 1) -The vision model is able to embed both images and text into the same vector space, which allows `image -> image` and `text -> image` similarity searches. Frigate uses this model on tracked objects to encode the thumbnail image and store it in the database. When searching for tracked objects via text in the search box, Frigate will perform a `text -> image` similarity search against this embedding. When clicking "Find Similar" in the tracked object detail pane, Frigate will perform an `image -> image` similarity search to retrieve the closest matching thumbnails. +The [V1 model from Jina](https://huggingface.co/jinaai/jina-clip-v1) has a vision model which is able to embed both images and text into the same vector space, which allows `image -> image` and `text -> image` similarity searches. Frigate uses this model on tracked objects to encode the thumbnail image and store it in the database. When searching for tracked objects via text in the search box, Frigate will perform a `text -> image` similarity search against this embedding. When clicking "Find Similar" in the tracked object detail pane, Frigate will perform an `image -> image` similarity search to retrieve the closest matching thumbnails. -The text model is used to embed tracked object descriptions and perform searches against them. Descriptions can be created, viewed, and modified on the Explore page when clicking on thumbnail of a tracked object. See [the Generative AI docs](/configuration/genai.md) for more information on how to automatically generate tracked object descriptions. +The V1 text model is used to embed tracked object descriptions and perform searches against them. Descriptions can be created, viewed, and modified on the Explore page when clicking on thumbnail of a tracked object. See [the Generative AI docs](/configuration/genai.md) for more information on how to automatically generate tracked object descriptions. -Differently weighted versions of the Jina model are available and can be selected by setting the `model_size` config option as `small` or `large`: +Differently weighted versions of the Jina models are available and can be selected by setting the `model_size` config option as `small` or `large`: ```yaml semantic_search: enabled: True + model: "jinav1" model_size: small ``` - Configuring the `large` model employs the full Jina model and will automatically run on the GPU if applicable. - Configuring the `small` model employs a quantized version of the Jina model that uses less RAM and runs on CPU with a very negligible difference in embedding quality. +### Jina AI CLIP (version 2) + +Frigate also supports the [V2 model from Jina](https://huggingface.co/jinaai/jina-clip-v2), which introduces multilingual support (89 languages). In contrast, the V1 model only supports English. + +V2 offers only a 3% performance improvement over V1 in both text-image and text-text retrieval tasks, an upgrade that is unlikely to yield noticeable real-world benefits. Additionally, V2 has _significantly_ higher RAM and GPU requirements, leading to increased inference time and memory usage. If you plan to use V2, ensure your system has ample RAM and a discrete GPU. CPU inference (with the `small` model) using V2 is not recommended. + +To use the V2 model, update the `model` parameter in your config: + +```yaml +semantic_search: + enabled: True + model: "jinav2" + model_size: large +``` + +For most users, especially native English speakers, the V1 model remains the recommended choice. + +:::note + +Switching between V1 and V2 requires reindexing your embeddings. To do this, set `reindex: True` in your Semantic Search configuration and restart Frigate. The embeddings from V1 and V2 are incompatible, and failing to reindex will result in incorrect search results. + +::: + ### GPU Acceleration The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. diff --git a/frigate/config/classification.py b/frigate/config/classification.py index 8a8e95861..f3416b009 100644 --- a/frigate/config/classification.py +++ b/frigate/config/classification.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Dict, List, Optional from pydantic import Field @@ -11,6 +12,11 @@ __all__ = [ ] +class SemanticSearchModelEnum(str, Enum): + jinav1 = "jinav1" + jinav2 = "jinav2" + + class BirdClassificationConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable bird classification.") threshold: float = Field( @@ -30,7 +36,11 @@ class ClassificationConfig(FrigateBaseModel): class SemanticSearchConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable semantic search.") reindex: Optional[bool] = Field( - default=False, title="Reindex all detections on startup." + default=False, title="Reindex all tracked objects on startup." + ) + model: Optional[SemanticSearchModelEnum] = Field( + default=SemanticSearchModelEnum.jinav1, + title="The CLIP model to use for semantic search.", ) model_size: str = Field( default="small", title="The size of the embeddings model used." diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index c06f46ba4..7e866d1fe 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -10,6 +10,7 @@ from playhouse.shortcuts import model_to_dict from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig +from frigate.config.classification import SemanticSearchModelEnum from frigate.const import ( CONFIG_DIR, UPDATE_EMBEDDINGS_REINDEX_PROGRESS, @@ -23,6 +24,7 @@ from frigate.util.builtin import serialize from frigate.util.path import get_event_thumbnail_bytes from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding +from .onnx.jina_v2_embedding import JinaV2Embedding logger = logging.getLogger(__name__) @@ -75,18 +77,7 @@ class Embeddings: # Create tables if they don't exist self.db.create_embeddings_tables() - models = [ - "jinaai/jina-clip-v1-text_model_fp16.onnx", - "jinaai/jina-clip-v1-tokenizer", - "jinaai/jina-clip-v1-vision_model_fp16.onnx" - if config.semantic_search.model_size == "large" - else "jinaai/jina-clip-v1-vision_model_quantized.onnx", - "jinaai/jina-clip-v1-preprocessor_config.json", - "facenet-facenet.onnx", - "paddleocr-onnx-detection.onnx", - "paddleocr-onnx-classification.onnx", - "paddleocr-onnx-recognition.onnx", - ] + models = self.get_model_definitions() for model in models: self.requestor.send_data( @@ -97,17 +88,64 @@ class Embeddings: }, ) - self.text_embedding = JinaV1TextEmbedding( - model_size=config.semantic_search.model_size, - requestor=self.requestor, - device="CPU", + if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2: + # Single JinaV2Embedding instance for both text and vision + self.embedding = JinaV2Embedding( + model_size=self.config.semantic_search.model_size, + requestor=self.requestor, + device="GPU" + if self.config.semantic_search.model_size == "large" + else "CPU", + ) + self.text_embedding = lambda input_data: self.embedding( + input_data, embedding_type="text" + ) + self.vision_embedding = lambda input_data: self.embedding( + input_data, embedding_type="vision" + ) + else: # Default to jinav1 + self.text_embedding = JinaV1TextEmbedding( + model_size=config.semantic_search.model_size, + requestor=self.requestor, + device="CPU", + ) + self.vision_embedding = JinaV1ImageEmbedding( + model_size=config.semantic_search.model_size, + requestor=self.requestor, + device="GPU" if config.semantic_search.model_size == "large" else "CPU", + ) + + def get_model_definitions(self): + # Version-specific models + if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2: + models = [ + "jinaai/jina-clip-v2-tokenizer", + "jinaai/jina-clip-v2-model_fp16.onnx" + if self.config.semantic_search.model_size == "large" + else "jinaai/jina-clip-v2-model_quantized.onnx", + "jinaai/jina-clip-v2-preprocessor_config.json", + ] + else: # Default to jinav1 + models = [ + "jinaai/jina-clip-v1-text_model_fp16.onnx", + "jinaai/jina-clip-v1-tokenizer", + "jinaai/jina-clip-v1-vision_model_fp16.onnx" + if self.config.semantic_search.model_size == "large" + else "jinaai/jina-clip-v1-vision_model_quantized.onnx", + "jinaai/jina-clip-v1-preprocessor_config.json", + ] + + # Add common models + models.extend( + [ + "facenet-facenet.onnx", + "paddleocr-onnx-detection.onnx", + "paddleocr-onnx-classification.onnx", + "paddleocr-onnx-recognition.onnx", + ] ) - self.vision_embedding = JinaV1ImageEmbedding( - model_size=config.semantic_search.model_size, - requestor=self.requestor, - device="GPU" if config.semantic_search.model_size == "large" else "CPU", - ) + return models def embed_thumbnail( self, event_id: str, thumbnail: bytes, upsert: bool = True @@ -244,7 +282,11 @@ class Embeddings: # Get total count of events to process total_events = Event.select().count() - batch_size = 32 + batch_size = ( + 4 + if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2 + else 32 + ) current_page = 1 totals = { diff --git a/frigate/embeddings/onnx/base_embedding.py b/frigate/embeddings/onnx/base_embedding.py index 6f74afa2a..a2ea92674 100644 --- a/frigate/embeddings/onnx/base_embedding.py +++ b/frigate/embeddings/onnx/base_embedding.py @@ -72,6 +72,9 @@ class BaseEmbedding(ABC): return image + def _postprocess_outputs(self, outputs: any) -> any: + return outputs + def __call__( self, inputs: list[str] | list[Image.Image] | list[str] ) -> list[np.ndarray]: @@ -91,5 +94,7 @@ class BaseEmbedding(ABC): else: logger.warning(f"Expected input '{key}' not found in onnx_inputs") - embeddings = self.runner.run(onnx_inputs)[0] + outputs = self.runner.run(onnx_inputs)[0] + embeddings = self._postprocess_outputs(outputs) + return [embedding for embedding in embeddings] diff --git a/frigate/embeddings/onnx/jina_v2_embedding.py b/frigate/embeddings/onnx/jina_v2_embedding.py new file mode 100644 index 000000000..be6573e50 --- /dev/null +++ b/frigate/embeddings/onnx/jina_v2_embedding.py @@ -0,0 +1,231 @@ +"""JinaV2 Embeddings.""" + +import io +import logging +import os + +import numpy as np +from PIL import Image +from transformers import AutoTokenizer +from transformers.utils.logging import disable_progress_bar, set_verbosity_error + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.const import MODEL_CACHE_DIR, UPDATE_MODEL_STATE +from frigate.types import ModelStatusTypesEnum +from frigate.util.downloader import ModelDownloader + +from .base_embedding import BaseEmbedding +from .runner import ONNXModelRunner + +# disables the progress bar and download logging for downloading tokenizers and image processors +disable_progress_bar() +set_verbosity_error() +logger = logging.getLogger(__name__) + + +class JinaV2Embedding(BaseEmbedding): + def __init__( + self, + model_size: str, + requestor: InterProcessRequestor, + device: str = "AUTO", + embedding_type: str = None, + ): + model_file = ( + "model_fp16.onnx" if model_size == "large" else "model_quantized.onnx" + ) + super().__init__( + model_name="jinaai/jina-clip-v2", + model_file=model_file, + download_urls={ + model_file: f"https://huggingface.co/jinaai/jina-clip-v2/resolve/main/onnx/{model_file}", + "preprocessor_config.json": "https://huggingface.co/jinaai/jina-clip-v2/resolve/main/preprocessor_config.json", + }, + ) + self.tokenizer_file = "tokenizer" + self.embedding_type = embedding_type + self.requestor = requestor + self.model_size = model_size + self.device = device + self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) + self.tokenizer = None + self.image_processor = None + self.runner = None + files_names = list(self.download_urls.keys()) + [self.tokenizer_file] + if not all( + os.path.exists(os.path.join(self.download_path, n)) for n in files_names + ): + logger.debug(f"starting model download for {self.model_name}") + self.downloader = ModelDownloader( + model_name=self.model_name, + download_path=self.download_path, + file_names=files_names, + download_func=self._download_model, + ) + self.downloader.ensure_model_files() + else: + self.downloader = None + ModelDownloader.mark_files_state( + self.requestor, + self.model_name, + files_names, + ModelStatusTypesEnum.downloaded, + ) + self._load_model_and_utils() + logger.debug(f"models are already downloaded for {self.model_name}") + + def _download_model(self, path: str): + try: + file_name = os.path.basename(path) + + if file_name in self.download_urls: + ModelDownloader.download_from_url(self.download_urls[file_name], path) + elif file_name == self.tokenizer_file: + if not os.path.exists(os.path.join(path, self.model_name)): + logger.info(f"Downloading {self.model_name} tokenizer") + + tokenizer = AutoTokenizer.from_pretrained( + self.model_name, + trust_remote_code=True, + cache_dir=os.path.join( + MODEL_CACHE_DIR, self.model_name, "tokenizer" + ), + clean_up_tokenization_spaces=True, + ) + tokenizer.save_pretrained(path) + self.requestor.send_data( + UPDATE_MODEL_STATE, + { + "model": f"{self.model_name}-{file_name}", + "state": ModelStatusTypesEnum.downloaded, + }, + ) + except Exception: + self.requestor.send_data( + UPDATE_MODEL_STATE, + { + "model": f"{self.model_name}-{file_name}", + "state": ModelStatusTypesEnum.error, + }, + ) + + def _load_model_and_utils(self): + if self.runner is None: + if self.downloader: + self.downloader.wait_for_download() + + tokenizer_path = os.path.join( + f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer" + ) + self.tokenizer = AutoTokenizer.from_pretrained( + self.model_name, + cache_dir=tokenizer_path, + trust_remote_code=True, + clean_up_tokenization_spaces=True, + ) + + self.runner = ONNXModelRunner( + os.path.join(self.download_path, self.model_file), + self.device, + self.model_size, + ) + + def _preprocess_image(self, image_data: bytes | Image.Image) -> np.ndarray: + """ + Manually preprocess a single image from bytes or PIL.Image to (3, 512, 512). + """ + if isinstance(image_data, bytes): + image = Image.open(io.BytesIO(image_data)) + else: + image = image_data + + if image.mode != "RGB": + image = image.convert("RGB") + + image = image.resize((512, 512), Image.Resampling.LANCZOS) + + # Convert to numpy array, normalize to [0, 1], and transpose to (channels, height, width) + image_array = np.array(image, dtype=np.float32) / 255.0 + image_array = np.transpose(image_array, (2, 0, 1)) # (H, W, C) -> (C, H, W) + + return image_array + + def _preprocess_inputs(self, raw_inputs): + """ + Preprocess inputs into a list of real input tensors (no dummies). + - For text: Returns list of input_ids. + - For vision: Returns list of pixel_values. + """ + if not isinstance(raw_inputs, list): + raw_inputs = [raw_inputs] + + processed = [] + if self.embedding_type == "text": + for text in raw_inputs: + input_ids = self.tokenizer([text], return_tensors="np")["input_ids"] + processed.append(input_ids) + elif self.embedding_type == "vision": + for img in raw_inputs: + pixel_values = self._preprocess_image(img) + processed.append( + pixel_values[np.newaxis, ...] + ) # Add batch dim: (1, 3, 512, 512) + else: + raise ValueError( + f"Invalid embedding_type: {self.embedding_type}. Must be 'text' or 'vision'." + ) + return processed + + def _postprocess_outputs(self, outputs): + """ + Process ONNX model outputs, truncating each embedding in the array to truncate_dim. + - outputs: NumPy array of embeddings. + - Returns: List of truncated embeddings. + """ + # size of vector in database + truncate_dim = 768 + + # jina v2 defaults to 1024 and uses Matryoshka representation, so + # truncating only causes an extremely minor decrease in retrieval accuracy + if outputs.shape[-1] > truncate_dim: + outputs = outputs[..., :truncate_dim] + + return outputs + + def __call__( + self, inputs: list[str] | list[Image.Image] | list[str], embedding_type=None + ) -> list[np.ndarray]: + self.embedding_type = embedding_type + if not self.embedding_type: + raise ValueError( + "embedding_type must be specified either in __init__ or __call__" + ) + + self._load_model_and_utils() + processed = self._preprocess_inputs(inputs) + batch_size = len(processed) + + # Prepare ONNX inputs with matching batch sizes + onnx_inputs = {} + if self.embedding_type == "text": + onnx_inputs["input_ids"] = np.stack([x[0] for x in processed]) + onnx_inputs["pixel_values"] = np.zeros( + (batch_size, 3, 512, 512), dtype=np.float32 + ) + elif self.embedding_type == "vision": + onnx_inputs["input_ids"] = np.zeros((batch_size, 16), dtype=np.int64) + onnx_inputs["pixel_values"] = np.stack([x[0] for x in processed]) + else: + raise ValueError("Invalid embedding type") + + # Run inference + outputs = self.runner.run(onnx_inputs) + if self.embedding_type == "text": + embeddings = outputs[2] # text embeddings + elif self.embedding_type == "vision": + embeddings = outputs[3] # image embeddings + else: + raise ValueError("Invalid embedding type") + + embeddings = self._postprocess_outputs(embeddings) + return [embedding for embedding in embeddings] diff --git a/frigate/embeddings/onnx/runner.py b/frigate/embeddings/onnx/runner.py index d380f45c1..c785c28f1 100644 --- a/frigate/embeddings/onnx/runner.py +++ b/frigate/embeddings/onnx/runner.py @@ -66,14 +66,9 @@ class ONNXModelRunner: def run(self, input: dict[str, Any]) -> Any: if self.type == "ov": infer_request = self.interpreter.create_infer_request() - input_tensor = list(input.values()) - if len(input_tensor) == 1: - input_tensor = ov.Tensor(array=input_tensor[0]) - else: - input_tensor = ov.Tensor(array=input_tensor) + outputs = infer_request.infer(input) - infer_request.infer(input_tensor) - return [infer_request.get_output_tensor().data] + return outputs elif self.type == "ort": return self.ort.run(None, input) diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index c005c43c2..af23c18f4 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -267,20 +267,41 @@ export default function Explore() { // model states - const { payload: textModelState } = useModelState( - "jinaai/jina-clip-v1-text_model_fp16.onnx", - ); - const { payload: textTokenizerState } = useModelState( - "jinaai/jina-clip-v1-tokenizer", - ); - const modelFile = - config?.semantic_search.model_size === "large" - ? "jinaai/jina-clip-v1-vision_model_fp16.onnx" - : "jinaai/jina-clip-v1-vision_model_quantized.onnx"; + const modelVersion = config?.semantic_search.model || "jinav1"; + const modelSize = config?.semantic_search.model_size || "small"; - const { payload: visionModelState } = useModelState(modelFile); + // Text model state + const { payload: textModelState } = useModelState( + modelVersion === "jinav1" + ? "jinaai/jina-clip-v1-text_model_fp16.onnx" + : modelSize === "large" + ? "jinaai/jina-clip-v2-model_fp16.onnx" + : "jinaai/jina-clip-v2-model_quantized.onnx", + ); + + // Tokenizer state + const { payload: textTokenizerState } = useModelState( + modelVersion === "jinav1" + ? "jinaai/jina-clip-v1-tokenizer" + : "jinaai/jina-clip-v2-tokenizer", + ); + + // Vision model state (same as text model for jinav2) + const visionModelFile = + modelVersion === "jinav1" + ? modelSize === "large" + ? "jinaai/jina-clip-v1-vision_model_fp16.onnx" + : "jinaai/jina-clip-v1-vision_model_quantized.onnx" + : modelSize === "large" + ? "jinaai/jina-clip-v2-model_fp16.onnx" + : "jinaai/jina-clip-v2-model_quantized.onnx"; + const { payload: visionModelState } = useModelState(visionModelFile); + + // Preprocessor/feature extractor state const { payload: visionFeatureExtractorState } = useModelState( - "jinaai/jina-clip-v1-preprocessor_config.json", + modelVersion === "jinav1" + ? "jinaai/jina-clip-v1-preprocessor_config.json" + : "jinaai/jina-clip-v2-preprocessor_config.json", ); const allModelsLoaded = useMemo(() => { diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 263883976..d021fde0f 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -20,6 +20,7 @@ export interface BirdseyeConfig { width: number; } +export type SearchModel = "jinav1" | "jinav2"; export type SearchModelSize = "small" | "large"; export interface CameraConfig { @@ -458,6 +459,7 @@ export interface FrigateConfig { semantic_search: { enabled: boolean; reindex: boolean; + model: SearchModel; model_size: SearchModelSize; }; From 4f855f82ea440d7a5f24eba0f93f5d633c251a86 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 26 Feb 2025 13:39:19 -0700 Subject: [PATCH 28/63] Simplify tensorrt (#16835) * Remove unneccessary trt wheels build * Cleanup * Try without local cuda * Keep specific cuda libs only * Cleanup * Add newer libcufft * remove target * Include more --- docker/tensorrt/Dockerfile.amd64 | 20 +++++++------------ docker/tensorrt/Dockerfile.base | 7 ++++++- .../etc/ld.so.conf.d/cuda_tensorrt.conf | 2 +- docker/tensorrt/requirements-amd64.txt | 1 + docker/tensorrt/trt.hcl | 1 - 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/docker/tensorrt/Dockerfile.amd64 b/docker/tensorrt/Dockerfile.amd64 index 6be11c210..e6429aa90 100644 --- a/docker/tensorrt/Dockerfile.amd64 +++ b/docker/tensorrt/Dockerfile.amd64 @@ -3,22 +3,16 @@ # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND=noninteractive -# Make this a separate target so it can be built/cached optionally -FROM wheels as trt-wheels -ARG DEBIAN_FRONTEND -ARG TARGETARCH -RUN python3 -m pip config set global.break-system-packages true - -# Add TensorRT wheels to another folder -COPY docker/tensorrt/requirements-amd64.txt /requirements-tensorrt.txt -RUN mkdir -p /trt-wheels && pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt +# Globally set pip break-system-packages option to avoid having to specify it every time +ARG PIP_BREAK_SYSTEM_PACKAGES=1 FROM tensorrt-base AS frigate-tensorrt +ARG PIP_BREAK_SYSTEM_PACKAGES ENV TRT_VER=8.6.1 -RUN python3 -m pip config set global.break-system-packages true -RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \ - pip3 install -U /deps/trt-wheels/*.whl && \ - ldconfig + +# Install TensorRT wheels +COPY docker/tensorrt/requirements-amd64.txt /requirements-tensorrt.txt +RUN pip3 install -U -r /requirements-tensorrt.txt && ldconfig WORKDIR /opt/frigate/ COPY --from=rootfs / / diff --git a/docker/tensorrt/Dockerfile.base b/docker/tensorrt/Dockerfile.base index f9cdde587..6d8d9591b 100644 --- a/docker/tensorrt/Dockerfile.base +++ b/docker/tensorrt/Dockerfile.base @@ -22,9 +22,14 @@ FROM deps AS tensorrt-base #Disable S6 Global timeout ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 +# COPY TensorRT Model Generation Deps COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos -COPY --from=trt-deps /usr/local/cuda-12.* /usr/local/cuda + +# COPY Individual CUDA deps +COPY --from=trt-deps /usr/local/cuda-12.3/targets/x86_64-linux/lib/libcurand.so.* /usr/local/cuda/ +COPY --from=trt-deps /usr/local/cuda-12.3/targets/x86_64-linux/lib/libnvrtc.so.* /usr/local/cuda/ + COPY docker/tensorrt/detector/rootfs/ / ENV YOLO_MODELS="" diff --git a/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf b/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf index 561b7bcd4..72eec56e0 100644 --- a/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf +++ b/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf @@ -1,5 +1,5 @@ /usr/local/lib -/usr/local/cuda/lib64 +/usr/local/cuda /usr/local/lib/python3.11/dist-packages/nvidia/cudnn/lib /usr/local/lib/python3.11/dist-packages/nvidia/cuda_runtime/lib /usr/local/lib/python3.11/dist-packages/nvidia/cublas/lib diff --git a/docker/tensorrt/requirements-amd64.txt b/docker/tensorrt/requirements-amd64.txt index 8d520d9f9..0e003ca3d 100644 --- a/docker/tensorrt/requirements-amd64.txt +++ b/docker/tensorrt/requirements-amd64.txt @@ -11,6 +11,7 @@ nvidia-cublas-cu11 == 11.11.3.6; platform_machine == 'x86_64' nvidia-cudnn-cu11 == 8.6.0.*; platform_machine == 'x86_64' nvidia-cudnn-cu12 == 9.5.0.*; platform_machine == 'x86_64' nvidia-cufft-cu11==10.*; platform_machine == 'x86_64' +nvidia-cufft-cu12==11.*; platform_machine == 'x86_64' onnx==1.16.*; platform_machine == 'x86_64' onnxruntime-gpu==1.20.*; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64' diff --git a/docker/tensorrt/trt.hcl b/docker/tensorrt/trt.hcl index ba3b93244..80757ba6d 100644 --- a/docker/tensorrt/trt.hcl +++ b/docker/tensorrt/trt.hcl @@ -95,7 +95,6 @@ target "tensorrt" { wget = "target:wget", tensorrt-base = "target:tensorrt-base", rootfs = "target:rootfs" - wheels = "target:wheels" } target = "frigate-tensorrt" inherits = ["_build_args"] From 2b7b5e3f08e982c55eac890cc446bfc6db37c767 Mon Sep 17 00:00:00 2001 From: toperichvania Date: Thu, 27 Feb 2025 16:28:53 +0100 Subject: [PATCH 29/63] Fix incorrect storage usage per camera (#16825) (#16851) --- frigate/record/maintainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index faa41f75f..1cabbfdda 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -473,7 +473,7 @@ class RecordingMaintainer(threading.Thread): # get the segment size of the cache file # file without faststart is same size segment_size = round( - float(os.path.getsize(cache_path)) / pow(2, 20), 1 + float(os.path.getsize(cache_path)) / pow(2, 20), 2 ) except OSError: segment_size = 0 From f221a7ae74573b60898a863de86c0161cfe0511b Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 27 Feb 2025 09:45:32 -0700 Subject: [PATCH 30/63] Quality of life documentation updates (#16852) * Update getting_started with full host:container syntax for hwacc * Update edgetpu.md Add a tip about the coral TPU not changing identification until after Frigate runs an inference on the TPU. --- docs/docs/guides/getting_started.md | 2 +- docs/docs/troubleshooting/edgetpu.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index bb880b8f0..ed2cfb4f4 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -177,7 +177,7 @@ services: frigate: ... devices: - - /dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware + - /dev/dri/renderD128:/dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware ... ``` diff --git a/docs/docs/troubleshooting/edgetpu.md b/docs/docs/troubleshooting/edgetpu.md index 2e10f0839..90006c41e 100644 --- a/docs/docs/troubleshooting/edgetpu.md +++ b/docs/docs/troubleshooting/edgetpu.md @@ -10,6 +10,12 @@ There are many possible causes for a USB coral not being detected and some are O 1. When the device is first plugged in and has not initialized it will appear as `1a6e:089a Global Unichip Corp.` when running `lsusb` or checking the hardware page in HA OS. 2. Once initialized, the device will appear as `18d1:9302 Google Inc.` when running `lsusb` or checking the hardware page in HA OS. +:::tip + +Using `lsusb` or checking the hardware page in HA OS will show as `1a6e:089a Global Unichip Corp.` until Frigate runs an inferance using the coral. So don't worry about the identification until after Frigate has attempted to detect the coral. + +::: + If the coral does not initialize then Frigate can not interface with it. Some common reasons for the USB based Coral not initializing are: ### Not Enough Power From db4152c4cab10decbc81eeaf3111f99a76e38e14 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 27 Feb 2025 16:24:03 -0700 Subject: [PATCH 31/63] Fix jetson (#16854) * Fix jetson build * Update ci.yml * Update Dockerfile.base * Update Dockerfile.base * Update Dockerfile.base * Fix * Update ci.yml --- docker/tensorrt/Dockerfile.base | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docker/tensorrt/Dockerfile.base b/docker/tensorrt/Dockerfile.base index 6d8d9591b..5ae018773 100644 --- a/docker/tensorrt/Dockerfile.base +++ b/docker/tensorrt/Dockerfile.base @@ -16,8 +16,16 @@ RUN apt-get update \ RUN --mount=type=bind,source=docker/tensorrt/detector/tensorrt_libyolo.sh,target=/tensorrt_libyolo.sh \ /tensorrt_libyolo.sh +# COPY required individual CUDA deps +RUN mkdir -p /usr/local/cuda-deps +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + cp /usr/local/cuda-12.3/targets/x86_64-linux/lib/libcurand.so.* /usr/local/cuda-deps/ && \ + cp /usr/local/cuda-12.3/targets/x86_64-linux/lib/libnvrtc.so.* /usr/local/cuda-deps/ ; \ + fi + # Frigate w/ TensorRT Support as separate image FROM deps AS tensorrt-base +ARG TARGETARCH #Disable S6 Global timeout ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 @@ -26,9 +34,8 @@ ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos -# COPY Individual CUDA deps -COPY --from=trt-deps /usr/local/cuda-12.3/targets/x86_64-linux/lib/libcurand.so.* /usr/local/cuda/ -COPY --from=trt-deps /usr/local/cuda-12.3/targets/x86_64-linux/lib/libnvrtc.so.* /usr/local/cuda/ +# COPY Individual CUDA deps folder +COPY --from=trt-deps /usr/local/cuda-deps /usr/local/cuda COPY docker/tensorrt/detector/rootfs/ / ENV YOLO_MODELS="" From 8d2f461350ed6f1881eee15d7089014e24a9b0ed Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 28 Feb 2025 12:43:08 -0600 Subject: [PATCH 32/63] Embeddings tweaks (#16864) * make semantic search optional * config * frontend metrics * docs * tweak * fixes * also check genai cameras for embeddings context --- docs/docs/configuration/face_recognition.md | 5 +- docs/docs/configuration/genai.md | 6 -- docs/docs/configuration/reference.md | 1 - frigate/api/event.py | 5 +- frigate/app.py | 30 +++++++++- frigate/config/config.py | 11 ---- frigate/embeddings/__init__.py | 4 -- frigate/embeddings/maintainer.py | 65 ++++++++++++--------- frigate/stats/util.py | 29 +++++---- web/src/pages/System.tsx | 6 +- web/src/types/frigateConfig.ts | 4 ++ 11 files changed, 95 insertions(+), 71 deletions(-) diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md index aaab92e6d..4d934afce 100644 --- a/docs/docs/configuration/face_recognition.md +++ b/docs/docs/configuration/face_recognition.md @@ -9,7 +9,7 @@ Frigate has support for CV2 Local Binary Pattern Face Recognizer to recognize fa ## Configuration -Face recognition is disabled by default and requires semantic search to be enabled, face recognition must be enabled in your config file before it can be used. Semantic Search and face recognition are global configuration settings. +Face recognition is disabled by default, face recognition must be enabled in your config file before it can be used. Face recognition is a global configuration setting. ```yaml face_recognition: @@ -36,6 +36,7 @@ The accuracy of face recognition is heavily dependent on the quality of data giv :::tip When choosing images to include in the face training set it is recommended to always follow these recommendations: + - If it is difficult to make out details in a persons face it will not be helpful in training. - Avoid images with under/over-exposure. - Avoid blurry / pixelated images. @@ -52,4 +53,4 @@ Then it is recommended to use the `Face Library` tab in Frigate to select and tr ### Step 2 - Expanding The Dataset -Once straight-on images are performing well, start choosing slightly off-angle images to include for training. It is important to still choose images where enough face detail is visible to recognize someone. \ No newline at end of file +Once straight-on images are performing well, start choosing slightly off-angle images to include for training. It is important to still choose images where enough face detail is visible to recognize someone. diff --git a/docs/docs/configuration/genai.md b/docs/docs/configuration/genai.md index 23f1c06be..e46107a82 100644 --- a/docs/docs/configuration/genai.md +++ b/docs/docs/configuration/genai.md @@ -7,12 +7,6 @@ Generative AI can be used to automatically generate descriptive text based on th Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle. Descriptions can also be regenerated manually via the Frigate UI. -:::info - -Semantic Search must be enabled to use Generative AI. - -::: - ## Configuration Generative AI can be enabled for all cameras or only for specific cameras. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below. diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index c64272214..b53d9268f 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -570,7 +570,6 @@ lpr: known_plates: {} # Optional: Configuration for AI generated tracked object descriptions -# NOTE: Semantic Search must be enabled for this to do anything. # WARNING: Depending on the provider, this will send thumbnails over the internet # to Google or OpenAI's LLMs to generate descriptions. It can be overridden at # the camera level (enabled: False) to enhance privacy for indoor cameras. diff --git a/frigate/api/event.py b/frigate/api/event.py index bb1bf7395..9a5578bae 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -1083,10 +1083,7 @@ def regenerate_description( camera_config = request.app.frigate_config.cameras[event.camera] - if ( - request.app.frigate_config.semantic_search.enabled - and camera_config.genai.enabled - ): + if camera_config.genai.enabled: request.app.event_metadata_updater.publish((event.id, params.source)) return JSONResponse( diff --git a/frigate/app.py b/frigate/app.py index 400d4bca0..8b63ab0a0 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -93,7 +93,13 @@ class FrigateApp: self.log_queue: Queue = mp.Queue() self.camera_metrics: dict[str, CameraMetrics] = {} self.embeddings_metrics: DataProcessorMetrics | None = ( - DataProcessorMetrics() if config.semantic_search.enabled else None + DataProcessorMetrics() + if ( + config.semantic_search.enabled + or config.lpr.enabled + or config.face_recognition.enabled + ) + else None ) self.ptz_metrics: dict[str, PTZMetrics] = {} self.processes: dict[str, int] = {} @@ -236,7 +242,16 @@ class FrigateApp: logger.info(f"Review process started: {review_segment_process.pid}") def init_embeddings_manager(self) -> None: - if not self.config.semantic_search.enabled: + genai_cameras = [ + c for c in self.config.cameras.values() if c.enabled and c.genai.enabled + ] + + if ( + not self.config.semantic_search.enabled + and not genai_cameras + and not self.config.lpr.enabled + and not self.config.face_recognition.enabled + ): return embedding_process = util.Process( @@ -293,7 +308,16 @@ class FrigateApp: migrate_exports(self.config.ffmpeg, list(self.config.cameras.keys())) def init_embeddings_client(self) -> None: - if self.config.semantic_search.enabled: + genai_cameras = [ + c for c in self.config.cameras.values() if c.enabled and c.genai.enabled + ] + + if ( + self.config.semantic_search.enabled + or self.config.lpr.enabled + or genai_cameras + or self.config.face_recognition.enabled + ): # Create a client for other processes to use self.embeddings = EmbeddingsContext(self.db) diff --git a/frigate/config/config.py b/frigate/config/config.py index 39ee31411..d2ca9a6f5 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -172,16 +172,6 @@ class RestreamConfig(BaseModel): model_config = ConfigDict(extra="allow") -def verify_semantic_search_dependent_configs(config: FrigateConfig) -> None: - """Verify that semantic search is enabled if required features are enabled.""" - if not config.semantic_search.enabled: - if config.genai.enabled: - raise ValueError("Genai requires semantic search to be enabled.") - - if config.face_recognition.enabled: - raise ValueError("Face recognition requires semantic to be enabled.") - - def verify_config_roles(camera_config: CameraConfig) -> None: """Verify that roles are setup in the config correctly.""" assigned_roles = list( @@ -647,7 +637,6 @@ class FrigateConfig(FrigateBaseModel): detector_config.model = model self.detectors[key] = detector_config - verify_semantic_search_dependent_configs(self) return self @field_validator("cameras") diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index 18673c4e9..56bd097d6 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -28,10 +28,6 @@ logger = logging.getLogger(__name__) def manage_embeddings(config: FrigateConfig, metrics: DataProcessorMetrics) -> None: - # Only initialize embeddings if semantic search is enabled - if not config.semantic_search.enabled: - return - stop_event = mp.Event() def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None: diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index a18ca7a7f..c9b6062c9 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -71,11 +71,14 @@ class EmbeddingMaintainer(threading.Thread): super().__init__(name="embeddings_maintainer") self.config = config self.metrics = metrics - self.embeddings = Embeddings(config, db, metrics) + self.embeddings = None - # Check if we need to re-index events - if config.semantic_search.reindex: - self.embeddings.reindex() + if config.semantic_search.enabled: + self.embeddings = Embeddings(config, db, metrics) + + # Check if we need to re-index events + if config.semantic_search.reindex: + self.embeddings.reindex() # create communication for updating event descriptions self.requestor = InterProcessRequestor() @@ -152,30 +155,30 @@ class EmbeddingMaintainer(threading.Thread): def _handle_request(topic: str, data: dict[str, any]) -> str: try: - if topic == EmbeddingsRequestEnum.embed_description.value: - return serialize( - self.embeddings.embed_description( - data["id"], data["description"] - ), - pack=False, - ) - elif topic == EmbeddingsRequestEnum.embed_thumbnail.value: - thumbnail = base64.b64decode(data["thumbnail"]) - return serialize( - self.embeddings.embed_thumbnail(data["id"], thumbnail), - pack=False, - ) - elif topic == EmbeddingsRequestEnum.generate_search.value: - return serialize( - self.embeddings.embed_description("", data, upsert=False), - pack=False, - ) - else: - processors = [self.realtime_processors, self.post_processors] - for processor_list in processors: - for processor in processor_list: - resp = processor.handle_request(topic, data) - + # First handle the embedding-specific topics when semantic search is enabled + if self.config.semantic_search.enabled: + if topic == EmbeddingsRequestEnum.embed_description.value: + return serialize( + self.embeddings.embed_description( + data["id"], data["description"] + ), + pack=False, + ) + elif topic == EmbeddingsRequestEnum.embed_thumbnail.value: + thumbnail = base64.b64decode(data["thumbnail"]) + return serialize( + self.embeddings.embed_thumbnail(data["id"], thumbnail), + pack=False, + ) + elif topic == EmbeddingsRequestEnum.generate_search.value: + return serialize( + self.embeddings.embed_description("", data, upsert=False), + pack=False, + ) + processors = [self.realtime_processors, self.post_processors] + for processor_list in processors: + for processor in processor_list: + resp = processor.handle_request(topic, data) if resp is not None: return resp except Exception as e: @@ -432,6 +435,9 @@ class EmbeddingMaintainer(threading.Thread): def _embed_thumbnail(self, event_id: str, thumbnail: bytes) -> None: """Embed the thumbnail for an event.""" + if not self.config.semantic_search.enabled: + return + self.embeddings.embed_thumbnail(event_id, thumbnail) def _embed_description(self, event: Event, thumbnails: list[bytes]) -> None: @@ -457,7 +463,8 @@ class EmbeddingMaintainer(threading.Thread): ) # Embed the description - self.embeddings.embed_description(event.id, description) + if self.config.semantic_search.enabled: + self.embeddings.embed_description(event.id, description) logger.debug( "Generated description for %s (%d images): %s", diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 3d836868e..287c384cd 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -282,16 +282,24 @@ def stats_snapshot( } stats["detection_fps"] = round(total_detection_fps, 2) - if config.semantic_search.enabled: - embeddings_metrics = stats_tracking["embeddings_metrics"] - stats["embeddings"] = { - "image_embedding_speed": round( - embeddings_metrics.image_embeddings_fps.value * 1000, 2 - ), - "text_embedding_speed": round( - embeddings_metrics.text_embeddings_sps.value * 1000, 2 - ), - } + stats["embeddings"] = {} + + # Get metrics if available + embeddings_metrics = stats_tracking.get("embeddings_metrics") + + if embeddings_metrics: + # Add metrics based on what's enabled + if config.semantic_search.enabled: + stats["embeddings"].update( + { + "image_embedding_speed": round( + embeddings_metrics.image_embeddings_fps.value * 1000, 2 + ), + "text_embedding_speed": round( + embeddings_metrics.text_embeddings_sps.value * 1000, 2 + ), + } + ) if config.face_recognition.enabled: stats["embeddings"]["face_recognition_speed"] = round( @@ -302,6 +310,7 @@ def stats_snapshot( stats["embeddings"]["plate_recognition_speed"] = round( embeddings_metrics.alpr_pps.value * 1000, 2 ) + if "license_plate" not in config.objects.all_objects: stats["embeddings"]["yolov9_plate_detection_speed"] = round( embeddings_metrics.yolov9_lpr_fps.value * 1000, 2 diff --git a/web/src/pages/System.tsx b/web/src/pages/System.tsx index 491149be2..05eed5b3e 100644 --- a/web/src/pages/System.tsx +++ b/web/src/pages/System.tsx @@ -28,7 +28,11 @@ function System() { const metrics = useMemo(() => { const metrics = [...allMetrics]; - if (!config?.semantic_search.enabled) { + if ( + !config?.semantic_search.enabled && + !config?.lpr.enabled && + !config?.face_recognition.enabled + ) { const index = metrics.indexOf("features"); metrics.splice(index, 1); } diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index d021fde0f..4ec4de853 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -363,6 +363,10 @@ export interface FrigateConfig { camera_groups: { [groupName: string]: CameraGroupConfig }; + lpr: { + enabled: boolean; + }; + logger: { default: string; logs: Record; From 06d6e21de813f3cd8665a82d004cd9d0138a2423 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 28 Feb 2025 13:48:08 -0700 Subject: [PATCH 33/63] Fix cuda targetarch (#16869) --- docker/tensorrt/Dockerfile.base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/tensorrt/Dockerfile.base b/docker/tensorrt/Dockerfile.base index 5ae018773..4305f1d74 100644 --- a/docker/tensorrt/Dockerfile.base +++ b/docker/tensorrt/Dockerfile.base @@ -8,6 +8,7 @@ ARG TRT_BASE=nvcr.io/nvidia/tensorrt:23.12-py3 # Build TensorRT-specific library FROM ${TRT_BASE} AS trt-deps +ARG TARGETARCH ARG COMPUTE_LEVEL RUN apt-get update \ @@ -25,7 +26,6 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ # Frigate w/ TensorRT Support as separate image FROM deps AS tensorrt-base -ARG TARGETARCH #Disable S6 Global timeout ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 From 458134de5d02002084e65c0b5301a3450dbac9b8 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 1 Mar 2025 05:35:09 +0100 Subject: [PATCH 34/63] Reuse constants (#16874) --- frigate/api/media.py | 7 +++++-- frigate/api/preview.py | 4 ++-- frigate/const.py | 1 + frigate/data_processing/real_time/face.py | 6 ++++-- frigate/detectors/detector_config.py | 4 ++-- frigate/detectors/plugins/hailo8l.py | 3 ++- frigate/detectors/plugins/openvino.py | 7 +++++-- frigate/detectors/plugins/rknn.py | 3 ++- frigate/detectors/plugins/rocm.py | 3 ++- frigate/embeddings/onnx/runner.py | 4 +++- frigate/output/birdseye.py | 6 ++++-- frigate/test/http_api/base_http_test.py | 7 ++++--- frigate/test/test_config.py | 4 ++-- frigate/test/test_http.py | 7 ++++--- frigate/util/config.py | 2 +- frigate/util/model.py | 17 ++++++++++++----- 16 files changed, 55 insertions(+), 30 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 74e9e7aaa..e3f74ea98 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -31,6 +31,7 @@ from frigate.config import FrigateConfig from frigate.const import ( CACHE_DIR, CLIPS_DIR, + INSTALL_DIR, MAX_SEGMENT_DURATION, PREVIEW_FRAME_TYPE, RECORD_DIR, @@ -155,7 +156,9 @@ def latest_frame( frame_processor.get_current_frame_time(camera_name) + retry_interval ): if request.app.camera_error_image is None: - error_image = glob.glob("/opt/frigate/frigate/images/camera-error.jpg") + error_image = glob.glob( + os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg") + ) if len(error_image) > 0: request.app.camera_error_image = cv2.imread( @@ -550,7 +553,7 @@ def recording_clip( ) file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt") - file_path = f"/tmp/cache/{file_name}" + file_path = os.path.join(CACHE_DIR, file_name) with open(file_path, "w") as file: clip: Recordings for clip in recordings: diff --git a/frigate/api/preview.py b/frigate/api/preview.py index d14a15ff1..2db2326ab 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -9,7 +9,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from frigate.api.defs.tags import Tags -from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE +from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.models import Previews logger = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def preview_ts(camera_name: str, start_ts: float, end_ts: float): clips.append( { "camera": preview["camera"], - "src": preview["path"].replace("/media/frigate", ""), + "src": preview["path"].replace(BASE_DIR, ""), "type": "video/mp4", "start": preview["start_time"], "end": preview["end_time"], diff --git a/frigate/const.py b/frigate/const.py index 866fa3d29..ffd1ca406 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -1,6 +1,7 @@ import os import re +INSTALL_DIR = "/opt/frigate" CONFIG_DIR = "/config" DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db" MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache" diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index d2b677653..e7cf622e9 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -76,14 +76,16 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): def __build_detector(self) -> None: self.face_detector = cv2.FaceDetectorYN.create( - "/config/model_cache/facedet/facedet.onnx", + os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"), config="", input_size=(320, 320), score_threshold=0.8, nms_threshold=0.3, ) self.landmark_detector = cv2.face.createFacemarkLBF() - self.landmark_detector.loadModel("/config/model_cache/facedet/landmarkdet.yaml") + self.landmark_detector.loadModel( + os.path.join(MODEL_CACHE_DIR, "facedet/landmarkdet.yaml") + ) def __build_classifier(self) -> None: if not self.landmark_detector: diff --git a/frigate/detectors/detector_config.py b/frigate/detectors/detector_config.py index 16599b141..fceab5a19 100644 --- a/frigate/detectors/detector_config.py +++ b/frigate/detectors/detector_config.py @@ -9,7 +9,7 @@ import requests from pydantic import BaseModel, ConfigDict, Field from pydantic.fields import PrivateAttr -from frigate.const import DEFAULT_ATTRIBUTE_LABEL_MAP +from frigate.const import DEFAULT_ATTRIBUTE_LABEL_MAP, MODEL_CACHE_DIR from frigate.plus import PlusApi from frigate.util.builtin import generate_color_palette, load_labels @@ -123,7 +123,7 @@ class ModelConfig(BaseModel): return model_id = self.path[7:] - self.path = f"/config/model_cache/{model_id}" + self.path = os.path.join(MODEL_CACHE_DIR, model_id) model_info_path = f"{self.path}.json" # download the model if it doesn't exist diff --git a/frigate/detectors/plugins/hailo8l.py b/frigate/detectors/plugins/hailo8l.py index b66d78bd6..69e86bc5b 100644 --- a/frigate/detectors/plugins/hailo8l.py +++ b/frigate/detectors/plugins/hailo8l.py @@ -22,6 +22,7 @@ except ModuleNotFoundError: from pydantic import BaseModel, Field from typing_extensions import Literal +from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig @@ -57,7 +58,7 @@ class HailoDetector(DetectionApi): self.h8l_tensor_format = detector_config.model.input_tensor self.h8l_pixel_format = detector_config.model.input_pixel_format self.model_url = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.11.0/hailo8l/ssd_mobilenet_v1.hef" - self.cache_dir = "/config/model_cache/h8l_cache" + self.cache_dir = os.path.join(MODEL_CACHE_DIR, "h8l_cache") self.expected_model_filename = "ssd_mobilenet_v1.hef" output_type = "FLOAT32" diff --git a/frigate/detectors/plugins/openvino.py b/frigate/detectors/plugins/openvino.py index 27be6b9bd..0f0b99a1f 100644 --- a/frigate/detectors/plugins/openvino.py +++ b/frigate/detectors/plugins/openvino.py @@ -7,6 +7,7 @@ import openvino.properties as props from pydantic import Field from typing_extensions import Literal +from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum from frigate.util.model import post_process_yolov9 @@ -41,8 +42,10 @@ class OvDetector(DetectionApi): logger.error(f"OpenVino model file {detector_config.model.path} not found.") raise FileNotFoundError - os.makedirs("/config/model_cache/openvino", exist_ok=True) - self.ov_core.set_property({props.cache_dir: "/config/model_cache/openvino"}) + os.makedirs(os.path.join(MODEL_CACHE_DIR, "openvino"), exist_ok=True) + self.ov_core.set_property( + {props.cache_dir: os.path.join(MODEL_CACHE_DIR, "openvino")} + ) self.interpreter = self.ov_core.compile_model( model=detector_config.model.path, device_name=detector_config.device ) diff --git a/frigate/detectors/plugins/rknn.py b/frigate/detectors/plugins/rknn.py index bfd7866e6..407c93917 100644 --- a/frigate/detectors/plugins/rknn.py +++ b/frigate/detectors/plugins/rknn.py @@ -6,6 +6,7 @@ from typing import Literal from pydantic import Field +from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum @@ -17,7 +18,7 @@ supported_socs = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"] supported_models = {ModelTypeEnum.yolonas: "^deci-fp16-yolonas_[sml]$"} -model_cache_dir = "/config/model_cache/rknn_cache/" +model_cache_dir = os.path.join(MODEL_CACHE_DIR, "rknn_cache/") class RknnDetectorConfig(BaseDetectorConfig): diff --git a/frigate/detectors/plugins/rocm.py b/frigate/detectors/plugins/rocm.py index 60118d129..7c87edb50 100644 --- a/frigate/detectors/plugins/rocm.py +++ b/frigate/detectors/plugins/rocm.py @@ -9,6 +9,7 @@ import numpy as np from pydantic import Field from typing_extensions import Literal +from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import ( BaseDetectorConfig, @@ -116,7 +117,7 @@ class ROCmDetector(DetectionApi): logger.info(f"AMD/ROCm: saving parsed model into {mxr_path}") - os.makedirs("/config/model_cache/rocm", exist_ok=True) + os.makedirs(os.path.join(MODEL_CACHE_DIR, "rocm"), exist_ok=True) migraphx.save(self.model, mxr_path) logger.info("AMD/ROCm: model loaded") diff --git a/frigate/embeddings/onnx/runner.py b/frigate/embeddings/onnx/runner.py index c785c28f1..7badae325 100644 --- a/frigate/embeddings/onnx/runner.py +++ b/frigate/embeddings/onnx/runner.py @@ -1,10 +1,12 @@ """Convenience runner for onnx models.""" import logging +import os.path from typing import Any import onnxruntime as ort +from frigate.const import MODEL_CACHE_DIR from frigate.util.model import get_ort_providers try: @@ -32,7 +34,7 @@ class ONNXModelRunner: self.type = "ov" self.ov = ov.Core() self.ov.set_property( - {ov.properties.cache_dir: "/config/model_cache/openvino"} + {ov.properties.cache_dir: os.path.join(MODEL_CACHE_DIR, "openvino")} ) self.interpreter = self.ov.compile_model( model=model_path, device_name=device diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 00f17c8f4..8331eb64a 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -16,7 +16,7 @@ import numpy as np from frigate.comms.config_updater import ConfigSubscriber from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig -from frigate.const import BASE_DIR, BIRDSEYE_PIPE +from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR from frigate.util.image import ( SharedMemoryFrameManager, copy_yuv_to_position, @@ -297,7 +297,9 @@ class BirdsEyeFrameManager: birdseye_logo = cv2.imread(custom_logo_files[0], cv2.IMREAD_UNCHANGED) if birdseye_logo is None: - logo_files = glob.glob("/opt/frigate/frigate/images/birdseye.png") + logo_files = glob.glob( + os.path.join(INSTALL_DIR, "frigate/images/birdseye.png") + ) if len(logo_files) > 0: birdseye_logo = cv2.imread(logo_files[0], cv2.IMREAD_UNCHANGED) diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index c16ab9926..f5a0aca3c 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -10,6 +10,7 @@ from pydantic import Json from frigate.api.fastapi_app import create_fastapi_app from frigate.config import FrigateConfig +from frigate.const import BASE_DIR, CACHE_DIR from frigate.models import Event, Recordings, ReviewSegment from frigate.review.types import SeverityEnum from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS @@ -73,19 +74,19 @@ class BaseTestHttp(unittest.TestCase): "total": 67.1, "used": 16.6, }, - "/media/frigate/clips": { + os.path.join(BASE_DIR, "clips"): { "free": 42429.9, "mount_type": "ext4", "total": 244529.7, "used": 189607.0, }, - "/media/frigate/recordings": { + os.path.join(BASE_DIR, "recordings"): { "free": 0.2, "mount_type": "ext4", "total": 8.0, "used": 7.8, }, - "/tmp/cache": { + CACHE_DIR: { "free": 976.8, "mount_type": "tmpfs", "total": 1000.0, diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index e6cb1274e..5a3deefda 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -854,9 +854,9 @@ class TestConfig(unittest.TestCase): assert frigate_config.model.merged_labelmap[0] == "person" def test_plus_labelmap(self): - with open("/config/model_cache/test", "w") as f: + with open(os.path.join(MODEL_CACHE_DIR, "test"), "w") as f: json.dump(self.plus_model_info, f) - with open("/config/model_cache/test.json", "w") as f: + with open(os.path.join(MODEL_CACHE_DIR, "test.json"), "w") as f: json.dump(self.plus_model_info, f) config = { diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 46de1307f..0238c766c 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -11,6 +11,7 @@ from playhouse.sqliteq import SqliteQueueDatabase from frigate.api.fastapi_app import create_fastapi_app from frigate.config import FrigateConfig +from frigate.const import BASE_DIR, CACHE_DIR from frigate.models import Event, Recordings, Timeline from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS @@ -74,19 +75,19 @@ class TestHttp(unittest.TestCase): "total": 67.1, "used": 16.6, }, - "/media/frigate/clips": { + os.path.join(BASE_DIR, "clips"): { "free": 42429.9, "mount_type": "ext4", "total": 244529.7, "used": 189607.0, }, - "/media/frigate/recordings": { + os.path.join(BASE_DIR, "recordings"): { "free": 0.2, "mount_type": "ext4", "total": 8.0, "used": 7.8, }, - "/tmp/cache": { + CACHE_DIR: { "free": 976.8, "mount_type": "tmpfs", "total": 1000.0, diff --git a/frigate/util/config.py b/frigate/util/config.py index 5b40fe37b..1ed82f802 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -14,7 +14,7 @@ from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) CURRENT_CONFIG_VERSION = "0.16-0" -DEFAULT_CONFIG_FILE = "/config/config.yml" +DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml") def find_config_file() -> str: diff --git a/frigate/util/model.py b/frigate/util/model.py index 0428a42ff..d96493ee6 100644 --- a/frigate/util/model.py +++ b/frigate/util/model.py @@ -7,6 +7,8 @@ import cv2 import numpy as np import onnxruntime as ort +from frigate.const import MODEL_CACHE_DIR + logger = logging.getLogger(__name__) @@ -105,7 +107,8 @@ def get_ort_providers( # so it is not enabled by default if device == "Tensorrt": os.makedirs( - "/config/model_cache/tensorrt/ort/trt-engines", exist_ok=True + os.path.join(MODEL_CACHE_DIR, "tensorrt/ort/trt-engines"), + exist_ok=True, ) device_id = 0 if not device.isdigit() else int(device) providers.append(provider) @@ -116,19 +119,23 @@ def get_ort_providers( and os.environ.get("USE_FP_16", "True") != "False", "trt_timing_cache_enable": True, "trt_engine_cache_enable": True, - "trt_timing_cache_path": "/config/model_cache/tensorrt/ort", - "trt_engine_cache_path": "/config/model_cache/tensorrt/ort/trt-engines", + "trt_timing_cache_path": os.path.join( + MODEL_CACHE_DIR, "tensorrt/ort" + ), + "trt_engine_cache_path": os.path.join( + MODEL_CACHE_DIR, "tensorrt/ort/trt-engines" + ), } ) else: continue elif provider == "OpenVINOExecutionProvider": - os.makedirs("/config/model_cache/openvino/ort", exist_ok=True) + os.makedirs(os.path.join(MODEL_CACHE_DIR, "openvino/ort"), exist_ok=True) providers.append(provider) options.append( { "arena_extend_strategy": "kSameAsRequested", - "cache_dir": "/config/model_cache/openvino/ort", + "cache_dir": os.path.join(MODEL_CACHE_DIR, "openvino/ort"), "device_type": device, } ) From f56668e4676232d9cd97b86770ae378e5e9121e4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 1 Mar 2025 16:09:41 -0700 Subject: [PATCH 35/63] Update d-fine documentation (#16881) --- docs/docs/configuration/object_detectors.md | 46 ++++++++++++--------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index bc76779cb..37ce86b07 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -562,30 +562,15 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl #### D-FINE -[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default. +[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the YOLO-NAS model for use in Frigate. -To export as ONNX: +:::warning -1. Clone: https://github.com/Peterande/D-FINE and install all dependencies. -2. Select and download a checkpoint from the [readme](https://github.com/Peterande/D-FINE). -3. Modify line 58 of `tools/deployment/export_onnx.py` and change batch size to 1: `data = torch.rand(1, 3, 640, 640)` -4. Run the export, making sure you select the right config, for your checkpoint. - -Example: - -``` -python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_m_obj2coco.yml -r output/dfine_m_obj2coco.pth -``` - -:::tip - -Model export has only been tested on Linux (or WSL2). Not all dependencies are in `requirements.txt`. Some live in the deployment folder, and some are still missing entirely and must be installed manually. - -Make sure you change the batch size to 1 before exporting. +D-FINE is currently not supported on OpenVINO ::: -After placing the downloaded onnx model in your config folder, you can use the following configuration: +After placing the downloaded onnx model in your config/model_cache folder, you can use the following configuration: ```yaml detectors: @@ -784,6 +769,29 @@ Some model types are not included in Frigate by default. Here are some tips for getting different model types +### Downloading D-FINE Model + +To export as ONNX: + +1. Clone: https://github.com/Peterande/D-FINE and install all dependencies. +2. Select and download a checkpoint from the [readme](https://github.com/Peterande/D-FINE). +3. Modify line 58 of `tools/deployment/export_onnx.py` and change batch size to 1: `data = torch.rand(1, 3, 640, 640)` +4. Run the export, making sure you select the right config, for your checkpoint. + +Example: + +``` +python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_m_obj2coco.yml -r output/dfine_m_obj2coco.pth +``` + +:::tip + +Model export has only been tested on Linux (or WSL2). Not all dependencies are in `requirements.txt`. Some live in the deployment folder, and some are still missing entirely and must be installed manually. + +Make sure you change the batch size to 1 before exporting. + +::: + ### Downloading YOLO-NAS Model You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb). From 4e03efaba99d29c57f4ebb8c4ce0ac8df7a93803 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 2 Mar 2025 08:26:59 -0700 Subject: [PATCH 36/63] Disable hailort log (#16888) --- docker/main/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 8dee8e642..674add58e 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -225,6 +225,9 @@ ENV TRANSFORMERS_NO_ADVISORY_WARNINGS=1 # Set OpenCV ffmpeg loglevel to fatal: https://ffmpeg.org/doxygen/trunk/log_8h.html ENV OPENCV_FFMPEG_LOGLEVEL=8 +# Set HailoRT to disable logging +ENV HAILORT_LOGGER_PATH=NONE + ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PATH}" # Install dependencies From b8f4cb5435afee3e9cd87202cd798cf9ce964420 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 2 Mar 2025 09:30:18 -0700 Subject: [PATCH 37/63] Fix docs (#16889) --- docs/docs/configuration/object_detectors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 37ce86b07..531ef5108 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -562,7 +562,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl #### D-FINE -[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the YOLO-NAS model for use in Frigate. +[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate. :::warning From 0128ec2ba60ac5229b8f88c3c271dc6e078da4c4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 2 Mar 2025 20:46:46 -0700 Subject: [PATCH 38/63] Upgrade RocM to 6.3.3 (#16900) * Simplify rocm install and update to 6.3.1 * Build out more necessary packages * Update to 6.3.3 * Set bake version * Fix typo * Ensure NHWC is used * Reset dev changes * Write to cache --- .github/workflows/ci.yml | 1 + docker/rocm/Dockerfile | 79 +-- docker/rocm/migraphx/CMakeLists.txt | 26 - docker/rocm/migraphx/migraphx_py.cpp | 582 ----------------------- docker/rocm/requirements-wheels-rocm.txt | 2 +- docker/rocm/rocm-pin-600 | 3 - docker/rocm/rocm.hcl | 10 +- docker/rocm/rocm.list | 1 - frigate/detectors/plugins/onnx.py | 2 +- 9 files changed, 36 insertions(+), 670 deletions(-) delete mode 100644 docker/rocm/migraphx/CMakeLists.txt delete mode 100644 docker/rocm/migraphx/migraphx_py.cpp delete mode 100644 docker/rocm/rocm-pin-600 delete mode 100644 docker/rocm/rocm.list diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a666b897..5b787b273 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,6 +175,7 @@ jobs: files: docker/rocm/rocm.hcl set: | rocm.tags=${{ steps.setup.outputs.image-name }}-rocm + *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm,mode=max *.cache-from=type=gha arm64_extra_builds: runs-on: ubuntu-22.04-arm diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index 34c7efffb..78f91b96f 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -2,79 +2,49 @@ # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND=noninteractive -ARG ROCM=5.7.3 +ARG ROCM=6.3.3 ARG AMDGPU=gfx900 ARG HSA_OVERRIDE_GFX_VERSION ARG HSA_OVERRIDE ####################################################################### -FROM ubuntu:focal as rocm +FROM wget AS rocm ARG ROCM +ARG AMDGPU -RUN apt-get update && apt-get -y upgrade -RUN apt-get -y install gnupg wget - -RUN mkdir --parents --mode=0755 /etc/apt/keyrings - -RUN wget https://repo.radeon.com/rocm/rocm.gpg.key -O - | gpg --dearmor | tee /etc/apt/keyrings/rocm.gpg > /dev/null -COPY docker/rocm/rocm.list /etc/apt/sources.list.d/ -COPY docker/rocm/rocm-pin-600 /etc/apt/preferences.d/ - -RUN apt-get update - -RUN apt-get -y install --no-install-recommends migraphx hipfft roctracer -RUN apt-get -y install --no-install-recommends migraphx-dev +RUN apt update && \ + apt install -y wget gpg && \ + wget -O rocm.deb https://repo.radeon.com/amdgpu-install/$ROCM/ubuntu/jammy/amdgpu-install_6.3.60303-1_all.deb && \ + apt install -y ./rocm.deb && \ + apt update && \ + apt install -y rocm RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib -RUN cd /opt/rocm-$ROCM/lib && cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocfft*.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ +RUN cd /opt/rocm-$ROCM/lib && \ + cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \ + mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib && \ + cp -dpr migraphx/lib/* /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib RUN cd /opt/rocm-dist/opt/ && ln -s rocm-$ROCM rocm RUN mkdir -p /opt/rocm-dist/etc/ld.so.conf.d/ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf -####################################################################### -FROM --platform=linux/amd64 debian:12 as debian-base - -RUN apt-get update && apt-get -y upgrade -RUN apt-get -y install --no-install-recommends libelf1 libdrm2 libdrm-amdgpu1 libnuma1 kmod - -RUN apt-get -y install python3 - -####################################################################### -# ROCm does not come with migraphx wrappers for python 3.9, so we build it here -FROM debian-base as debian-build - -ARG ROCM - -COPY --from=rocm /opt/rocm-$ROCM /opt/rocm-$ROCM -RUN ln -s /opt/rocm-$ROCM /opt/rocm - -RUN apt-get -y install g++ cmake -RUN apt-get -y install python3-pybind11 python3-distutils python3-dev - -WORKDIR /opt/build - -COPY docker/rocm/migraphx . - -RUN mkdir build && cd build && cmake .. && make install - ####################################################################### FROM deps AS deps-prelim -# need this to install libnuma1 -RUN apt-get update -# no ugprade?!?! -RUN apt-get -y install libnuma1 +RUN apt-get update && apt-get install -y libnuma1 -WORKDIR /opt/frigate/ +WORKDIR /opt/frigate COPY --from=rootfs / / -# Temporarily disabled to see if a new wheel can be built to support py3.11 -#COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt -#RUN python3 -m pip install --upgrade pip \ -# && pip3 uninstall -y onnxruntime-openvino \ -# && pip3 install -r /requirements.txt +RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ + && python3 get-pip.py "pip" --break-system-packages +RUN python3 -m pip config set global.break-system-packages true + +COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt +RUN pip3 uninstall -y onnxruntime-openvino \ + && pip3 install -r /requirements.txt ####################################################################### FROM scratch AS rocm-dist @@ -87,12 +57,11 @@ COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*$AMDGPU* /opt/rocm-$ROCM/share COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx908* /opt/rocm-$ROCM/share/miopen/db/ COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*$AMDGPU* /opt/rocm-$ROCM/lib/rocblas/library/ COPY --from=rocm /opt/rocm-dist/ / -COPY --from=debian-build /opt/rocm/lib/migraphx.cpython-311-x86_64-linux-gnu.so /opt/rocm-$ROCM/lib/ ####################################################################### FROM deps-prelim AS rocm-prelim-hsa-override0 -\ - ENV HSA_ENABLE_SDMA=0 +ENV HSA_ENABLE_SDMA=0 +ENV MIGRAPHX_ENABLE_NHWC=1 COPY --from=rocm-dist / / diff --git a/docker/rocm/migraphx/CMakeLists.txt b/docker/rocm/migraphx/CMakeLists.txt deleted file mode 100644 index 271dd094b..000000000 --- a/docker/rocm/migraphx/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ - -cmake_minimum_required(VERSION 3.1) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -if(NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Release) -endif() - -SET(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE) - -project(migraphx_py) - -include_directories(/opt/rocm/include) - -find_package(pybind11 REQUIRED) -pybind11_add_module(migraphx migraphx_py.cpp) - -target_link_libraries(migraphx PRIVATE /opt/rocm/lib/libmigraphx.so /opt/rocm/lib/libmigraphx_tf.so /opt/rocm/lib/libmigraphx_onnx.so) - -install(TARGETS migraphx - COMPONENT python - LIBRARY DESTINATION /opt/rocm/lib -) diff --git a/docker/rocm/migraphx/migraphx_py.cpp b/docker/rocm/migraphx/migraphx_py.cpp deleted file mode 100644 index 894c9d186..000000000 --- a/docker/rocm/migraphx/migraphx_py.cpp +++ /dev/null @@ -1,582 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015-2022 Advanced Micro Devices, Inc. All rights reserved. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef HAVE_GPU -#include -#endif - -using half = half_float::half; -namespace py = pybind11; - -#ifdef __clang__ -#define MIGRAPHX_PUSH_UNUSED_WARNING \ - _Pragma("clang diagnostic push") \ - _Pragma("clang diagnostic ignored \"-Wused-but-marked-unused\"") -#define MIGRAPHX_POP_WARNING _Pragma("clang diagnostic pop") -#else -#define MIGRAPHX_PUSH_UNUSED_WARNING -#define MIGRAPHX_POP_WARNING -#endif -#define MIGRAPHX_PYBIND11_MODULE(...) \ - MIGRAPHX_PUSH_UNUSED_WARNING \ - PYBIND11_MODULE(__VA_ARGS__) \ - MIGRAPHX_POP_WARNING - -#define MIGRAPHX_PYTHON_GENERATE_SHAPE_ENUM(x, t) .value(#x, migraphx::shape::type_t::x) -namespace migraphx { - -migraphx::value to_value(py::kwargs kwargs); -migraphx::value to_value(py::list lst); - -template -void visit_py(T x, F f) -{ - if(py::isinstance(x)) - { - f(to_value(x.template cast())); - } - else if(py::isinstance(x)) - { - f(to_value(x.template cast())); - } - else if(py::isinstance(x)) - { - f(x.template cast()); - } - else if(py::isinstance(x) or py::hasattr(x, "__index__")) - { - f(x.template cast()); - } - else if(py::isinstance(x)) - { - f(x.template cast()); - } - else if(py::isinstance(x)) - { - f(x.template cast()); - } - else if(py::isinstance(x)) - { - f(migraphx::to_value(x.template cast())); - } - else - { - MIGRAPHX_THROW("VISIT_PY: Unsupported data type!"); - } -} - -migraphx::value to_value(py::list lst) -{ - migraphx::value v = migraphx::value::array{}; - for(auto val : lst) - { - visit_py(val, [&](auto py_val) { v.push_back(py_val); }); - } - - return v; -} - -migraphx::value to_value(py::kwargs kwargs) -{ - migraphx::value v = migraphx::value::object{}; - - for(auto arg : kwargs) - { - auto&& key = py::str(arg.first); - auto&& val = arg.second; - visit_py(val, [&](auto py_val) { v[key] = py_val; }); - } - return v; -} -} // namespace migraphx - -namespace pybind11 { -namespace detail { - -template <> -struct npy_format_descriptor -{ - static std::string format() - { - // following: https://docs.python.org/3/library/struct.html#format-characters - return "e"; - } - static constexpr auto name() { return _("half"); } -}; - -} // namespace detail -} // namespace pybind11 - -template -void visit_type(const migraphx::shape& s, F f) -{ - s.visit_type(f); -} - -template -void visit(const migraphx::raw_data& x, F f) -{ - x.visit(f); -} - -template -void visit_types(F f) -{ - migraphx::shape::visit_types(f); -} - -template -py::buffer_info to_buffer_info(T& x) -{ - migraphx::shape s = x.get_shape(); - assert(s.type() != migraphx::shape::tuple_type); - if(s.dynamic()) - MIGRAPHX_THROW("MIGRAPHX PYTHON: dynamic shape argument passed to to_buffer_info"); - auto strides = s.strides(); - std::transform( - strides.begin(), strides.end(), strides.begin(), [&](auto i) { return i * s.type_size(); }); - py::buffer_info b; - visit_type(s, [&](auto as) { - // migraphx use int8_t data to store bool type, we need to - // explicitly specify the data type as bool for python - if(s.type() == migraphx::shape::bool_type) - { - b = py::buffer_info(x.data(), - as.size(), - py::format_descriptor::format(), - s.ndim(), - s.lens(), - strides); - } - else - { - b = py::buffer_info(x.data(), - as.size(), - py::format_descriptor::format(), - s.ndim(), - s.lens(), - strides); - } - }); - return b; -} - -migraphx::shape to_shape(const py::buffer_info& info) -{ - migraphx::shape::type_t t; - std::size_t n = 0; - visit_types([&](auto as) { - if(info.format == py::format_descriptor::format() or - (info.format == "l" and py::format_descriptor::format() == "q") or - (info.format == "L" and py::format_descriptor::format() == "Q")) - { - t = as.type_enum(); - n = sizeof(as()); - } - else if(info.format == "?" and py::format_descriptor::format() == "b") - { - t = migraphx::shape::bool_type; - n = sizeof(bool); - } - }); - - if(n == 0) - { - MIGRAPHX_THROW("MIGRAPHX PYTHON: Unsupported data type " + info.format); - } - - auto strides = info.strides; - std::transform(strides.begin(), strides.end(), strides.begin(), [&](auto i) -> std::size_t { - return n > 0 ? i / n : 0; - }); - - // scalar support - if(info.shape.empty()) - { - return migraphx::shape{t}; - } - else - { - return migraphx::shape{t, info.shape, strides}; - } -} - -MIGRAPHX_PYBIND11_MODULE(migraphx, m) -{ - py::class_ shape_cls(m, "shape"); - shape_cls - .def(py::init([](py::kwargs kwargs) { - auto v = migraphx::to_value(kwargs); - auto t = migraphx::shape::parse_type(v.get("type", "float")); - if(v.contains("dyn_dims")) - { - auto dyn_dims = - migraphx::from_value>( - v.at("dyn_dims")); - return migraphx::shape(t, dyn_dims); - } - auto lens = v.get("lens", {1}); - if(v.contains("strides")) - return migraphx::shape(t, lens, v.at("strides").to_vector()); - else - return migraphx::shape(t, lens); - })) - .def("type", &migraphx::shape::type) - .def("lens", &migraphx::shape::lens) - .def("strides", &migraphx::shape::strides) - .def("ndim", &migraphx::shape::ndim) - .def("elements", &migraphx::shape::elements) - .def("bytes", &migraphx::shape::bytes) - .def("type_string", &migraphx::shape::type_string) - .def("type_size", &migraphx::shape::type_size) - .def("dyn_dims", &migraphx::shape::dyn_dims) - .def("packed", &migraphx::shape::packed) - .def("transposed", &migraphx::shape::transposed) - .def("broadcasted", &migraphx::shape::broadcasted) - .def("standard", &migraphx::shape::standard) - .def("scalar", &migraphx::shape::scalar) - .def("dynamic", &migraphx::shape::dynamic) - .def("__eq__", std::equal_to{}) - .def("__ne__", std::not_equal_to{}) - .def("__repr__", [](const migraphx::shape& s) { return migraphx::to_string(s); }); - - py::enum_(shape_cls, "type_t") - MIGRAPHX_SHAPE_VISIT_TYPES(MIGRAPHX_PYTHON_GENERATE_SHAPE_ENUM); - - py::class_(shape_cls, "dynamic_dimension") - .def(py::init<>()) - .def(py::init()) - .def(py::init>()) - .def_readwrite("min", &migraphx::shape::dynamic_dimension::min) - .def_readwrite("max", &migraphx::shape::dynamic_dimension::max) - .def_readwrite("optimals", &migraphx::shape::dynamic_dimension::optimals) - .def("is_fixed", &migraphx::shape::dynamic_dimension::is_fixed); - - py::class_(m, "argument", py::buffer_protocol()) - .def_buffer([](migraphx::argument& x) -> py::buffer_info { return to_buffer_info(x); }) - .def(py::init([](py::buffer b) { - py::buffer_info info = b.request(); - return migraphx::argument(to_shape(info), info.ptr); - })) - .def("get_shape", &migraphx::argument::get_shape) - .def("data_ptr", - [](migraphx::argument& x) { return reinterpret_cast(x.data()); }) - .def("tolist", - [](migraphx::argument& x) { - py::list l{x.get_shape().elements()}; - visit(x, [&](auto data) { l = py::cast(data.to_vector()); }); - return l; - }) - .def("__eq__", std::equal_to{}) - .def("__ne__", std::not_equal_to{}) - .def("__repr__", [](const migraphx::argument& x) { return migraphx::to_string(x); }); - - py::class_(m, "target"); - - py::class_(m, "instruction_ref") - .def("shape", [](migraphx::instruction_ref i) { return i->get_shape(); }) - .def("op", [](migraphx::instruction_ref i) { return i->get_operator(); }); - - py::class_>(m, "module") - .def("print", [](const migraphx::module& mm) { std::cout << mm << std::endl; }) - .def( - "add_instruction", - [](migraphx::module& mm, - const migraphx::operation& op, - std::vector& args, - std::vector& mod_args) { - return mm.add_instruction(op, args, mod_args); - }, - py::arg("op"), - py::arg("args"), - py::arg("mod_args") = std::vector{}) - .def( - "add_literal", - [](migraphx::module& mm, py::buffer data) { - py::buffer_info info = data.request(); - auto literal_shape = to_shape(info); - return mm.add_literal(literal_shape, reinterpret_cast(info.ptr)); - }, - py::arg("data")) - .def( - "add_parameter", - [](migraphx::module& mm, const std::string& name, const migraphx::shape shape) { - return mm.add_parameter(name, shape); - }, - py::arg("name"), - py::arg("shape")) - .def( - "add_return", - [](migraphx::module& mm, std::vector& args) { - return mm.add_return(args); - }, - py::arg("args")) - .def("__repr__", [](const migraphx::module& mm) { return migraphx::to_string(mm); }); - - py::class_(m, "program") - .def(py::init([]() { return migraphx::program(); })) - .def("get_parameter_names", &migraphx::program::get_parameter_names) - .def("get_parameter_shapes", &migraphx::program::get_parameter_shapes) - .def("get_output_shapes", &migraphx::program::get_output_shapes) - .def("is_compiled", &migraphx::program::is_compiled) - .def( - "compile", - [](migraphx::program& p, - const migraphx::target& t, - bool offload_copy, - bool fast_math, - bool exhaustive_tune) { - migraphx::compile_options options; - options.offload_copy = offload_copy; - options.fast_math = fast_math; - options.exhaustive_tune = exhaustive_tune; - p.compile(t, options); - }, - py::arg("t"), - py::arg("offload_copy") = true, - py::arg("fast_math") = true, - py::arg("exhaustive_tune") = false) - .def("get_main_module", [](const migraphx::program& p) { return p.get_main_module(); }) - .def( - "create_module", - [](migraphx::program& p, const std::string& name) { return p.create_module(name); }, - py::arg("name")) - .def("run", - [](migraphx::program& p, py::dict params) { - migraphx::parameter_map pm; - for(auto x : params) - { - std::string key = x.first.cast(); - py::buffer b = x.second.cast(); - py::buffer_info info = b.request(); - pm[key] = migraphx::argument(to_shape(info), info.ptr); - } - return p.eval(pm); - }) - .def("run_async", - [](migraphx::program& p, - py::dict params, - std::uintptr_t stream, - std::string stream_name) { - migraphx::parameter_map pm; - for(auto x : params) - { - std::string key = x.first.cast(); - py::buffer b = x.second.cast(); - py::buffer_info info = b.request(); - pm[key] = migraphx::argument(to_shape(info), info.ptr); - } - migraphx::execution_environment exec_env{ - migraphx::any_ptr(reinterpret_cast(stream), stream_name), true}; - return p.eval(pm, exec_env); - }) - .def("sort", &migraphx::program::sort) - .def("print", [](const migraphx::program& p) { std::cout << p << std::endl; }) - .def("__eq__", std::equal_to{}) - .def("__ne__", std::not_equal_to{}) - .def("__repr__", [](const migraphx::program& p) { return migraphx::to_string(p); }); - - py::class_ op(m, "op"); - op.def(py::init([](const std::string& name, py::kwargs kwargs) { - migraphx::value v = migraphx::value::object{}; - if(kwargs) - { - v = migraphx::to_value(kwargs); - } - return migraphx::make_op(name, v); - })) - .def("name", &migraphx::operation::name); - - py::enum_(op, "pooling_mode") - .value("average", migraphx::op::pooling_mode::average) - .value("max", migraphx::op::pooling_mode::max) - .value("lpnorm", migraphx::op::pooling_mode::lpnorm); - - py::enum_(op, "rnn_direction") - .value("forward", migraphx::op::rnn_direction::forward) - .value("reverse", migraphx::op::rnn_direction::reverse) - .value("bidirectional", migraphx::op::rnn_direction::bidirectional); - - m.def( - "argument_from_pointer", - [](const migraphx::shape shape, const int64_t address) { - return migraphx::argument(shape, reinterpret_cast(address)); - }, - py::arg("shape"), - py::arg("address")); - - m.def( - "parse_tf", - [](const std::string& filename, - bool is_nhwc, - unsigned int batch_size, - std::unordered_map> map_input_dims, - std::vector output_names) { - return migraphx::parse_tf( - filename, migraphx::tf_options{is_nhwc, batch_size, map_input_dims, output_names}); - }, - "Parse tf protobuf (default format is nhwc)", - py::arg("filename"), - py::arg("is_nhwc") = true, - py::arg("batch_size") = 1, - py::arg("map_input_dims") = std::unordered_map>(), - py::arg("output_names") = std::vector()); - - m.def( - "parse_onnx", - [](const std::string& filename, - unsigned int default_dim_value, - migraphx::shape::dynamic_dimension default_dyn_dim_value, - std::unordered_map> map_input_dims, - std::unordered_map> - map_dyn_input_dims, - bool skip_unknown_operators, - bool print_program_on_error, - int64_t max_loop_iterations) { - migraphx::onnx_options options; - options.default_dim_value = default_dim_value; - options.default_dyn_dim_value = default_dyn_dim_value; - options.map_input_dims = map_input_dims; - options.map_dyn_input_dims = map_dyn_input_dims; - options.skip_unknown_operators = skip_unknown_operators; - options.print_program_on_error = print_program_on_error; - options.max_loop_iterations = max_loop_iterations; - return migraphx::parse_onnx(filename, options); - }, - "Parse onnx file", - py::arg("filename"), - py::arg("default_dim_value") = 0, - py::arg("default_dyn_dim_value") = migraphx::shape::dynamic_dimension{1, 1}, - py::arg("map_input_dims") = std::unordered_map>(), - py::arg("map_dyn_input_dims") = - std::unordered_map>(), - py::arg("skip_unknown_operators") = false, - py::arg("print_program_on_error") = false, - py::arg("max_loop_iterations") = 10); - - m.def( - "parse_onnx_buffer", - [](const std::string& onnx_buffer, - unsigned int default_dim_value, - migraphx::shape::dynamic_dimension default_dyn_dim_value, - std::unordered_map> map_input_dims, - std::unordered_map> - map_dyn_input_dims, - bool skip_unknown_operators, - bool print_program_on_error) { - migraphx::onnx_options options; - options.default_dim_value = default_dim_value; - options.default_dyn_dim_value = default_dyn_dim_value; - options.map_input_dims = map_input_dims; - options.map_dyn_input_dims = map_dyn_input_dims; - options.skip_unknown_operators = skip_unknown_operators; - options.print_program_on_error = print_program_on_error; - return migraphx::parse_onnx_buffer(onnx_buffer, options); - }, - "Parse onnx file", - py::arg("filename"), - py::arg("default_dim_value") = 0, - py::arg("default_dyn_dim_value") = migraphx::shape::dynamic_dimension{1, 1}, - py::arg("map_input_dims") = std::unordered_map>(), - py::arg("map_dyn_input_dims") = - std::unordered_map>(), - py::arg("skip_unknown_operators") = false, - py::arg("print_program_on_error") = false); - - m.def( - "load", - [](const std::string& name, const std::string& format) { - migraphx::file_options options; - options.format = format; - return migraphx::load(name, options); - }, - "Load MIGraphX program", - py::arg("filename"), - py::arg("format") = "msgpack"); - - m.def( - "save", - [](const migraphx::program& p, const std::string& name, const std::string& format) { - migraphx::file_options options; - options.format = format; - return migraphx::save(p, name, options); - }, - "Save MIGraphX program", - py::arg("p"), - py::arg("filename"), - py::arg("format") = "msgpack"); - - m.def("get_target", &migraphx::make_target); - m.def("create_argument", [](const migraphx::shape& s, const std::vector& values) { - if(values.size() != s.elements()) - MIGRAPHX_THROW("Values and shape elements do not match"); - migraphx::argument a{s}; - a.fill(values.begin(), values.end()); - return a; - }); - m.def("generate_argument", &migraphx::generate_argument, py::arg("s"), py::arg("seed") = 0); - m.def("fill_argument", &migraphx::fill_argument, py::arg("s"), py::arg("value")); - m.def("quantize_fp16", - &migraphx::quantize_fp16, - py::arg("prog"), - py::arg("ins_names") = std::vector{"all"}); - m.def("quantize_int8", - &migraphx::quantize_int8, - py::arg("prog"), - py::arg("t"), - py::arg("calibration") = std::vector{}, - py::arg("ins_names") = std::vector{"dot", "convolution"}); - -#ifdef HAVE_GPU - m.def("allocate_gpu", &migraphx::gpu::allocate_gpu, py::arg("s"), py::arg("host") = false); - m.def("to_gpu", &migraphx::gpu::to_gpu, py::arg("arg"), py::arg("host") = false); - m.def("from_gpu", &migraphx::gpu::from_gpu); - m.def("gpu_sync", [] { migraphx::gpu::gpu_sync(); }); -#endif - -#ifdef VERSION_INFO - m.attr("__version__") = VERSION_INFO; -#else - m.attr("__version__") = "dev"; -#endif -} diff --git a/docker/rocm/requirements-wheels-rocm.txt b/docker/rocm/requirements-wheels-rocm.txt index 89d0e6096..85450768e 100644 --- a/docker/rocm/requirements-wheels-rocm.txt +++ b/docker/rocm/requirements-wheels-rocm.txt @@ -1 +1 @@ -onnxruntime-rocm @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v1.0.0/onnxruntime_rocm-1.17.3-cp39-cp39-linux_x86_64.whl \ No newline at end of file +onnxruntime-rocm @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v6.3.3/onnxruntime_rocm-1.20.1-cp311-cp311-linux_x86_64.whl \ No newline at end of file diff --git a/docker/rocm/rocm-pin-600 b/docker/rocm/rocm-pin-600 deleted file mode 100644 index 88348a5c1..000000000 --- a/docker/rocm/rocm-pin-600 +++ /dev/null @@ -1,3 +0,0 @@ -Package: * -Pin: release o=repo.radeon.com -Pin-Priority: 600 diff --git a/docker/rocm/rocm.hcl b/docker/rocm/rocm.hcl index 33a2d2323..6a84b350d 100644 --- a/docker/rocm/rocm.hcl +++ b/docker/rocm/rocm.hcl @@ -2,7 +2,7 @@ variable "AMDGPU" { default = "gfx900" } variable "ROCM" { - default = "5.7.3" + default = "6.3.3" } variable "HSA_OVERRIDE_GFX_VERSION" { default = "" @@ -10,6 +10,13 @@ variable "HSA_OVERRIDE_GFX_VERSION" { variable "HSA_OVERRIDE" { default = "1" } + +target wget { + dockerfile = "docker/main/Dockerfile" + platforms = ["linux/amd64"] + target = "wget" +} + target deps { dockerfile = "docker/main/Dockerfile" platforms = ["linux/amd64"] @@ -26,6 +33,7 @@ target rocm { dockerfile = "docker/rocm/Dockerfile" contexts = { deps = "target:deps", + wget = "target:wget", rootfs = "target:rootfs" } platforms = ["linux/amd64"] diff --git a/docker/rocm/rocm.list b/docker/rocm/rocm.list deleted file mode 100644 index 0915b4094..000000000 --- a/docker/rocm/rocm.list +++ /dev/null @@ -1 +0,0 @@ -deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/5.7.3 focal main diff --git a/frigate/detectors/plugins/onnx.py b/frigate/detectors/plugins/onnx.py index 13a948de9..d94b4660f 100644 --- a/frigate/detectors/plugins/onnx.py +++ b/frigate/detectors/plugins/onnx.py @@ -99,5 +99,5 @@ class ONNXDetector(DetectionApi): return post_process_yolov9(predictions, self.w, self.h) else: raise Exception( - f"{self.onnx_model_type} is currently not supported for rocm. See the docs for more info on supported models." + f"{self.onnx_model_type} is currently not supported for onnx. See the docs for more info on supported models." ) From 71e6e04d778538e33424c0dac7e5ca08f120b1f0 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 3 Mar 2025 07:16:14 -0700 Subject: [PATCH 39/63] Remove rocm detector (#16913) * Remove rocm detector plugin * Update docs to recommend using onnx for rocm * Formatting --- docs/docs/configuration/object_detectors.md | 30 +--- docs/docs/plus/index.md | 6 +- frigate/detectors/plugins/rocm.py | 170 -------------------- frigate/object_detection.py | 9 +- 4 files changed, 9 insertions(+), 206 deletions(-) delete mode 100644 frigate/detectors/plugins/rocm.py diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 531ef5108..6834f8014 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -49,7 +49,7 @@ This does not affect using hardware for accelerating other tasks such as [semant # Officially Supported Detectors -Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `hailo8l`, `onnx`, `openvino`, `rknn`, `rocm`, and `tensorrt`. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras. +Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `hailo8l`, `onnx`, `openvino`, `rknn`, and `tensorrt`. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras. ## Edge TPU Detector @@ -367,7 +367,7 @@ model: ### Setup -The `rocm` detector supports running YOLO-NAS models on AMD GPUs. Use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`. +Support for AMD GPUs is provided using the [ONNX detector](#ONNX). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`. ### Docker settings for GPU access @@ -446,29 +446,9 @@ $ docker exec -it frigate /bin/bash -c '(unset HSA_OVERRIDE_GFX_VERSION && /opt/ ### Supported Models -There is no default model provided, the following formats are supported: - -#### YOLO-NAS - -[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. See [the models section](#downloading-yolo-nas-model) for more information on downloading the YOLO-NAS model for use in Frigate. - -After placing the downloaded onnx model in your config folder, you can use the following configuration: - -```yaml -detectors: - rocm: - type: rocm - -model: - model_type: yolonas - width: 320 # <--- should match whatever was set in notebook - height: 320 # <--- should match whatever was set in notebook - input_pixel_format: bgr - path: /config/yolo_nas_s.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. +See [ONNX supported models](#supported-models) for supported models, there are some caveats: +- D-FINE models are not supported +- YOLO-NAS models are known to not run well on integrated GPUs ## ONNX diff --git a/docs/docs/plus/index.md b/docs/docs/plus/index.md index 37798badb..589adca72 100644 --- a/docs/docs/plus/index.md +++ b/docs/docs/plus/index.md @@ -28,11 +28,11 @@ Not all model types are supported by all detectors, so it's important to choose ## Supported detector types -Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), and ROCm (`rocm`) detectors. +Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), and ONNX (`onnx`) detectors. :::warning -Using Frigate+ models with `onnx` and `rocm` is only available with Frigate 0.15 and later. +Using Frigate+ models with `onnx` is only available with Frigate 0.15 and later. ::: @@ -42,7 +42,7 @@ Using Frigate+ models with `onnx` and `rocm` is only available with Frigate 0.15 | [Coral (all form factors)](/configuration/object_detectors.md#edge-tpu-detector) | `edgetpu` | `mobiledet` | | [Intel](/configuration/object_detectors.md#openvino-detector) | `openvino` | `yolonas` | | [NVidia GPU](https://deploy-preview-13787--frigate-docs.netlify.app/configuration/object_detectors#onnx)\* | `onnx` | `yolonas` | -| [AMD ROCm GPU](https://deploy-preview-13787--frigate-docs.netlify.app/configuration/object_detectors#amdrocm-gpu-detector)\* | `rocm` | `yolonas` | +| [AMD ROCm GPU](https://deploy-preview-13787--frigate-docs.netlify.app/configuration/object_detectors#amdrocm-gpu-detector)\* | `onnx` | `yolonas` | _\* Requires Frigate 0.15_ diff --git a/frigate/detectors/plugins/rocm.py b/frigate/detectors/plugins/rocm.py deleted file mode 100644 index 7c87edb50..000000000 --- a/frigate/detectors/plugins/rocm.py +++ /dev/null @@ -1,170 +0,0 @@ -import ctypes -import logging -import os -import subprocess -import sys - -import cv2 -import numpy as np -from pydantic import Field -from typing_extensions import Literal - -from frigate.const import MODEL_CACHE_DIR -from frigate.detectors.detection_api import DetectionApi -from frigate.detectors.detector_config import ( - BaseDetectorConfig, - ModelTypeEnum, - PixelFormatEnum, -) - -logger = logging.getLogger(__name__) - -DETECTOR_KEY = "rocm" - - -def detect_gfx_version(): - return subprocess.getoutput( - "unset HSA_OVERRIDE_GFX_VERSION && /opt/rocm/bin/rocminfo | grep gfx |head -1|awk '{print $2}'" - ) - - -def auto_override_gfx_version(): - # If environment variable already in place, do not override - gfx_version = detect_gfx_version() - old_override = os.getenv("HSA_OVERRIDE_GFX_VERSION") - if old_override not in (None, ""): - logger.warning( - f"AMD/ROCm: detected {gfx_version} but HSA_OVERRIDE_GFX_VERSION already present ({old_override}), not overriding!" - ) - return old_override - mapping = { - "gfx90c": "9.0.0", - "gfx1031": "10.3.0", - "gfx1103": "11.0.0", - } - override = mapping.get(gfx_version) - if override is not None: - logger.warning( - f"AMD/ROCm: detected {gfx_version}, overriding HSA_OVERRIDE_GFX_VERSION={override}" - ) - os.putenv("HSA_OVERRIDE_GFX_VERSION", override) - return override - return "" - - -class ROCmDetectorConfig(BaseDetectorConfig): - type: Literal[DETECTOR_KEY] - conserve_cpu: bool = Field( - default=True, - title="Conserve CPU at the expense of latency (and reduced max throughput)", - ) - auto_override_gfx: bool = Field( - default=True, title="Automatically detect and override gfx version" - ) - - -class ROCmDetector(DetectionApi): - type_key = DETECTOR_KEY - - def __init__(self, detector_config: ROCmDetectorConfig): - if detector_config.auto_override_gfx: - auto_override_gfx_version() - - try: - sys.path.append("/opt/rocm/lib") - import migraphx - - logger.info("AMD/ROCm: loaded migraphx module") - except ModuleNotFoundError: - logger.error("AMD/ROCm: module loading failed, missing ROCm environment?") - raise - - if detector_config.conserve_cpu: - logger.info("AMD/ROCm: switching HIP to blocking mode to conserve CPU") - ctypes.CDLL("/opt/rocm/lib/libamdhip64.so").hipSetDeviceFlags(4) - - self.h = detector_config.model.height - self.w = detector_config.model.width - self.rocm_model_type = detector_config.model.model_type - self.rocm_model_px = detector_config.model.input_pixel_format - path = detector_config.model.path - - mxr_path = os.path.splitext(path)[0] + ".mxr" - if path.endswith(".mxr"): - logger.info(f"AMD/ROCm: loading parsed model from {mxr_path}") - self.model = migraphx.load(mxr_path) - elif os.path.exists(mxr_path): - logger.info(f"AMD/ROCm: loading parsed model from {mxr_path}") - self.model = migraphx.load(mxr_path) - else: - logger.info(f"AMD/ROCm: loading model from {path}") - - if ( - path.endswith(".tf") - or path.endswith(".tf2") - or path.endswith(".tflite") - ): - # untested - self.model = migraphx.parse_tf(path) - else: - self.model = migraphx.parse_onnx(path) - - logger.info("AMD/ROCm: compiling the model") - - self.model.compile( - migraphx.get_target("gpu"), offload_copy=True, fast_math=True - ) - - logger.info(f"AMD/ROCm: saving parsed model into {mxr_path}") - - os.makedirs(os.path.join(MODEL_CACHE_DIR, "rocm"), exist_ok=True) - migraphx.save(self.model, mxr_path) - - logger.info("AMD/ROCm: model loaded") - - def detect_raw(self, tensor_input): - model_input_name = self.model.get_parameter_names()[0] - model_input_shape = tuple( - self.model.get_parameter_shapes()[model_input_name].lens() - ) - - tensor_input = cv2.dnn.blobFromImage( - tensor_input[0], - 1.0, - (model_input_shape[3], model_input_shape[2]), - None, - swapRB=self.rocm_model_px == PixelFormatEnum.bgr, - ).astype(np.uint8) - - detector_result = self.model.run({model_input_name: tensor_input})[0] - addr = ctypes.cast(detector_result.data_ptr(), ctypes.POINTER(ctypes.c_float)) - - tensor_output = np.ctypeslib.as_array( - addr, shape=detector_result.get_shape().lens() - ) - - if self.rocm_model_type == ModelTypeEnum.yolonas: - predictions = tensor_output - - detections = np.zeros((20, 6), np.float32) - - for i, prediction in enumerate(predictions): - if i == 20: - break - (_, x_min, y_min, x_max, y_max, confidence, class_id) = prediction - # when running in GPU mode, empty predictions in the output have class_id of -1 - if class_id < 0: - break - detections[i] = [ - class_id, - confidence, - y_min / self.h, - x_min / self.w, - y_max / self.h, - x_max / self.w, - ] - return detections - else: - raise Exception( - f"{self.rocm_model_type} is currently not supported for rocm. See the docs for more info on supported models." - ) diff --git a/frigate/object_detection.py b/frigate/object_detection.py index 022e565f0..8e88ae578 100644 --- a/frigate/object_detection.py +++ b/frigate/object_detection.py @@ -17,7 +17,6 @@ from frigate.detectors.detector_config import ( InputDTypeEnum, InputTensorEnum, ) -from frigate.detectors.plugins.rocm import DETECTOR_KEY as ROCM_DETECTOR_KEY from frigate.util.builtin import EventsPerSecond, load_labels from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory from frigate.util.services import listen @@ -52,13 +51,7 @@ class LocalObjectDetector(ObjectDetector): self.labels = load_labels(labels) if detector_config: - if detector_config.type == ROCM_DETECTOR_KEY: - # ROCm requires NHWC as input - self.input_transform = None - else: - self.input_transform = tensor_transform( - detector_config.model.input_tensor - ) + self.input_transform = tensor_transform(detector_config.model.input_tensor) self.dtype = detector_config.model.input_dtype else: From 531042467ab85180bf20b3f265ae9256f157f5bf Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 3 Mar 2025 09:30:52 -0600 Subject: [PATCH 40/63] Dynamically enable/disable cameras (#16894) * config options * metrics * stop and restart ffmpeg processes * dispatcher * frontend websocket * buttons for testing * don't recreate log pipe * add/remove cam from birdseye when enabling/disabling * end all objects and send empty camera activity * enable/disable switch in ui * disable buttons when camera is disabled * use enabled_in_config for some frontend checks * tweaks * handle settings pane with disabled cameras * frontend tweaks * change to debug log * mqtt docs * tweak * ensure all ffmpeg processes are initially started * clean up * use zmq * remove camera metrics * remove camera metrics * tweaks * frontend tweaks --- docs/docs/integrations/mqtt.md | 8 + frigate/camera/activity_manager.py | 2 +- frigate/comms/dispatcher.py | 23 ++ frigate/config/camera/camera.py | 3 + frigate/config/config.py | 1 + frigate/object_processing.py | 62 +++++ frigate/output/birdseye.py | 227 ++++++++++-------- frigate/output/output.py | 20 ++ frigate/video.py | 142 +++++++++-- web/src/api/ws.tsx | 13 + web/src/components/camera/CameraImage.tsx | 8 +- .../components/camera/ResizingCameraImage.tsx | 4 +- .../dynamic/CameraFeatureToggle.tsx | 23 +- web/src/components/menu/LiveContextMenu.tsx | 114 +++++++-- web/src/components/player/LivePlayer.tsx | 90 +++++-- web/src/components/settings/ZoneEditPane.tsx | 2 +- web/src/hooks/use-camera-activity.ts | 18 +- web/src/pages/Live.tsx | 6 +- web/src/pages/Settings.tsx | 84 +++++-- web/src/types/frigateConfig.ts | 1 + web/src/types/ws.ts | 1 + web/src/views/live/LiveCameraView.tsx | 32 ++- web/src/views/settings/CameraSettingsView.tsx | 29 ++- .../settings/NotificationsSettingsView.tsx | 2 +- 24 files changed, 713 insertions(+), 202 deletions(-) diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 4eaf61919..fc8888e40 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -222,6 +222,14 @@ Publishes the rms value for audio detected on this camera. **NOTE:** Requires audio detection to be enabled +### `frigate//enabled/set` + +Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`. + +### `frigate//enabled/state` + +Topic with current state of processing for a camera. Published values are `ON` and `OFF`. + ### `frigate//detect/set` Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. diff --git a/frigate/camera/activity_manager.py b/frigate/camera/activity_manager.py index a6e40f4ca..7f6354641 100644 --- a/frigate/camera/activity_manager.py +++ b/frigate/camera/activity_manager.py @@ -20,7 +20,7 @@ class CameraActivityManager: self.all_zone_labels: dict[str, set[str]] = {} for camera_config in config.cameras.values(): - if not camera_config.enabled: + if not camera_config.enabled_in_config: continue self.last_camera_activity[camera_config.name] = {} diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 61530d086..586b70cbb 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -55,6 +55,7 @@ class Dispatcher: self._camera_settings_handlers: dict[str, Callable] = { "audio": self._on_audio_command, "detect": self._on_detect_command, + "enabled": self._on_enabled_command, "improve_contrast": self._on_motion_improve_contrast_command, "ptz_autotracker": self._on_ptz_autotracker_command, "motion": self._on_motion_command, @@ -167,6 +168,7 @@ class Dispatcher: for camera in camera_status.keys(): camera_status[camera]["config"] = { "detect": self.config.cameras[camera].detect.enabled, + "enabled": self.config.cameras[camera].enabled, "snapshots": self.config.cameras[camera].snapshots.enabled, "record": self.config.cameras[camera].record.enabled, "audio": self.config.cameras[camera].audio.enabled, @@ -278,6 +280,27 @@ class Dispatcher: self.config_updater.publish(f"config/detect/{camera_name}", detect_settings) self.publish(f"{camera_name}/detect/state", payload, retain=True) + def _on_enabled_command(self, camera_name: str, payload: str) -> None: + """Callback for camera topic.""" + camera_settings = self.config.cameras[camera_name] + + if payload == "ON": + if not self.config.cameras[camera_name].enabled_in_config: + logger.error( + "Camera must be enabled in the config to be turned on via MQTT." + ) + return + if not camera_settings.enabled: + logger.info(f"Turning on camera {camera_name}") + camera_settings.enabled = True + elif payload == "OFF": + if camera_settings.enabled: + logger.info(f"Turning off camera {camera_name}") + camera_settings.enabled = False + + self.config_updater.publish(f"config/enabled/{camera_name}", camera_settings) + self.publish(f"{camera_name}/enabled/state", payload, retain=True) + def _on_motion_command(self, camera_name: str, payload: str) -> None: """Callback for motion topic.""" detect_settings = self.config.cameras[camera_name].detect diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 50f61f33c..2d928661e 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -102,6 +102,9 @@ class CameraConfig(FrigateBaseModel): zones: dict[str, ZoneConfig] = Field( default_factory=dict, title="Zone configuration." ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of camera." + ) _ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr() diff --git a/frigate/config/config.py b/frigate/config/config.py index d2ca9a6f5..633aef803 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -516,6 +516,7 @@ class FrigateConfig(FrigateBaseModel): camera_config.detect.stationary.interval = stationary_threshold # set config pre-value + camera_config.enabled_in_config = camera_config.enabled camera_config.audio.enabled_in_config = camera_config.audio.enabled camera_config.record.enabled_in_config = camera_config.record.enabled camera_config.notifications.enabled_in_config = ( diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 137883b2b..783c2b2d0 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -10,6 +10,7 @@ from typing import Callable, Optional import cv2 import numpy as np +from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.dispatcher import Dispatcher from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher @@ -61,6 +62,7 @@ class CameraState: self.previous_frame_id = None self.callbacks = defaultdict(list) self.ptz_autotracker_thread = ptz_autotracker_thread + self.prev_enabled = self.camera_config.enabled def get_current_frame(self, draw_options={}): with self.current_frame_lock: @@ -310,6 +312,7 @@ class CameraState: # TODO: can i switch to looking this up and only changing when an event ends? # maintain best objects camera_activity: dict[str, list[any]] = { + "enabled": True, "motion": len(motion_boxes) > 0, "objects": [], } @@ -437,6 +440,11 @@ class TrackedObjectProcessor(threading.Thread): self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread + self.enabled_subscribers = { + camera: ConfigSubscriber(f"config/enabled/{camera}", True) + for camera in config.cameras.keys() + } + self.requestor = InterProcessRequestor() self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) self.event_sender = EventUpdatePublisher() @@ -679,8 +687,55 @@ class TrackedObjectProcessor(threading.Thread): """Returns the latest frame time for a given camera.""" return self.camera_states[camera].current_frame_time + def force_end_all_events(self, camera: str, camera_state: CameraState): + """Ends all active events on camera when disabling.""" + last_frame_name = camera_state.previous_frame_id + for obj_id, obj in list(camera_state.tracked_objects.items()): + if "end_time" not in obj.obj_data: + logger.debug(f"Camera {camera} disabled, ending active event {obj_id}") + obj.obj_data["end_time"] = datetime.datetime.now().timestamp() + # end callbacks + for callback in camera_state.callbacks["end"]: + callback(camera, obj, last_frame_name) + + # camera activity callbacks + for callback in camera_state.callbacks["camera_activity"]: + callback( + camera, + {"enabled": False, "motion": 0, "objects": []}, + ) + + def _get_enabled_state(self, camera: str) -> bool: + _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: + enabled = config_data.enabled + if self.camera_states[camera].prev_enabled is None: + self.camera_states[camera].prev_enabled = enabled + return enabled + return ( + self.camera_states[camera].prev_enabled + if self.camera_states[camera].prev_enabled is not None + else self.config.cameras[camera].enabled + ) + def run(self): while not self.stop_event.is_set(): + for camera, config in self.config.cameras.items(): + if not config.enabled_in_config: + continue + + current_enabled = self._get_enabled_state(camera) + camera_state = self.camera_states[camera] + + if camera_state.prev_enabled and not current_enabled: + logger.debug(f"Not processing objects for disabled camera {camera}") + self.force_end_all_events(camera, camera_state) + + camera_state.prev_enabled = current_enabled + + if not current_enabled: + continue + try: ( camera, @@ -693,6 +748,10 @@ class TrackedObjectProcessor(threading.Thread): except queue.Empty: continue + if not self._get_enabled_state(camera): + logger.debug(f"Camera {camera} disabled, skipping update") + continue + camera_state = self.camera_states[camera] camera_state.update( @@ -735,4 +794,7 @@ class TrackedObjectProcessor(threading.Thread): self.detection_publisher.stop() self.event_sender.stop() self.event_end_subscriber.stop() + for subscriber in self.enabled_subscribers.values(): + subscriber.stop() + logger.info("Exiting object processor...") diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 8331eb64a..3d036e9d5 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -10,6 +10,7 @@ import queue import subprocess as sp import threading import traceback +from typing import Optional import cv2 import numpy as np @@ -280,6 +281,12 @@ class BirdsEyeFrameManager: self.stop_event = stop_event self.inactivity_threshold = config.birdseye.inactivity_threshold + self.enabled_subscribers = { + cam: ConfigSubscriber(f"config/enabled/{cam}", True) + for cam in config.cameras.keys() + if config.cameras[cam].enabled_in_config + } + if config.birdseye.layout.max_cameras: self.last_refresh_time = 0 @@ -380,8 +387,18 @@ class BirdsEyeFrameManager: if mode == BirdseyeModeEnum.objects and object_box_count > 0: return True - def update_frame(self, frame: np.ndarray): - """Update to a new frame for birdseye.""" + def _get_enabled_state(self, camera: str) -> bool: + """Fetch the latest enabled state for a camera from ZMQ.""" + _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: + return config_data.enabled + return self.config.cameras[camera].enabled + + def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: + """ + Update birdseye, optionally with a new frame. + When no frame is passed, check the layout and update for any disabled cameras. + """ # determine how many cameras are tracking objects within the last inactivity_threshold seconds active_cameras: set[str] = set( @@ -389,11 +406,14 @@ class BirdsEyeFrameManager: cam for cam, cam_data in self.cameras.items() if self.config.cameras[cam].birdseye.enabled + and self.config.cameras[cam].enabled_in_config + and self._get_enabled_state(cam) and cam_data["last_active_frame"] > 0 and cam_data["current_frame_time"] - cam_data["last_active_frame"] < self.inactivity_threshold ] ) + logger.debug(f"Active cameras: {active_cameras}") max_cameras = self.config.birdseye.layout.max_cameras max_camera_refresh = False @@ -411,118 +431,125 @@ class BirdsEyeFrameManager: - self.cameras[active_camera]["last_active_frame"] ), ) - active_cameras = limited_active_cameras[ - : self.config.birdseye.layout.max_cameras - ] + active_cameras = limited_active_cameras[:max_cameras] max_camera_refresh = True self.last_refresh_time = now - # if there are no active cameras + # Track if the frame changes + frame_changed = False + + # If no active cameras and layout is already empty, no update needed if len(active_cameras) == 0: # if the layout is already cleared if len(self.camera_layout) == 0: return False # if the layout needs to be cleared - else: - self.camera_layout = [] - self.active_cameras = set() - self.clear_frame() - return True - - # check if we need to reset the layout because there is a different number of cameras - if len(self.active_cameras) - len(active_cameras) == 0: - if len(self.active_cameras) == 1 and self.active_cameras != active_cameras: - reset_layout = True - elif max_camera_refresh: - reset_layout = True - else: - reset_layout = False - else: - reset_layout = True - - # reset the layout if it needs to be different - if reset_layout: - logger.debug("Added new cameras, resetting layout...") + self.camera_layout = [] + self.active_cameras = set() self.clear_frame() - self.active_cameras = active_cameras - - # this also converts added_cameras from a set to a list since we need - # to pop elements in order - active_cameras_to_add = sorted( - active_cameras, - # sort cameras by order and by name if the order is the same - key=lambda active_camera: ( - self.config.cameras[active_camera].birdseye.order, - active_camera, - ), - ) - - if len(active_cameras) == 1: - # show single camera as fullscreen - camera = active_cameras_to_add[0] - camera_dims = self.cameras[camera]["dimensions"].copy() - scaled_width = int(self.canvas.height * camera_dims[0] / camera_dims[1]) - - # center camera view in canvas and ensure that it fits - if scaled_width < self.canvas.width: - coefficient = 1 - x_offset = int((self.canvas.width - scaled_width) / 2) + frame_changed = True + else: + # Determine if layout needs resetting + if len(self.active_cameras) - len(active_cameras) == 0: + if ( + len(self.active_cameras) == 1 + and self.active_cameras != active_cameras + ): + reset_layout = True + elif max_camera_refresh: + reset_layout = True else: - coefficient = self.canvas.width / scaled_width - x_offset = int( - (self.canvas.width - (scaled_width * coefficient)) / 2 - ) - - self.camera_layout = [ - [ - ( - camera, - ( - x_offset, - 0, - int(scaled_width * coefficient), - int(self.canvas.height * coefficient), - ), - ) - ] - ] + reset_layout = False else: - # calculate optimal layout - coefficient = self.canvas.get_coefficient(len(active_cameras)) - calculating = True + reset_layout = True - # decrease scaling coefficient until height of all cameras can fit into the birdseye canvas - while calculating: - if self.stop_event.is_set(): - return + if reset_layout: + logger.debug("Resetting Birdseye layout...") + self.clear_frame() + self.active_cameras = active_cameras - layout_candidate = self.calculate_layout( - active_cameras_to_add, - coefficient, + # this also converts added_cameras from a set to a list since we need + # to pop elements in order + active_cameras_to_add = sorted( + active_cameras, + # sort cameras by order and by name if the order is the same + key=lambda active_camera: ( + self.config.cameras[active_camera].birdseye.order, + active_camera, + ), + ) + if len(active_cameras) == 1: + # show single camera as fullscreen + camera = active_cameras_to_add[0] + camera_dims = self.cameras[camera]["dimensions"].copy() + scaled_width = int( + self.canvas.height * camera_dims[0] / camera_dims[1] ) - if not layout_candidate: - if coefficient < 10: - coefficient += 1 - continue - else: - logger.error("Error finding appropriate birdseye layout") + # center camera view in canvas and ensure that it fits + if scaled_width < self.canvas.width: + coefficient = 1 + x_offset = int((self.canvas.width - scaled_width) / 2) + else: + coefficient = self.canvas.width / scaled_width + x_offset = int( + (self.canvas.width - (scaled_width * coefficient)) / 2 + ) + + self.camera_layout = [ + [ + ( + camera, + ( + x_offset, + 0, + int(scaled_width * coefficient), + int(self.canvas.height * coefficient), + ), + ) + ] + ] + else: + # calculate optimal layout + coefficient = self.canvas.get_coefficient(len(active_cameras)) + calculating = True + + # decrease scaling coefficient until height of all cameras can fit into the birdseye canvas + while calculating: + if self.stop_event.is_set(): return - calculating = False - self.canvas.set_coefficient(len(active_cameras), coefficient) + layout_candidate = self.calculate_layout( + active_cameras_to_add, coefficient + ) - self.camera_layout = layout_candidate + if not layout_candidate: + if coefficient < 10: + coefficient += 1 + continue + else: + logger.error( + "Error finding appropriate birdseye layout" + ) + return + calculating = False + self.canvas.set_coefficient(len(active_cameras), coefficient) - for row in self.camera_layout: - for position in row: - self.copy_to_position( - position[1], - position[0], - self.cameras[position[0]]["current_frame"], - ) + self.camera_layout = layout_candidate + frame_changed = True - return True + # Draw the layout + for row in self.camera_layout: + for position in row: + src_frame = self.cameras[position[0]]["current_frame"] + if src_frame is None or src_frame.size == 0: + logger.debug(f"Skipping invalid frame for {position[0]}") + continue + self.copy_to_position(position[1], position[0], src_frame) + if frame is not None: # Frame presence indicates a potential change + frame_changed = True + + return frame_changed def calculate_layout( self, @@ -678,11 +705,8 @@ class BirdsEyeFrameManager: # don't process if birdseye is disabled for this camera camera_config = self.config.cameras[camera].birdseye - if not camera_config.enabled: - return False - # disabling birdseye is a little tricky - if not camera_config.enabled: + if not camera_config.enabled or not self._get_enabled_state(camera): # if we've rendered a frame (we have a value for last_active_frame) # then we need to set it to zero if self.cameras[camera]["last_active_frame"] > 0: @@ -716,6 +740,11 @@ class BirdsEyeFrameManager: return True return False + def stop(self): + """Clean up subscribers when stopping.""" + for subscriber in self.enabled_subscribers.values(): + subscriber.stop() + class Birdseye: def __init__( @@ -743,6 +772,7 @@ class Birdseye: self.birdseye_manager = BirdsEyeFrameManager(config, stop_event) self.config_subscriber = ConfigSubscriber("config/birdseye/") self.frame_manager = SharedMemoryFrameManager() + self.stop_event = stop_event if config.birdseye.restream: self.birdseye_buffer = self.frame_manager.create( @@ -794,5 +824,6 @@ class Birdseye: def stop(self) -> None: self.config_subscriber.stop() + self.birdseye_manager.stop() self.converter.join() self.broadcaster.join() diff --git a/frigate/output/output.py b/frigate/output/output.py index bb2d73511..9beb87250 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -17,6 +17,7 @@ from ws4py.server.wsgirefserver import ( ) from ws4py.server.wsgiutils import WebSocketWSGIApplication +from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.ws import WebSocket from frigate.config import FrigateConfig @@ -59,6 +60,12 @@ def output_frames( detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video) + enabled_subscribers = { + camera: ConfigSubscriber(f"config/enabled/{camera}", True) + for camera in config.cameras.keys() + if config.cameras[camera].enabled_in_config + } + jsmpeg_cameras: dict[str, JsmpegCamera] = {} birdseye: Optional[Birdseye] = None preview_recorders: dict[str, PreviewRecorder] = {} @@ -80,6 +87,13 @@ def output_frames( websocket_thread.start() + def get_enabled_state(camera: str) -> bool: + _, config_data = enabled_subscribers[camera].check_for_update() + if config_data: + return config_data.enabled + # default + return config.cameras[camera].enabled + while not stop_event.is_set(): (topic, data) = detection_subscriber.check_for_update(timeout=1) @@ -95,6 +109,9 @@ def output_frames( _, ) = data + if not get_enabled_state(camera): + continue + frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv) if frame is None: @@ -184,6 +201,9 @@ def output_frames( if birdseye is not None: birdseye.stop() + for subscriber in enabled_subscribers.values(): + subscriber.stop() + websocket_server.manager.close_all() websocket_server.manager.stop() websocket_server.manager.join() diff --git a/frigate/video.py b/frigate/video.py index 233cebb9e..69f6c1bfa 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -108,8 +108,20 @@ def capture_frames( frame_rate.start() skipped_eps = EventsPerSecond() skipped_eps.start() + config_subscriber = ConfigSubscriber(f"config/enabled/{config.name}", True) + + def get_enabled_state(): + """Fetch the latest enabled state from ZMQ.""" + _, config_data = config_subscriber.check_for_update() + if config_data: + return config_data.enabled + return config.enabled + + while not stop_event.is_set(): + if not get_enabled_state(): + logger.debug(f"Stopping capture thread for disabled {config.name}") + break - while True: fps.value = frame_rate.eps() skipped_fps.value = skipped_eps.eps() current_frame.value = datetime.datetime.now().timestamp() @@ -178,26 +190,37 @@ class CameraWatchdog(threading.Thread): self.stop_event = stop_event self.sleeptime = self.config.ffmpeg.retry_interval - def run(self): - self.start_ffmpeg_detect() + self.config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True) + self.was_enabled = self.config.enabled - for c in self.config.ffmpeg_cmds: - if "detect" in c["roles"]: - continue - logpipe = LogPipe( - f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}" - ) - self.ffmpeg_other_processes.append( - { - "cmd": c["cmd"], - "roles": c["roles"], - "logpipe": logpipe, - "process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe), - } - ) + def _update_enabled_state(self) -> bool: + """Fetch the latest config and update enabled state.""" + _, config_data = self.config_subscriber.check_for_update() + if config_data: + enabled = config_data.enabled + return enabled + return self.was_enabled if self.was_enabled is not None else self.config.enabled + + def run(self): + if self._update_enabled_state(): + self.start_all_ffmpeg() time.sleep(self.sleeptime) while not self.stop_event.wait(self.sleeptime): + enabled = self._update_enabled_state() + if enabled != self.was_enabled: + if enabled: + self.logger.debug(f"Enabling camera {self.camera_name}") + self.start_all_ffmpeg() + else: + self.logger.debug(f"Disabling camera {self.camera_name}") + self.stop_all_ffmpeg() + self.was_enabled = enabled + continue + + if not enabled: + continue + now = datetime.datetime.now().timestamp() if not self.capture_thread.is_alive(): @@ -279,11 +302,9 @@ class CameraWatchdog(threading.Thread): p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] ) - stop_ffmpeg(self.ffmpeg_detect_process, self.logger) - for p in self.ffmpeg_other_processes: - stop_ffmpeg(p["process"], self.logger) - p["logpipe"].close() + self.stop_all_ffmpeg() self.logpipe.close() + self.config_subscriber.stop() def start_ffmpeg_detect(self): ffmpeg_cmd = [ @@ -306,6 +327,43 @@ class CameraWatchdog(threading.Thread): ) self.capture_thread.start() + def start_all_ffmpeg(self): + """Start all ffmpeg processes (detection and others).""" + logger.debug(f"Starting all ffmpeg processes for {self.camera_name}") + self.start_ffmpeg_detect() + for c in self.config.ffmpeg_cmds: + if "detect" in c["roles"]: + continue + logpipe = LogPipe( + f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}" + ) + self.ffmpeg_other_processes.append( + { + "cmd": c["cmd"], + "roles": c["roles"], + "logpipe": logpipe, + "process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe), + } + ) + + def stop_all_ffmpeg(self): + """Stop all ffmpeg processes (detection and others).""" + logger.debug(f"Stopping all ffmpeg processes for {self.camera_name}") + if self.capture_thread is not None and self.capture_thread.is_alive(): + self.capture_thread.join(timeout=5) + if self.capture_thread.is_alive(): + self.logger.warning( + f"Capture thread for {self.camera_name} did not stop gracefully." + ) + if self.ffmpeg_detect_process is not None: + stop_ffmpeg(self.ffmpeg_detect_process, self.logger) + self.ffmpeg_detect_process = None + for p in self.ffmpeg_other_processes[:]: + if p["process"] is not None: + stop_ffmpeg(p["process"], self.logger) + p["logpipe"].close() + self.ffmpeg_other_processes.clear() + def get_latest_segment_datetime(self, latest_segment: datetime.datetime) -> int: """Checks if ffmpeg is still writing recording segments to cache.""" cache_files = sorted( @@ -539,7 +597,8 @@ def process_frames( exit_on_empty: bool = False, ): next_region_update = get_tomorrow_at_time(2) - config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True) + detect_config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True) + enabled_config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True) fps_tracker = EventsPerSecond() fps_tracker.start() @@ -549,9 +608,43 @@ def process_frames( region_min_size = get_min_region_size(model_config) + prev_enabled = None + while not stop_event.is_set(): + _, enabled_config = enabled_config_subscriber.check_for_update() + current_enabled = ( + enabled_config.enabled + if enabled_config + else (prev_enabled if prev_enabled is not None else True) + ) + if prev_enabled is None: + prev_enabled = current_enabled + + if prev_enabled and not current_enabled and camera_metrics.frame_queue.empty(): + logger.debug(f"Camera {camera_name} disabled, clearing tracked objects") + + # Clear norfair's dictionaries + object_tracker.tracked_objects.clear() + object_tracker.disappeared.clear() + object_tracker.stationary_box_history.clear() + object_tracker.positions.clear() + object_tracker.track_id_map.clear() + + # Clear internal norfair states + for trackers_by_type in object_tracker.trackers.values(): + for tracker in trackers_by_type.values(): + tracker.tracked_objects = [] + for tracker in object_tracker.default_tracker.values(): + tracker.tracked_objects = [] + + prev_enabled = current_enabled + + if not current_enabled: + time.sleep(0.1) + continue + # check for updated detect config - _, updated_detect_config = config_subscriber.check_for_update() + _, updated_detect_config = detect_config_subscriber.check_for_update() if updated_detect_config: detect_config = updated_detect_config @@ -845,4 +938,5 @@ def process_frames( motion_detector.stop() requestor.stop() - config_subscriber.stop() + detect_config_subscriber.stop() + enabled_config_subscriber.stop() diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 7ca9ae69d..27600993a 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -56,6 +56,7 @@ function useValue(): useValueReturn { const { record, detect, + enabled, snapshots, audio, notifications, @@ -67,6 +68,7 @@ function useValue(): useValueReturn { // @ts-expect-error we know this is correct state["config"]; cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF"; + cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF"; cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF"; cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF"; cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF"; @@ -164,6 +166,17 @@ export function useWs(watchTopic: string, publishTopic: string) { return { value, send }; } +export function useEnabledState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`); + return { payload: payload as ToggleableSetting, send }; +} + export function useDetectState(camera: string): { payload: ToggleableSetting; send: (payload: ToggleableSetting, retain?: boolean) => void; diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index ba35d643e..fe6586fcc 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -5,6 +5,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; import { useResizeObserver } from "@/hooks/resize-observer"; import { isDesktop } from "react-device-detect"; import { cn } from "@/lib/utils"; +import { useEnabledState } from "@/api/ws"; type CameraImageProps = { className?: string; @@ -26,7 +27,8 @@ export default function CameraImage({ const imgRef = useRef(null); const { name } = config ? config.cameras[camera] : ""; - const enabled = config ? config.cameras[camera].enabled : "True"; + const { payload: enabledState } = useEnabledState(camera); + const enabled = enabledState === "ON" || enabledState === undefined; const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(containerRef); @@ -96,9 +98,7 @@ export default function CameraImage({ loading="lazy" /> ) : ( -
- Camera is disabled in config, no stream or snapshot available! -
+
)} {!imageLoaded && enabled ? (
diff --git a/web/src/components/camera/ResizingCameraImage.tsx b/web/src/components/camera/ResizingCameraImage.tsx index 81545c625..fbb57677b 100644 --- a/web/src/components/camera/ResizingCameraImage.tsx +++ b/web/src/components/camera/ResizingCameraImage.tsx @@ -108,9 +108,7 @@ export default function CameraImage({ width={scaledWidth} /> ) : ( -
- Camera is disabled in config, no stream or snapshot available! -
+
Camera is disabled.
)} {!hasLoaded && enabled ? (
void; + disabled?: boolean; // New prop for disabling }; export default function CameraFeatureToggle({ @@ -35,18 +40,28 @@ export default function CameraFeatureToggle({ Icon, title, onClick, + disabled = false, // Default to false }: CameraFeatureToggleProps) { const content = (
); @@ -54,7 +69,7 @@ export default function CameraFeatureToggle({ if (isDesktop) { return ( - {content} + {content}

{title}

diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 969e647a0..9c775e0ac 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -39,7 +39,11 @@ import { import { cn } from "@/lib/utils"; import { useNavigate } from "react-router-dom"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; -import { useNotifications, useNotificationSuspend } from "@/api/ws"; +import { + useEnabledState, + useNotifications, + useNotificationSuspend, +} from "@/api/ws"; type LiveContextMenuProps = { className?: string; @@ -83,6 +87,11 @@ export default function LiveContextMenu({ }: LiveContextMenuProps) { const [showSettings, setShowSettings] = useState(false); + // camera enabled + + const { payload: enabledState, send: sendEnabled } = useEnabledState(camera); + const isEnabled = enabledState === "ON"; + // streaming settings const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = @@ -263,7 +272,7 @@ export default function LiveContextMenu({ onClick={handleVolumeIconClick} />
sendEnabled(isEnabled ? "OFF" : "ON")} + > +
+ {isEnabled ? "Disable" : "Enable"} Camera +
+
+ + + +
Mute All Cameras
- +
Unmute All Cameras
- +
{statsState ? "Hide" : "Show"} Stream Stats
- +
navigate(`/settings?page=debug&camera=${camera}`)} + onClick={ + isEnabled + ? () => navigate(`/settings?page=debug&camera=${camera}`) + : undefined + } >
Debug View
@@ -315,10 +339,10 @@ export default function LiveContextMenu({ {cameraGroup && cameraGroup !== "default" && ( <> - +
setShowSettings(true)} + onClick={isEnabled ? () => setShowSettings(true) : undefined} >
Streaming Settings
@@ -328,10 +352,10 @@ export default function LiveContextMenu({ {preferredLiveMode == "jsmpeg" && isRestreamed && ( <> - +
Reset
@@ -342,7 +366,7 @@ export default function LiveContextMenu({ <> - +
Notifications
@@ -382,10 +406,15 @@ export default function LiveContextMenu({ <> { - sendNotification("ON"); - sendNotificationSuspend(0); - }} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => { + sendNotification("ON"); + sendNotificationSuspend(0); + } + : undefined + } >
{notificationState === "ON" ? ( @@ -405,36 +434,71 @@ export default function LiveContextMenu({ Suspend for:

- handleSuspend("5")}> + handleSuspend("5") : undefined + } + > 5 minutes handleSuspend("10")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("10") + : undefined + } > 10 minutes handleSuspend("30")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("30") + : undefined + } > 30 minutes handleSuspend("60")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("60") + : undefined + } > 1 hour handleSuspend("840")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("840") + : undefined + } > 12 hours handleSuspend("1440")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("1440") + : undefined + } > 24 hours handleSuspend("off")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("off") + : undefined + } > Until restart diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 4bd751469..f2b0639a4 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -22,6 +22,7 @@ import { TbExclamationCircle } from "react-icons/tb"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { baseUrl } from "@/api/baseUrl"; import { PlayerStats } from "./PlayerStats"; +import { LuVideoOff } from "react-icons/lu"; type LivePlayerProps = { cameraRef?: (ref: HTMLDivElement | null) => void; @@ -86,8 +87,13 @@ export default function LivePlayer({ // camera activity - const { activeMotion, activeTracking, objects, offline } = - useCameraActivity(cameraConfig); + const { + enabled: cameraEnabled, + activeMotion, + activeTracking, + objects, + offline, + } = useCameraActivity(cameraConfig); const cameraActive = useMemo( () => @@ -191,12 +197,37 @@ export default function LivePlayer({ setLiveReady(true); }, []); + // enabled states + + const [isReEnabling, setIsReEnabling] = useState(false); + const prevCameraEnabledRef = useRef(cameraEnabled); + + useEffect(() => { + if (!prevCameraEnabledRef.current && cameraEnabled) { + // Camera enabled + setLiveReady(false); + setIsReEnabling(true); + setKey((prevKey) => prevKey + 1); + } else if (prevCameraEnabledRef.current && !cameraEnabled) { + // Camera disabled + setLiveReady(false); + setKey((prevKey) => prevKey + 1); + } + prevCameraEnabledRef.current = cameraEnabled; + }, [cameraEnabled]); + + useEffect(() => { + if (liveReady && isReEnabling) { + setIsReEnabling(false); + } + }, [liveReady, isReEnabling]); + if (!cameraConfig) { return ; } let player; - if (!autoLive || !streamName) { + if (!autoLive || !streamName || !cameraEnabled) { player = null; } else if (preferredLiveMode == "webrtc") { player = ( @@ -267,6 +298,22 @@ export default function LivePlayer({ player = ; } + // if (cameraConfig.name == "lpr") + // console.log( + // cameraConfig.name, + // "enabled", + // cameraEnabled, + // "prev enabled", + // prevCameraEnabledRef.current, + // "offline", + // offline, + // "show still", + // showStillWithoutActivity, + // "live ready", + // liveReady, + // player, + // ); + return (
- {((showStillWithoutActivity && !liveReady) || liveReady) && ( - <> -
-
- - )} + {cameraEnabled && + ((showStillWithoutActivity && !liveReady) || liveReady) && ( + <> +
+
+ + )} {player} - {!offline && !showStillWithoutActivity && !liveReady && ( - - )} + {cameraEnabled && + !offline && + (!showStillWithoutActivity || isReEnabling) && + !liveReady && } {((showStillWithoutActivity && !liveReady) || liveReady) && objects.length > 0 && ( @@ -344,7 +393,9 @@ export default function LivePlayer({
)} + {!cameraEnabled && ( +
+
+ +

+ Camera is disabled +

+
+
+ )} +
{autoLive && !offline && @@ -378,7 +440,7 @@ export default function LivePlayer({ ((showStillWithoutActivity && !liveReady) || liveReady) && ( )} - {offline && showStillWithoutActivity && ( + {((offline && showStillWithoutActivity) || !cameraEnabled) && ( diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 247ae8991..c6c5ee474 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -68,7 +68,7 @@ export default function ZoneEditPane({ } return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) + .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index bbf70ba32..14a575224 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -1,4 +1,5 @@ import { + useEnabledState, useFrigateEvents, useInitialCameraState, useMotionActivity, @@ -15,6 +16,7 @@ import useSWR from "swr"; import { getAttributeLabels } from "@/utils/iconUtil"; type useCameraActivityReturn = { + enabled: boolean; activeTracking: boolean; activeMotion: boolean; objects: ObjectType[]; @@ -56,6 +58,7 @@ export function useCameraActivity( [objects], ); + const { payload: cameraEnabled } = useEnabledState(camera.name); const { payload: detectingMotion } = useMotionActivity(camera.name); const { payload: event } = useFrigateEvents(); const updatedEvent = useDeepMemo(event); @@ -145,12 +148,17 @@ export function useCameraActivity( return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60; }, [camera, stats]); + const isCameraEnabled = cameraEnabled === "ON"; + return { - activeTracking: hasActiveObjects, - activeMotion: detectingMotion - ? detectingMotion === "ON" - : updatedCameraState?.motion === true, - objects, + enabled: isCameraEnabled, + activeTracking: isCameraEnabled ? hasActiveObjects : false, + activeMotion: isCameraEnabled + ? detectingMotion + ? detectingMotion === "ON" + : updatedCameraState?.motion === true + : false, + objects: isCameraEnabled ? objects : [], offline, }; } diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 97e565ef1..016f3cba1 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -101,12 +101,14 @@ function Live() { ) { const group = config.camera_groups[cameraGroup]; return Object.values(config.cameras) - .filter((conf) => conf.enabled && group.cameras.includes(conf.name)) + .filter( + (conf) => conf.enabled_in_config && group.cameras.includes(conf.name), + ) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); } return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) + .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config, cameraGroup]); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 6eeb5bcc3..33f854ba3 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -39,6 +39,7 @@ import SearchSettingsView from "@/views/settings/SearchSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; +import { useInitialCameraState } from "@/api/ws"; const allSettingsViews = [ "UI settings", @@ -71,12 +72,33 @@ export default function Settings() { } return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) + .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); const [selectedCamera, setSelectedCamera] = useState(""); + const { payload: allCameraStates } = useInitialCameraState( + cameras.length > 0 ? cameras[0].name : "", + true, + ); + + const cameraEnabledStates = useMemo(() => { + const states: Record = {}; + if (allCameraStates) { + Object.entries(allCameraStates).forEach(([camName, state]) => { + states[camName] = state.config?.enabled ?? false; + }); + } + // fallback to config if ws data isn’t available yet + cameras.forEach((cam) => { + if (!(cam.name in states)) { + states[cam.name] = cam.enabled; + } + }); + return states; + }, [allCameraStates, cameras]); + const [filterZoneMask, setFilterZoneMask] = useState(); const handleDialog = useCallback( @@ -91,10 +113,25 @@ export default function Settings() { ); useEffect(() => { - if (cameras.length > 0 && selectedCamera === "") { - setSelectedCamera(cameras[0].name); + if (cameras.length > 0) { + if (!selectedCamera) { + // Set to first enabled camera initially if no selection + const firstEnabledCamera = + cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; + setSelectedCamera(firstEnabledCamera.name); + } else if ( + !cameraEnabledStates[selectedCamera] && + page !== "camera settings" + ) { + // Switch to first enabled camera if current one is disabled, unless on "camera settings" page + const firstEnabledCamera = + cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; + if (firstEnabledCamera.name !== selectedCamera) { + setSelectedCamera(firstEnabledCamera.name); + } + } } - }, [cameras, selectedCamera]); + }, [cameras, selectedCamera, cameraEnabledStates, page]); useEffect(() => { if (tabsRef.current) { @@ -177,6 +214,8 @@ export default function Settings() { allCameras={cameras} selectedCamera={selectedCamera} setSelectedCamera={setSelectedCamera} + cameraEnabledStates={cameraEnabledStates} + currentPage={page} />
)} @@ -244,17 +283,21 @@ type CameraSelectButtonProps = { allCameras: CameraConfig[]; selectedCamera: string; setSelectedCamera: React.Dispatch>; + cameraEnabledStates: Record; + currentPage: SettingsType; }; function CameraSelectButton({ allCameras, selectedCamera, setSelectedCamera, + cameraEnabledStates, + currentPage, }: CameraSelectButtonProps) { const [open, setOpen] = useState(false); if (!allCameras.length) { - return; + return null; } const trigger = ( @@ -283,19 +326,24 @@ function CameraSelectButton({ )}
- {allCameras.map((item) => ( - { - if (isChecked) { - setSelectedCamera(item.name); - setOpen(false); - } - }} - /> - ))} + {allCameras.map((item) => { + const isEnabled = cameraEnabledStates[item.name]; + const isCameraSettingsPage = currentPage === "camera settings"; + return ( + { + if (isChecked && (isEnabled || isCameraSettingsPage)) { + setSelectedCamera(item.name); + setOpen(false); + } + }} + disabled={!isEnabled && !isCameraSettingsPage} + /> + ); + })}
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 4ec4de853..e468c534f 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -57,6 +57,7 @@ export interface CameraConfig { width: number; }; enabled: boolean; + enabled_in_config: boolean; ffmpeg: { global_args: string[]; hwaccel_args: string; diff --git a/web/src/types/ws.ts b/web/src/types/ws.ts index 397b213f6..2590d45a7 100644 --- a/web/src/types/ws.ts +++ b/web/src/types/ws.ts @@ -52,6 +52,7 @@ export type ObjectType = { }; export interface FrigateCameraState { + enabled: boolean; motion: boolean; objects: ObjectType[]; } diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index ccf06de7b..9b45c5a60 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -2,6 +2,7 @@ import { useAudioState, useAutotrackingState, useDetectState, + useEnabledState, usePtzCommand, useRecordingsState, useSnapshotsState, @@ -82,6 +83,8 @@ import { LuHistory, LuInfo, LuPictureInPicture, + LuPower, + LuPowerOff, LuVideo, LuVideoOff, LuX, @@ -185,6 +188,10 @@ export default function LiveCameraView({ ); }, [cameraMetadata]); + // camera enabled state + const { payload: enabledState } = useEnabledState(camera.name); + const cameraEnabled = enabledState === "ON"; + // click overlay for ptzs const [clickOverlay, setClickOverlay] = useState(false); @@ -470,6 +477,7 @@ export default function LiveCameraView({ setPip(false); } }} + disabled={!cameraEnabled} /> )} {supports2WayTalk && ( @@ -481,11 +489,11 @@ export default function LiveCameraView({ title={`${mic ? "Disable" : "Enable"} Two Way Talk`} onClick={() => { setMic(!mic); - // Turn on audio when enabling the mic if audio is currently off if (!mic && !audio) { setAudio(true); } }} + disabled={!cameraEnabled} /> )} {supportsAudioOutput && preferredLiveMode != "jsmpeg" && ( @@ -496,6 +504,7 @@ export default function LiveCameraView({ isActive={audio ?? false} title={`${audio ? "Disable" : "Enable"} Camera Audio`} onClick={() => setAudio(!audio)} + disabled={!cameraEnabled} /> )}
@@ -913,6 +923,7 @@ type FrigateCameraFeaturesProps = { setLowBandwidth: React.Dispatch>; supportsAudioOutput: boolean; supports2WayTalk: boolean; + cameraEnabled: boolean; }; function FrigateCameraFeatures({ camera, @@ -931,10 +942,14 @@ function FrigateCameraFeatures({ setLowBandwidth, supportsAudioOutput, supports2WayTalk, + cameraEnabled, }: FrigateCameraFeaturesProps) { const { payload: detectState, send: sendDetect } = useDetectState( camera.name, ); + const { payload: enabledState, send: sendEnabled } = useEnabledState( + camera.name, + ); const { payload: recordState, send: sendRecord } = useRecordingsState( camera.name, ); @@ -1043,6 +1058,15 @@ function FrigateCameraFeatures({ if (isDesktop || isTablet) { return ( <> + sendEnabled(enabledState == "ON" ? "OFF" : "ON")} + disabled={false} + /> sendDetect(detectState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> sendRecord(recordState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> {audioDetectEnabled && ( sendAudio(audioState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> )} {autotrackingEnabled && ( @@ -1087,6 +1115,7 @@ function FrigateCameraFeatures({ onClick={() => sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") } + disabled={!cameraEnabled} /> )} diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index fa9d0ba58..e2c1ca563 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -29,7 +29,7 @@ import { MdCircle } from "react-icons/md"; import { cn } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { useAlertsState, useDetectionsState } from "@/api/ws"; +import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws"; type CameraSettingsViewProps = { selectedCamera: string; @@ -108,6 +108,8 @@ export default function CameraSettingsView({ const watchedAlertsZones = form.watch("alerts_zones"); const watchedDetectionsZones = form.watch("detections_zones"); + const { payload: enabledState, send: sendEnabled } = + useEnabledState(selectedCamera); const { payload: alertsState, send: sendAlerts } = useAlertsState(selectedCamera); const { payload: detectionsState, send: sendDetections } = @@ -252,6 +254,31 @@ export default function CameraSettingsView({ + + Streams + + +
+ { + sendEnabled(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+ Disabling a camera completely stops Frigate's processing of this + camera's streams. Detection, recording, and debugging will be + unavailable. +
Note: This does not disable go2rtc restreams. +
+ + Review diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index edae6ba28..fcda4adb1 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -80,7 +80,7 @@ export default function NotificationView({ return Object.values(config.cameras) .filter( (conf) => - conf.enabled && + conf.enabled_in_config && conf.notifications && conf.notifications.enabled_in_config, ) From f3765bc391eb629b049fc494544a9a1dc8ce4120 Mon Sep 17 00:00:00 2001 From: leccelecce <24962424+leccelecce@users.noreply.github.com> Date: Mon, 3 Mar 2025 17:01:02 +0000 Subject: [PATCH 41/63] GenAI minor refactor (#16916) --- frigate/embeddings/maintainer.py | 200 +++++++++++++++---------------- 1 file changed, 96 insertions(+), 104 deletions(-) diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index c9b6062c9..dfaed532e 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -293,6 +293,7 @@ class EmbeddingMaintainer(threading.Thread): # Embed the thumbnail self._embed_thumbnail(event_id, thumbnail) + # Run GenAI if ( camera_config.genai.enabled and self.genai_client is not None @@ -306,82 +307,7 @@ class EmbeddingMaintainer(threading.Thread): or set(event.zones) & set(camera_config.genai.required_zones) ) ): - if event.has_snapshot and camera_config.genai.use_snapshot: - with open( - os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"), - "rb", - ) as image_file: - snapshot_image = image_file.read() - - img = cv2.imdecode( - np.frombuffer(snapshot_image, dtype=np.int8), - cv2.IMREAD_COLOR, - ) - - # crop snapshot based on region before sending off to genai - height, width = img.shape[:2] - x1_rel, y1_rel, width_rel, height_rel = event.data["region"] - - x1, y1 = int(x1_rel * width), int(y1_rel * height) - cropped_image = img[ - y1 : y1 + int(height_rel * height), - x1 : x1 + int(width_rel * width), - ] - - _, buffer = cv2.imencode(".jpg", cropped_image) - snapshot_image = buffer.tobytes() - - num_thumbnails = len(self.tracked_events.get(event_id, [])) - - embed_image = ( - [snapshot_image] - if event.has_snapshot and camera_config.genai.use_snapshot - else ( - [ - data["thumbnail"] - for data in self.tracked_events[event_id] - ] - if num_thumbnails > 0 - else [thumbnail] - ) - ) - - if camera_config.genai.debug_save_thumbnails and num_thumbnails > 0: - logger.debug( - f"Saving {num_thumbnails} thumbnails for event {event.id}" - ) - - Path( - os.path.join(CLIPS_DIR, f"genai-requests/{event.id}") - ).mkdir(parents=True, exist_ok=True) - - for idx, data in enumerate(self.tracked_events[event_id], 1): - jpg_bytes: bytes = data["thumbnail"] - - if jpg_bytes is None: - logger.warning( - f"Unable to save thumbnail {idx} for {event.id}." - ) - else: - with open( - os.path.join( - CLIPS_DIR, - f"genai-requests/{event.id}/{idx}.jpg", - ), - "wb", - ) as j: - j.write(jpg_bytes) - - # Generate the description. Call happens in a thread since it is network bound. - threading.Thread( - target=self._embed_description, - name=f"_embed_description_{event.id}", - daemon=True, - args=( - event, - embed_image, - ), - ).start() + self._process_genai_description(event, camera_config, thumbnail) # Delete tracked events based on the event_id if event_id in self.tracked_events: @@ -440,7 +366,58 @@ class EmbeddingMaintainer(threading.Thread): self.embeddings.embed_thumbnail(event_id, thumbnail) - def _embed_description(self, event: Event, thumbnails: list[bytes]) -> None: + def _process_genai_description(self, event, camera_config, thumbnail) -> None: + if event.has_snapshot and camera_config.genai.use_snapshot: + snapshot_image = self._read_and_crop_snapshot(event, camera_config) + if not snapshot_image: + return + + num_thumbnails = len(self.tracked_events.get(event.id, [])) + + embed_image = ( + [snapshot_image] + if event.has_snapshot and camera_config.genai.use_snapshot + else ( + [data["thumbnail"] for data in self.tracked_events[event.id]] + if num_thumbnails > 0 + else [thumbnail] + ) + ) + + if camera_config.genai.debug_save_thumbnails and num_thumbnails > 0: + logger.debug(f"Saving {num_thumbnails} thumbnails for event {event.id}") + + Path(os.path.join(CLIPS_DIR, f"genai-requests/{event.id}")).mkdir( + parents=True, exist_ok=True + ) + + for idx, data in enumerate(self.tracked_events[event.id], 1): + jpg_bytes: bytes = data["thumbnail"] + + if jpg_bytes is None: + logger.warning(f"Unable to save thumbnail {idx} for {event.id}.") + else: + with open( + os.path.join( + CLIPS_DIR, + f"genai-requests/{event.id}/{idx}.jpg", + ), + "wb", + ) as j: + j.write(jpg_bytes) + + # Generate the description. Call happens in a thread since it is network bound. + threading.Thread( + target=self._genai_embed_description, + name=f"_genai_embed_description_{event.id}", + daemon=True, + args=( + event, + embed_image, + ), + ).start() + + def _genai_embed_description(self, event: Event, thumbnails: list[bytes]) -> None: """Embed the description for an event.""" camera_config = self.config.cameras[event.camera] @@ -473,6 +450,45 @@ class EmbeddingMaintainer(threading.Thread): description, ) + def _read_and_crop_snapshot(self, event: Event, camera_config) -> bytes | None: + """Read, decode, and crop the snapshot image.""" + + snapshot_file = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg") + + if not os.path.isfile(snapshot_file): + logger.error( + f"Cannot load snapshot for {event.id}, file not found: {snapshot_file}" + ) + return None + + try: + with open(snapshot_file, "rb") as image_file: + snapshot_image = image_file.read() + + img = cv2.imdecode( + np.frombuffer(snapshot_image, dtype=np.int8), + cv2.IMREAD_COLOR, + ) + + # Crop snapshot based on region + # provide full image if region doesn't exist (manual events) + height, width = img.shape[:2] + x1_rel, y1_rel, width_rel, height_rel = event.data.get( + "region", [0, 0, 1, 1] + ) + x1, y1 = int(x1_rel * width), int(y1_rel * height) + + cropped_image = img[ + y1 : y1 + int(height_rel * height), + x1 : x1 + int(width_rel * width), + ] + + _, buffer = cv2.imencode(".jpg", cropped_image) + + return buffer.tobytes() + except Exception: + return None + def handle_regenerate_description(self, event_id: str, source: str) -> None: try: event: Event = Event.get(Event.id == event_id) @@ -492,34 +508,10 @@ class EmbeddingMaintainer(threading.Thread): ) if event.has_snapshot and source == "snapshot": - snapshot_file = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg") - - if not os.path.isfile(snapshot_file): - logger.error( - f"Cannot regenerate description for {event.id}, snapshot file not found: {snapshot_file}" - ) + snapshot_image = self._read_and_crop_snapshot(event, camera_config) + if not snapshot_image: return - with open(snapshot_file, "rb") as image_file: - snapshot_image = image_file.read() - img = cv2.imdecode( - np.frombuffer(snapshot_image, dtype=np.int8), cv2.IMREAD_COLOR - ) - - # crop snapshot based on region before sending off to genai - # provide full image if region doesn't exist (manual events) - region = event.data.get("region", [0, 0, 1, 1]) - height, width = img.shape[:2] - x1_rel, y1_rel, width_rel, height_rel = region - - x1, y1 = int(x1_rel * width), int(y1_rel * height) - cropped_image = img[ - y1 : y1 + int(height_rel * height), x1 : x1 + int(width_rel * width) - ] - - _, buffer = cv2.imencode(".jpg", cropped_image) - snapshot_image = buffer.tobytes() - embed_image = ( [snapshot_image] if event.has_snapshot and source == "snapshot" @@ -530,4 +522,4 @@ class EmbeddingMaintainer(threading.Thread): ) ) - self._embed_description(event, embed_image) + self._genai_embed_description(event, embed_image) From 180b0af3c9c28172e8401123961258079ef96393 Mon Sep 17 00:00:00 2001 From: D34DC3N73R Date: Mon, 3 Mar 2025 11:53:24 -0800 Subject: [PATCH 42/63] Adapt openai.py to work with xAI (#16903) * Adapt openai.py to work with xAI It appears xAI is a bit more strict in regards to how the prompt is sent. This changes the prompt to be a dictionary with `"type": "text"` which works with OpenAI and xAI. * Adapt openai.py to work with xAI add "detail": "low" * Adapt openai.py to work with xAI Apply Ruff formatting and linting fixes --- frigate/genai/openai.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/frigate/genai/openai.py b/frigate/genai/openai.py index 4568905a3..4b1926099 100644 --- a/frigate/genai/openai.py +++ b/frigate/genai/openai.py @@ -26,23 +26,30 @@ class OpenAIClient(GenAIClient): def _send(self, prompt: str, images: list[bytes]) -> Optional[str]: """Submit a request to OpenAI.""" encoded_images = [base64.b64encode(image).decode("utf-8") for image in images] + messages_content = [] + for image in encoded_images: + messages_content.append( + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{image}", + "detail": "low", + }, + } + ) + messages_content.append( + { + "type": "text", + "text": prompt, + } + ) try: result = self.provider.chat.completions.create( model=self.genai_config.model, messages=[ { "role": "user", - "content": [ - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{image}", - "detail": "low", - }, - } - for image in encoded_images - ] - + [prompt], + "content": messages_content, }, ], timeout=self.timeout, From 2946c935eea3d492493278f621000d0806594920 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 3 Mar 2025 14:05:49 -0700 Subject: [PATCH 43/63] Disabled camera output (#16920) * Fix live cameras not showing on refresh * Fix live dashboard when birdseye is added * Handle cameras that are offline / disabled * Use black instead of green frame * Fix missing mqtt topics --- frigate/comms/mqtt.py | 6 ++ frigate/output/birdseye.py | 42 +++++++++----- frigate/output/output.py | 66 ++++++++++++++++------ frigate/util/image.py | 16 ++++++ web/src/components/player/LivePlayer.tsx | 2 +- web/src/views/live/DraggableGridLayout.tsx | 10 ++-- web/src/views/live/LiveDashboardView.tsx | 10 ++-- 7 files changed, 110 insertions(+), 42 deletions(-) diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 9e11a0af1..316813518 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -43,6 +43,11 @@ class MqttClient(Communicator): # type: ignore[misc] def _set_initial_topics(self) -> None: """Set initial state topics.""" for camera_name, camera in self.config.cameras.items(): + self.publish( + f"{camera_name}/enabled/state", + "ON" if camera.enabled_in_config else "OFF", + retain=True, + ) self.publish( f"{camera_name}/recordings/state", "ON" if camera.record.enabled_in_config else "OFF", @@ -196,6 +201,7 @@ class MqttClient(Communicator): # type: ignore[misc] # register callbacks callback_types = [ + "enabled", "recordings", "snapshots", "detect", diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 3d036e9d5..cd4aa26ec 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -390,8 +390,11 @@ class BirdsEyeFrameManager: def _get_enabled_state(self, camera: str) -> bool: """Fetch the latest enabled state for a camera from ZMQ.""" _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: + self.config.cameras[camera].enabled = config_data.enabled return config_data.enabled + return self.config.cameras[camera].enabled def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: @@ -704,15 +707,17 @@ class BirdsEyeFrameManager: ) -> bool: # don't process if birdseye is disabled for this camera camera_config = self.config.cameras[camera].birdseye + force_update = False # disabling birdseye is a little tricky - if not camera_config.enabled or not self._get_enabled_state(camera): + if not self._get_enabled_state(camera): # if we've rendered a frame (we have a value for last_active_frame) # then we need to set it to zero if self.cameras[camera]["last_active_frame"] > 0: self.cameras[camera]["last_active_frame"] = 0 - - return False + force_update = True + else: + return False # update the last active frame for the camera self.cameras[camera]["current_frame"] = frame.copy() @@ -723,7 +728,7 @@ class BirdsEyeFrameManager: now = datetime.datetime.now().timestamp() # limit output to 10 fps - if (now - self.last_output_time) < 1 / 10: + if not force_update and (now - self.last_output_time) < 1 / 10: return False try: @@ -735,7 +740,7 @@ class BirdsEyeFrameManager: print(traceback.format_exc()) # if the frame was updated or the fps is too low, send frame - if updated_frame or (now - self.last_output_time) > 1: + if force_update or updated_frame or (now - self.last_output_time) > 1: self.last_output_time = now return True return False @@ -783,6 +788,22 @@ class Birdseye: self.converter.start() self.broadcaster.start() + def __send_new_frame(self) -> None: + frame_bytes = self.birdseye_manager.frame.tobytes() + + if self.config.birdseye.restream: + self.birdseye_buffer[:] = frame_bytes + + try: + self.input.put_nowait(frame_bytes) + except queue.Full: + # drop frames if queue is full + pass + + def all_cameras_disabled(self) -> None: + self.birdseye_manager.clear_frame() + self.__send_new_frame() + def write_data( self, camera: str, @@ -811,16 +832,7 @@ class Birdseye: frame_time, frame, ): - frame_bytes = self.birdseye_manager.frame.tobytes() - - if self.config.birdseye.restream: - self.birdseye_buffer[:] = frame_bytes - - try: - self.input.put_nowait(frame_bytes) - except queue.Full: - # drop frames if queue is full - pass + self.__send_new_frame() def stop(self) -> None: self.config_subscriber.stop() diff --git a/frigate/output/output.py b/frigate/output/output.py index 9beb87250..e0e64e298 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -1,12 +1,12 @@ """Handle outputting raw frigate frames""" +import datetime import logging import multiprocessing as mp import os import shutil import signal import threading -from typing import Optional from wsgiref.simple_server import make_server from setproctitle import setproctitle @@ -25,11 +25,43 @@ from frigate.const import CACHE_DIR, CLIPS_DIR from frigate.output.birdseye import Birdseye from frigate.output.camera import JsmpegCamera from frigate.output.preview import PreviewRecorder -from frigate.util.image import SharedMemoryFrameManager +from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame logger = logging.getLogger(__name__) +def check_disabled_camera_update( + config: FrigateConfig, + birdseye: Birdseye | None, + previews: dict[str, PreviewRecorder], + write_times: dict[str, float], +) -> None: + """Check if camera is disabled / offline and needs an update.""" + now = datetime.datetime.now().timestamp() + has_enabled_camera = False + + for camera, last_update in write_times.items(): + if config.cameras[camera].enabled: + has_enabled_camera = True + + if now - last_update > 1: + # last camera update was more than one second ago + # need to send empty data to updaters because current + # frame is now out of date + frame = get_blank_yuv_frame( + config.cameras[camera].detect.width, + config.cameras[camera].detect.height, + ) + + if birdseye: + birdseye.write_data(camera, [], [], now, frame) + + previews[camera].write_data([], [], now, frame) + + if not has_enabled_camera and birdseye: + birdseye.all_cameras_disabled() + + def output_frames( config: FrigateConfig, ): @@ -67,10 +99,11 @@ def output_frames( } jsmpeg_cameras: dict[str, JsmpegCamera] = {} - birdseye: Optional[Birdseye] = None + birdseye: Birdseye | None = None preview_recorders: dict[str, PreviewRecorder] = {} preview_write_times: dict[str, float] = {} failed_frame_requests: dict[str, int] = {} + last_disabled_cam_check = datetime.datetime.now().timestamp() move_preview_frames("cache") @@ -89,13 +122,23 @@ def output_frames( def get_enabled_state(camera: str) -> bool: _, config_data = enabled_subscribers[camera].check_for_update() + if config_data: + config.cameras[camera].enabled = config_data.enabled return config_data.enabled - # default + return config.cameras[camera].enabled while not stop_event.is_set(): (topic, data) = detection_subscriber.check_for_update(timeout=1) + now = datetime.datetime.now().timestamp() + + if now - last_disabled_cam_check > 5: + # check disabled cameras every 5 seconds + last_disabled_cam_check = now + check_disabled_camera_update( + config, birdseye, preview_recorders, preview_write_times + ) if not topic: continue @@ -151,23 +194,10 @@ def output_frames( ) # send frames for low fps recording - generated_preview = preview_recorders[camera].write_data( + preview_recorders[camera].write_data( current_tracked_objects, motion_boxes, frame_time, frame ) preview_write_times[camera] = frame_time - - # if another camera generated a preview, - # check for any cameras that are currently offline - # and need to generate a preview - if generated_preview: - logger.debug( - "Checking for offline cameras because another camera generated a preview." - ) - for camera, time in preview_write_times.copy().items(): - if time != 0 and frame_time - time > 10: - preview_recorders[camera].flag_offline(frame_time) - preview_write_times[camera] = frame_time - frame_manager.close(frame_name) move_preview_frames("clips") diff --git a/frigate/util/image.py b/frigate/util/image.py index 7e4915821..20806372c 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -632,6 +632,22 @@ def copy_yuv_to_position( ) +def get_blank_yuv_frame(width: int, height: int) -> np.ndarray: + """Creates a black YUV 4:2:0 frame.""" + yuv_height = height * 3 // 2 + yuv_frame = np.zeros((yuv_height, width), dtype=np.uint8) + + uv_height = height // 2 + + # The U and V planes are stored after the Y plane. + u_start = height # U plane starts right after Y plane + v_start = u_start + uv_height // 2 # V plane starts after U plane + yuv_frame[u_start : u_start + uv_height, :width] = 128 + yuv_frame[v_start : v_start + uv_height, :width] = 128 + + return yuv_frame + + def yuv_region_2_yuv(frame, region): try: # TODO: does this copy the numpy array? diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index f2b0639a4..913373774 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -200,7 +200,7 @@ export default function LivePlayer({ // enabled states const [isReEnabling, setIsReEnabling] = useState(false); - const prevCameraEnabledRef = useRef(cameraEnabled); + const prevCameraEnabledRef = useRef(cameraEnabled ?? true); useEffect(() => { if (!prevCameraEnabledRef.current && cameraEnabled) { diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 3b85de4b3..d0da3e5ac 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -396,10 +396,12 @@ export default function DraggableGridLayout({ const initialVolumeStates: VolumeState = {}; Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { - Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { - initialAudioStates[camera] = cameraSettings.playAudio ?? false; - initialVolumeStates[camera] = cameraSettings.volume ?? 1; - }); + if (groupSettings) { + Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { + initialAudioStates[camera] = cameraSettings.playAudio ?? false; + initialVolumeStates[camera] = cameraSettings.volume ?? 1; + }); + } }); setAudioStates(initialAudioStates); diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 45d0d5302..e59fd96ca 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -268,10 +268,12 @@ export default function LiveDashboardView({ const initialVolumeStates: VolumeState = {}; Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { - Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { - initialAudioStates[camera] = cameraSettings.playAudio ?? false; - initialVolumeStates[camera] = cameraSettings.volume ?? 1; - }); + if (groupSettings) { + Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { + initialAudioStates[camera] = cameraSettings.playAudio ?? false; + initialVolumeStates[camera] = cameraSettings.volume ?? 1; + }); + } }); setAudioStates(initialAudioStates); From 56079d080dbd9d414adb5b4be893d576ecfaca86 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 3 Mar 2025 16:28:34 -0700 Subject: [PATCH 44/63] Quick fix (#16926) * fix * Fix * Fix incorrect default websocket value * Cleanup value setting --- frigate/object_processing.py | 14 ++++++-------- web/src/api/ws.tsx | 2 +- web/src/components/player/LivePlayer.tsx | 3 +++ web/src/hooks/use-camera-activity.ts | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 783c2b2d0..a7a2fb066 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -707,16 +707,14 @@ class TrackedObjectProcessor(threading.Thread): def _get_enabled_state(self, camera: str) -> bool: _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: - enabled = config_data.enabled + self.config.cameras[camera].enabled = config_data.enabled + if self.camera_states[camera].prev_enabled is None: - self.camera_states[camera].prev_enabled = enabled - return enabled - return ( - self.camera_states[camera].prev_enabled - if self.camera_states[camera].prev_enabled is not None - else self.config.cameras[camera].enabled - ) + self.camera_states[camera].prev_enabled = config_data.enabled + + return self.config.cameras[camera].enabled def run(self): while not self.stop_event.is_set(): diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 27600993a..5eedcdbcd 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -174,7 +174,7 @@ export function useEnabledState(camera: string): { value: { payload }, send, } = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`); - return { payload: payload as ToggleableSetting, send }; + return { payload: (payload ?? "ON") as ToggleableSetting, send }; } export function useDetectState(camera: string): { diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 913373774..ae9fd6197 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -203,6 +203,9 @@ export default function LivePlayer({ const prevCameraEnabledRef = useRef(cameraEnabled ?? true); useEffect(() => { + if (cameraEnabled == undefined) { + return; + } if (!prevCameraEnabledRef.current && cameraEnabled) { // Camera enabled setLiveReady(false); diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index 14a575224..28eb8c67d 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -16,7 +16,7 @@ import useSWR from "swr"; import { getAttributeLabels } from "@/utils/iconUtil"; type useCameraActivityReturn = { - enabled: boolean; + enabled?: boolean; activeTracking: boolean; activeMotion: boolean; objects: ObjectType[]; @@ -148,7 +148,7 @@ export function useCameraActivity( return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60; }, [camera, stats]); - const isCameraEnabled = cameraEnabled === "ON"; + const isCameraEnabled = cameraEnabled ? cameraEnabled === "ON" : undefined; return { enabled: isCameraEnabled, From 5210d8c0a296c269b828233bf2361bc485baf65c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 3 Mar 2025 18:41:28 -0600 Subject: [PATCH 45/63] Add camera enable switch to mobile drawer (#16929) --- web/src/views/live/LiveCameraView.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 9b45c5a60..96a0ed2bd 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -1399,6 +1399,13 @@ function FrigateCameraFeatures({
+ + sendEnabled(enabledState == "ON" ? "OFF" : "ON") + } + /> Date: Tue, 4 Mar 2025 22:19:40 +0900 Subject: [PATCH 46/63] Fixed the issue where internal context copy occurs frequently. (#16931) remove cache mount in nginx build Co-authored-by: Ludis Hur --- docker/main/Dockerfile | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 674add58e..7a0351240 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -39,10 +39,7 @@ ARG DEBIAN_FRONTEND ENV CCACHE_DIR /root/.ccache ENV CCACHE_MAXSIZE 2G -# bind /var/cache/apt to tmpfs to speed up nginx build -RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \ - --mount=type=bind,source=docker/main/build_nginx.sh,target=/deps/build_nginx.sh \ - --mount=type=cache,target=/root/.ccache \ +RUN --mount=type=bind,source=docker/main/build_nginx.sh,target=/deps/build_nginx.sh \ /deps/build_nginx.sh FROM wget AS sqlite-vec From 76c35307b2ac4689ece53016354fad872c465b78 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 4 Mar 2025 08:34:19 -0600 Subject: [PATCH 47/63] Ensure genai thumbnails are always jpegs (#16939) --- frigate/embeddings/maintainer.py | 12 +++++++++++- frigate/util/image.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index dfaed532e..b19a626b2 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -48,7 +48,11 @@ from frigate.genai import get_genai_client from frigate.models import Event from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import serialize -from frigate.util.image import SharedMemoryFrameManager, calculate_region +from frigate.util.image import ( + SharedMemoryFrameManager, + calculate_region, + ensure_jpeg_bytes, +) from frigate.util.path import get_event_thumbnail_bytes from .embeddings import Embeddings @@ -374,6 +378,9 @@ class EmbeddingMaintainer(threading.Thread): num_thumbnails = len(self.tracked_events.get(event.id, [])) + # ensure we have a jpeg to pass to the model + thumbnail = ensure_jpeg_bytes(thumbnail) + embed_image = ( [snapshot_image] if event.has_snapshot and camera_config.genai.use_snapshot @@ -503,6 +510,9 @@ class EmbeddingMaintainer(threading.Thread): thumbnail = get_event_thumbnail_bytes(event) + # ensure we have a jpeg to pass to the model + thumbnail = ensure_jpeg_bytes(thumbnail) + logger.debug( f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}" ) diff --git a/frigate/util/image.py b/frigate/util/image.py index 20806372c..0b80efe88 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -975,3 +975,22 @@ def get_histogram(image, x_min, y_min, x_max, y_max): [image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256] ) return cv2.normalize(hist, hist).flatten() + + +def ensure_jpeg_bytes(image_data): + """Ensure image data is jpeg bytes for genai""" + try: + img_array = np.frombuffer(image_data, dtype=np.uint8) + img = cv2.imdecode(img_array, cv2.IMREAD_COLOR) + + if img is None: + return image_data + + success, encoded_img = cv2.imencode(".jpg", img) + + if success: + return encoded_img.tobytes() + except Exception as e: + logger.warning(f"Error when converting thumbnail to jpeg for genai: {e}") + + return image_data From c23653338f699f8b88d0035fa6209545c7cf5ea9 Mon Sep 17 00:00:00 2001 From: leccelecce <24962424+leccelecce@users.noreply.github.com> Date: Tue, 4 Mar 2025 16:23:51 +0000 Subject: [PATCH 48/63] GenAI: allow configuring additional send trigger after_significant_updates as well as event_end (#16919) --- docs/docs/configuration/genai.md | 11 +++++++- docs/docs/configuration/reference.md | 6 +++++ frigate/config/camera/genai.py | 15 +++++++++++ frigate/embeddings/maintainer.py | 40 +++++++++++++++++++++++++++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/docs/docs/configuration/genai.md b/docs/docs/configuration/genai.md index e46107a82..ec733684f 100644 --- a/docs/docs/configuration/genai.md +++ b/docs/docs/configuration/genai.md @@ -5,7 +5,7 @@ title: Generative AI Generative AI can be used to automatically generate descriptive text based on the thumbnails of your tracked objects. This helps with [Semantic Search](/configuration/semantic_search) in Frigate to provide more context about your tracked objects. Descriptions are accessed via the _Explore_ view in the Frigate UI by clicking on a tracked object's thumbnail. -Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle. Descriptions can also be regenerated manually via the Frigate UI. +Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle, or can optionally be sent earlier after a number of significantly changed frames, for example in use in more real-time notifications. Descriptions can also be regenerated manually via the Frigate UI. Note that if you are manually entering a description for tracked objects prior to its end, this will be overwritten by the generated response. ## Configuration @@ -148,6 +148,15 @@ While generating simple descriptions of detected objects is useful, understandin Frigate provides an [MQTT topic](/integrations/mqtt), `frigate/tracked_object_update`, that is updated with a JSON payload containing `event_id` and `description` when your AI provider returns a description for a tracked object. This description could be used directly in notifications, such as sending alerts to your phone or making audio announcements. If additional details from the tracked object are needed, you can query the [HTTP API](/integrations/api/event-events-event-id-get) using the `event_id`, eg: `http://frigate_ip:5000/api/events/`. +If looking to get notifications earlier than when an object ceases to be tracked, an additional send trigger can be configured of `after_significant_updates`. + +```yaml +genai: + send_triggers: + tracked_object_end: true # default + after_significant_updates: 3 # how many updates to a tracked object before we should send an image +``` + ## Custom Prompts Frigate sends multiple frames from the tracked object along with a prompt to your Generative AI provider asking it to generate a description. The default prompt is as follows: diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index b53d9268f..47d1ccb07 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -813,6 +813,12 @@ cameras: - cat # Optional: Restrict generation to objects that entered any of the listed zones (default: none, all zones qualify) required_zones: [] + # Optional: What triggers to use to send frames for a tracked object to generative AI (default: shown below) + send_triggers: + # Once the object is no longer tracked + tracked_object_end: True + # Optional: After X many significant updates are received (default: shown below) + after_significant_updates: None # Optional: Save thumbnails sent to generative AI for review/debugging purposes (default: shown below) debug_save_thumbnails: False diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py index e6b327836..6ef93682b 100644 --- a/frigate/config/camera/genai.py +++ b/frigate/config/camera/genai.py @@ -16,6 +16,17 @@ class GenAIProviderEnum(str, Enum): ollama = "ollama" +class GenAISendTriggersConfig(BaseModel): + tracked_object_end: bool = Field( + default=True, title="Send once the object is no longer tracked." + ) + after_significant_updates: Optional[int] = Field( + default=None, + title="Send an early request to generative AI when X frames accumulated.", + ge=1, + ) + + # uses BaseModel because some global attributes are not available at the camera level class GenAICameraConfig(BaseModel): enabled: bool = Field(default=False, title="Enable GenAI for camera.") @@ -42,6 +53,10 @@ class GenAICameraConfig(BaseModel): default=False, title="Save thumbnails sent to generative AI for debugging purposes.", ) + send_triggers: GenAISendTriggersConfig = Field( + default_factory=GenAISendTriggersConfig, + title="What triggers to use to send frames to generative AI for a tracked object.", + ) @field_validator("required_zones", mode="before") @classmethod diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index b19a626b2..b3bd6c204 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -132,6 +132,7 @@ class EmbeddingMaintainer(threading.Thread): self.stop_event = stop_event self.tracked_events: dict[str, list[any]] = {} + self.early_request_sent: dict[str, bool] = {} self.genai_client = get_genai_client(config) # recordings data @@ -240,6 +241,43 @@ class EmbeddingMaintainer(threading.Thread): self.tracked_events[data["id"]].append(data) + # check if we're configured to send an early request after a minimum number of updates received + if ( + self.genai_client is not None + and camera_config.genai.send_triggers.after_significant_updates + ): + if ( + len(self.tracked_events.get(data["id"], [])) + >= camera_config.genai.send_triggers.after_significant_updates + and data["id"] not in self.early_request_sent + ): + if data["has_clip"] and data["has_snapshot"]: + event: Event = Event.get(Event.id == data["id"]) + + if ( + not camera_config.genai.objects + or event.label in camera_config.genai.objects + ) and ( + not camera_config.genai.required_zones + or set(data["entered_zones"]) + & set(camera_config.genai.required_zones) + ): + logger.debug(f"{camera} sending early request to GenAI") + + self.early_request_sent[data["id"]] = True + threading.Thread( + target=self._genai_embed_description, + name=f"_genai_embed_description_{event.id}", + daemon=True, + args=( + event, + [ + data["thumbnail"] + for data in self.tracked_events[data["id"]] + ], + ), + ).start() + self.frame_manager.close(frame_name) def _process_finalized(self) -> None: @@ -300,8 +338,8 @@ class EmbeddingMaintainer(threading.Thread): # Run GenAI if ( camera_config.genai.enabled + and camera_config.genai.send_triggers.tracked_object_end and self.genai_client is not None - and event.data.get("description") is None and ( not camera_config.genai.objects or event.label in camera_config.genai.objects From 389c707ad2ab30a1406345b7973fac735b453fb4 Mon Sep 17 00:00:00 2001 From: jdryden572 Date: Tue, 4 Mar 2025 15:30:34 -0500 Subject: [PATCH 49/63] Orient live camera feed for best screen fit when in fullscreen mode (#16947) * Change orientation in fullscreen to best fit video * Refactor effect to simplify, add more comments --- web/src/views/live/LiveCameraView.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 96a0ed2bd..cacdc7c1d 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -362,6 +362,28 @@ export default function LiveCameraView({ } }, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]); + // On mobile devices that support it, try to orient screen + // to best fit the camera feed in fullscreen mode + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const screenOrientation = screen.orientation as any; + if (!screenOrientation.lock || !screenOrientation.unlock) { + // Browser does not support ScreenOrientation APIs that we need + return; + } + + if (fullscreen) { + const orientationForBestFit = + cameraAspectRatio > 1 ? "landscape" : "portrait"; + + // If the current device doesn't support locking orientation, + // this promise will reject with an error that we can ignore + screenOrientation.lock(orientationForBestFit).catch(() => {}); + } + + return () => screenOrientation.unlock(); + }, [fullscreen, cameraAspectRatio]); + const handleError = useCallback( (e: LivePlayerError) => { if (e) { From 73f8c97d1da6acc65ec02daf3cb4202faee63d21 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:29:11 -0600 Subject: [PATCH 50/63] Docs updates (#16949) * live and lpr docs updates * disabled clarity * more disable clarity * clarify sync_recordings --- docs/docs/configuration/cameras.md | 2 +- .../license_plate_recognition.md | 2 +- docs/docs/configuration/live.md | 26 ++++++++++++++----- docs/docs/configuration/record.md | 2 ++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index 50a8c6f93..f1a6b7bf5 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -7,7 +7,7 @@ title: Camera Configuration Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa. -A camera is enabled by default but can be temporarily disabled by using `enabled: False`. Existing tracked objects and recordings can still be accessed. Live streams, recording and detecting are not working. Camera specific configurations will be used. +A camera is enabled by default but can be disabled by using `enabled: False`. Cameras that are disabled through the configuration file will not appear in the Frigate UI and will not consume system resources. Each role can only be assigned to one input per camera. The options for roles are as follows: diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index 103c3bf14..3fe1ee852 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -115,7 +115,7 @@ lpr: Ensure that: -- Your camera has a clear, well-lit view of the plate. +- Your camera has a clear, human-readable, well-lit view of the plate. If you can't read the plate, Frigate certainly won't be able to. This may require changing video size, quality, or frame rate settings on your camera, depending on your scene and how fast the vehicles are traveling. - The plate is large enough in the image (try adjusting `min_area`) or increasing the resolution of your camera's stream. - A `car` is detected first, as LPR only runs on recognized vehicles. diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index 76fcf6826..42809739a 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -183,32 +183,46 @@ The default dashboard ("All Cameras") will always use Smart Streaming and the fi ::: +### Disabling cameras + +Cameras can be temporarily disabled through the Frigate UI and through [MQTT](/integrations/mqtt#frigatecamera_nameenabledset) to conserve system resources. When disabled, Frigate's ffmpeg processes are terminated — recording stops, object detection is paused, and the Live dashboard displays a blank image with a disabled message. Review items, tracked objects, and historical footage for disabled cameras can still be accessed via the UI. + +For restreamed cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source). + +Note that disabling a camera through the config file (`enabled: False`) removes all related UI elements, including historical footage access. To retain access while disabling the camera, keep it enabled in the config and use the UI or MQTT to disable it temporarily. + ## Live view FAQ -1. Why don't I have audio in my Live view? +1. **Why don't I have audio in my Live view?** + You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc. Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc. -2. Frigate shows that my live stream is in "low bandwidth mode". What does this mean? +2. **Frigate shows that my live stream is in "low bandwidth mode". What does this mean?** + Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible. When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. You can also try using the _Reset_ button to force a reload of your stream. If you are still experiencing Frigate falling back to low bandwidth mode, you may need to adjust your camera's settings per the recommendations above or ensure you have enough bandwidth available. -3. It doesn't seem like my cameras are streaming on the Live dashboard. Why? +3. **It doesn't seem like my cameras are streaming on the Live dashboard. Why?** + On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group. -4. I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it? +4. **I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it?** + This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line. -5. How does "smart streaming" work? +5. **How does "smart streaming" work?** + Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream. This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats. This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras. -6. I have unmuted some cameras on my dashboard, but I do not hear sound. Why? +6. **I have unmuted some cameras on my dashboard, but I do not hear sound. Why?** + If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior. diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index fd7de42d0..f84d84cee 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -183,6 +183,8 @@ record: sync_recordings: True ``` +This feature is meant to fix variations in files, not completely delete entries in the database. If you delete all of your media, don't use `sync_recordings`, just stop Frigate, delete the `frigate.db` database, and restart. + :::warning The sync operation uses considerable CPU resources and in most cases is not needed, only enable when necessary. From ad0e89e147de72f60569a5b005d7afad2032d361 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 5 Mar 2025 07:30:23 -0600 Subject: [PATCH 51/63] Ensure disabling a camera also disables audio detection (#16961) * Ensure disabling a camera also disables audio detection * fix enabled state * fix path --- frigate/events/audio.py | 56 ++++++++++++++++++++++++++++++++++++++++- frigate/video.py | 7 +++--- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/frigate/events/audio.py b/frigate/events/audio.py index 7675f821b..505802b8c 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -135,8 +135,13 @@ class AudioEventMaintainer(threading.Thread): # create communication for audio detections self.requestor = InterProcessRequestor() self.config_subscriber = ConfigSubscriber(f"config/audio/{camera.name}") + self.enabled_subscriber = ConfigSubscriber( + f"config/enabled/{camera.name}", True + ) self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio) + self.was_enabled = camera.enabled + def detect_audio(self, audio) -> None: if not self.config.audio.enabled or self.stop_event.is_set(): return @@ -248,6 +253,23 @@ class AudioEventMaintainer(threading.Thread): f"Failed to end audio event {detection['id']} with status code {resp.status_code}" ) + def expire_all_detections(self) -> None: + """Immediately end all current detections""" + now = datetime.datetime.now().timestamp() + for label, detection in list(self.detections.items()): + if detection: + self.requestor.send_data(f"{self.config.name}/audio/{label}", "OFF") + resp = requests.put( + f"{FRIGATE_LOCALHOST}/api/events/{detection['id']}/end", + json={"end_time": now}, + ) + if resp.status_code == 200: + self.detections[label] = None + else: + self.logger.warning( + f"Failed to end audio event {detection['id']} with status code {resp.status_code}" + ) + def start_or_restart_ffmpeg(self) -> None: self.audio_listener = start_or_restart_ffmpeg( self.ffmpeg_cmd, @@ -283,10 +305,41 @@ class AudioEventMaintainer(threading.Thread): self.logger.error(f"Error reading audio data from ffmpeg process: {e}") log_and_restart() + def _update_enabled_state(self) -> bool: + """Fetch the latest config and update enabled state.""" + _, config_data = self.enabled_subscriber.check_for_update() + if config_data: + self.config.enabled = config_data.enabled + return config_data.enabled + + return self.config.enabled + def run(self) -> None: - self.start_or_restart_ffmpeg() + if self._update_enabled_state(): + self.start_or_restart_ffmpeg() while not self.stop_event.is_set(): + enabled = self._update_enabled_state() + if enabled != self.was_enabled: + if enabled: + self.logger.debug( + f"Enabling audio detections for {self.config.name}" + ) + self.start_or_restart_ffmpeg() + else: + self.logger.debug( + f"Disabling audio detections for {self.config.name}, ending events" + ) + self.expire_all_detections() + stop_ffmpeg(self.audio_listener, self.logger) + self.audio_listener = None + self.was_enabled = enabled + continue + + if not enabled: + time.sleep(0.1) + continue + # check if there is an updated config ( updated_topic, @@ -302,6 +355,7 @@ class AudioEventMaintainer(threading.Thread): self.logpipe.close() self.requestor.stop() self.config_subscriber.stop() + self.enabled_subscriber.stop() self.detection_publisher.stop() diff --git a/frigate/video.py b/frigate/video.py index 69f6c1bfa..89543e21a 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -197,9 +197,10 @@ class CameraWatchdog(threading.Thread): """Fetch the latest config and update enabled state.""" _, config_data = self.config_subscriber.check_for_update() if config_data: - enabled = config_data.enabled - return enabled - return self.was_enabled if self.was_enabled is not None else self.config.enabled + self.config.enabled = config_data.enabled + return config_data.enabled + + return self.config.enabled def run(self): if self._update_enabled_state(): From 73c2c34127791221107480690a10806c3deb2bab Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 5 Mar 2025 07:07:48 -0700 Subject: [PATCH 52/63] Fix previews failing when disabled (#16962) * Fix previews failing when offline * Simplify frame cache handling --- frigate/output/output.py | 43 +++++++++++++++++------------ frigate/output/preview.py | 58 ++++++++++++++++++++++----------------- 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/frigate/output/output.py b/frigate/output/output.py index e0e64e298..30900a5ab 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -41,22 +41,30 @@ def check_disabled_camera_update( has_enabled_camera = False for camera, last_update in write_times.items(): + offline_time = now - last_update + if config.cameras[camera].enabled: has_enabled_camera = True + else: + # flag camera as offline when it is disabled + previews[camera].flag_offline(now) - if now - last_update > 1: - # last camera update was more than one second ago - # need to send empty data to updaters because current + if offline_time > 1: + # last camera update was more than 1 second ago + # need to send empty data to birdseye because current # frame is now out of date - frame = get_blank_yuv_frame( - config.cameras[camera].detect.width, - config.cameras[camera].detect.height, - ) - - if birdseye: - birdseye.write_data(camera, [], [], now, frame) - - previews[camera].write_data([], [], now, frame) + if birdseye and offline_time < 10: + # we only need to send blank frames to birdseye at the beginning of a camera being offline + birdseye.write_data( + camera, + [], + [], + now, + get_blank_yuv_frame( + config.cameras[camera].detect.width, + config.cameras[camera].detect.height, + ), + ) if not has_enabled_camera and birdseye: birdseye.all_cameras_disabled() @@ -170,6 +178,12 @@ def output_frames( else: failed_frame_requests[camera] = 0 + # send frames for low fps recording + preview_recorders[camera].write_data( + current_tracked_objects, motion_boxes, frame_time, frame + ) + preview_write_times[camera] = frame_time + # send camera frame to ffmpeg process if websockets are connected if any( ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager @@ -193,11 +207,6 @@ def output_frames( frame, ) - # send frames for low fps recording - preview_recorders[camera].write_data( - current_tracked_objects, motion_boxes, frame_time, frame - ) - preview_write_times[camera] = frame_time frame_manager.close(frame_name) move_preview_frames("clips") diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 4f8796d39..247886bfd 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -23,7 +23,7 @@ from frigate.ffmpeg_presets import ( ) from frigate.models import Previews from frigate.object_processing import TrackedObject -from frigate.util.image import copy_yuv_to_position, get_yuv_crop +from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop logger = logging.getLogger(__name__) @@ -153,6 +153,7 @@ class PreviewRecorder: self.config = config self.start_time = 0 self.last_output_time = 0 + self.offline = False self.output_frames = [] if config.detect.width > config.detect.height: @@ -241,6 +242,17 @@ class PreviewRecorder: self.last_output_time = ts self.output_frames.append(ts) + def reset_frame_cache(self, frame_time: float) -> None: + self.segment_end = ( + (datetime.datetime.now() + datetime.timedelta(hours=1)) + .astimezone(datetime.timezone.utc) + .replace(minute=0, second=0, microsecond=0) + .timestamp() + ) + self.start_time = frame_time + self.last_output_time = frame_time + self.output_frames: list[float] = [] + def should_write_frame( self, current_tracked_objects: list[dict[str, any]], @@ -307,7 +319,9 @@ class PreviewRecorder: motion_boxes: list[list[int]], frame_time: float, frame: np.ndarray, - ) -> bool: + ) -> None: + self.offline = False + # check for updated record config _, updated_record_config = self.config_subscriber.check_for_update() @@ -319,7 +333,7 @@ class PreviewRecorder: self.start_time = frame_time self.output_frames.append(frame_time) self.write_frame_to_cache(frame_time, frame) - return False + return # check if PREVIEW clip should be generated and cached frames reset if frame_time >= self.segment_end: @@ -340,32 +354,35 @@ class PreviewRecorder: f"Not saving preview for {self.config.name} because there are no saved frames." ) - # reset frame cache - self.segment_end = ( - (datetime.datetime.now() + datetime.timedelta(hours=1)) - .astimezone(datetime.timezone.utc) - .replace(minute=0, second=0, microsecond=0) - .timestamp() - ) - self.start_time = frame_time - self.last_output_time = frame_time - self.output_frames: list[float] = [] + self.reset_frame_cache(frame_time) # include first frame to ensure consistent duration if self.config.record.enabled: self.output_frames.append(frame_time) self.write_frame_to_cache(frame_time, frame) - return True + return elif self.should_write_frame(current_tracked_objects, motion_boxes, frame_time): self.output_frames.append(frame_time) self.write_frame_to_cache(frame_time, frame) - return False + return def flag_offline(self, frame_time: float) -> None: + if not self.offline: + self.write_frame_to_cache( + frame_time, + get_blank_yuv_frame( + self.config.detect.width, self.config.detect.height + ), + ) + self.offline = True + # check if PREVIEW clip should be generated and cached frames reset if frame_time >= self.segment_end: if len(self.output_frames) == 0: + # camera has been offline for entire hour + # we have no preview to create + self.reset_frame_cache(frame_time) return old_frame_path = get_cache_image_name( @@ -382,16 +399,7 @@ class PreviewRecorder: self.requestor, ).start() - # reset frame cache - self.segment_end = ( - (datetime.datetime.now() + datetime.timedelta(hours=1)) - .astimezone(datetime.timezone.utc) - .replace(minute=0, second=0, microsecond=0) - .timestamp() - ) - self.start_time = frame_time - self.last_output_time = frame_time - self.output_frames = [] + self.reset_frame_cache(frame_time) def stop(self) -> None: self.requestor.stop() From 66d5f4f3b8662aef71229c73fd768d19d613c7b8 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 6 Mar 2025 06:59:35 -0700 Subject: [PATCH 53/63] Refactor enabled camera listeners (#16979) * Monitor if camera is disabled for review items * Simplify multi camera disabled check * Cleanup birdseye config handling * Cleanup * Remove old listeners --- frigate/object_processing.py | 44 +++++++++++++++------------ frigate/output/birdseye.py | 58 ++++++++++++++++-------------------- frigate/review/maintainer.py | 23 ++++++++++++-- 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/frigate/object_processing.py b/frigate/object_processing.py index a7a2fb066..8faf91cb5 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -440,10 +440,7 @@ class TrackedObjectProcessor(threading.Thread): self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread - self.enabled_subscribers = { - camera: ConfigSubscriber(f"config/enabled/{camera}", True) - for camera in config.cameras.keys() - } + self.config_enabled_subscriber = ConfigSubscriber("config/enabled/") self.requestor = InterProcessRequestor() self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) @@ -705,24 +702,34 @@ class TrackedObjectProcessor(threading.Thread): {"enabled": False, "motion": 0, "objects": []}, ) - def _get_enabled_state(self, camera: str) -> bool: - _, config_data = self.enabled_subscribers[camera].check_for_update() - - if config_data: - self.config.cameras[camera].enabled = config_data.enabled - - if self.camera_states[camera].prev_enabled is None: - self.camera_states[camera].prev_enabled = config_data.enabled - - return self.config.cameras[camera].enabled - def run(self): while not self.stop_event.is_set(): + # check for config updates + while True: + ( + updated_enabled_topic, + updated_enabled_config, + ) = self.config_enabled_subscriber.check_for_update() + + if not updated_enabled_topic: + break + + camera_name = updated_enabled_topic.rpartition("/")[-1] + self.config.cameras[ + camera_name + ].enabled = updated_enabled_config.enabled + + if self.camera_states[camera_name].prev_enabled is None: + self.camera_states[ + camera_name + ].prev_enabled = updated_enabled_config.enabled + + # manage camera disabled state for camera, config in self.config.cameras.items(): if not config.enabled_in_config: continue - current_enabled = self._get_enabled_state(camera) + current_enabled = config.enabled camera_state = self.camera_states[camera] if camera_state.prev_enabled and not current_enabled: @@ -746,7 +753,7 @@ class TrackedObjectProcessor(threading.Thread): except queue.Empty: continue - if not self._get_enabled_state(camera): + if not self.config.cameras[camera].enabled: logger.debug(f"Camera {camera} disabled, skipping update") continue @@ -792,7 +799,6 @@ class TrackedObjectProcessor(threading.Thread): self.detection_publisher.stop() self.event_sender.stop() self.event_end_subscriber.stop() - for subscriber in self.enabled_subscribers.values(): - subscriber.stop() + self.config_enabled_subscriber.stop() logger.info("Exiting object processor...") diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index cd4aa26ec..9bbd3abee 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -281,12 +281,6 @@ class BirdsEyeFrameManager: self.stop_event = stop_event self.inactivity_threshold = config.birdseye.inactivity_threshold - self.enabled_subscribers = { - cam: ConfigSubscriber(f"config/enabled/{cam}", True) - for cam in config.cameras.keys() - if config.cameras[cam].enabled_in_config - } - if config.birdseye.layout.max_cameras: self.last_refresh_time = 0 @@ -387,16 +381,6 @@ class BirdsEyeFrameManager: if mode == BirdseyeModeEnum.objects and object_box_count > 0: return True - def _get_enabled_state(self, camera: str) -> bool: - """Fetch the latest enabled state for a camera from ZMQ.""" - _, config_data = self.enabled_subscribers[camera].check_for_update() - - if config_data: - self.config.cameras[camera].enabled = config_data.enabled - return config_data.enabled - - return self.config.cameras[camera].enabled - def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: """ Update birdseye, optionally with a new frame. @@ -410,7 +394,7 @@ class BirdsEyeFrameManager: for cam, cam_data in self.cameras.items() if self.config.cameras[cam].birdseye.enabled and self.config.cameras[cam].enabled_in_config - and self._get_enabled_state(cam) + and self.config.cameras[cam].enabled and cam_data["last_active_frame"] > 0 and cam_data["current_frame_time"] - cam_data["last_active_frame"] < self.inactivity_threshold @@ -706,11 +690,11 @@ class BirdsEyeFrameManager: frame: np.ndarray, ) -> bool: # don't process if birdseye is disabled for this camera - camera_config = self.config.cameras[camera].birdseye + camera_config = self.config.cameras[camera] force_update = False # disabling birdseye is a little tricky - if not self._get_enabled_state(camera): + if not camera_config.birdseye.enabled or not camera_config.enabled: # if we've rendered a frame (we have a value for last_active_frame) # then we need to set it to zero if self.cameras[camera]["last_active_frame"] > 0: @@ -722,7 +706,7 @@ class BirdsEyeFrameManager: # update the last active frame for the camera self.cameras[camera]["current_frame"] = frame.copy() self.cameras[camera]["current_frame_time"] = frame_time - if self.camera_active(camera_config.mode, object_count, motion_count): + if self.camera_active(camera_config.birdseye.mode, object_count, motion_count): self.cameras[camera]["last_active_frame"] = frame_time now = datetime.datetime.now().timestamp() @@ -745,11 +729,6 @@ class BirdsEyeFrameManager: return True return False - def stop(self): - """Clean up subscribers when stopping.""" - for subscriber in self.enabled_subscribers.values(): - subscriber.stop() - class Birdseye: def __init__( @@ -775,7 +754,8 @@ class Birdseye: "birdseye", self.converter, websocket_server, stop_event ) self.birdseye_manager = BirdsEyeFrameManager(config, stop_event) - self.config_subscriber = ConfigSubscriber("config/birdseye/") + self.config_enabled_subscriber = ConfigSubscriber("config/enabled/") + self.birdseye_subscriber = ConfigSubscriber("config/birdseye/") self.frame_manager = SharedMemoryFrameManager() self.stop_event = stop_event @@ -815,15 +795,27 @@ class Birdseye: # check if there is an updated config while True: ( - updated_topic, + updated_birdseye_topic, updated_birdseye_config, - ) = self.config_subscriber.check_for_update() + ) = self.birdseye_subscriber.check_for_update() - if not updated_topic: + ( + updated_enabled_topic, + updated_enabled_config, + ) = self.config_enabled_subscriber.check_for_update() + + if not updated_birdseye_topic and not updated_enabled_topic: break - camera_name = updated_topic.rpartition("/")[-1] - self.config.cameras[camera_name].birdseye = updated_birdseye_config + if updated_birdseye_config: + camera_name = updated_birdseye_topic.rpartition("/")[-1] + self.config.cameras[camera_name].birdseye = updated_birdseye_config + + if updated_enabled_config: + camera_name = updated_enabled_topic.rpartition("/")[-1] + self.config.cameras[ + camera_name + ].enabled = updated_enabled_config.enabled if self.birdseye_manager.update( camera, @@ -835,7 +827,7 @@ class Birdseye: self.__send_new_frame() def stop(self) -> None: - self.config_subscriber.stop() - self.birdseye_manager.stop() + self.birdseye_subscriber.stop() + self.config_enabled_subscriber.stop() self.converter.join() self.broadcaster.join() diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 158bc3ac4..1c015d217 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -150,6 +150,7 @@ class ReviewSegmentMaintainer(threading.Thread): self.requestor = InterProcessRequestor() self.record_config_subscriber = ConfigSubscriber("config/record/") self.review_config_subscriber = ConfigSubscriber("config/review/") + self.enabled_config_subscriber = ConfigSubscriber("config/enabled/") self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all) # manual events @@ -450,7 +451,16 @@ class ReviewSegmentMaintainer(threading.Thread): updated_review_config, ) = self.review_config_subscriber.check_for_update() - if not updated_record_topic and not updated_review_topic: + ( + updated_enabled_topic, + updated_enabled_config, + ) = self.enabled_config_subscriber.check_for_update() + + if ( + not updated_record_topic + and not updated_review_topic + and not updated_enabled_topic + ): break if updated_record_topic: @@ -461,6 +471,12 @@ class ReviewSegmentMaintainer(threading.Thread): camera_name = updated_review_topic.rpartition("/")[-1] self.config.cameras[camera_name].review = updated_review_config + if updated_enabled_config: + camera_name = updated_enabled_topic.rpartition("/")[-1] + self.config.cameras[ + camera_name + ].enabled = updated_enabled_config.enabled + (topic, data) = self.detection_subscriber.check_for_update(timeout=1) if not topic: @@ -494,7 +510,10 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment = self.active_review_segments.get(camera) - if not self.config.cameras[camera].record.enabled: + if ( + not self.config.cameras[camera].enabled + or not self.config.cameras[camera].record.enabled + ): if current_segment: self.end_segment(camera) continue From 30acd26898dea461cdb2b43394079eb28733efeb Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 6 Mar 2025 07:00:29 -0700 Subject: [PATCH 54/63] Disable detection by default (#16980) * Enable detection by default * Migrate config to have detect enabled if it is not --- docs/docs/configuration/reference.md | 4 ++-- docs/docs/guides/getting_started.md | 4 +--- frigate/config/camera/detect.py | 2 +- frigate/util/config.py | 6 ++++++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 47d1ccb07..37884259a 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -255,6 +255,8 @@ ffmpeg: # Optional: Detect configuration # NOTE: Can be overridden at the camera level detect: + # Optional: enables detection for the camera (default: shown below) + enabled: False # Optional: width of the frame for the input with the detect role (default: use native stream resolution) width: 1280 # Optional: height of the frame for the input with the detect role (default: use native stream resolution) @@ -262,8 +264,6 @@ detect: # Optional: desired fps for your camera for the input with the detect role (default: shown below) # NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera. fps: 5 - # Optional: enables detection for the camera (default: True) - enabled: True # Optional: Number of consecutive detection hits required for an object to be initialized in the tracker. (default: 1/2 the frame rate) min_initialized: 2 # Optional: Number of frames without a detection before Frigate considers an object to be gone. (default: 5x the frame rate) diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index ed2cfb4f4..6fe3a8e22 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -151,8 +151,6 @@ cameras: - path: rtsp://10.0.10.10:554/rtsp # <----- The stream you want to use for detection roles: - detect - detect: - enabled: False # <---- disable detection until you have a working camera feed ``` ### Step 2: Start Frigate @@ -307,7 +305,7 @@ By default, Frigate will retain video of all tracked objects for 10 days. The fu ### Step 7: Complete config -At this point you have a complete config with basic functionality. +At this point you have a complete config with basic functionality. - View [common configuration examples](../configuration/index.md#common-configuration-examples) for a list of common configuration examples. - View [full config reference](../configuration/reference.md) for a complete list of configuration options. diff --git a/frigate/config/camera/detect.py b/frigate/config/camera/detect.py index 273364e61..99e02c2c8 100644 --- a/frigate/config/camera/detect.py +++ b/frigate/config/camera/detect.py @@ -32,6 +32,7 @@ class StationaryConfig(FrigateBaseModel): class DetectConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Detection Enabled.") height: Optional[int] = Field( default=None, title="Height of the stream for the detect role." ) @@ -41,7 +42,6 @@ class DetectConfig(FrigateBaseModel): fps: int = Field( default=5, title="Number of frames per second to process through detection." ) - enabled: bool = Field(default=True, title="Detection Enabled.") min_initialized: Optional[int] = Field( default=None, title="Minimum number of consecutive hits for an object to be initialized by the tracker.", diff --git a/frigate/util/config.py b/frigate/util/config.py index 1ed82f802..7bdc0c3d6 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -300,6 +300,12 @@ def migrate_016_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any] """Handle migrating frigate config to 0.16-0""" new_config = config.copy() + # migrate config that does not have detect -> enabled explicitly set to have it enabled + if new_config.get("detect", {}).get("enabled") is None: + detect_config = new_config.get("detect", {}) + detect_config["enabled"] = True + new_config["detect"] = detect_config + for name, camera in config.get("cameras", {}).items(): camera_config: dict[str, dict[str, any]] = camera.copy() From 433da8ffce844798a7adf1a22ba438d7b287089e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 6 Mar 2025 09:50:37 -0700 Subject: [PATCH 55/63] Update web deps (#16983) * Update vite * Update LuIcons * Update radix packages * Fix other icons * Use correct node version * Remove superfluous web build on python tests * Move web build to test --- .github/workflows/pull_request.yml | 13 +- web/package-lock.json | 2446 ++++++++--------- web/package.json | 66 +- .../components/graph/CombinedStorageGraph.tsx | 4 +- .../indicators/activity-indicator.tsx | 4 +- web/src/components/menu/GeneralSettings.tsx | 4 +- .../components/menu/SearchResultActions.tsx | 5 +- .../overlay/detail/ObjectLifecycle.tsx | 4 +- web/src/views/explore/ExploreView.tsx | 4 +- .../settings/NotificationsSettingsView.tsx | 5 +- web/src/views/system/StorageMetrics.tsx | 4 +- 11 files changed, 1150 insertions(+), 1409 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 0d0492d0c..37f75bf85 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@master with: - node-version: 16.x + node-version: 20.x - name: Install devcontainer cli run: npm install --global @devcontainers/cli - name: Build devcontainer @@ -64,6 +64,9 @@ jobs: node-version: 20.x - run: npm install working-directory: ./web + - name: Build web + run: npm run build + working-directory: ./web # - name: Test # run: npm run test # working-directory: ./web @@ -99,14 +102,6 @@ jobs: uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-node@master - with: - node-version: 16.x - - run: npm install - working-directory: ./web - - name: Build web - run: npm run build - working-directory: ./web - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx diff --git a/web/package-lock.json b/web/package-lock.json index f2b186312..97a0d991b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,45 +8,45 @@ "name": "web-new", "version": "0.0.0", "dependencies": { - "@cycjimmy/jsmpeg-player": "^6.1.1", + "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^3.9.0", "@melloware/react-logviewer": "^6.1.2", - "@radix-ui/react-alert-dialog": "^1.1.2", - "@radix-ui/react-aspect-ratio": "^1.1.0", - "@radix-ui/react-checkbox": "^1.1.2", - "@radix-ui/react-context-menu": "^2.2.2", - "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-hover-card": "^1.1.2", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-popover": "^1.1.2", - "@radix-ui/react-radio-group": "^1.2.1", - "@radix-ui/react-scroll-area": "^1.2.0", - "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-switch": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.1", - "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-toggle-group": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.3", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", "apexcharts": "^3.52.0", "axios": "^1.7.7", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "embla-carousel-react": "^8.2.0", "framer-motion": "^11.5.4", - "hls.js": "^1.5.17", + "hls.js": "^1.5.20", "idb-keyval": "^6.2.1", "immer": "^10.1.1", - "konva": "^9.3.16", + "konva": "^9.3.18", "lodash": "^4.17.21", - "lucide-react": "^0.407.0", - "monaco-yaml": "^5.2.2", + "lucide-react": "^0.477.0", + "monaco-yaml": "^5.3.1", "next-themes": "^0.3.0", "nosleep.js": "^0.12.0", "react": "^18.3.1", @@ -56,10 +56,10 @@ "react-dom": "^18.3.1", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", - "react-icons": "^5.2.1", + "react-icons": "^5.5.0", "react-konva": "^18.2.10", "react-router-dom": "^6.26.0", - "react-swipeable": "^7.0.1", + "react-swipeable": "^7.0.2", "react-tracked": "^2.0.1", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", @@ -69,7 +69,7 @@ "sonner": "^1.5.0", "sort-by": "^1.2.0", "strftime": "^0.10.3", - "swr": "^2.2.5", + "swr": "^2.3.2", "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", @@ -91,8 +91,8 @@ "@types/strftime": "^0.9.8", "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^7.5.0", - "@vitejs/plugin-react-swc": "^3.7.1", - "@vitest/coverage-v8": "^2.0.5", + "@vitejs/plugin-react-swc": "^3.8.0", + "@vitest/coverage-v8": "^3.0.7", "autoprefixer": "^10.4.20", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -109,9 +109,9 @@ "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.5", "tailwindcss": "^3.4.9", - "typescript": "^5.5.4", - "vite": "^5.4.0", - "vitest": "^2.0.5" + "typescript": "^5.8.2", + "vite": "^6.2.0", + "vitest": "^3.0.7" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -156,9 +156,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "dev": true, "license": "MIT", "engines": { @@ -166,9 +166,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, "license": "MIT", "engines": { @@ -176,11 +176,14 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -200,33 +203,37 @@ } }, "node_modules/@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/@bundled-es-modules/cookie": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.0.tgz", - "integrity": "sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", "dev": true, + "license": "ISC", "dependencies": { - "cookie": "^0.5.0" + "cookie": "^0.7.2" } }, "node_modules/@bundled-es-modules/statuses": { @@ -250,15 +257,15 @@ } }, "node_modules/@cycjimmy/jsmpeg-player": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.1.tgz", - "integrity": "sha512-YqT7U/3LxGu+6ikd+GGPe3rA2o6P4xrBHsWi/WRqv4n58h91fWDxS/3smneod+u6H2RnWlmXvZqx960dQ9T9gQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.2.tgz", + "integrity": "sha512-U9DBDe5fxHmbwQww9rFxMLNI2Wlg7DhPzI7AVFpq8GehiUP7+NwuMPXpP4zAd52sgkxtOqOeMjgE5g0ZLnQZ0w==", "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", "cpu": [ "ppc64" ], @@ -269,13 +276,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], @@ -286,13 +293,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], @@ -303,13 +310,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], @@ -320,13 +327,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], @@ -337,13 +344,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], @@ -354,13 +361,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], @@ -371,13 +378,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], @@ -388,13 +395,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], @@ -405,13 +412,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], @@ -422,13 +429,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], @@ -439,13 +446,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], @@ -456,13 +463,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], @@ -473,13 +480,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], @@ -490,13 +497,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], @@ -507,13 +514,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], @@ -524,13 +531,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], @@ -541,13 +548,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], @@ -558,13 +582,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], @@ -575,13 +616,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], @@ -592,13 +633,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -609,13 +650,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], @@ -626,13 +667,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], @@ -643,7 +684,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -705,28 +746,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz", - "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.4" + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz", - "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==", + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.4" + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.0.0" @@ -737,9 +778,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", - "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, "node_modules/@hookform/resolvers": { @@ -787,50 +828,81 @@ "license": "BSD-3-Clause" }, "node_modules/@inquirer/confirm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.0.0.tgz", - "integrity": "sha512-LHeuYP1D8NmQra1eR4UqvZMXwxEdDXyElJmmZfU44xdNLL6+GcQBS0uE16vyfZVjH8c22p9e+DStROfE/hyHrg==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", + "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^7.0.0", - "@inquirer/type": "^1.2.0" + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/core": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-7.0.0.tgz", - "integrity": "sha512-g13W5yEt9r1sEVVriffJqQ8GWy94OnfxLCreNSOTw0HPVcszmc/If1KIf7YBmlwtX4klmvwpZHnQpl3N7VX2xA==", + "version": "10.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.7.tgz", + "integrity": "sha512-AA9CQhlrt6ZgiSy6qoAigiA1izOa751ugX6ioSjqgJ+/Gd+tEN/TORk5sUYNjXuHWfW0r1n/a6ak4u/NqHHrtA==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/type": "^1.2.0", - "@types/mute-stream": "^0.0.4", - "@types/node": "^20.11.16", - "@types/wrap-ansi": "^3.0.0", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "cli-spinners": "^2.9.2", "cli-width": "^4.1.0", - "figures": "^3.2.0", - "mute-stream": "^1.0.0", - "run-async": "^3.0.0", + "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0" + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.10.tgz", + "integrity": "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==", + "dev": true, + "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@inquirer/type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.2.0.tgz", - "integrity": "sha512-/vvkUkYhrjbm+RolU7V1aUFDydZVKNKqKHR5TsE+j5DXgXFwrsOPcoGUJ02K0O7q7O53CU2DOTMYCHeGZ25WHA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.4.tgz", + "integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@isaacs/cliui": { @@ -990,9 +1062,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -1020,16 +1093,17 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz", - "integrity": "sha512-3rDakgJZ77+RiQUuSK69t1F0m8BQKA8Vh5DCS5V0DWvNY67zob2JhhQrhCO0AKLGINTRSFd1tBaHcJTkhefoSw==", + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", "dev": true, + "license": "MIT", "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", - "outvariant": "^1.2.1", + "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" }, "engines": { @@ -1072,13 +1146,15 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@open-draft/logger": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", "dev": true, + "license": "MIT", "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" @@ -1088,7 +1164,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -1128,23 +1205,23 @@ "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "license": "MIT" }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz", - "integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", + "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dialog": "1.1.2", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1161,46 +1238,13 @@ } } }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", - "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -1218,12 +1262,12 @@ } }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.0.tgz", - "integrity": "sha512-dP87DM/Y7jFlPgUZTlhx6FF5CEzOiaxp2rBCKlaXlpH5Ip/9Fg5zZ9lDOQ5o/MOfUlf36eak14zoWYpgcgGoOg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.2.tgz", + "integrity": "sha512-TaJxYoCpxJ7vfEkv2PTNox/6zzmpKXT6ewvCuf2tTOIVN45/Jahhlld29Yw4pciOXS2Xq91/rSGEdmEnUWZCqA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -1241,16 +1285,16 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.2.tgz", - "integrity": "sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", + "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" @@ -1270,31 +1314,16 @@ } } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1311,28 +1340,10 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1345,9 +1356,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1360,15 +1371,15 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.2.tgz", - "integrity": "sha512-99EatSTpW+hRYHt7m8wdDlLtkmTovEe8Z/hnxUPV+SKuuNL5HWNhQI4QSdjZqNSgXHay2z4M3Dym73j9p2Gx5Q==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.6.tgz", + "integrity": "sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", + "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-menu": "2.1.2", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, @@ -1387,41 +1398,26 @@ } } }, - "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz", - "integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", + "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -1438,39 +1434,6 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -1487,14 +1450,14 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", - "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, @@ -1514,17 +1477,17 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz", - "integrity": "sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", + "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-menu": "2.1.2", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -1542,21 +1505,6 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", @@ -1573,13 +1521,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", - "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { @@ -1598,19 +1546,19 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.2.tgz", - "integrity": "sha512-Y5w0qGhysvmqsIy6nQxaPa6mXNKznfoGjOfBgzOjocLxr2XlSjqBMYQQL+FfyogsMuX+m8cZyQGYhJxvxUzO4w==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz", + "integrity": "sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -1628,21 +1576,6 @@ } } }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -1662,12 +1595,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", - "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", + "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -1685,29 +1618,29 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz", - "integrity": "sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", + "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -1724,60 +1657,27 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", - "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -1794,50 +1694,17 @@ } } }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", @@ -1860,12 +1727,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", - "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { @@ -1884,12 +1751,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", - "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { @@ -1908,12 +1775,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.1.0" + "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1930,37 +1797,19 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", - "integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz", + "integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" @@ -1980,34 +1829,19 @@ } } }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, @@ -2027,18 +1861,18 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.0.tgz", - "integrity": "sha512-q2jMBdsJ9zB7QG6ngQNzNwlvxLQqONyL58QbEGwuyRZZb/ARQwk3uQVbCF7GvQVOtV6EU/pDxAw3zRzJZI3rpQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.3.tgz", + "integrity": "sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.0", - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, @@ -2057,48 +1891,33 @@ } } }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-select": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", - "integrity": "sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.0", - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -2115,46 +1934,13 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", - "integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", + "integrity": "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -2172,18 +1958,18 @@ } }, "node_modules/@radix-ui/react-slider": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.1.tgz", - "integrity": "sha512-bEzQoDW0XP+h/oGbutF5VMWJPAl/UU8IJjr7h02SOHDIIIxq+cep8nItVNoBV+OMmahCdqdF38FTpmXoqQUGvw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz", + "integrity": "sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.0", - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", @@ -2204,21 +1990,6 @@ } } }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", @@ -2237,31 +2008,16 @@ } } }, - "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-switch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz", - "integrity": "sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz", + "integrity": "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" @@ -2281,34 +2037,19 @@ } } }, - "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", - "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", + "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -2326,29 +2067,14 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", - "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.2.tgz", + "integrity": "sha512-lntKchNWx3aCHuWKiDY+8WudiegQvBpDRAYL8dKLRvKEH8VOpl0XX6SSU/bUBqIRJbcTy4+MW06Wv8vgp10rzQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -2367,17 +2093,17 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", - "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.2.tgz", + "integrity": "sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-toggle": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-toggle": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -2396,23 +2122,23 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.3.tgz", - "integrity": "sha512-Z4w1FIS0BqVFI2c1jZvb/uDVJijJjJ2ZMuPV81oVgTZ7g3BZxobplnMVvXtFWgtozdvYJ+MFWtwkM5S2HnAong==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", + "integrity": "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" + "@radix-ui/react-visually-hidden": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2429,39 +2155,6 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -2580,12 +2273,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", - "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -2618,169 +2311,266 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", + "integrity": "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz", + "integrity": "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz", + "integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", + "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz", + "integrity": "sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz", + "integrity": "sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz", + "integrity": "sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz", + "integrity": "sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", + "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", + "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz", + "integrity": "sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz", + "integrity": "sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz", + "integrity": "sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz", + "integrity": "sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", + "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", + "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", + "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz", + "integrity": "sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz", + "integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2793,15 +2583,15 @@ "dev": true }, "node_modules/@swc/core": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.39.tgz", - "integrity": "sha512-jns6VFeOT49uoTKLWIEfiQqJAlyqldNAt80kAr8f7a5YjX0zgnG3RBiLMpksx4Ka4SlK4O6TJ/lumIM3Trp82g==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.7.tgz", + "integrity": "sha512-ICuzjyfz8Hh3U16Mb21uCRJeJd/lUgV999GjgvPhJSISM1L8GDSB5/AMNcwuGs7gFywTKI4vAeeXWyCETUXHAg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.13" + "@swc/types": "^0.1.19" }, "engines": { "node": ">=10" @@ -2811,16 +2601,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.39", - "@swc/core-darwin-x64": "1.7.39", - "@swc/core-linux-arm-gnueabihf": "1.7.39", - "@swc/core-linux-arm64-gnu": "1.7.39", - "@swc/core-linux-arm64-musl": "1.7.39", - "@swc/core-linux-x64-gnu": "1.7.39", - "@swc/core-linux-x64-musl": "1.7.39", - "@swc/core-win32-arm64-msvc": "1.7.39", - "@swc/core-win32-ia32-msvc": "1.7.39", - "@swc/core-win32-x64-msvc": "1.7.39" + "@swc/core-darwin-arm64": "1.11.7", + "@swc/core-darwin-x64": "1.11.7", + "@swc/core-linux-arm-gnueabihf": "1.11.7", + "@swc/core-linux-arm64-gnu": "1.11.7", + "@swc/core-linux-arm64-musl": "1.11.7", + "@swc/core-linux-x64-gnu": "1.11.7", + "@swc/core-linux-x64-musl": "1.11.7", + "@swc/core-win32-arm64-msvc": "1.11.7", + "@swc/core-win32-ia32-msvc": "1.11.7", + "@swc/core-win32-x64-msvc": "1.11.7" }, "peerDependencies": { "@swc/helpers": "*" @@ -2832,9 +2622,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.39.tgz", - "integrity": "sha512-o2nbEL6scMBMCTvY9OnbyVXtepLuNbdblV9oNJEFia5v5eGj9WMrnRQiylH3Wp/G2NYkW7V1/ZVW+kfvIeYe9A==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.7.tgz", + "integrity": "sha512-3+LhCP2H50CLI6yv/lhOtoZ5B/hi7Q/23dye1KhbSDeDprLTm/KfLJh/iQqwaHUponf5m8C2U0y6DD+HGLz8Yw==", "cpu": [ "arm64" ], @@ -2849,9 +2639,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.39.tgz", - "integrity": "sha512-qMlv3XPgtPi/Fe11VhiPDHSLiYYk2dFYl747oGsHZPq+6tIdDQjIhijXPcsUHIXYDyG7lNpODPL8cP/X1sc9MA==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.7.tgz", + "integrity": "sha512-1diWpJqwX1XmOghf9ENFaeRaTtqLiqlZIW56RfOqmeZ7tPp3qS7VygWb9akptBsO5pEA5ZwNgSerD6AJlQcjAw==", "cpu": [ "x64" ], @@ -2866,9 +2656,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.39.tgz", - "integrity": "sha512-NP+JIkBs1ZKnpa3Lk2W1kBJMwHfNOxCUJXuTa2ckjFsuZ8OUu2gwdeLFkTHbR43dxGwH5UzSmuGocXeMowra/Q==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.7.tgz", + "integrity": "sha512-MV8+hLREf0NN23NuSKemsjFaWjl/HnqdOkE7uhXTnHzg8WTwp6ddVtU5Yriv15+d/ktfLWPVAOhLHQ4gzaoa8A==", "cpu": [ "arm" ], @@ -2883,9 +2673,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.39.tgz", - "integrity": "sha512-cPc+/HehyHyHcvAsk3ML/9wYcpWVIWax3YBaA+ScecJpSE04l/oBHPfdqKUPslqZ+Gcw0OWnIBGJT/fBZW2ayw==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.7.tgz", + "integrity": "sha512-5GNs8ZjHQy/UTSnzzn+gm1RCUpCYo43lsxYOl8mpcnZSfxkNFVpjfylBv0QuJ5qhdfZ2iU55+v4iJCwCMtw0nA==", "cpu": [ "arm64" ], @@ -2900,9 +2690,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.39.tgz", - "integrity": "sha512-8RxgBC6ubFem66bk9XJ0vclu3exJ6eD7x7CwDhp5AD/tulZslTYXM7oNPjEtje3xxabXuj/bEUMNvHZhQRFdqA==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.7.tgz", + "integrity": "sha512-cTydaYBwDbVV5CspwVcCp9IevYWpGD1cF5B5KlBdjmBzxxeWyTAJRtKzn8w5/UJe/MfdAptarpqMPIs2f33YEQ==", "cpu": [ "arm64" ], @@ -2917,9 +2707,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.39.tgz", - "integrity": "sha512-3gtCPEJuXLQEolo9xsXtuPDocmXQx12vewEyFFSMSjOfakuPOBmOQMa0sVL8Wwius8C1eZVeD1fgk0omMqeC+Q==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.7.tgz", + "integrity": "sha512-YAX2KfYPlbDsnZiVMI4ZwotF3VeURUrzD+emJgFf1g26F4eEmslldgnDrKybW7V+bObsH22cDqoy6jmQZgpuPQ==", "cpu": [ "x64" ], @@ -2934,9 +2724,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.39.tgz", - "integrity": "sha512-mg39pW5x/eqqpZDdtjZJxrUvQNSvJF4O8wCl37fbuFUqOtXs4TxsjZ0aolt876HXxxhsQl7rS+N4KioEMSgTZw==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.7.tgz", + "integrity": "sha512-mYT6FTDZyYx5pailc8xt6ClS2yjKmP8jNHxA9Ce3K21n5qkKilI5M2N7NShwXkd3Ksw3F29wKrg+wvEMXTRY/A==", "cpu": [ "x64" ], @@ -2951,9 +2741,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.39.tgz", - "integrity": "sha512-NZwuS0mNJowH3e9bMttr7B1fB8bW5svW/yyySigv9qmV5VcQRNz1kMlCvrCLYRsa93JnARuiaBI6FazSeG8mpA==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.7.tgz", + "integrity": "sha512-uLDQEcv0BHcepypstyxKkNsW6KfLyI5jVxTbcxka+B2UnMcFpvoR87nGt2JYW0grO2SNZPoFz+UnoKL9c6JxpA==", "cpu": [ "arm64" ], @@ -2968,9 +2758,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.39.tgz", - "integrity": "sha512-qFmvv5UExbJPXhhvCVDBnjK5Duqxr048dlVB6ZCgGzbRxuarOlawCzzLK4N172230pzlAWGLgn9CWl3+N6zfHA==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.7.tgz", + "integrity": "sha512-wiq5G3fRizdxAJVFcon7zpyfbfrb+YShuTy+TqJ4Nf5PC0ueMOXmsmeuyQGApn6dVWtGCyymYQYt77wHeQajdA==", "cpu": [ "ia32" ], @@ -2985,9 +2775,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.39.tgz", - "integrity": "sha512-o+5IMqgOtj9+BEOp16atTfBgCogVak9svhBpwsbcJQp67bQbxGYhAPPDW/hZ2rpSSF7UdzbY9wudoX9G4trcuQ==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.7.tgz", + "integrity": "sha512-/zQdqY4fHkSORxEJ2cKtRBOwglvf/8gs6Tl4Q6VMx2zFtFpIOwFQstfY5u8wBNN2Z+PkAzyUCPoi8/cQFK8HLQ==", "cpu": [ "x64" ], @@ -3009,9 +2799,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz", - "integrity": "sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==", + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz", + "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3072,10 +2862,11 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/lodash": { "version": "4.17.12", @@ -3084,15 +2875,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mute-stream": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", - "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "20.14.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", @@ -3182,12 +2964,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/wrap-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", - "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", - "dev": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.12.0.tgz", @@ -3415,127 +3191,132 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react-swc": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.1.tgz", - "integrity": "sha512-vgWOY0i1EROUK0Ctg1hwhtC3SdcDjZcdit4Ups4aPkDcB1jYhmo+RMYWY87cmXMhvtD5uf8lV89j2w16vkdSVg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.8.0.tgz", + "integrity": "sha512-T4sHPvS+DIqDP51ifPqa9XIRAz/kIvIi8oXcnOZZgHmMotgmmdxe/DD5tMFlt5nuIRzT0/QuiwmKlH0503Aapw==", "dev": true, "license": "MIT", "dependencies": { - "@swc/core": "^1.7.26" + "@swc/core": "^1.10.15" }, "peerDependencies": { - "vite": "^4 || ^5" + "vite": "^4 || ^5 || ^6" } }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.7.tgz", + "integrity": "sha512-Av8WgBJLTrfLOer0uy3CxjlVuWK4CzcLBndW1Nm2vI+3hZ2ozHututkfc7Blu1u6waeQ7J8gzPK/AsBRnWA5mQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "@bcoe/v8-coverage": "^1.0.2", + "debug": "^4.4.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", - "magicast": "^0.3.4", - "std-env": "^3.7.0", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.8.0", "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "3.0.7", + "vitest": "3.0.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.7.tgz", + "integrity": "sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", - "chai": "^5.1.1", - "tinyrainbow": "^1.2.0" + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.7.tgz", + "integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.7.tgz", + "integrity": "sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", - "pathe": "^1.1.2" + "@vitest/utils": "3.0.7", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.7.tgz", + "integrity": "sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", - "pathe": "^1.1.2" + "@vitest/pretty-format": "3.0.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.7.tgz", + "integrity": "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^3.0.0" + "tinyspy": "^3.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.7.tgz", + "integrity": "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", - "loupe": "^3.1.1", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "3.0.7", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3603,6 +3384,7 @@ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -3618,6 +3400,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -3694,9 +3477,10 @@ "license": "Python-2.0" }, "node_modules/aria-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", - "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "license": "MIT", "dependencies": { "tslib": "^2.0.0" }, @@ -3939,9 +3723,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", - "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { @@ -4019,34 +3803,15 @@ } }, "node_modules/class-variance-authority": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", - "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { - "clsx": "2.0.0" + "clsx": "^2.1.1" }, "funding": { - "url": "https://joebell.co.uk" - } - }, - "node_modules/class-variance-authority/node_modules/clsx": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", - "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://polar.sh/cva" } }, "node_modules/cli-width": { @@ -4054,15 +3819,11 @@ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, + "license": "ISC", "engines": { "node": ">= 12" } }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4528,10 +4289,11 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -4615,13 +4377,13 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4762,7 +4524,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "engines": { "node": ">=6" } @@ -4893,10 +4654,17 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4904,32 +4672,34 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { @@ -5231,27 +5001,14 @@ "node": ">=0.10.0" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "node_modules/expect-type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.0.tgz", + "integrity": "sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==", "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, + "license": "Apache-2.0", "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": ">=12.0.0" } }, "node_modules/fake-indexeddb": { @@ -5329,30 +5086,6 @@ "reusify": "^1.0.4" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5537,16 +5270,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -5556,18 +5279,6 @@ "node": ">=6" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5683,9 +5394,9 @@ "dev": true }, "node_modules/hls.js": { - "version": "1.5.17", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.17.tgz", - "integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==", + "version": "1.5.20", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz", + "integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==", "license": "Apache-2.0" }, "node_modules/hotkeys-js": { @@ -5744,15 +5455,6 @@ "node": ">= 14" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/idb-keyval": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", @@ -5826,15 +5528,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5922,7 +5615,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-number": { "version": "7.0.0", @@ -6217,9 +5911,9 @@ } }, "node_modules/konva": { - "version": "9.3.16", - "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.16.tgz", - "integrity": "sha512-qa47cefGDDHzkToGRGDsy24f/Njrz7EHP56jQ8mlDcjAPO7vkfTDeoBDIfmF7PZtpfzDdooafQmEUJMDU2F7FQ==", + "version": "9.3.18", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.18.tgz", + "integrity": "sha512-ad5h0Y9phUrinBrKXyIISbURRHQO7Rx5cz7mAEEfdVCs45gDqRD8Y0I0nJRk8S6iqEbiRE87CEZu5GVSnU8oow==", "funding": [ { "type": "patreon", @@ -6300,14 +5994,11 @@ } }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-cache": { "version": "10.4.3", @@ -6317,33 +6008,33 @@ "license": "ISC" }, "node_modules/lucide-react": { - "version": "0.407.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.407.0.tgz", - "integrity": "sha512-+dRIu9Sry+E8wPF9+sY5eKld2omrU4X5IKXxrgqBt+o11IIHVU0QOfNoVWFuj0ZRDrxr4Wci26o2mKZqLGE0lA==", + "version": "0.477.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.477.0.tgz", + "integrity": "sha512-yCf7aYxerFZAbd8jHJxjwe1j7jEMPptjnaOqdYeirFnEy85cNR3/L+o0I875CYFYya+eEVzZSbNuRk8BZPDpVw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/magicast": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.4.tgz", - "integrity": "sha512-TyDF/Pn36bBji9rWKHlZe+PZb6Mx5V8IHCSxk7X4aljM4e/vyDvZZYwHewdVaqiA0nb3ghfHU/6AUpDxWoER2Q==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.24.4", - "@babel/types": "^7.24.0", + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, @@ -6523,9 +6214,9 @@ } }, "node_modules/monaco-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.2.2.tgz", - "integrity": "sha512-NWO/UhtJATlIsqwWPzK7YfbcIvPo3riFGsUkaGxNJoGiNPOvHD8vZ83ecqMQGkHPOpgHtSbe94uokE1AJvpbyQ==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.3.1.tgz", + "integrity": "sha512-1MN8i1Tnc8d8RugQGqv5jp+Ce2xtNhrnbm0ZZbe5ceExj9C2PkKZfHJhY9kbdUS4G7xSVwKlVdMTmLlStepOtw==", "license": "MIT", "workspaces": [ "examples/*" @@ -6537,7 +6228,7 @@ "monaco-types": "^0.1.0", "monaco-worker-manager": "^2.0.0", "path-browserify": "^1.0.0", - "prettier": "^2.0.0", + "prettier": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.0", "vscode-languageserver-types": "^3.0.0", "vscode-uri": "^3.0.0", @@ -6550,50 +6241,38 @@ "monaco-editor": ">=0.36" } }, - "node_modules/monaco-yaml/node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/msw": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.3.5.tgz", - "integrity": "sha512-+GUI4gX5YC5Bv33epBrD+BGdmDvBg2XGruiWnI3GbIbRmMMBeZ5gs3mJ51OWSGHgJKztZ8AtZeYMMNMVrje2/Q==", + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.3.tgz", + "integrity": "sha512-+mycXv8l2fEAjFZ5sjrtjJDmm2ceKGjrNbBr1durRg6VkU9fNUE/gsmQ51hWbHqs+l35W1iM+ZsmOD9Fd6lspw==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@bundled-es-modules/cookie": "^2.0.0", + "@bundled-es-modules/cookie": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", - "@inquirer/confirm": "^3.0.0", - "@mswjs/interceptors": "^0.29.0", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.37.0", + "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", - "chalk": "^4.1.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", - "outvariant": "^1.4.2", - "path-to-regexp": "^6.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", "strict-event-emitter": "^0.5.1", - "type-fest": "^4.9.0", + "type-fest": "^4.26.1", "yargs": "^17.7.2" }, "bin": { @@ -6606,7 +6285,7 @@ "url": "https://github.com/sponsors/mswjs" }, "peerDependencies": { - "typescript": ">= 4.7.x" + "typescript": ">= 4.8.x" }, "peerDependenciesMeta": { "typescript": { @@ -6615,10 +6294,11 @@ } }, "node_modules/msw/node_modules/type-fest": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.2.tgz", - "integrity": "sha512-anpAG63wSpdEbLwOqH8L84urkL6PiVIov3EMmgIhhThevh9aiMQov+6Btx0wldNcvm4wV+e2/Rt1QdDwKHFbHw==", + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.37.0.tgz", + "integrity": "sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" }, @@ -6627,12 +6307,13 @@ } }, "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, + "license": "ISC", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/mz": { @@ -6646,15 +6327,16 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6823,10 +6505,11 @@ } }, "node_modules/outvariant": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.2.tgz", - "integrity": "sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==", - "dev": true + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" }, "node_modules/p-limit": { "version": "3.1.0", @@ -6944,10 +6627,11 @@ } }, "node_modules/path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", - "dev": true + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -6960,9 +6644,9 @@ } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, @@ -7010,9 +6694,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "funding": [ { "type": "opencollective", @@ -7029,8 +6713,8 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -7161,7 +6845,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", - "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -7469,9 +7152,10 @@ } }, "node_modules/react-icons": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz", - "integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", "peerDependencies": { "react": "*" } @@ -7528,23 +7212,23 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", - "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", + "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", "license": "MIT", "dependencies": { - "react-remove-scroll-bar": "^2.3.6", - "react-style-singleton": "^2.2.1", + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" }, "engines": { "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -7553,20 +7237,20 @@ } }, "node_modules/react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "license": "MIT", "dependencies": { - "react-style-singleton": "^2.2.1", + "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "engines": { "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -7628,21 +7312,20 @@ } }, "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", "license": "MIT", "dependencies": { "get-nonce": "^1.0.0", - "invariant": "^2.2.4", "tslib": "^2.0.0" }, "engines": { "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -7651,11 +7334,12 @@ } }, "node_modules/react-swipeable": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.1.tgz", - "integrity": "sha512-RKB17JdQzvECfnVj9yDZsiYn3vH0eyva/ZbrCZXZR0qp66PBRhtg4F9yJcJTWYT5Adadi+x4NoG53BxKHwIYLQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz", + "integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==", + "license": "MIT", "peerDependencies": { - "react": "^16.8.3 || ^17 || ^18" + "react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc" } }, "node_modules/react-tracked": { @@ -7841,12 +7525,13 @@ } }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", + "integrity": "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -7856,19 +7541,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.34.9", + "@rollup/rollup-android-arm64": "4.34.9", + "@rollup/rollup-darwin-arm64": "4.34.9", + "@rollup/rollup-darwin-x64": "4.34.9", + "@rollup/rollup-freebsd-arm64": "4.34.9", + "@rollup/rollup-freebsd-x64": "4.34.9", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", + "@rollup/rollup-linux-arm-musleabihf": "4.34.9", + "@rollup/rollup-linux-arm64-gnu": "4.34.9", + "@rollup/rollup-linux-arm64-musl": "4.34.9", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", + "@rollup/rollup-linux-riscv64-gnu": "4.34.9", + "@rollup/rollup-linux-s390x-gnu": "4.34.9", + "@rollup/rollup-linux-x64-gnu": "4.34.9", + "@rollup/rollup-linux-x64-musl": "4.34.9", + "@rollup/rollup-win32-arm64-msvc": "4.34.9", + "@rollup/rollup-win32-ia32-msvc": "4.34.9", + "@rollup/rollup-win32-x64-msvc": "4.34.9", "fsevents": "~2.3.2" } }, @@ -8000,15 +7691,6 @@ "node": ">=6" } }, - "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8172,9 +7854,9 @@ } }, "node_modules/std-env": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", - "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", + "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", "dev": true, "license": "MIT" }, @@ -8191,7 +7873,8 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/string-width": { "version": "4.2.3", @@ -8433,15 +8116,16 @@ } }, "node_modules/swr": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", - "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.2.tgz", + "integrity": "sha512-RosxFpiabojs75IwQ316DGoDRmOqtiAj0tg8wCcbEu4CiLZBs/a9QNtHV7TUfDXmmlgqij/NqzKq/eLelyv9xA==", + "license": "MIT", "dependencies": { - "client-only": "^0.0.1", - "use-sync-external-store": "^1.2.0" + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" }, "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/symbol-tree": { @@ -8620,16 +8304,23 @@ } }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, "license": "MIT" }, "node_modules/tinypool": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.0.tgz", - "integrity": "sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", "dev": true, "license": "MIT", "engines": { @@ -8637,9 +8328,9 @@ } }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { @@ -8647,9 +8338,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, "license": "MIT", "engines": { @@ -8668,16 +8359,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8771,9 +8452,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8884,9 +8565,9 @@ } }, "node_modules/use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -8895,8 +8576,8 @@ "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -8923,9 +8604,9 @@ } }, "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", @@ -8935,8 +8616,8 @@ "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -8945,11 +8626,12 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/util-deprecate": { @@ -9000,21 +8682,21 @@ } }, "node_modules/vite": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", - "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.40", - "rollup": "^4.13.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -9023,19 +8705,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -9056,27 +8744,33 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.7.tgz", + "integrity": "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", - "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0" + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -9091,46 +8785,48 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.7.tgz", + "integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", - "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", - "pathe": "^1.1.2", - "std-env": "^3.7.0", - "tinybench": "^2.8.0", - "tinypool": "^1.0.0", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.0.5", + "@vitest/expect": "3.0.7", + "@vitest/mocker": "3.0.7", + "@vitest/pretty-format": "^3.0.7", + "@vitest/runner": "3.0.7", + "@vitest/snapshot": "3.0.7", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.7", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.7", + "@vitest/ui": "3.0.7", "happy-dom": "*", "jsdom": "*" }, @@ -9138,6 +8834,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, @@ -9155,6 +8854,33 @@ } } }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.7.tgz", + "integrity": "sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -9295,6 +9021,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -9375,9 +9102,13 @@ } }, "node_modules/yaml": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", - "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { "node": ">= 14" } @@ -9421,6 +9152,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/web/package.json b/web/package.json index 700fd12d7..59a0a5d03 100644 --- a/web/package.json +++ b/web/package.json @@ -14,45 +14,45 @@ "coverage": "vitest run --coverage" }, "dependencies": { - "@cycjimmy/jsmpeg-player": "^6.1.1", + "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^3.9.0", "@melloware/react-logviewer": "^6.1.2", - "@radix-ui/react-alert-dialog": "^1.1.2", - "@radix-ui/react-aspect-ratio": "^1.1.0", - "@radix-ui/react-checkbox": "^1.1.2", - "@radix-ui/react-context-menu": "^2.2.2", - "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-hover-card": "^1.1.2", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-popover": "^1.1.2", - "@radix-ui/react-radio-group": "^1.2.1", - "@radix-ui/react-scroll-area": "^1.2.0", - "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-switch": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.1", - "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-toggle-group": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.3", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", "apexcharts": "^3.52.0", "axios": "^1.7.7", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "embla-carousel-react": "^8.2.0", "framer-motion": "^11.5.4", - "hls.js": "^1.5.17", + "hls.js": "^1.5.20", "idb-keyval": "^6.2.1", "immer": "^10.1.1", - "konva": "^9.3.16", + "konva": "^9.3.18", "lodash": "^4.17.21", - "lucide-react": "^0.407.0", - "monaco-yaml": "^5.2.2", + "lucide-react": "^0.477.0", + "monaco-yaml": "^5.3.1", "next-themes": "^0.3.0", "nosleep.js": "^0.12.0", "react": "^18.3.1", @@ -62,10 +62,10 @@ "react-dom": "^18.3.1", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", - "react-icons": "^5.2.1", + "react-icons": "^5.5.0", "react-konva": "^18.2.10", "react-router-dom": "^6.26.0", - "react-swipeable": "^7.0.1", + "react-swipeable": "^7.0.2", "react-tracked": "^2.0.1", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", @@ -75,7 +75,7 @@ "sonner": "^1.5.0", "sort-by": "^1.2.0", "strftime": "^0.10.3", - "swr": "^2.2.5", + "swr": "^2.3.2", "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", @@ -97,8 +97,8 @@ "@types/strftime": "^0.9.8", "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^7.5.0", - "@vitejs/plugin-react-swc": "^3.7.1", - "@vitest/coverage-v8": "^2.0.5", + "@vitejs/plugin-react-swc": "^3.8.0", + "@vitest/coverage-v8": "^3.0.7", "autoprefixer": "^10.4.20", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -115,8 +115,8 @@ "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.5", "tailwindcss": "^3.4.9", - "typescript": "^5.5.4", - "vite": "^5.4.0", - "vitest": "^2.0.5" + "typescript": "^5.8.2", + "vite": "^6.2.0", + "vitest": "^3.0.7" } } diff --git a/web/src/components/graph/CombinedStorageGraph.tsx b/web/src/components/graph/CombinedStorageGraph.tsx index 2a52d82b6..7f10f0ab5 100644 --- a/web/src/components/graph/CombinedStorageGraph.tsx +++ b/web/src/components/graph/CombinedStorageGraph.tsx @@ -16,7 +16,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { getUnitSize } from "@/utils/storageUtil"; -import { LuAlertCircle } from "react-icons/lu"; +import { CiCircleAlert } from "react-icons/ci"; type CameraStorage = { [key: string]: { @@ -199,7 +199,7 @@ export function CombinedStorageGraph({ className="focus:outline-none" aria-label="Unused Storage Information" > - diff --git a/web/src/components/indicators/activity-indicator.tsx b/web/src/components/indicators/activity-indicator.tsx index 5591c9b7d..677a815ae 100644 --- a/web/src/components/indicators/activity-indicator.tsx +++ b/web/src/components/indicators/activity-indicator.tsx @@ -1,5 +1,5 @@ import { cn } from "@/lib/utils"; -import { LuLoader2 } from "react-icons/lu"; +import { AiOutlineLoading3Quarters } from "react-icons/ai"; export default function ActivityIndicator({ className = "w-full", size = 30 }) { return ( @@ -7,7 +7,7 @@ export default function ActivityIndicator({ className = "w-full", size = 30 }) { className={cn("flex items-center justify-center", className)} aria-label="Loading…" > - +
); } diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 7473d26d7..c6f920461 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -5,7 +5,7 @@ import { LuList, LuLogOut, LuMoon, - LuPenSquare, + LuSquarePen, LuRotateCw, LuSettings, LuSun, @@ -200,7 +200,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { } aria-label="Configuration editor" > - + Configuration editor diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index fee12a50f..8db67e43e 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -4,7 +4,8 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { baseUrl } from "@/api/baseUrl"; import { toast } from "sonner"; import axios from "axios"; -import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu"; +import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu"; +import { FiMoreVertical } from "react-icons/fi"; import { FaArrowsRotate } from "react-icons/fa6"; import { MdImageSearch } from "react-icons/md"; import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; @@ -216,7 +217,7 @@ export default function SearchResultActions({ - + {menuItems} diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index 40ab543c3..da9bd61b0 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -23,7 +23,6 @@ import { LuEar, LuFolderX, LuPlay, - LuPlayCircle, LuSettings, LuTruck, } from "react-icons/lu"; @@ -54,6 +53,7 @@ import { import { useNavigate } from "react-router-dom"; import { ObjectPath } from "./ObjectPath"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; +import { IoPlayCircleOutline } from "react-icons/io5"; type ObjectLifecycleProps = { className?: string; @@ -733,7 +733,7 @@ export function LifecycleIcon({ case "gone": return ; case "active": - return ; + return ; case "stationary": return ; case "entered_zone": diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx index c3ef25ad1..e754bc1bf 100644 --- a/web/src/views/explore/ExploreView.tsx +++ b/web/src/views/explore/ExploreView.tsx @@ -3,7 +3,7 @@ import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect"; import useSWR from "swr"; import { useApiHost } from "@/api"; import { cn } from "@/lib/utils"; -import { LuArrowRightCircle } from "react-icons/lu"; +import { BsArrowRightCircle } from "react-icons/bs"; import { useNavigate } from "react-router-dom"; import { Tooltip, @@ -183,7 +183,7 @@ function ThumbnailRow({ > - diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index fcda4adb1..d30de487e 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -20,7 +20,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import axios from "axios"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { LuAlertCircle, LuCheck, LuExternalLink, LuX } from "react-icons/lu"; +import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; +import { CiCircleAlert } from "react-icons/ci"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import useSWR from "swr"; @@ -311,7 +312,7 @@ export default function NotificationView({
- + Notifications Unavailable diff --git a/web/src/views/system/StorageMetrics.tsx b/web/src/views/system/StorageMetrics.tsx index ea2f1b7b2..4f057be29 100644 --- a/web/src/views/system/StorageMetrics.tsx +++ b/web/src/views/system/StorageMetrics.tsx @@ -8,7 +8,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import useSWR from "swr"; -import { LuAlertCircle } from "react-icons/lu"; +import { CiCircleAlert } from "react-icons/ci"; import { FrigateConfig } from "@/types/frigateConfig"; import { useTimezone } from "@/hooks/use-date-utils"; import { RecordingsSummary } from "@/types/review"; @@ -86,7 +86,7 @@ export default function StorageMetrics({ className="focus:outline-none" aria-label="Unused Storage Information" > - From f81bab8895d693a96ab2ef87fef28c2e29cb9365 Mon Sep 17 00:00:00 2001 From: Chris Oelerich Date: Thu, 6 Mar 2025 17:21:16 -0500 Subject: [PATCH 56/63] video['global'] can be empty resulting in a divide by zero (#16993) * video['global'] can be empty resulting in a divide by zero * formatting :( --- frigate/util/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/util/services.py b/frigate/util/services.py index c9c1b61a2..ce7041c26 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -362,7 +362,7 @@ def get_intel_gpu_stats(sriov: bool) -> dict[str, str]: if video_frame is not None: video[key].append(float(video_frame)) - if render["global"]: + if render["global"] and video["global"]: results["gpu"] = ( f"{round(((sum(render['global']) / len(render['global'])) + (sum(video['global']) / len(video['global']))) / 2, 2)}%" ) From 0e3e2e5cccdc669ebd5dc0842411fbf4cd6726d7 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 6 Mar 2025 20:00:15 -0600 Subject: [PATCH 57/63] Add cameras filter to history view (#16995) --- .../components/filter/CamerasFilterButton.tsx | 26 +++++++++++++++++-- .../components/filter/ReviewFilterGroup.tsx | 3 +++ web/src/views/recording/RecordingView.tsx | 19 ++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index c584dc09d..d9deb87bd 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -18,6 +18,7 @@ type CameraFilterButtonProps = { groups: [string, CameraGroupConfig][]; selectedCameras: string[] | undefined; hideText?: boolean; + mainCamera?: string; updateCameraFilter: (cameras: string[] | undefined) => void; }; export function CamerasFilterButton({ @@ -25,6 +26,7 @@ export function CamerasFilterButton({ groups, selectedCameras, hideText = isMobile, + mainCamera, updateCameraFilter, }: CameraFilterButtonProps) { const [open, setOpen] = useState(false); @@ -74,6 +76,7 @@ export function CamerasFilterButton({ allCameras={allCameras} groups={groups} currentCameras={currentCameras} + mainCamera={mainCamera} setCurrentCameras={setCurrentCameras} setOpen={setOpen} updateCameraFilter={updateCameraFilter} @@ -120,6 +123,7 @@ export function CamerasFilterButton({ type CamerasFilterContentProps = { allCameras: string[]; currentCameras: string[] | undefined; + mainCamera?: string; groups: [string, CameraGroupConfig][]; setCurrentCameras: (cameras: string[] | undefined) => void; setOpen: (open: boolean) => void; @@ -128,6 +132,7 @@ type CamerasFilterContentProps = { export function CamerasFilterContent({ allCameras, currentCameras, + mainCamera, groups, setCurrentCameras, setOpen, @@ -178,12 +183,29 @@ export function CamerasFilterContent({ key={item} isChecked={currentCameras?.includes(item) ?? false} label={item.replaceAll("_", " ")} + disabled={ + mainCamera !== undefined && + currentCameras !== undefined && + item === mainCamera + } // Disable only if mainCamera exists and cameras are filtered onCheckedChange={(isChecked) => { + if ( + mainCamera !== undefined && // Only enforce if mainCamera is defined + item === mainCamera && + !isChecked && + currentCameras !== undefined + ) { + return; // Prevent deselecting mainCamera when filtered and mainCamera is defined + } if (isChecked) { const updatedCameras = currentCameras ? [...currentCameras] - : []; - updatedCameras.push(item); + : mainCamera !== undefined && item !== mainCamera // If mainCamera exists and this isn’t it + ? [mainCamera] // Start with mainCamera when transitioning from undefined + : []; // Otherwise start empty + if (!updatedCameras.includes(item)) { + updatedCameras.push(item); + } setCurrentCameras(updatedCameras); } else { const updatedCameras = currentCameras diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index dedcb06fc..09eb8092a 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -49,6 +49,7 @@ type ReviewFilterGroupProps = { motionOnly: boolean; filterList?: FilterList; showReviewed: boolean; + mainCamera?: string; setShowReviewed: (show: boolean) => void; onUpdateFilter: (filter: ReviewFilter) => void; setMotionOnly: React.Dispatch>; @@ -63,6 +64,7 @@ export default function ReviewFilterGroup({ motionOnly, filterList, showReviewed, + mainCamera, setShowReviewed, onUpdateFilter, setMotionOnly, @@ -185,6 +187,7 @@ export default function ReviewFilterGroup({ allCameras={filterValues.cameras} groups={groups} selectedCameras={filter?.cameras} + mainCamera={mainCamera} updateCameraFilter={(newCameras) => { onUpdateFilter({ ...filter, cameras: newCameras }); }} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index c5e528736..0baeca994 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -442,7 +442,7 @@ export function RecordingView({ )} {isDesktop && ( {}} - onUpdateFilter={updateFilter} + mainCamera={mainCamera} + onUpdateFilter={(newFilter: ReviewFilter) => { + const updatedCameras = + newFilter.cameras === undefined + ? undefined // Respect undefined as "all cameras" + : newFilter.cameras + ? Array.from( + new Set([mainCamera, ...(newFilter.cameras || [])]), + ) // Include mainCamera if specific cameras are selected + : [mainCamera]; + const adjustedFilter: ReviewFilter = { + ...newFilter, + cameras: updatedCameras, + }; + updateFilter(adjustedFilter); + }} setMotionOnly={() => {}} /> )} From d831fab381aacd6d7c54dd213785f9aa4fd5769e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Mar 2025 06:47:15 -0700 Subject: [PATCH 58/63] Bump actions/setup-python from 5.3.0 to 5.4.0 (#16184) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.3.0 to 5.4.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.3.0...v5.4.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 37f75bf85..02fde5861 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -80,7 +80,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From 09705fd1b41067e8c1f72679eb54f97ed04f2c7d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 7 Mar 2025 06:54:53 -0700 Subject: [PATCH 59/63] Update python deps (#17006) --- docker/main/requirements-wheels.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 25286617e..795007e86 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -1,7 +1,7 @@ aiofiles == 24.1.* click == 8.1.* # FastAPI -aiohttp == 3.11.2 +aiohttp == 3.11.3 starlette == 0.41.2 starlette-context == 0.3.6 fastapi == 0.115.* @@ -20,9 +20,9 @@ pandas == 2.2.* peewee == 3.17.* peewee_migrate == 1.13.* psutil == 6.1.* -pydantic == 2.8.* +pydantic == 2.10.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml -pytz == 2024.* +pytz == 2025.* pyzmq == 26.2.* ruamel.yaml == 0.18.* tzlocal == 5.2 @@ -34,8 +34,8 @@ ws4py == 0.5.* unidecode == 1.3.* # Image Manipulation numpy == 1.26.* -opencv-python-headless == 4.10.0.* -opencv-contrib-python == 4.9.0.* +opencv-python-headless == 4.11.0.* +opencv-contrib-python == 4.11.0.* scipy == 1.14.* # OpenVino & ONNX openvino == 2024.4.* @@ -46,7 +46,7 @@ transformers == 4.45.* # Generative AI google-generativeai == 0.8.* ollama == 0.3.* -openai == 1.51.* +openai == 1.65.* # push notifications py-vapid == 1.9.* pywebpush == 2.0.* From 6f9d9cd5a853b7c7cb4058ada8dccdca8cbc3f5b Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 7 Mar 2025 07:50:04 -0700 Subject: [PATCH 60/63] Fix yolov9 link (#17007) --- docs/docs/configuration/object_detectors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 6834f8014..3423ac3c6 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -230,7 +230,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl #### YOLOv9 -[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default. +[YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default. :::tip @@ -513,7 +513,7 @@ model: #### YOLOv9 -[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default. +[YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default. :::tip From 74ca009b0b767a8393562a932673f8fd5b11b7fe Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 8 Mar 2025 10:01:08 -0600 Subject: [PATCH 61/63] UI viewer role (#16978) * db migration * db model * assign admin role on password reset * add role to jwt and api responses * don't restrict api access for admins yet * use json response * frontend auth context * update auth form for profile endpoint * add access denied page * add protected routes * auth hook * dialogs * user settings view * restrict viewer access to settings * restrict camera functions for viewer role * add password dialog to account menu * spacing tweak * migrator default to admin * escape quotes in migrator * ui tweaks * tweaks * colors * colors * fix merge conflict * fix icons * add api layer enforcement * ui tweaks * fix error message * debug * clean up * remove print * guard apis for admin only * fix tests * fix review tests * use correct error responses from api in toasts * add role to account menu --- .../usr/local/nginx/conf/auth_request.conf | 4 +- frigate/api/app.py | 7 +- frigate/api/auth.py | 177 ++++++-- frigate/api/classification.py | 11 +- frigate/api/defs/request/app_body.py | 7 + frigate/api/event.py | 55 ++- frigate/api/export.py | 9 +- frigate/api/review.py | 7 +- frigate/app.py | 1 + frigate/config/proxy.py | 4 + frigate/models.py | 4 + frigate/test/http_api/test_http_review.py | 18 +- frigate/test/test_http.py | 9 +- migrations/029_add_user_role.py | 37 ++ web/src/App.tsx | 83 ++-- web/src/components/auth/AuthForm.tsx | 20 +- web/src/components/auth/ProtectedRoute.tsx | 40 ++ .../components/filter/CameraGroupSelector.tsx | 22 +- .../components/filter/SearchActionGroup.tsx | 8 +- web/src/components/menu/AccountSettings.tsx | 53 ++- web/src/components/menu/GeneralSettings.tsx | 417 ++++++++++-------- .../components/menu/SearchResultActions.tsx | 8 +- .../components/overlay/CreateUserDialog.tsx | 229 ++++++++-- .../components/overlay/DeleteUserDialog.tsx | 59 ++- web/src/components/overlay/ExportDialog.tsx | 17 +- .../overlay/MobileReviewSettingsDrawer.tsx | 17 +- .../components/overlay/RoleChangeDialog.tsx | 119 +++++ .../components/overlay/SetPasswordDialog.tsx | 198 ++++++++- .../overlay/detail/AnnotationSettingsPane.tsx | 11 +- .../overlay/detail/SearchDetailDialog.tsx | 26 +- .../settings/MotionMaskEditPane.tsx | 11 +- .../settings/ObjectMaskEditPane.tsx | 11 +- web/src/components/settings/PolygonItem.tsx | 11 +- web/src/components/settings/ZoneEditPane.tsx | 11 +- web/src/context/auth-context.tsx | 74 ++++ web/src/context/providers.tsx | 29 +- web/src/hooks/use-is-admin.ts | 10 + web/src/pages/AccessDenied.tsx | 21 + web/src/pages/ConfigEditor.tsx | 11 +- web/src/pages/Exports.tsx | 17 +- web/src/pages/FaceLibrary.tsx | 101 ++--- web/src/pages/Settings.tsx | 26 +- web/src/types/user.ts | 1 + web/src/views/events/EventView.tsx | 17 +- web/src/views/live/LiveCameraView.tsx | 228 +++++----- web/src/views/settings/AuthenticationView.tsx | 372 ++++++++++++---- web/src/views/settings/CameraSettingsView.tsx | 11 +- .../settings/NotificationsSettingsView.tsx | 11 +- web/src/views/settings/SearchSettingsView.tsx | 11 +- web/src/views/settings/UiSettingsView.tsx | 22 +- 50 files changed, 1951 insertions(+), 732 deletions(-) create mode 100644 migrations/029_add_user_role.py create mode 100644 web/src/components/auth/ProtectedRoute.tsx create mode 100644 web/src/components/overlay/RoleChangeDialog.tsx create mode 100644 web/src/context/auth-context.tsx create mode 100644 web/src/hooks/use-is-admin.ts create mode 100644 web/src/pages/AccessDenied.tsx diff --git a/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf b/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf index b054a6b97..9e745b6dc 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf @@ -1,14 +1,16 @@ ## Send a subrequest to verify if the user is authenticated and has permission to access the resource. auth_request /auth; -## Save the upstream metadata response headers from Authelia to variables. +## Save the upstream metadata response headers from the auth request to variables auth_request_set $user $upstream_http_remote_user; +auth_request_set $role $upstream_http_remote_role; auth_request_set $groups $upstream_http_remote_groups; auth_request_set $name $upstream_http_remote_name; auth_request_set $email $upstream_http_remote_email; ## Inject the metadata response headers from the variables into the request made to the backend. proxy_set_header Remote-User $user; +proxy_set_header Remote-Role $role; proxy_set_header Remote-Groups $groups; proxy_set_header Remote-Email $email; proxy_set_header Remote-Name $name; diff --git a/frigate/api/app.py b/frigate/api/app.py index c55e36a4b..5ce90130f 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -22,6 +22,7 @@ from markupsafe import escape from peewee import operator from pydantic import ValidationError +from frigate.api.auth import require_role from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.request.app_body import AppConfigSetBody from frigate.api.defs.tags import Tags @@ -201,7 +202,7 @@ def config_raw(): ) -@router.post("/config/save") +@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))]) def config_save(save_option: str, body: Any = Body(media_type="text/plain")): new_config = body.decode() if not new_config: @@ -326,7 +327,7 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): ) -@router.put("/config/set") +@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) def config_set(request: Request, body: AppConfigSetBody): config_file = find_config_file() @@ -542,7 +543,7 @@ async def logs( ) -@router.post("/restart") +@router.post("/restart", dependencies=[Depends(require_role(["admin"]))]) def restart(): try: restart_frigate() diff --git a/frigate/api/auth.py b/frigate/api/auth.py index be5917450..1752b19c9 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -11,8 +11,9 @@ import secrets import time from datetime import datetime from pathlib import Path +from typing import List -from fastapi import APIRouter, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse, RedirectResponse from joserfc import jwt from peewee import DoesNotExist @@ -22,6 +23,7 @@ from frigate.api.defs.request.app_body import ( AppPostLoginBody, AppPostUsersBody, AppPutPasswordBody, + AppPutRoleBody, ) from frigate.api.defs.tags import Tags from frigate.config import AuthConfig, ProxyConfig @@ -169,8 +171,10 @@ def verify_password(password, password_hash): return secrets.compare_digest(password_hash, compare_hash) -def create_encoded_jwt(user, expiration, secret): - return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret) +def create_encoded_jwt(user, role, expiration, secret): + return jwt.encode( + {"alg": "HS256"}, {"sub": user, "role": role, "exp": expiration}, secret + ) def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure): @@ -184,7 +188,48 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, sec ) -# Endpoint for use with nginx auth_request +async def get_current_user(request: Request): + JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name + encoded_token = request.cookies.get(JWT_COOKIE_NAME) + if not encoded_token: + return JSONResponse(content={"message": "No JWT token found"}, status_code=401) + + try: + token = jwt.decode(encoded_token, request.app.jwt_token) + if "sub" not in token.claims or "role" not in token.claims: + return JSONResponse( + content={"message": "Invalid JWT token"}, status_code=401 + ) + return {"username": token.claims["sub"], "role": token.claims["role"]} + except Exception as e: + logger.error(f"Error parsing JWT: {e}") + return JSONResponse(content={"message": "Invalid JWT token"}, status_code=401) + + +def require_role(required_roles: List[str]): + async def role_checker(request: Request): + # Get role from header (could be comma-separated) + role_header = request.headers.get("remote-role") + roles = [r.strip() for r in role_header.split(",")] if role_header else [] + + # Check if we have any roles + if not roles: + raise HTTPException(status_code=403, detail="Role not provided") + + # Check if any role matches required_roles + if not any(role in required_roles for role in roles): + raise HTTPException( + status_code=403, + detail=f"Role {', '.join(roles)} not authorized. Required: {', '.join(required_roles)}", + ) + + # Return the first matching role + return next((role for role in roles if role in required_roles), roles[0]) + + return role_checker + + +# Endpoints @router.get("/auth") def auth(request: Request): auth_config: AuthConfig = request.app.frigate_config.auth @@ -195,6 +240,8 @@ def auth(request: Request): # dont require auth if the request is on the internal port # this header is set by Frigate's nginx proxy, so it cant be spoofed if int(request.headers.get("x-server-port", default=0)) == 5000: + success_response.headers["remote-user"] = "anonymous" + success_response.headers["remote-role"] = "admin" return success_response fail_response = Response("", status_code=401) @@ -211,14 +258,18 @@ def auth(request: Request): if not auth_config.enabled: # pass the user header value from the upstream proxy if a mapping is specified # or use anonymous if none are specified - if proxy_config.header_map.user is not None: - upstream_user_header_value = request.headers.get( - proxy_config.header_map.user, - default="anonymous", - ) - success_response.headers["remote-user"] = upstream_user_header_value - else: - success_response.headers["remote-user"] = "anonymous" + user_header = proxy_config.header_map.user + role_header = proxy_config.header_map.get("role", "Remote-Role") + success_response.headers["remote-user"] = ( + request.headers.get(user_header, default="anonymous") + if user_header + else "anonymous" + ) + success_response.headers["remote-role"] = ( + request.headers.get(role_header, default="viewer") + if role_header + else "viewer" + ) return success_response # now apply authentication @@ -251,11 +302,15 @@ def auth(request: Request): if "sub" not in token.claims: logger.debug("user not set in jwt token") return fail_response + if "role" not in token.claims: + logger.debug("role not set in jwt token") + return fail_response if "exp" not in token.claims: logger.debug("exp not set in jwt token") return fail_response user = token.claims.get("sub") + role = token.claims.get("role") current_time = int(time.time()) # if the jwt is expired @@ -283,7 +338,7 @@ def auth(request: Request): return fail_response new_expiration = current_time + JWT_SESSION_LENGTH new_encoded_jwt = create_encoded_jwt( - user, new_expiration, request.app.jwt_token + user, role, new_expiration, request.app.jwt_token ) set_jwt_cookie( success_response, @@ -294,6 +349,7 @@ def auth(request: Request): ) success_response.headers["remote-user"] = user + success_response.headers["remote-role"] = role return success_response except Exception as e: logger.error(f"Error parsing jwt: {e}") @@ -302,8 +358,16 @@ def auth(request: Request): @router.get("/profile") def profile(request: Request): - username = request.headers.get("remote-user") - return JSONResponse(content={"username": username}) + username = request.headers.get("remote-user", "anonymous") + if username != "anonymous": + try: + user = User.get_by_id(username) + role = getattr(user, "role", "viewer") + except DoesNotExist: + role = "viewer" # Fallback if user deleted + else: + role = None + return JSONResponse(content={"username": username, "role": role}) @router.get("/logout") @@ -333,8 +397,11 @@ def login(request: Request, body: AppPostLoginBody): password_hash = db_user.password_hash if verify_password(password, password_hash): + role = getattr(db_user, "role", "viewer") + if role not in ["admin", "viewer"]: + role = "viewer" # Enforce valid roles expiration = int(time.time()) + JWT_SESSION_LENGTH - encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token) + encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token) response = Response("", 200) set_jwt_cookie( response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE @@ -343,25 +410,31 @@ def login(request: Request, body: AppPostLoginBody): return JSONResponse(content={"message": "Login failed"}, status_code=401) -@router.get("/users") +@router.get("/users", dependencies=[Depends(require_role(["admin"]))]) def get_users(): - exports = User.select(User.username).order_by(User.username).dicts().iterator() + exports = ( + User.select(User.username, User.role).order_by(User.username).dicts().iterator() + ) return JSONResponse([e for e in exports]) -@router.post("/users") -def create_user(request: Request, body: AppPostUsersBody): +@router.post("/users", dependencies=[Depends(require_role(["admin"]))]) +def create_user( + request: Request, + body: AppPostUsersBody, +): HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations if not re.match("^[A-Za-z0-9._]+$", body.username): - JSONResponse(content={"message": "Invalid username"}, status_code=400) + return JSONResponse(content={"message": "Invalid username"}, status_code=400) + role = body.role if body.role in ["admin", "viewer"] else "viewer" password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) - User.insert( { User.username: body.username, User.password_hash: password_hash, + User.role: role, User.notification_tokens: [], } ).execute() @@ -375,15 +448,61 @@ def delete_user(username: str): @router.put("/users/{username}/password") -def update_password(request: Request, username: str, body: AppPutPasswordBody): +async def update_password( + request: Request, + username: str, + body: AppPutPasswordBody, +): + current_user = await get_current_user(request) + if isinstance(current_user, JSONResponse): + # auth failed + return current_user + + current_username = current_user.get("username") + current_role = current_user.get("role") + + # viewers can only change their own password + if current_role == "viewer" and current_username != username: + raise HTTPException( + status_code=403, detail="Viewers can only update their own password" + ) + HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) + User.set_by_id(username, {User.password_hash: password_hash}) - User.set_by_id( - username, - { - User.password_hash: password_hash, - }, - ) + return JSONResponse(content={"success": True}) + + +@router.put( + "/users/{username}/role", + dependencies=[Depends(require_role(["admin"]))], +) +async def update_role( + request: Request, + username: str, + body: AppPutRoleBody, +): + current_user = await get_current_user(request) + if isinstance(current_user, JSONResponse): + # auth failed + return current_user + + current_role = current_user.get("role") + # viewers can't change anyone's role + if current_role == "viewer": + raise HTTPException( + status_code=403, detail="Admin role is required to change user roles" + ) + if username == "admin": + return JSONResponse( + content={"message": "Cannot modify admin user's role"}, status_code=403 + ) + if body.role not in ["admin", "viewer"]: + return JSONResponse( + content={"message": "Role must be 'admin' or 'viewer'"}, status_code=400 + ) + + User.set_by_id(username, {User.role: body.role}) return JSONResponse(content={"success": True}) diff --git a/frigate/api/classification.py b/frigate/api/classification.py index bd395737a..85b604379 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -6,12 +6,13 @@ import random import shutil import string -from fastapi import APIRouter, Request, UploadFile +from fastapi import APIRouter, Depends, Request, UploadFile from fastapi.responses import JSONResponse from pathvalidate import sanitize_filename from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.tags import Tags from frigate.const import FACE_DIR from frigate.embeddings import EmbeddingsContext @@ -44,7 +45,7 @@ def get_faces(): return JSONResponse(status_code=200, content=face_dict) -@router.post("/faces/reprocess") +@router.post("/faces/reprocess", dependencies=[Depends(require_role(["admin"]))]) def reclassify_face(request: Request, body: dict = None): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( @@ -121,7 +122,7 @@ def train_face(request: Request, name: str, body: dict = None): ) -@router.post("/faces/{name}/create") +@router.post("/faces/{name}/create", dependencies=[Depends(require_role(["admin"]))]) async def create_face(request: Request, name: str): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( @@ -138,7 +139,7 @@ async def create_face(request: Request, name: str): ) -@router.post("/faces/{name}/register") +@router.post("/faces/{name}/register", dependencies=[Depends(require_role(["admin"]))]) async def register_face(request: Request, name: str, file: UploadFile): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( @@ -154,7 +155,7 @@ async def register_face(request: Request, name: str, file: UploadFile): ) -@router.post("/faces/{name}/delete") +@router.post("/faces/{name}/delete", dependencies=[Depends(require_role(["admin"]))]) def deregister_faces(request: Request, name: str, body: dict = None): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 85daa5631..1fc05db2f 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel @@ -12,8 +14,13 @@ class AppPutPasswordBody(BaseModel): class AppPostUsersBody(BaseModel): username: str password: str + role: Optional[str] = "viewer" class AppPostLoginBody(BaseModel): user: str password: str + + +class AppPutRoleBody(BaseModel): + role: str diff --git a/frigate/api/event.py b/frigate/api/event.py index 9a5578bae..100bdfd9e 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse from peewee import JOIN, DoesNotExist, fn, operator from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.query.events_query_parameters import ( DEFAULT_TIME_RANGE, EventsQueryParams, @@ -708,7 +709,11 @@ def event(event_id: str): return JSONResponse(content="Event not found", status_code=404) -@router.post("/events/{event_id}/retain", response_model=GenericResponse) +@router.post( + "/events/{event_id}/retain", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def set_retain(event_id: str): try: event = Event.get(Event.id == event_id) @@ -928,7 +933,11 @@ def false_positive(request: Request, event_id: str): ) -@router.delete("/events/{event_id}/retain", response_model=GenericResponse) +@router.delete( + "/events/{event_id}/retain", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def delete_retain(event_id: str): try: event = Event.get(Event.id == event_id) @@ -947,7 +956,11 @@ def delete_retain(event_id: str): ) -@router.post("/events/{event_id}/sub_label", response_model=GenericResponse) +@router.post( + "/events/{event_id}/sub_label", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def set_sub_label( request: Request, event_id: str, @@ -1022,7 +1035,11 @@ def set_sub_label( ) -@router.post("/events/{event_id}/description", response_model=GenericResponse) +@router.post( + "/events/{event_id}/description", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def set_description( request: Request, event_id: str, @@ -1069,7 +1086,11 @@ def set_description( ) -@router.put("/events/{event_id}/description/regenerate", response_model=GenericResponse) +@router.put( + "/events/{event_id}/description/regenerate", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def regenerate_description( request: Request, event_id: str, params: RegenerateQueryParameters = Depends() ): @@ -1137,14 +1158,22 @@ def delete_single_event(event_id: str, request: Request) -> dict: return {"success": True, "message": f"Event {event_id} deleted"} -@router.delete("/events/{event_id}", response_model=GenericResponse) +@router.delete( + "/events/{event_id}", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def delete_event(request: Request, event_id: str): result = delete_single_event(event_id, request) status_code = 200 if result["success"] else 404 return JSONResponse(content=result, status_code=status_code) -@router.delete("/events/", response_model=EventMultiDeleteResponse) +@router.delete( + "/events/", + response_model=EventMultiDeleteResponse, + dependencies=[Depends(require_role(["admin"]))], +) def delete_events(request: Request, body: EventsDeleteBody): if not body.event_ids: return JSONResponse( @@ -1170,7 +1199,11 @@ def delete_events(request: Request, body: EventsDeleteBody): return JSONResponse(content=response, status_code=200) -@router.post("/events/{camera_name}/{label}/create", response_model=EventCreateResponse) +@router.post( + "/events/{camera_name}/{label}/create", + response_model=EventCreateResponse, + dependencies=[Depends(require_role(["admin"]))], +) def create_event( request: Request, camera_name: str, @@ -1226,7 +1259,11 @@ def create_event( ) -@router.put("/events/{event_id}/end", response_model=GenericResponse) +@router.put( + "/events/{event_id}/end", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def end_event(request: Request, event_id: str, body: EventsEndBody): try: end_time = body.end_time or datetime.datetime.now().timestamp() diff --git a/frigate/api/export.py b/frigate/api/export.py index 2ccbc4beb..160434c68 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -6,11 +6,12 @@ import string from pathlib import Path import psutil -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody from frigate.api.defs.request.export_rename_body import ExportRenameBody from frigate.api.defs.tags import Tags @@ -130,7 +131,9 @@ def export_recording( ) -@router.patch("/export/{event_id}/rename") +@router.patch( + "/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))] +) def export_rename(event_id: str, body: ExportRenameBody): try: export: Export = Export.get(Export.id == event_id) @@ -158,7 +161,7 @@ def export_rename(event_id: str, body: ExportRenameBody): ) -@router.delete("/export/{event_id}") +@router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))]) def export_delete(event_id: str): try: export: Export = Export.get(Export.id == event_id) diff --git a/frigate/api/review.py b/frigate/api/review.py index 3e503d400..4788356f3 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse from peewee import Case, DoesNotExist, fn, operator from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.query.review_query_parameters import ( ReviewActivityMotionQueryParams, ReviewQueryParams, @@ -343,7 +344,11 @@ def set_multiple_reviewed(body: ReviewModifyMultipleBody): ) -@router.post("/reviews/delete", response_model=GenericResponse) +@router.post( + "/reviews/delete", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def delete_reviews(body: ReviewModifyMultipleBody): list_of_ids = body.ids reviews = ( diff --git a/frigate/app.py b/frigate/app.py index 8b63ab0a0..cdb4877cc 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -620,6 +620,7 @@ class FrigateApp: ) User.replace( username="admin", + role="admin", password_hash=password_hash, notification_tokens=[], ).execute() diff --git a/frigate/config/proxy.py b/frigate/config/proxy.py index 3427f60a0..df8a665fb 100644 --- a/frigate/config/proxy.py +++ b/frigate/config/proxy.py @@ -12,6 +12,10 @@ class HeaderMappingConfig(FrigateBaseModel): user: str = Field( default=None, title="Header name from upstream proxy to identify user." ) + role: str = Field( + default=None, + title="Header name from upstream proxy to identify user role.", + ) class ProxyConfig(FrigateBaseModel): diff --git a/frigate/models.py b/frigate/models.py index 62bbf0bd3..26375432e 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -117,5 +117,9 @@ class RecordingsToDelete(Model): # type: ignore[misc] class User(Model): # type: ignore[misc] username = CharField(null=False, primary_key=True, max_length=30) + role = CharField( + max_length=20, + default="viewer", + ) password_hash = CharField(null=False, max_length=120) notification_tokens = JSONField() diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index c8f2b1719..ee7d96bc5 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -504,7 +504,7 @@ class TestHttpReview(BaseTestHttp): def test_post_reviews_delete_no_body(self): with TestClient(self.app) as client: super().insert_mock_review_segment("123456.random") - response = client.post("/reviews/delete") + response = client.post("/reviews/delete", headers={"remote-role": "admin"}) # Missing ids assert response.status_code == 422 @@ -512,7 +512,9 @@ class TestHttpReview(BaseTestHttp): with TestClient(self.app) as client: super().insert_mock_review_segment("123456.random") body = {"ids": [""]} - response = client.post("/reviews/delete", json=body) + response = client.post( + "/reviews/delete", json=body, headers={"remote-role": "admin"} + ) # Missing ids assert response.status_code == 422 @@ -521,7 +523,9 @@ class TestHttpReview(BaseTestHttp): id = "123456.random" super().insert_mock_review_segment(id) body = {"ids": ["1"]} - response = client.post("/reviews/delete", json=body) + response = client.post( + "/reviews/delete", json=body, headers={"remote-role": "admin"} + ) assert response.status_code == 200 response_json = response.json() assert response_json["success"] == True @@ -536,7 +540,9 @@ class TestHttpReview(BaseTestHttp): id = "123456.random" super().insert_mock_review_segment(id) body = {"ids": [id]} - response = client.post("/reviews/delete", json=body) + response = client.post( + "/reviews/delete", json=body, headers={"remote-role": "admin"} + ) assert response.status_code == 200 response_json = response.json() assert response_json["success"] == True @@ -558,7 +564,9 @@ class TestHttpReview(BaseTestHttp): assert len(recordings_ids_in_db_before) == 2 body = {"ids": ids} - response = client.post("/reviews/delete", json=body) + response = client.post( + "/reviews/delete", json=body, headers={"remote-role": "admin"} + ) assert response.status_code == 200 response_json = response.json() assert response_json["success"] == True diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 0238c766c..d6ff91a83 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -172,7 +172,7 @@ class TestHttp(unittest.TestCase): event = client.get(f"/events/{id}").json() assert event assert event["id"] == id - client.delete(f"/events/{id}") + client.delete(f"/events/{id}", headers={"remote-role": "admin"}) event = client.get(f"/events/{id}").json() assert event == "Event not found" @@ -192,12 +192,12 @@ class TestHttp(unittest.TestCase): with TestClient(app) as client: _insert_mock_event(id) - client.post(f"/events/{id}/retain") + client.post(f"/events/{id}/retain", headers={"remote-role": "admin"}) event = client.get(f"/events/{id}").json() assert event assert event["id"] == id assert event["retain_indefinitely"] is True - client.delete(f"/events/{id}/retain") + client.delete(f"/events/{id}/retain", headers={"remote-role": "admin"}) event = client.get(f"/events/{id}").json() assert event assert event["id"] == id @@ -262,6 +262,7 @@ class TestHttp(unittest.TestCase): new_sub_label_response = client.post( f"/events/{id}/sub_label", json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, ) assert new_sub_label_response.status_code == 200 event = client.get(f"/events/{id}").json() @@ -271,6 +272,7 @@ class TestHttp(unittest.TestCase): empty_sub_label_response = client.post( f"/events/{id}/sub_label", json={"subLabel": ""}, + headers={"remote-role": "admin"}, ) assert empty_sub_label_response.status_code == 200 event = client.get(f"/events/{id}").json() @@ -298,6 +300,7 @@ class TestHttp(unittest.TestCase): client.post( f"/events/{id}/sub_label", json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, ) sub_labels = client.get("/sub_labels").json() assert sub_labels diff --git a/migrations/029_add_user_role.py b/migrations/029_add_user_role.py new file mode 100644 index 000000000..484e0c548 --- /dev/null +++ b/migrations/029_add_user_role.py @@ -0,0 +1,37 @@ +"""Peewee migrations -- 029_add_user_role.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.sql( + 'ALTER TABLE "user" ADD COLUMN "role" VARCHAR(20) NOT NULL DEFAULT \'admin\'' + ) + migrator.sql('UPDATE "user" SET "role" = \'admin\' WHERE "role" IS NULL') + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.sql('ALTER TABLE "user" DROP COLUMN "role"') diff --git a/web/src/App.tsx b/web/src/App.tsx index ef0a9497e..a0062549f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,8 @@ import { Suspense, lazy } from "react"; import { Redirect } from "./components/navigation/Redirect"; import { cn } from "./lib/utils"; import { isPWA } from "./utils/isPWA"; +import ProtectedRoute from "@/components/auth/ProtectedRoute"; +import { AuthProvider } from "@/context/auth-context"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); @@ -21,45 +23,58 @@ const Settings = lazy(() => import("@/pages/Settings")); const UIPlayground = lazy(() => import("@/pages/UIPlayground")); const FaceLibrary = lazy(() => import("@/pages/FaceLibrary")); const Logs = lazy(() => import("@/pages/Logs")); +const AccessDenied = lazy(() => import("@/pages/AccessDenied")); function App() { return ( - - -
- {isDesktop && } - {isDesktop && } - {isMobile && } -
- - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + +
+ {isDesktop && } + {isDesktop && } + {isMobile && } +
+ + + + } + > + } /> + } /> + } /> + } /> + } /> + + } + > + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + +
-
- - + + + ); } diff --git a/web/src/components/auth/AuthForm.tsx b/web/src/components/auth/AuthForm.tsx index 99ce37283..617ce1693 100644 --- a/web/src/components/auth/AuthForm.tsx +++ b/web/src/components/auth/AuthForm.tsx @@ -20,24 +20,23 @@ import { import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; +import { AuthContext } from "@/context/auth-context"; interface UserAuthFormProps extends React.HTMLAttributes {} export function UserAuthForm({ className, ...props }: UserAuthFormProps) { const [isLoading, setIsLoading] = React.useState(false); + const { login } = React.useContext(AuthContext); const formSchema = z.object({ - user: z.string(), - password: z.string(), + user: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", - defaultValues: { - user: "", - password: "", - }, + defaultValues: { user: "", password: "" }, }); const onSubmit = async (values: z.infer) => { @@ -50,11 +49,14 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { password: values.password, }, { - headers: { - "X-CSRF-TOKEN": 1, - }, + headers: { "X-CSRF-TOKEN": 1 }, }, ); + const profileRes = await axios.get("/profile", { withCredentials: true }); + login({ + username: profileRes.data.username, + role: profileRes.data.role || "viewer", + }); window.location.href = baseUrl; } catch (error) { if (axios.isAxiosError(error)) { diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 000000000..c35fdaebc --- /dev/null +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,40 @@ +import { useContext } from "react"; +import { Navigate, Outlet } from "react-router-dom"; +import { AuthContext } from "@/context/auth-context"; +import ActivityIndicator from "../indicators/activity-indicator"; + +export default function ProtectedRoute({ + requiredRoles, +}: { + requiredRoles: ("admin" | "viewer")[]; +}) { + const { auth } = useContext(AuthContext); + + if (auth.isLoading) { + return ( + + ); + } + + // Unauthenticated mode + if (!auth.isAuthenticated) { + return ; + } + + // Authenticated mode (8971): require login + if (!auth.user) { + return ; + } + + // If role is null (shouldn’t happen if isAuthenticated, but type safety), fallback + // though isAuthenticated should catch this + if (auth.user.role === null) { + return ; + } + + if (!requiredRoles.includes(auth.user.role)) { + return ; + } + + return ; +} diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 8aec2a117..cf6881056 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -281,10 +281,13 @@ function NewGroupDialog({ .catch((error) => { setOpen(false); setEditState("none"); - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); @@ -725,10 +728,13 @@ export function CameraGroupEdit({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/filter/SearchActionGroup.tsx b/web/src/components/filter/SearchActionGroup.tsx index aac03ad1c..32751a56f 100644 --- a/web/src/components/filter/SearchActionGroup.tsx +++ b/web/src/components/filter/SearchActionGroup.tsx @@ -44,8 +44,12 @@ export default function SearchActionGroup({ pullLatestData(); } }) - .catch(() => { - toast.error("Failed to delete tracked objects.", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete tracked objects.: ${errorMessage}`, { position: "top-center", }); }); diff --git a/web/src/components/menu/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx index 0bc968061..7e948308f 100644 --- a/web/src/components/menu/AccountSettings.tsx +++ b/web/src/components/menu/AccountSettings.tsx @@ -18,22 +18,52 @@ import { } from "../ui/dropdown-menu"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { DialogClose } from "../ui/dialog"; -import { LuLogOut } from "react-icons/lu"; +import { LuLogOut, LuSquarePen } from "react-icons/lu"; import useSWR from "swr"; +import { useState } from "react"; +import axios from "axios"; +import { toast } from "sonner"; +import SetPasswordDialog from "../overlay/SetPasswordDialog"; type AccountSettingsProps = { className?: string; }; + export default function AccountSettings({ className }: AccountSettingsProps) { const { data: profile } = useSWR("profile"); const { data: config } = useSWR("config"); const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`; + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const Container = isDesktop ? DropdownMenu : Drawer; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Content = isDesktop ? DropdownMenuContent : DrawerContent; const MenuItem = isDesktop ? DropdownMenuItem : DialogClose; + const handlePasswordSave = async (password: string) => { + if (!profile?.username || profile.username === "anonymous") return; + axios + .put(`users/${profile.username}/password`, { password }) + .then((response) => { + if (response.status === 200) { + setPasswordDialogOpen(false); + toast.success("Password updated successfully.", { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Error setting password: ${errorMessage}`, { + position: "top-center", + }); + }); + }; + return ( @@ -65,9 +95,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) { >
- Current User: {profile?.username || "anonymous"} + Current User: {profile?.username || "anonymous"}{" "} + {profile?.role && `(${profile.role})`} + {profile?.username && profile.username !== "anonymous" && ( + setPasswordDialogOpen(true)} + > + + Set Password + + )}
+ setPasswordDialogOpen(false)} + username={profile?.username} + />
); } diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index c6f920461..b07ace2a3 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -24,7 +24,6 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "../ui/dropdown-menu"; - import { Link } from "react-router-dom"; import { CgDarkMode } from "react-icons/cg"; import { @@ -33,10 +32,8 @@ import { useTheme, } from "@/context/theme-provider"; import { IoColorPalette } from "react-icons/io5"; - import { useState } from "react"; import { useRestart } from "@/api/ws"; - import { Tooltip, TooltipContent, @@ -55,21 +52,27 @@ import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; import useSWR from "swr"; import RestartDialog from "../overlay/dialog/RestartDialog"; +import { useIsAdmin } from "@/hooks/use-is-admin"; +import SetPasswordDialog from "../overlay/SetPasswordDialog"; +import { toast } from "sonner"; +import axios from "axios"; type GeneralSettingsProps = { className?: string; }; + export default function GeneralSettings({ className }: GeneralSettingsProps) { const { data: profile } = useSWR("profile"); const { data: config } = useSWR("config"); const logoutUrl = config?.proxy?.logout_url || "/api/logout"; - // settings - const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); + const isAdmin = useIsAdmin(); + const Container = isDesktop ? DropdownMenu : Drawer; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Content = isDesktop ? DropdownMenuContent : DrawerContent; @@ -79,6 +82,29 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent; const Portal = isDesktop ? DropdownMenuPortal : DialogPortal; + const handlePasswordSave = async (password: string) => { + if (!profile?.username || profile.username === "anonymous") return; + axios + .put(`users/${profile.username}/password`, { password }) + .then((response) => { + if (response.status === 200) { + setPasswordDialogOpen(false); + toast.success("Password updated successfully.", { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Error setting password: ${errorMessage}`, { + position: "top-center", + }); + }); + }; + return ( <> @@ -121,13 +147,28 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { >
{isMobile && ( - <> + + )} + {isAdmin && ( + <> + System + + + + + + System metrics + + + + + + System logs + + + )} - System - - - - - - System metrics - - - - - - System logs - - - - + Configuration @@ -191,143 +238,143 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { Settings - - - - Configuration editor - - - - Appearance - - - - - - Dark Mode - - - - + {isAdmin && ( + <> + setTheme("light")} + aria-label="Configuration editor" > - {theme === "light" ? ( - <> - - Light - - ) : ( - Light - )} + + Configuration editor - setTheme("dark")} - > - {theme === "dark" ? ( - <> - - Dark - - ) : ( - Dark - )} - - setTheme("system")} - > - {theme === "system" ? ( - <> - - System - - ) : ( - System - )} - - - - - - - - Theme - - - - - {colorSchemes.map((scheme) => ( - setColorScheme(scheme)} - > - {scheme === colorScheme ? ( - <> - - {friendlyColorSchemeName(scheme)} - - ) : ( - - {friendlyColorSchemeName(scheme)} - - )} - - ))} - - - + + + )} + + Appearance + + + + + + Dark Mode + + + + + setTheme("light")} + > + {theme === "light" ? ( + <> + + Light + + ) : ( + Light + )} + + setTheme("dark")} + > + {theme === "dark" ? ( + <> + + Dark + + ) : ( + Dark + )} + + setTheme("system")} + > + {theme === "system" ? ( + <> + + System + + ) : ( + System + )} + + + + + + + + Theme + + + + + {colorSchemes.map((scheme) => ( + setColorScheme(scheme)} + > + {scheme === colorScheme ? ( + <> + + {friendlyColorSchemeName(scheme)} + + ) : ( + + {friendlyColorSchemeName(scheme)} + + )} + + ))} + + + Help @@ -357,17 +404,25 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { GitHub - - setRestartDialogOpen(true)} - > - - Restart Frigate - + {isAdmin && ( + <> + + setRestartDialogOpen(true)} + > + + Restart Frigate + + + )}
@@ -376,6 +431,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { onClose={() => setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} /> + setPasswordDialogOpen(false)} + username={profile?.username} + /> ); } diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 8db67e43e..4d1fd4966 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -74,8 +74,12 @@ export default function SearchResultActions({ refreshResults(); } }) - .catch(() => { - toast.error("Failed to delete tracked object.", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete tracked object: ${errorMessage}`, { position: "top-center", }); }); diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx index 7d44159dd..89403c37f 100644 --- a/web/src/components/overlay/CreateUserDialog.tsx +++ b/web/src/components/overlay/CreateUserDialog.tsx @@ -2,6 +2,7 @@ import { Button } from "../ui/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -12,20 +13,31 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import ActivityIndicator from "../indicators/activity-indicator"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Shield, User } from "lucide-react"; +import { LuCheck, LuX } from "react-icons/lu"; type CreateUserOverlayProps = { show: boolean; - onCreate: (user: string, password: string) => void; + onCreate: (user: string, password: string, role: "admin" | "viewer") => void; onCancel: () => void; }; + export default function CreateUserDialog({ show, onCreate, @@ -33,15 +45,22 @@ export default function CreateUserDialog({ }: CreateUserOverlayProps) { const [isLoading, setIsLoading] = useState(false); - const formSchema = z.object({ - user: z - .string() - .min(1) - .regex(/^[A-Za-z0-9._]+$/, { - message: "Username may only include letters, numbers, . or _", - }), - password: z.string(), - }); + const formSchema = z + .object({ + user: z + .string() + .min(1, "Username is required") + .regex(/^[A-Za-z0-9._]+$/, { + message: "Username may only include letters, numbers, . or _", + }), + password: z.string().min(1, "Password is required"), + confirmPassword: z.string().min(1, "Please confirm your password"), + role: z.enum(["admin", "viewer"]), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); const form = useForm>({ resolver: zodResolver(formSchema), @@ -49,32 +68,93 @@ export default function CreateUserDialog({ defaultValues: { user: "", password: "", + confirmPassword: "", + role: "viewer", }, }); const onSubmit = async (values: z.infer) => { setIsLoading(true); - await onCreate(values.user, values.password); + await onCreate(values.user, values.password, values.role); form.reset(); setIsLoading(false); }; + // Check if passwords match for real-time feedback + const password = form.watch("password"); + const confirmPassword = form.watch("confirmPassword"); + const passwordsMatch = password === confirmPassword; + const showMatchIndicator = password && confirmPassword; + + useEffect(() => { + if (!show) { + form.reset({ + user: "", + password: "", + role: "viewer", + }); + } + }, [show, form]); + + const handleCancel = () => { + form.reset({ + user: "", + password: "", + role: "viewer", + }); + onCancel(); + }; + return ( - + - Create User + Create New User + + Add a new user account and specify an role for access to areas of + the Frigate UI. + +
- + ( - User + + Username + + + + Only letters, numbers, periods and underscores allowed. + + + + )} + /> + + ( + + + Password + + + @@ -82,30 +162,121 @@ export default function CreateUserDialog({ )} /> + ( - Password + + Confirm Password + + {showMatchIndicator && ( +
+ {passwordsMatch ? ( + <> + + + Passwords match + + + ) : ( + <> + + + Passwords don't match + + + )} +
+ )} +
)} /> - - + + ( + + Role + + + Admins have full access to all features in the Frigate UI. + Viewers are limited to viewing cameras, review items, and + historical footage in the UI. + + + + )} + /> + + +
+
+ + +
+
diff --git a/web/src/components/overlay/DeleteUserDialog.tsx b/web/src/components/overlay/DeleteUserDialog.tsx index 8638b9145..e8dfb79c1 100644 --- a/web/src/components/overlay/DeleteUserDialog.tsx +++ b/web/src/components/overlay/DeleteUserDialog.tsx @@ -6,34 +6,61 @@ import { DialogHeader, DialogTitle, } from "../ui/dialog"; +import { DialogDescription } from "@radix-ui/react-dialog"; -type SetPasswordProps = { +type DeleteUserDialogProps = { show: boolean; + username?: string; onDelete: () => void; onCancel: () => void; }; export default function DeleteUserDialog({ show, + username, onDelete, onCancel, -}: SetPasswordProps) { +}: DeleteUserDialogProps) { return ( - - - Delete User + + +
+ Delete User + + This action cannot be undone. This will permanently delete the + user account and remove all associated data. + +
-
Are you sure?
- - + +
+

+ Are you sure you want to delete{" "} + {username}? +

+
+ + +
+
+ + +
+
diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 00966e06a..4f49abaf0 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -99,16 +99,13 @@ export default function ExportDialog({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to start export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to start export: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to start export: ${errorMessage}`, { + position: "top-center", + }); }); }, [camera, name, range, setRange, setName, setMode]); diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 0a316acc7..81b1eefe9 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -106,16 +106,13 @@ export default function MobileReviewSettingsDrawer({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to start export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to start export: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to start export: ${errorMessage}`, { + position: "top-center", + }); }); }, [camera, name, range, setRange, setName, setMode]); diff --git a/web/src/components/overlay/RoleChangeDialog.tsx b/web/src/components/overlay/RoleChangeDialog.tsx new file mode 100644 index 000000000..577c748ff --- /dev/null +++ b/web/src/components/overlay/RoleChangeDialog.tsx @@ -0,0 +1,119 @@ +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { useState } from "react"; +import { LuShield, LuUser } from "react-icons/lu"; + +type RoleChangeDialogProps = { + show: boolean; + username: string; + currentRole: "admin" | "viewer"; + onSave: (role: "admin" | "viewer") => void; + onCancel: () => void; +}; + +export default function RoleChangeDialog({ + show, + username, + currentRole, + onSave, + onCancel, +}: RoleChangeDialogProps) { + const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">( + currentRole, + ); + + return ( + + + + + Change User Role + + + Update permissions for{" "} + {username} + + + +
+
+

Select the appropriate role for this user:

+
    +
  • + • Admin: Full access to all + features. +
  • +
  • + • Viewer: Limited to Live + dashboards, Review, Explore, and Exports only. +
  • +
+
+ + +
+ + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/web/src/components/overlay/SetPasswordDialog.tsx b/web/src/components/overlay/SetPasswordDialog.tsx index 2f6cc4eaf..108b568d7 100644 --- a/web/src/components/overlay/SetPasswordDialog.tsx +++ b/web/src/components/overlay/SetPasswordDialog.tsx @@ -1,50 +1,202 @@ +"use client"; + import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; +import { Label } from "../ui/label"; +import { LuCheck, LuX } from "react-icons/lu"; type SetPasswordProps = { show: boolean; onSave: (password: string) => void; onCancel: () => void; + username?: string; }; + export default function SetPasswordDialog({ show, onSave, onCancel, + username, }: SetPasswordProps) { - const [password, setPassword] = useState(); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordStrength, setPasswordStrength] = useState(0); + const [error, setError] = useState(null); + + // Reset state when dialog opens/closes + useEffect(() => { + if (show) { + setPassword(""); + setConfirmPassword(""); + setError(null); + } + }, [show]); + + // Simple password strength calculation + useEffect(() => { + if (!password) { + setPasswordStrength(0); + return; + } + + let strength = 0; + // Length check + if (password.length >= 8) strength += 1; + // Contains number + if (/\d/.test(password)) strength += 1; + // Contains special char + if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1; + // Contains uppercase + if (/[A-Z]/.test(password)) strength += 1; + + setPasswordStrength(strength); + }, [password]); + + const handleSave = () => { + if (!password) { + setError("Password cannot be empty"); + return; + } + + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + onSave(password); + }; + + const getStrengthLabel = () => { + if (!password) return ""; + if (passwordStrength <= 1) return "Weak"; + if (passwordStrength === 2) return "Medium"; + if (passwordStrength === 3) return "Strong"; + return "Very Strong"; + }; + + const getStrengthColor = () => { + if (!password) return "bg-gray-200"; + if (passwordStrength <= 1) return "bg-red-500"; + if (passwordStrength === 2) return "bg-yellow-500"; + if (passwordStrength === 3) return "bg-green-500"; + return "bg-green-600"; + }; return ( - e.preventDefault()}> - - Set Password + + + + {username ? `Update Password for ${username}` : "Set Password"} + + + Create a strong password to secure this account. + - setPassword(event.target.value)} - /> - - + +
+
+ + { + setPassword(event.target.value); + setError(null); + }} + placeholder="Enter new password" + autoFocus + /> + + {/* Password strength indicator */} + {password && ( +
+
+
+
+

+ Password strength:{" "} + {getStrengthLabel()} +

+
+ )} +
+ +
+ + { + setConfirmPassword(event.target.value); + setError(null); + }} + placeholder="Confirm new password" + /> + + {/* Password match indicator */} + {password && confirmPassword && ( +
+ {password === confirmPassword ? ( + <> + + Passwords match + + ) : ( + <> + + Passwords don't match + + )} +
+ )} +
+ + {error && ( +
+ {error} +
+ )} +
+ + +
+
+ + +
+
diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx index 79d078c1f..df529c0dc 100644 --- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -87,10 +87,13 @@ export function AnnotationSettingsPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 9d3610e49..c94c2cd2d 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -394,8 +394,12 @@ function ObjectDetailsTab({ }, ); }) - .catch(() => { - toast.error("Failed to update the description", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update the description: ${errorMessage}`, { position: "top-center", }); setDesc(search.data.description); @@ -422,11 +426,13 @@ function ObjectDetailsTab({ } }) .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; toast.error( - `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`, - { - position: "top-center", - }, + `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`, + { position: "top-center" }, ); }); }, @@ -492,8 +498,12 @@ function ObjectDetailsTab({ setIsSubLabelDialogOpen(false); } }) - .catch(() => { - toast.error("Failed to update sub label.", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update sub label: ${errorMessage}`, { position: "top-center", }); }); diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index 3b73c6a23..5c83f7720 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -176,10 +176,13 @@ export default function MotionMaskEditPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 2c63d2e63..32e878c41 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -208,10 +208,13 @@ export default function ObjectMaskEditPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index 707df7a8f..db3f173a3 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -186,10 +186,13 @@ export default function PolygonItem({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index c6c5ee474..7adb3e194 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -414,10 +414,13 @@ export default function ZoneEditPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/context/auth-context.tsx b/web/src/context/auth-context.tsx new file mode 100644 index 000000000..a047d6fa3 --- /dev/null +++ b/web/src/context/auth-context.tsx @@ -0,0 +1,74 @@ +import axios from "axios"; +import { createContext, useEffect, useState } from "react"; +import useSWR from "swr"; + +interface AuthState { + user: { username: string; role: "admin" | "viewer" | null } | null; + isLoading: boolean; + isAuthenticated: boolean; // true if auth is required +} + +interface AuthContextType { + auth: AuthState; + login: (user: AuthState["user"]) => void; + logout: () => void; +} + +export const AuthContext = createContext({ + auth: { user: null, isLoading: true, isAuthenticated: false }, + login: () => {}, + logout: () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [auth, setAuth] = useState({ + user: null, + isLoading: true, + isAuthenticated: false, + }); + + const { data: profile, error } = useSWR("/profile", { + revalidateOnFocus: false, + revalidateOnReconnect: true, + fetcher: (url) => + axios.get(url, { withCredentials: true }).then((res) => res.data), + }); + + useEffect(() => { + if (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + // auth required but not logged in + setAuth({ user: null, isLoading: false, isAuthenticated: true }); + } + return; + } + + if (profile) { + if (profile.username && profile.username !== "anonymous") { + const newUser = { + username: profile.username, + role: profile.role || "viewer", + }; + setAuth({ user: newUser, isLoading: false, isAuthenticated: true }); + } else { + // Unauthenticated mode (anonymous) + setAuth({ user: null, isLoading: false, isAuthenticated: false }); + } + } + }, [profile, error]); + + const login = (user: AuthState["user"]) => { + setAuth({ user, isLoading: false, isAuthenticated: true }); + }; + + const logout = () => { + setAuth({ user: null, isLoading: false, isAuthenticated: true }); + axios.get("/logout", { withCredentials: true }); + }; + + return ( + + {children} + + ); +} diff --git a/web/src/context/providers.tsx b/web/src/context/providers.tsx index 61b4a6426..b0a5f55c9 100644 --- a/web/src/context/providers.tsx +++ b/web/src/context/providers.tsx @@ -6,6 +6,7 @@ import { IconContext } from "react-icons"; import { TooltipProvider } from "@/components/ui/tooltip"; import { StatusBarMessagesProvider } from "@/context/statusbar-provider"; import { StreamingSettingsProvider } from "./streaming-settings-provider"; +import { AuthProvider } from "./auth-context"; type TProvidersProps = { children: ReactNode; @@ -14,19 +15,21 @@ type TProvidersProps = { function providers({ children }: TProvidersProps) { return ( - - - - - - - {children} - - - - - - + + + + + + + + {children} + + + + + + + ); } diff --git a/web/src/hooks/use-is-admin.ts b/web/src/hooks/use-is-admin.ts new file mode 100644 index 000000000..222a43fce --- /dev/null +++ b/web/src/hooks/use-is-admin.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { AuthContext } from "@/context/auth-context"; + +export function useIsAdmin() { + const { auth } = useContext(AuthContext); + const isAdmin = + (auth.isAuthenticated && auth.user?.role === "admin") || + auth.user?.role === undefined; + return isAdmin; +} diff --git a/web/src/pages/AccessDenied.tsx b/web/src/pages/AccessDenied.tsx new file mode 100644 index 000000000..53d83282b --- /dev/null +++ b/web/src/pages/AccessDenied.tsx @@ -0,0 +1,21 @@ +import Heading from "@/components/ui/heading"; +import { useEffect } from "react"; +import { FaExclamationTriangle } from "react-icons/fa"; + +export default function AccessDenied() { + useEffect(() => { + document.title = "Access Denied - Frigate"; + }, []); + + return ( +
+ + + Access Denied + +

+ You don't have permission to view this page. +

+
+ ); +} diff --git a/web/src/pages/ConfigEditor.tsx b/web/src/pages/ConfigEditor.tsx index bcb0c4c65..a8ca0eda3 100644 --- a/web/src/pages/ConfigEditor.tsx +++ b/web/src/pages/ConfigEditor.tsx @@ -59,11 +59,12 @@ function ConfigEditor() { .catch((error) => { toast.error("Error saving config", { position: "top-center" }); - if (error.response) { - setError(error.response.data.message); - } else { - setError(error.message); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + setError(errorMessage); }); }, [editorRef], diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 529bb2e26..93cfa6b11 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -93,16 +93,13 @@ function Exports() { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to rename export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to rename export: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to rename export: ${errorMessage}`, { + position: "top-center", + }); }); }, [mutate], diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 8daf7e325..b9d3ee71a 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -99,16 +99,13 @@ export default function FaceLibrary() { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to upload image: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to upload image: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to upload image: ${errorMessage}`, { + position: "top-center", + }); }); }, [pageToggle, refreshFaces], @@ -132,16 +129,13 @@ export default function FaceLibrary() { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to set face name: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to set face name: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to set face name: ${errorMessage}`, { + position: "top-center", + }); }); }, [refreshFaces], @@ -308,15 +302,13 @@ function FaceAttempt({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error(`Failed to train: ${error.response.data.message}`, { - position: "top-center", - }); - } else { - toast.error(`Failed to train: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to train: ${errorMessage}`, { + position: "top-center", + }); }); }, [image, onRefresh], @@ -334,18 +326,13 @@ function FaceAttempt({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to update score: ${error.response.data.message}`, - { - position: "top-center", - }, - ); - } else { - toast.error(`Failed to update score: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update face score: ${errorMessage}`, { + position: "top-center", + }); }); }, [image, onRefresh]); @@ -361,15 +348,13 @@ function FaceAttempt({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error(`Failed to delete: ${error.response.data.message}`, { - position: "top-center", - }); - } else { - toast.error(`Failed to delete: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete: ${errorMessage}`, { + position: "top-center", + }); }); }, [image, onRefresh]); @@ -478,15 +463,13 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error(`Failed to delete: ${error.response.data.message}`, { - position: "top-center", - }); - } else { - toast.error(`Failed to delete: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete: ${errorMessage}`, { + position: "top-center", + }); }); }, [name, image, onRefresh]); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 33f854ba3..30be2afc2 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -40,6 +40,7 @@ import UiSettingsView from "@/views/settings/UiSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; +import { useIsAdmin } from "@/hooks/use-is-admin"; const allSettingsViews = [ "UI settings", @@ -62,6 +63,15 @@ export default function Settings() { const [searchParams] = useSearchParams(); + // auth and roles + + const isAdmin = useIsAdmin(); + + const allowedViewsForViewer: SettingsType[] = ["UI settings", "debug"]; + const visibleSettingsViews = !isAdmin + ? allowedViewsForViewer + : allSettingsViews; + // TODO: confirm leave page const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); @@ -149,7 +159,12 @@ export default function Settings() { useSearchEffect("page", (page: string) => { if (allSettingsViews.includes(page as SettingsType)) { - setPage(page as SettingsType); + // Restrict viewer to UI settings + if (!isAdmin && !["UI settings", "debug"].includes(page)) { + setPage("UI settings"); + } else { + setPage(page as SettingsType); + } } // don't clear url params if we're creating a new object mask return !searchParams.has("object_mask"); @@ -180,11 +195,16 @@ export default function Settings() { value={pageToggle} onValueChange={(value: SettingsType) => { if (value) { - setPageToggle(value); + // Restrict viewer navigation + if (!isAdmin && !["UI settings", "debug"].includes(value)) { + setPageToggle("UI settings"); + } else { + setPageToggle(value); + } } }} > - {Object.values(allSettingsViews).map((item) => ( + {visibleSettingsViews.map((item) => ( { - if (error.response?.data?.message) { - toast.error( - `Failed to start export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to start export: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to start export: ${errorMessage}`, { + position: "top-center", + }); }); }, [reviewItems], diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index cacdc7c1d..15dea59d6 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -116,6 +116,7 @@ import { Switch } from "@/components/ui/switch"; import axios from "axios"; import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; +import { useIsAdmin } from "@/hooks/use-is-admin"; type LiveCameraViewProps = { config?: FrigateConfig; @@ -982,6 +983,10 @@ function FrigateCameraFeatures({ const { payload: autotrackingState, send: sendAutotracking } = useAutotrackingState(camera.name); + // roles + + const isAdmin = useIsAdmin(); + // manual event const recordingEventIdRef = useRef(null); @@ -1080,65 +1085,71 @@ function FrigateCameraFeatures({ if (isDesktop || isTablet) { return ( <> - sendEnabled(enabledState == "ON" ? "OFF" : "ON")} - disabled={false} - /> - sendDetect(detectState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - sendRecord(recordState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - {audioDetectEnabled && ( - sendAudio(audioState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - )} - {autotrackingEnabled && ( - - sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") - } - disabled={!cameraEnabled} - /> + {isAdmin && ( + <> + sendEnabled(enabledState == "ON" ? "OFF" : "ON")} + disabled={false} + /> + sendDetect(detectState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + sendRecord(recordState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + {audioDetectEnabled && ( + sendAudio(audioState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + )} + {autotrackingEnabled && ( + + sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") + } + disabled={!cameraEnabled} + /> + )} + )}
- - sendEnabled(enabledState == "ON" ? "OFF" : "ON") - } - /> - - sendDetect(detectState == "ON" ? "OFF" : "ON") - } - /> - {recordingEnabled && ( - - sendRecord(recordState == "ON" ? "OFF" : "ON") - } - /> - )} - - sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") - } - /> - {audioDetectEnabled && ( - - sendAudio(audioState == "ON" ? "OFF" : "ON") - } - /> - )} - {autotrackingEnabled && ( - - sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") - } - /> + {isAdmin && ( + <> + + sendEnabled(enabledState == "ON" ? "OFF" : "ON") + } + /> + + sendDetect(detectState == "ON" ? "OFF" : "ON") + } + /> + {recordingEnabled && ( + + sendRecord(recordState == "ON" ? "OFF" : "ON") + } + /> + )} + + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") + } + /> + {audioDetectEnabled && ( + + sendAudio(audioState == "ON" ? "OFF" : "ON") + } + /> + )} + {autotrackingEnabled && ( + + sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") + } + /> + )} + )}
+
{!isRestreamed && (
diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx index 1c6df5c52..118d102d4 100644 --- a/web/src/views/settings/AuthenticationView.tsx +++ b/web/src/views/settings/AuthenticationView.tsx @@ -11,10 +11,25 @@ import axios from "axios"; import CreateUserDialog from "@/components/overlay/CreateUserDialog"; import { toast } from "sonner"; import DeleteUserDialog from "@/components/overlay/DeleteUserDialog"; -import { Card } from "@/components/ui/card"; import { HiTrash } from "react-icons/hi"; import { FaUserEdit } from "react-icons/fa"; -import { LuPlus } from "react-icons/lu"; +import { LuPlus, LuShield, LuUserCog } from "react-icons/lu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import RoleChangeDialog from "@/components/overlay/RoleChangeDialog"; export default function AuthenticationView() { const { data: config } = useSWR("config"); @@ -23,8 +38,12 @@ export default function AuthenticationView() { const [showSetPassword, setShowSetPassword] = useState(false); const [showCreate, setShowCreate] = useState(false); const [showDelete, setShowDelete] = useState(false); + const [showRoleChange, setShowRoleChange] = useState(false); const [selectedUser, setSelectedUser] = useState(); + const [selectedUserRole, setSelectedUserRole] = useState< + "admin" | "viewer" + >(); useEffect(() => { document.title = "Authentication Settings - Frigate"; @@ -32,142 +51,303 @@ export default function AuthenticationView() { const onSavePassword = useCallback((user: string, password: string) => { axios - .put(`users/${user}/password`, { - password: password, - }) + .put(`users/${user}/password`, { password }) .then((response) => { - if (response.status == 200) { + if (response.status === 200) { setShowSetPassword(false); + toast.success("Password updated successfully", { + position: "top-center", + }); } }) - .catch((_error) => { - toast.error("Error setting password", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save password: ${errorMessage}`, { position: "top-center", }); }); }, []); - const onCreate = async (user: string, password: string) => { - try { - await axios.post("users", { - username: user, - password: password, + const onCreate = ( + user: string, + password: string, + role: "admin" | "viewer", + ) => { + axios + .post("users", { username: user, password, role }) + .then((response) => { + if (response.status === 200 || response.status === 201) { + setShowCreate(false); + mutateUsers((users) => { + users?.push({ username: user, role: role }); + return users; + }, false); + toast.success(`User ${user} created successfully`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to create user: ${errorMessage}`, { + position: "top-center", + }); }); - setShowCreate(false); - mutateUsers((users) => { - users?.push({ username: user }); - return users; - }, false); - } catch (error) { - toast.error("Error creating user. Check server logs.", { - position: "top-center", - }); - } }; - const onDelete = async (user: string) => { - try { - await axios.delete(`users/${user}`); - setShowDelete(false); - mutateUsers((users) => { - return users?.filter((u) => { - return u.username !== user; + const onDelete = (user: string) => { + axios + .delete(`users/${user}`) + .then((response) => { + if (response.status === 200) { + setShowDelete(false); + mutateUsers( + (users) => users?.filter((u) => u.username !== user), + false, + ); + toast.success(`User ${user} deleted successfully`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete user: ${errorMessage}`, { + position: "top-center", + }); + }); + }; + + const onChangeRole = (user: string, newRole: "admin" | "viewer") => { + if (user === "admin") return; // Prevent role change for 'admin' + + axios + .put(`users/${user}/role`, { role: newRole }) + .then((response) => { + if (response.status === 200) { + setShowRoleChange(false); + mutateUsers( + (users) => + users?.map((u) => + u.username === user ? { ...u, role: newRole } : u, + ), + false, + ); + toast.success(`Role updated for ${user}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update role: ${errorMessage}`, { + position: "top-center", }); - }, false); - } catch (error) { - toast.error("Error deleting user. Check server logs.", { - position: "top-center", }); - } }; if (!config || !users) { - return ; + return ( +
+ +
+ ); } return (
-
- - Users - +
+
+ + User Management + +

+ Manage this Frigate instance's user accounts. +

+
-
- {users.map((u) => ( - -
-
- {u.username} -
-
- - -
-
-
- ))} +
+
+
+ + + + Username + Role + Actions + + + + {users.length === 0 ? ( + + + No users found. + + + ) : ( + users.map((user) => ( + + +
+ {user.username === "admin" ? ( + + ) : ( + + )} + {user.username} +
+
+ + + {user.role || "viewer"} + + + + +
+ {user.username !== "admin" && ( + + + + + +

Change user role

+
+
+ )} + + + + + + +

Update password

+
+
+ + {user.username !== "admin" && ( + + + + + +

Delete user

+
+
+ )} +
+
+
+
+ )) + )} +
+
+
+
+ { - setShowSetPassword(false); - }} - onSave={(password) => { - onSavePassword(selectedUser!, password); - }} + onCancel={() => setShowSetPassword(false)} + onSave={(password) => onSavePassword(selectedUser!, password)} /> { - setShowDelete(false); - }} - onDelete={() => { - onDelete(selectedUser!); - }} + username={selectedUser ?? "this user"} + onCancel={() => setShowDelete(false)} + onDelete={() => onDelete(selectedUser!)} /> { - setShowCreate(false); - }} + onCancel={() => setShowCreate(false)} /> + {selectedUser && selectedUserRole && ( + onChangeRole(selectedUser, role)} + onCancel={() => setShowRoleChange(false)} + /> + )}
); } diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index e2c1ca563..f83bdde50 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -171,10 +171,13 @@ export default function CameraSettingsView({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index d30de487e..a7dd1c9d4 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -267,10 +267,13 @@ export default function NotificationView({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/views/settings/SearchSettingsView.tsx b/web/src/views/settings/SearchSettingsView.tsx index 027f55070..b3f35bde7 100644 --- a/web/src/views/settings/SearchSettingsView.tsx +++ b/web/src/views/settings/SearchSettingsView.tsx @@ -103,10 +103,13 @@ export default function SearchSettingsView({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index e3b5c8c7a..03375670f 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -38,10 +38,13 @@ export default function UiSettingsView() { }); }) .catch((error) => { - toast.error( - `Failed to clear stored layout: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to clear stored layout: ${errorMessage}`, { + position: "top-center", + }); }); }); }, [config]); @@ -58,10 +61,13 @@ export default function UiSettingsView() { }); }) .catch((error) => { - toast.error( - `Failed to clear camera groups streaming settings: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to clear streaming settings: ${errorMessage}`, { + position: "top-center", + }); }); }, [config]); From cf3c0b2eb5179fadc68417997f8d03eb695a7d03 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 8 Mar 2025 10:13:07 -0600 Subject: [PATCH 62/63] Prevent settings menu scroll on iOS proxy iframe from shifting entire UI (#17024) --- web/src/pages/Settings.tsx | 6 ++++-- web/src/utils/isIFrame.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 web/src/utils/isIFrame.ts diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 30be2afc2..f1a45ebc2 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 { Button } from "@/components/ui/button"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; -import { isMobile } from "react-device-detect"; +import { isMobile, isMobileSafari } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; @@ -40,6 +40,8 @@ import UiSettingsView from "@/views/settings/UiSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; +import { isInIframe } from "@/utils/isIFrame"; +import { isPWA } from "@/utils/isPWA"; import { useIsAdmin } from "@/hooks/use-is-admin"; const allSettingsViews = [ @@ -150,7 +152,7 @@ export default function Settings() { ); if (element instanceof HTMLElement) { scrollIntoView(element, { - behavior: "smooth", + behavior: isMobileSafari && !isPWA && isInIframe ? "auto" : "smooth", inline: "start", }); } diff --git a/web/src/utils/isIFrame.ts b/web/src/utils/isIFrame.ts new file mode 100644 index 000000000..1f78a40aa --- /dev/null +++ b/web/src/utils/isIFrame.ts @@ -0,0 +1,8 @@ +export const isInIframe = (() => { + try { + return window.self !== window.top; + } catch (e) { + // If we get a security error, we're definitely in an iframe + return true; + } +})(); From 95b5854449647a931943e412b4c854c6f3cb0d1b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 9 Mar 2025 08:47:10 -0500 Subject: [PATCH 63/63] Small UI bugfix (#17035) * test for more HA elements * check if mobile and iOS instead of mobilesafari * simplify * fix for logs view --- web/src/pages/Logs.tsx | 6 +++++- web/src/pages/Settings.tsx | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index a4b67f441..196e6fdd7 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -31,6 +31,9 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { debounce } from "lodash"; +import { isIOS, isMobile } from "react-device-detect"; +import { isPWA } from "@/utils/isPWA"; +import { isInIframe } from "@/utils/isIFrame"; function Logs() { const [logService, setLogService] = useState("frigate"); @@ -54,7 +57,8 @@ function Logs() { ); if (element instanceof HTMLElement) { scrollIntoView(element, { - behavior: "smooth", + behavior: + isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth", inline: "start", }); } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index f1a45ebc2..bfc3f6f8e 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 { Button } from "@/components/ui/button"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; -import { isMobile, isMobileSafari } from "react-device-detect"; +import { isIOS, isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; @@ -152,7 +152,8 @@ export default function Settings() { ); if (element instanceof HTMLElement) { scrollIntoView(element, { - behavior: isMobileSafari && !isPWA && isInIframe ? "auto" : "smooth", + behavior: + isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth", inline: "start", }); }