diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 6dddfc615..46241c5ab 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -320,6 +320,12 @@ http { add_header Cache-Control "public"; } + location /fonts/ { + access_log off; + expires 1y; + add_header Cache-Control "public"; + } + location /locales/ { access_log off; add_header Cache-Control "public"; diff --git a/docs/docs/configuration/genai.md b/docs/docs/configuration/genai.md index 55b61f9f3..018dc2050 100644 --- a/docs/docs/configuration/genai.md +++ b/docs/docs/configuration/genai.md @@ -70,7 +70,7 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru genai: provider: ollama base_url: http://localhost:11434 - model: llava:7b + model: qwen3-vl:4b ``` ## Google Gemini diff --git a/docs/docs/configuration/genai/config.md b/docs/docs/configuration/genai/config.md index ac822a3a6..7e5618b5b 100644 --- a/docs/docs/configuration/genai/config.md +++ b/docs/docs/configuration/genai/config.md @@ -35,19 +35,18 @@ Each model is available in multiple parameter sizes (3b, 4b, 8b, etc.). Larger s :::tip -If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. https://github.com/skye-harris/ollama-modelfiles contains optimized model configs for this task. +If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. qwen3-VL supports vision and tools simultaneously in Ollama. ::: The following models are recommended: -| Model | Notes | -| ----------------- | ----------------------------------------------------------- | -| `qwen3-vl` | Strong visual and situational understanding | -| `Intern3.5VL` | Relatively fast with good vision comprehension | -| `gemma3` | Strong frame-to-frame understanding, slower inference times | -| `qwen2.5-vl` | Fast but capable model with good vision comprehension | -| `llava-phi3` | Lightweight and fast model with vision comprehension | +| Model | Notes | +| ----------------- | -------------------------------------------------------------------- | +| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement | +| `Intern3.5VL` | Relatively fast with good vision comprehension | +| `gemma3` | Strong frame-to-frame understanding, slower inference times | +| `qwen2.5-vl` | Fast but capable model with good vision comprehension | :::note diff --git a/frigate/stats/util.py b/frigate/stats/util.py index cfc5ae42b..17b45d1d4 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -362,7 +362,7 @@ def stats_snapshot( stats["embeddings"]["review_description_speed"] = round( embeddings_metrics.review_desc_speed.value * 1000, 2 ) - stats["embeddings"]["review_descriptions"] = round( + stats["embeddings"]["review_description_events_per_second"] = round( embeddings_metrics.review_desc_dps.value, 2 ) @@ -370,7 +370,7 @@ def stats_snapshot( stats["embeddings"]["object_description_speed"] = round( embeddings_metrics.object_desc_speed.value * 1000, 2 ) - stats["embeddings"]["object_descriptions"] = round( + stats["embeddings"]["object_description_events_per_second"] = round( embeddings_metrics.object_desc_dps.value, 2 ) @@ -378,7 +378,7 @@ def stats_snapshot( stats["embeddings"][f"{key}_classification_speed"] = round( embeddings_metrics.classification_speeds[key].value * 1000, 2 ) - stats["embeddings"][f"{key}_classification"] = round( + stats["embeddings"][f"{key}_classification_events_per_second"] = round( embeddings_metrics.classification_cps[key].value, 2 ) diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json index 085aa0a49..21f367ea9 100644 --- a/web/public/locales/en/views/live.json +++ b/web/public/locales/en/views/live.json @@ -177,6 +177,10 @@ "noCameras": { "title": "No Cameras Configured", "description": "Get started by connecting a camera to Frigate.", - "buttonText": "Add Camera" + "buttonText": "Add Camera", + "restricted": { + "title": "No Cameras Available", + "description": "You don't have permission to view any cameras in this group." + } } } diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index c4c6fd4f6..e72b993cb 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -169,6 +169,7 @@ "enrichments": { "title": "Enrichments", "infPerSecond": "Inferences Per Second", + "averageInf": "Average Inference Time", "embeddings": { "image_embedding": "Image Embedding", "text_embedding": "Text Embedding", @@ -180,7 +181,13 @@ "plate_recognition_speed": "Plate Recognition Speed", "text_embedding_speed": "Text Embedding Speed", "yolov9_plate_detection_speed": "YOLOv9 Plate Detection Speed", - "yolov9_plate_detection": "YOLOv9 Plate Detection" + "yolov9_plate_detection": "YOLOv9 Plate Detection", + "review_description": "Review Description", + "review_description_speed": "Review Description Speed", + "review_description_events_per_second": "Review Description", + "object_description": "Object Description", + "object_description_speed": "Object Description Speed", + "object_description_events_per_second": "Object Description" } } } diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index a700981b6..c772bc2ba 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -9,7 +9,7 @@ import useSWR from "swr"; import { MdHome } from "react-icons/md"; import { usePersistedOverlayState } from "@/hooks/use-overlay-state"; import { Button, buttonVariants } from "../ui/button"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { LuPencil, LuPlus } from "react-icons/lu"; import { @@ -87,6 +87,8 @@ type CameraGroupSelectorProps = { export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { const { t } = useTranslation(["components/camera"]); const { data: config } = useSWR("config"); + const allowedCameras = useAllowedCameras(); + const isCustomRole = useIsCustomRole(); // tooltip @@ -119,10 +121,22 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { return []; } - return Object.entries(config.camera_groups).sort( - (a, b) => a[1].order - b[1].order, - ); - }, [config]); + const allGroups = Object.entries(config.camera_groups); + + // If custom role, filter out groups where user has no accessible cameras + if (isCustomRole) { + return allGroups + .filter(([, groupConfig]) => { + // Check if user has access to at least one camera in this group + return groupConfig.cameras.some((cameraName) => + allowedCameras.includes(cameraName), + ); + }) + .sort((a, b) => a[1].order - b[1].order); + } + + return allGroups.sort((a, b) => a[1].order - b[1].order); + }, [config, allowedCameras, isCustomRole]); // add group @@ -139,6 +153,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { activeGroup={group} setGroup={setGroup} deleteGroup={deleteGroup} + isCustomRole={isCustomRole} />
setAddGroup(true)} - > - - + {!isCustomRole && ( + + )} {isMobile && }
@@ -228,6 +245,7 @@ type NewGroupDialogProps = { activeGroup?: string; setGroup: (value: string | undefined, replace?: boolean | undefined) => void; deleteGroup: () => void; + isCustomRole?: boolean; }; function NewGroupDialog({ open, @@ -236,6 +254,7 @@ function NewGroupDialog({ activeGroup, setGroup, deleteGroup, + isCustomRole, }: NewGroupDialogProps) { const { t } = useTranslation(["components/camera"]); const { mutate: updateConfig } = useSWR("config"); @@ -261,6 +280,12 @@ function NewGroupDialog({ `${activeGroup}-draggable-layout`, ); + useEffect(() => { + if (!open) { + setEditState("none"); + } + }, [open]); + // callbacks const onDeleteGroup = useCallback( @@ -349,13 +374,7 @@ function NewGroupDialog({ position="top-center" closeButton={true} /> - { - setEditState("none"); - setOpen(open); - }} - > + {t("group.label")} {t("group.edit")} -
- -
+ + + )}
{currentGroups.map((group) => ( @@ -401,6 +422,7 @@ function NewGroupDialog({ group={group} onDeleteGroup={() => onDeleteGroup(group[0])} onEditGroup={() => onEditGroup(group)} + isReadOnly={isCustomRole} /> ))}
@@ -512,12 +534,14 @@ type CameraGroupRowProps = { group: [string, CameraGroupConfig]; onDeleteGroup: () => void; onEditGroup: () => void; + isReadOnly?: boolean; }; export function CameraGroupRow({ group, onDeleteGroup, onEditGroup, + isReadOnly, }: CameraGroupRowProps) { const { t } = useTranslation(["components/camera"]); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -564,7 +588,7 @@ export function CameraGroupRow({ - {isMobile && ( + {isMobile && !isReadOnly && ( <> @@ -589,7 +613,7 @@ export function CameraGroupRow({ )} - {!isMobile && ( + {!isMobile && !isReadOnly && (
diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 8bd90d51f..8efd0287c 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -807,6 +807,15 @@ function ObjectDetailsTab({ } }, [search]); + const isEventsKey = useCallback((key: unknown): boolean => { + const candidate = Array.isArray(key) ? key[0] : key; + const EVENTS_KEY_PATTERNS = ["events", "events/search", "events/explore"]; + return ( + typeof candidate === "string" && + EVENTS_KEY_PATTERNS.some((p) => candidate.includes(p)) + ); + }, []); + const updateDescription = useCallback(() => { if (!search) { return; @@ -821,11 +830,7 @@ function ObjectDetailsTab({ }); } mutate( - (key) => - typeof key === "string" && - (key.includes("events") || - key.includes("events/search") || - key.includes("events/explore")), + (key) => isEventsKey(key), (currentData: SearchResult[][] | SearchResult[] | undefined) => mapSearchResults(currentData, (event) => event.id === search.id @@ -838,6 +843,7 @@ function ObjectDetailsTab({ revalidate: false, }, ); + setSearch({ ...search, data: { ...search.data, description: desc } }); }) .catch((error) => { const errorMessage = @@ -854,7 +860,7 @@ function ObjectDetailsTab({ ); setDesc(search.data.description); }); - }, [desc, search, mutate, t, mapSearchResults]); + }, [desc, search, mutate, t, mapSearchResults, isEventsKey, setSearch]); const regenerateDescription = useCallback( (source: "snapshot" | "thumbnails") => { @@ -921,11 +927,7 @@ function ObjectDetailsTab({ }); mutate( - (key) => - typeof key === "string" && - (key.includes("events") || - key.includes("events/search") || - key.includes("events/explore")), + (key) => isEventsKey(key), (currentData: SearchResult[][] | SearchResult[] | undefined) => mapSearchResults(currentData, (event) => event.id === search.id @@ -972,7 +974,7 @@ function ObjectDetailsTab({ ); }); }, - [search, apiHost, mutate, setSearch, t, mapSearchResults], + [search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey], ); // recognized plate @@ -996,11 +998,7 @@ function ObjectDetailsTab({ }); mutate( - (key) => - typeof key === "string" && - (key.includes("events") || - key.includes("events/search") || - key.includes("events/explore")), + (key) => isEventsKey(key), (currentData: SearchResult[][] | SearchResult[] | undefined) => mapSearchResults(currentData, (event) => event.id === search.id @@ -1047,7 +1045,7 @@ function ObjectDetailsTab({ ); }); }, - [search, apiHost, mutate, setSearch, t, mapSearchResults], + [search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey], ); // speech transcription @@ -1103,12 +1101,9 @@ function ObjectDetailsTab({ }); setState("submitted"); + setSearch({ ...search, plus_id: "new_upload" }); mutate( - (key) => - typeof key === "string" && - (key.includes("events") || - key.includes("events/search") || - key.includes("events/explore")), + (key) => isEventsKey(key), (currentData: SearchResult[][] | SearchResult[] | undefined) => mapSearchResults(currentData, (event) => event.id === search.id @@ -1122,7 +1117,7 @@ function ObjectDetailsTab({ }, ); }, - [search, mutate, mapSearchResults], + [search, mutate, mapSearchResults, setSearch, isEventsKey], ); const popoverContainerRef = useRef(null); diff --git a/web/src/components/overlay/dialog/FrigatePlusDialog.tsx b/web/src/components/overlay/dialog/FrigatePlusDialog.tsx index 7eff75335..b57e73755 100644 --- a/web/src/components/overlay/dialog/FrigatePlusDialog.tsx +++ b/web/src/components/overlay/dialog/FrigatePlusDialog.tsx @@ -6,51 +6,199 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Event } from "@/types/event"; -import { isDesktop, isMobile } from "react-device-detect"; -import { ObjectSnapshotTab } from "../detail/SearchDetailDialog"; +import { isDesktop, isMobile, isSafari } from "react-device-detect"; import { cn } from "@/lib/utils"; +import { useCallback, useEffect, useState } from "react"; +import axios from "axios"; +import { useTranslation, Trans } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { FaCheckCircle } from "react-icons/fa"; +import { Card, CardContent } from "@/components/ui/card"; +import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; +import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; +import { baseUrl } from "@/api/baseUrl"; +import { getTranslatedLabel } from "@/utils/i18n"; +import useImageLoaded from "@/hooks/use-image-loaded"; -type FrigatePlusDialogProps = { +export type FrigatePlusDialogProps = { upload?: Event; dialog?: boolean; onClose: () => void; onEventUploaded: () => void; }; + export function FrigatePlusDialog({ upload, dialog = true, onClose, onEventUploaded, }: FrigatePlusDialogProps) { - if (!upload) { - return; - } - if (dialog) { - return ( - (!open ? onClose() : null)} + const { t, i18n } = useTranslation(["components/dialog"]); + + type SubmissionState = "reviewing" | "uploading" | "submitted"; + const [state, setState] = useState( + upload?.plus_id ? "submitted" : "reviewing", + ); + useEffect(() => { + setState(upload?.plus_id ? "submitted" : "reviewing"); + }, [upload?.plus_id]); + + const onSubmitToPlus = useCallback( + async (falsePositive: boolean) => { + if (!upload) return; + falsePositive + ? axios.put(`events/${upload.id}/false_positive`) + : axios.post(`events/${upload.id}/plus`, { include_annotation: 1 }); + setState("submitted"); + onEventUploaded(); + }, + [upload, onEventUploaded], + ); + + const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); + const showCard = + !!upload && + upload.data.type === "object" && + upload.plus_id !== "not_enabled" && + upload.end_time && + upload.label !== "on_demand"; + + if (!dialog || !upload) return null; + + return ( + (!open ? onClose() : null)}> + - - - Submit to Frigate+ - - Submit this snapshot to Frigate+ - - - + Submit to Frigate+ + + Submit this snapshot to Frigate+ + + + +
+ - -
- ); - } +
+ +
+ + {upload.id && ( +
+ {`${upload.label}`} +
+ )} +
+ + {showCard && ( + + +
+
+ {t("explore.plus.submitToPlus.label")} +
+
+ {t("explore.plus.submitToPlus.desc")} +
+
+
+ {state === "reviewing" && ( + <> +
+ {i18n.language === "en" ? ( + /^[aeiou]/i.test(upload.label || "") ? ( + + explore.plus.review.question.ask_an + + ) : ( + + explore.plus.review.question.ask_a + + ) + ) : ( + + explore.plus.review.question.ask_full + + )} +
+
+ + +
+ + )} + {state === "uploading" && } + {state === "submitted" && ( +
+ + {t("explore.plus.review.state.submitted")} +
+ )} +
+
+
+ )} +
+
+
+
+ + + ); } diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx index 9ef2f2c64..eb0dbe9fe 100644 --- a/web/src/components/settings/wizard/Step1NameCamera.tsx +++ b/web/src/components/settings/wizard/Step1NameCamera.tsx @@ -377,7 +377,7 @@ export default function Step1NameCamera({ ); return selectedBrand && selectedBrand.value != "other" ? ( - +