diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 5276eb71e..8976469f5 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -357,6 +357,7 @@ def create_user(request: Request, body: AppPostUsersBody): { User.username: body.username, User.password_hash: password_hash, + User.notification_tokens: [], } ).execute() return JSONResponse(content={"username": body.username}) diff --git a/frigate/api/media.py b/frigate/api/media.py index 5915875ab..d89774a6d 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -7,6 +7,7 @@ import os import subprocess as sp import time from datetime import datetime, timedelta, timezone +from pathlib import Path as FilePath from urllib.parse import unquote import cv2 @@ -450,8 +451,27 @@ def recording_clip( camera_name: str, start_ts: float, end_ts: float, - download: bool = False, ): + def run_download(ffmpeg_cmd: list[str], file_path: str): + with sp.Popen( + ffmpeg_cmd, + stderr=sp.PIPE, + stdout=sp.PIPE, + text=False, + ) as ffmpeg: + while True: + data = ffmpeg.stdout.read(1024) + if data is not None: + yield data + else: + if ffmpeg.returncode and ffmpeg.returncode != 0: + logger.error( + f"Failed to generate clip, ffmpeg logs: {ffmpeg.stderr.read()}" + ) + else: + FilePath(file_path).unlink(missing_ok=True) + break + recordings = ( Recordings.select( Recordings.path, @@ -467,18 +487,18 @@ def recording_clip( .order_by(Recordings.start_time.asc()) ) - playlist_lines = [] - clip: Recordings - for clip in recordings: - playlist_lines.append(f"file '{clip.path}'") - # if this is the starting clip, add an inpoint - if clip.start_time < start_ts: - playlist_lines.append(f"inpoint {int(start_ts - clip.start_time)}") - # if this is the ending clip, add an outpoint - if clip.end_time > end_ts: - playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}") - - file_name = sanitize_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4") + file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt") + file_path = f"/tmp/cache/{file_name}" + with open(file_path, "w") as file: + clip: Recordings + for clip in recordings: + file.write(f"file '{clip.path}'\n") + # if this is the starting clip, add an inpoint + if clip.start_time < start_ts: + file.write(f"inpoint {int(start_ts - clip.start_time)}\n") + # if this is the ending clip, add an outpoint + if clip.end_time > end_ts: + file.write(f"outpoint {int(end_ts - clip.start_time)}\n") if len(file_name) > 1000: return JSONResponse( @@ -489,67 +509,32 @@ def recording_clip( status_code=403, ) - path = os.path.join(CLIPS_DIR, f"cache/{file_name}") - config: FrigateConfig = request.app.frigate_config - if not os.path.exists(path): - ffmpeg_cmd = [ - config.ffmpeg.ffmpeg_path, - "-hide_banner", - "-y", - "-protocol_whitelist", - "pipe,file", - "-f", - "concat", - "-safe", - "0", - "-i", - "/dev/stdin", - "-c", - "copy", - "-movflags", - "+faststart", - path, - ] - p = sp.run( - ffmpeg_cmd, - input="\n".join(playlist_lines), - encoding="ascii", - capture_output=True, - ) + ffmpeg_cmd = [ + config.ffmpeg.ffmpeg_path, + "-hide_banner", + "-y", + "-protocol_whitelist", + "pipe,file", + "-f", + "concat", + "-safe", + "0", + "-i", + file_path, + "-c", + "copy", + "-movflags", + "frag_keyframe+empty_moov", + "-f", + "mp4", + "pipe:", + ] - if p.returncode != 0: - logger.error(p.stderr) - return JSONResponse( - content={ - "success": False, - "message": "Could not create clip from recordings", - }, - status_code=500, - ) - else: - logger.debug( - f"Ignoring subsequent request for {path} as it already exists in the cache." - ) - - headers = { - "Content-Description": "File Transfer", - "Cache-Control": "no-cache", - "Content-Type": "video/mp4", - "Content-Length": str(os.path.getsize(path)), - # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers - "X-Accel-Redirect": f"/clips/cache/{file_name}", - } - - if download: - headers["Content-Disposition"] = "attachment; filename=%s" % file_name - - return FileResponse( - path, + return StreamingResponse( + run_download(ffmpeg_cmd, file_path), media_type="video/mp4", - filename=file_name, - headers=headers, ) @@ -1028,7 +1013,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False @router.get("/events/{event_id}/clip.mp4") -def event_clip(request: Request, event_id: str, download: bool = False): +def event_clip(request: Request, event_id: str): try: event: Event = Event.get(Event.id == event_id) except DoesNotExist: @@ -1048,7 +1033,7 @@ def event_clip(request: Request, event_id: str, download: bool = False): end_ts = ( datetime.now().timestamp() if event.end_time is None else event.end_time ) - return recording_clip(request, event.camera, event.start_time, end_ts, download) + return recording_clip(request, event.camera, event.start_time, end_ts) headers = { "Content-Description": "File Transfer", @@ -1059,9 +1044,6 @@ def event_clip(request: Request, event_id: str, download: bool = False): "X-Accel-Redirect": f"/clips/{file_name}", } - if download: - headers["Content-Disposition"] = "attachment; filename=%s" % file_name - return FileResponse( clip_path, media_type="video/mp4", diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index cb0626f7b..9ee508823 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -174,7 +174,12 @@ class Embeddings: return embedding def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray: - embeddings = self.text_embedding(list(event_descriptions.values())) + # upsert embeddings one by one to avoid token limit + embeddings = [] + + for desc in event_descriptions.values(): + embeddings.append(self.text_embedding([desc])[0]) + ids = list(event_descriptions.keys()) items = [] diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index dccb74c1d..e2d509383 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -4,6 +4,8 @@ import importlib import os from typing import Optional +from playhouse.shortcuts import model_to_dict + from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum from frigate.models import Event @@ -36,8 +38,9 @@ class GenAIClient: ) -> Optional[str]: """Generate a description for the frame.""" prompt = camera_config.genai.object_prompts.get( - event.label, camera_config.genai.prompt - ).format(**event) + event.label, + camera_config.genai.prompt, + ).format(**model_to_dict(event)) return self._send(prompt, thumbnails) def _init_provider(self): diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index f43d1424f..314ff3646 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -142,6 +142,8 @@ class RecordingMaintainer(threading.Thread): ) ) ) + + # see if the recording mover is too slow and segments need to be deleted if processed_segment_count > keep_count: logger.warning( f"Unable to keep up with recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {processed_segment_count} and discarding the rest..." @@ -153,6 +155,21 @@ class RecordingMaintainer(threading.Thread): self.end_time_cache.pop(cache_path, None) grouped_recordings[camera] = grouped_recordings[camera][-keep_count:] + # see if detection has failed and unprocessed segments need to be deleted + unprocessed_segment_count = ( + len(grouped_recordings[camera]) - processed_segment_count + ) + if unprocessed_segment_count > keep_count: + logger.warning( + f"Too many unprocessed recording segments in cache for {camera}. This likely indicates an issue with the detect stream, keeping the {keep_count} most recent segments out of {unprocessed_segment_count} and discarding the rest..." + ) + to_remove = grouped_recordings[camera][:-keep_count] + for rec in to_remove: + cache_path = rec["cache_path"] + Path(cache_path).unlink(missing_ok=True) + self.end_time_cache.pop(cache_path, None) + grouped_recordings[camera] = grouped_recordings[camera][-keep_count:] + tasks = [] for camera, recordings in grouped_recordings.items(): # clear out all the object recording info for old frames diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 7c2c5790e..6dab16206 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -183,16 +183,11 @@ def update_yaml_from_url(file_path, url): update_yaml_file(file_path, key_path, new_value_list) else: value = new_value_list[0] - if "," in value: - # Skip conversion if we're a mask or zone string - update_yaml_file(file_path, key_path, value) - else: - try: - value = ast.literal_eval(value) - except (ValueError, SyntaxError): - pass - update_yaml_file(file_path, key_path, value) - + try: + # no need to convert if we have a mask/zone string + value = ast.literal_eval(value) if "," not in value else value + except (ValueError, SyntaxError): + pass update_yaml_file(file_path, key_path, value) diff --git a/web/src/components/button/DownloadVideoButton.tsx b/web/src/components/button/DownloadVideoButton.tsx new file mode 100644 index 000000000..8a8e541fa --- /dev/null +++ b/web/src/components/button/DownloadVideoButton.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { FaDownload } from "react-icons/fa"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; + +type DownloadVideoButtonProps = { + source: string; + camera: string; + startTime: number; +}; + +export function DownloadVideoButton({ + source, + camera, + startTime, +}: DownloadVideoButtonProps) { + const [isDownloading, setIsDownloading] = useState(false); + + const formattedDate = formatUnixTimestampToDateTime(startTime, { + strftime_fmt: "%D-%T", + time_style: "medium", + date_style: "medium", + }); + const filename = `${camera}_${formattedDate}.mp4`; + + const handleDownloadStart = () => { + setIsDownloading(true); + toast.success("Your review item video has started downloading.", { + position: "top-center", + }); + }; + + const handleDownloadEnd = () => { + setIsDownloading(false); + toast.success("Download completed successfully.", { + position: "top-center", + }); + }; + + return ( +
+ +
+ ); +} diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index fe174e968..4ad7d7c5c 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -1,50 +1,56 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useApiHost } from "@/api"; import { getIconForLabel } from "@/utils/iconUtil"; -import TimeAgo from "../dynamic/TimeAgo"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { isIOS, isSafari } from "react-device-detect"; import Chip from "@/components/indicators/Chip"; -import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useImageLoaded from "@/hooks/use-image-loaded"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; -import ActivityIndicator from "../indicators/activity-indicator"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { SearchResult } from "@/types/search"; -import useContextMenu from "@/hooks/use-contextmenu"; import { cn } from "@/lib/utils"; import { TooltipPortal } from "@radix-ui/react-tooltip"; type SearchThumbnailProps = { searchResult: SearchResult; - findSimilar: () => void; onClick: (searchResult: SearchResult) => void; }; export default function SearchThumbnail({ searchResult, - findSimilar, onClick, }: SearchThumbnailProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); - useContextMenu(imgRef, findSimilar); + // interactions const handleOnClick = useCallback(() => { onClick(searchResult); }, [searchResult, onClick]); - // date + const objectLabel = useMemo(() => { + if ( + !config || + !searchResult.sub_label || + !config.model.attributes_map[searchResult.label] + ) { + return searchResult.label; + } - const formattedDate = useFormattedTimestamp( - searchResult.start_time, - config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", - config?.ui.timezone, - ); + if ( + config.model.attributes_map[searchResult.label].includes( + searchResult.sub_label, + ) + ) { + return searchResult.sub_label; + } + + return `${searchResult.label}-verified`; + }, [config, searchResult]); return (
@@ -80,17 +86,21 @@ export default function SearchThumbnail({
onClick(searchResult)} > - {getIconForLabel(searchResult.label, "size-3 text-white")} + {getIconForLabel(objectLabel, "size-3 text-white")} + {Math.floor( + searchResult.score ?? searchResult.data.top_score * 100, + )} + %
- {[...new Set([searchResult.label])] + {[objectLabel] .filter( (item) => item !== undefined && !item.includes("-verified"), ) @@ -103,18 +113,7 @@ export default function SearchThumbnail({
-
-
- {searchResult.end_time ? ( - - ) : ( -
- -
- )} - {formattedDate} -
-
+
); diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx new file mode 100644 index 000000000..af7606b37 --- /dev/null +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -0,0 +1,217 @@ +import { useCallback, useState } from "react"; +import TimeAgo from "../dynamic/TimeAgo"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { SearchResult } from "@/types/search"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu"; +import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; +import { FrigatePlusDialog } from "../overlay/dialog/FrigatePlusDialog"; +import { Event } from "@/types/event"; +import { FaArrowsRotate } from "react-icons/fa6"; +import { baseUrl } from "@/api/baseUrl"; +import axios from "axios"; +import { toast } from "sonner"; +import { MdImageSearch } from "react-icons/md"; +import { isMobileOnly } from "react-device-detect"; + +type SearchThumbnailProps = { + searchResult: SearchResult; + findSimilar: () => void; + refreshResults: () => void; + showObjectLifecycle: () => void; +}; + +export default function SearchThumbnailFooter({ + searchResult, + findSimilar, + refreshResults, + showObjectLifecycle, +}: SearchThumbnailProps) { + const { data: config } = useSWR("config"); + + // interactions + + const [showFrigatePlus, setShowFrigatePlus] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const handleDelete = useCallback(() => { + axios + .delete(`events/${searchResult.id}`) + .then((resp) => { + if (resp.status == 200) { + toast.success("Tracked object deleted successfully.", { + position: "top-center", + }); + refreshResults(); + } + }) + .catch(() => { + toast.error("Failed to delete tracked object.", { + position: "top-center", + }); + }); + }, [searchResult, refreshResults]); + + // date + + const formattedDate = useFormattedTimestamp( + searchResult.start_time, + config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", + config?.ui.timezone, + ); + + return ( + <> + setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Are you sure you want to delete this tracked object? + + + Cancel + + Delete + + + + + setShowFrigatePlus(false)} + onEventUploaded={() => { + searchResult.plus_id = "submitted"; + }} + /> + +
+ {searchResult.end_time ? ( + + ) : ( +
+ +
+ )} + {formattedDate} +
+
+ {!isMobileOnly && + config?.plus?.enabled && + searchResult.has_snapshot && + searchResult.end_time && + !searchResult.plus_id && ( + + + setShowFrigatePlus(true)} + /> + + Submit to Frigate+ + + )} + + {config?.semantic_search?.enabled && ( + + + + + Find similar + + )} + + + + + + + {searchResult.has_clip && ( + + + + Download video + + + )} + {searchResult.has_snapshot && ( + + + + Download snapshot + + + )} + + + View object lifecycle + + + {isMobileOnly && + config?.plus?.enabled && + searchResult.has_snapshot && + searchResult.end_time && + !searchResult.plus_id && ( + setShowFrigatePlus(true)} + > + + Submit to Frigate+ + + )} + setDeleteDialogOpen(true)} + > + + Delete + + + +
+ + ); +} diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 731f440e4..d63fcd9bf 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -643,6 +643,11 @@ export function CameraGroupEdit({ setIsLoading(true); + let renamingQuery = ""; + if (editingGroup && editingGroup[0] !== values.name) { + renamingQuery = `camera_groups.${editingGroup[0]}&`; + } + const order = editingGroup === undefined ? currentGroups.length + 1 @@ -655,9 +660,12 @@ export function CameraGroupEdit({ .join(""); axios - .put(`config/set?${orderQuery}&${iconQuery}${cameraQueries}`, { - requires_restart: 0, - }) + .put( + `config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}`, + { + requires_restart: 0, + }, + ) .then((res) => { if (res.status === 200) { toast.success(`Camera group (${values.name}) has been saved.`, { @@ -712,7 +720,6 @@ export function CameraGroupEdit({ diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index 563af8752..94f1a838e 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -69,6 +69,70 @@ export function CamerasFilterButton({ ); const content = ( + + ); + + if (isMobile) { + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type CamerasFilterContentProps = { + allCameras: string[]; + currentCameras: string[] | undefined; + groups: [string, CameraGroupConfig][]; + setCurrentCameras: (cameras: string[] | undefined) => void; + setOpen: (open: boolean) => void; + updateCameraFilter: (cameras: string[] | undefined) => void; +}; +export function CamerasFilterContent({ + allCameras, + currentCameras, + groups, + setCurrentCameras, + setOpen, + updateCameraFilter, +}: CamerasFilterContentProps) { + return ( <> {isMobile && ( <> @@ -158,40 +222,4 @@ export function CamerasFilterButton({ ); - - if (isMobile) { - return ( - { - if (!open) { - setCurrentCameras(selectedCameras); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - - return ( - { - if (!open) { - setCurrentCameras(selectedCameras); - } - setOpen(open); - }} - > - {trigger} - {content} - - ); } diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 8ddb3fee6..ef816bb9f 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -1,5 +1,4 @@ import { Button } from "../ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -10,25 +9,19 @@ import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; import FilterSwitch from "./FilterSwitch"; import { FilterList } from "@/types/filter"; -import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton"; import { DEFAULT_SEARCH_FILTERS, SearchFilter, SearchFilters, SearchSource, - DEFAULT_TIME_RANGE_AFTER, - DEFAULT_TIME_RANGE_BEFORE, } from "@/types/search"; import { DateRange } from "react-day-picker"; import { cn } from "@/lib/utils"; -import SubFilterIcon from "../icons/SubFilterIcon"; -import { FaLocationDot } from "react-icons/fa6"; import { MdLabel } from "react-icons/md"; -import SearchSourceIcon from "../icons/SearchSourceIcon"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; -import { FaArrowRight, FaClock } from "react-icons/fa"; -import { useFormattedHour } from "@/hooks/use-date-utils"; +import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog"; +import { CalendarRangeFilterButton } from "./CalendarFilterButton"; type SearchFilterGroupProps = { className: string; @@ -79,8 +72,6 @@ export default function SearchFilterGroup({ return [...labels].sort(); }, [config, filterList, filter]); - const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]); - const allZones = useMemo(() => { if (filterList?.zones) { return filterList.zones; @@ -159,6 +150,15 @@ export default function SearchFilterGroup({ }} /> )} + {filters.includes("general") && ( + { + onUpdateFilter({ ...filter, labels: newLabels }); + }} + /> + )} {filters.includes("date") && ( )} - {filters.includes("time") && ( - - onUpdateFilter({ ...filter, time_range }) - } - /> - )} - {filters.includes("zone") && allZones.length > 0 && ( - - onUpdateFilter({ ...filter, zones: newZones }) - } - /> - )} - {filters.includes("general") && ( - { - onUpdateFilter({ ...filter, labels: newLabels }); - }} - /> - )} - {filters.includes("sub") && ( - - onUpdateFilter({ ...filter, sub_labels: newSubLabels }) - } - /> - )} - {config?.semantic_search?.enabled && - filters.includes("source") && - !filter?.search_type?.includes("similarity") && ( - - onUpdateFilter({ ...filter, search_type: newSearchSource }) - } - /> - )} + ); } @@ -295,7 +253,11 @@ function GeneralFilterButton({ { if (!open) { @@ -326,7 +288,7 @@ export function GeneralFilterContent({ }: GeneralFilterContentProps) { return ( <> -
+
-
+
Objects
{events?.map((event) => { diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 1cee70aaa..47af3e309 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -69,16 +69,20 @@ const SEARCH_TABS = [ "video", "object lifecycle", ] as const; -type SearchTab = (typeof SEARCH_TABS)[number]; +export type SearchTab = (typeof SEARCH_TABS)[number]; type SearchDetailDialogProps = { search?: SearchResult; + page: SearchTab; setSearch: (search: SearchResult | undefined) => void; + setSearchPage: (page: SearchTab) => void; setSimilarity?: () => void; }; export default function SearchDetailDialog({ search, + page, setSearch, + setSearchPage, setSimilarity, }: SearchDetailDialogProps) { const { data: config } = useSWR("config", { @@ -87,15 +91,20 @@ export default function SearchDetailDialog({ // tabs - const [page, setPage] = useState("details"); - const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + const [pageToggle, setPageToggle] = useOptimisticState( + page, + setSearchPage, + 100, + ); // dialog and mobile page const [isOpen, setIsOpen] = useState(search != undefined); useEffect(() => { - setIsOpen(search != undefined); + if (search) { + setIsOpen(search != undefined); + } }, [search]); const searchTabs = useMemo(() => { @@ -115,12 +124,6 @@ export default function SearchDetailDialog({ views.splice(index, 1); } - // TODO implement - //if (!config.semantic_search.enabled) { - // const index = views.indexOf("similar-calendar"); - // views.splice(index, 1); - // } - return views; }, [config, search]); @@ -130,9 +133,9 @@ export default function SearchDetailDialog({ } if (!searchTabs.includes(pageToggle)) { - setPage("details"); + setSearchPage("details"); } - }, [pageToggle, searchTabs]); + }, [pageToggle, searchTabs, setSearchPage]); if (!search) { return; @@ -147,14 +150,7 @@ export default function SearchDetailDialog({ const Description = isDesktop ? DialogDescription : MobilePageDescription; return ( - { - if (!open) { - setSearch(undefined); - } - }} - > + setIsOpen(!isOpen)}> ); } + +type PlatformAwareSheetProps = { + trigger: JSX.Element; + title?: string | JSX.Element; + content: JSX.Element; + triggerClassName?: string; + titleClassName?: string; + contentClassName?: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}; +export function PlatformAwareSheet({ + trigger, + title, + content, + triggerClassName = "", + titleClassName = "", + contentClassName = "", + open, + onOpenChange, +}: PlatformAwareSheetProps) { + if (isMobile) { + return ( +
+
onOpenChange(true)}>{trigger}
+ + + onOpenChange(false)} + > + More Filters + +
{content}
+
+
+
+ ); + } + + return ( + + + {trigger} + + + + + {title ?? ""} + + Information + + {content} + + + ); +} diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx new file mode 100644 index 000000000..9409707b6 --- /dev/null +++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx @@ -0,0 +1,477 @@ +import { FaArrowRight, FaCog } from "react-icons/fa"; + +import { useEffect, useMemo, useState } from "react"; +import { PlatformAwareSheet } from "./PlatformAwareDialog"; +import { Button } from "@/components/ui/button"; +import useSWR from "swr"; +import { + DEFAULT_TIME_RANGE_AFTER, + DEFAULT_TIME_RANGE_BEFORE, + SearchFilter, + SearchSource, +} from "@/types/search"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { isDesktop, isMobileOnly } from "react-device-detect"; +import { useFormattedHour } from "@/hooks/use-date-utils"; +import FilterSwitch from "@/components/filter/FilterSwitch"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +type SearchFilterDialogProps = { + config?: FrigateConfig; + filter?: SearchFilter; + filterValues: { + cameras: string[]; + labels: string[]; + zones: string[]; + search_type: SearchSource[]; + }; + onUpdateFilter: (filter: SearchFilter) => void; +}; +export default function SearchFilterDialog({ + config, + filter, + filterValues, + onUpdateFilter, +}: SearchFilterDialogProps) { + // data + + const [currentFilter, setCurrentFilter] = useState(filter ?? {}); + const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]); + + // state + + const [open, setOpen] = useState(false); + + const moreFiltersSelected = useMemo( + () => + currentFilter && + (currentFilter.time_range || + (currentFilter.zones?.length ?? 0) > 0 || + (currentFilter.sub_labels?.length ?? 0) > 0 || + (currentFilter.search_type?.length ?? 2) !== 2), + [currentFilter], + ); + + const trigger = ( + + ); + const content = ( +
+ + setCurrentFilter({ ...currentFilter, time_range: newRange }) + } + /> + + setCurrentFilter({ ...currentFilter, zones: newZones }) + } + /> + + setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels }) + } + /> + {config?.semantic_search?.enabled && + !currentFilter?.search_type?.includes("similarity") && ( + + setCurrentFilter({ + ...currentFilter, + search_type: newSearchSource, + }) + } + /> + )} + {isDesktop && } +
+ + +
+
+ ); + + return ( + { + if (!open) { + setCurrentFilter(filter ?? {}); + } + + setOpen(open); + }} + /> + ); +} + +type TimeRangeFilterContentProps = { + config?: FrigateConfig; + timeRange?: string; + updateTimeRange: (range: string | undefined) => void; +}; +function TimeRangeFilterContent({ + config, + timeRange, + updateTimeRange, +}: TimeRangeFilterContentProps) { + const [startOpen, setStartOpen] = useState(false); + const [endOpen, setEndOpen] = useState(false); + + const [afterHour, beforeHour] = useMemo(() => { + if (!timeRange || !timeRange.includes(",")) { + return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE]; + } + + return timeRange.split(","); + }, [timeRange]); + + const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour); + const [selectedBeforeHour, setSelectedBeforeHour] = useState(beforeHour); + + // format based on locale + + const formattedSelectedAfter = useFormattedHour(config, selectedAfterHour); + const formattedSelectedBefore = useFormattedHour(config, selectedBeforeHour); + + useEffect(() => { + setSelectedAfterHour(afterHour); + setSelectedBeforeHour(beforeHour); + // only refresh when state changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeRange]); + + useEffect(() => { + if ( + selectedAfterHour == DEFAULT_TIME_RANGE_AFTER && + selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE + ) { + updateTimeRange(undefined); + } else { + updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`); + } + // only refresh when state changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedAfterHour, selectedBeforeHour]); + + return ( +
+
Time Range
+
+ { + if (!open) { + setStartOpen(false); + } + }} + > + + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedAfterHour(`${hour}:${minute}`); + }} + /> + + + + { + if (!open) { + setEndOpen(false); + } + }} + > + + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedBeforeHour(`${hour}:${minute}`); + }} + /> + + +
+
+ ); +} + +type ZoneFilterContentProps = { + allZones?: string[]; + zones?: string[]; + updateZones: (zones: string[] | undefined) => void; +}; +export function ZoneFilterContent({ + allZones, + zones, + updateZones, +}: ZoneFilterContentProps) { + return ( + <> +
+ +
Zones
+ {allZones && ( + <> +
+ + { + if (isChecked) { + updateZones(undefined); + } + }} + /> +
+
+ {allZones.map((item) => ( + { + if (isChecked) { + const updatedZones = zones ? [...zones] : []; + + updatedZones.push(item); + updateZones(updatedZones); + } else { + const updatedZones = zones ? [...zones] : []; + + // can not deselect the last item + if (updatedZones.length > 1) { + updatedZones.splice(updatedZones.indexOf(item), 1); + updateZones(updatedZones); + } + } + }} + /> + ))} +
+ + )} +
+ + ); +} + +type SubFilterContentProps = { + allSubLabels: string[]; + subLabels: string[] | undefined; + setSubLabels: (labels: string[] | undefined) => void; +}; +export function SubFilterContent({ + allSubLabels, + subLabels, + setSubLabels, +}: SubFilterContentProps) { + return ( +
+ +
Sub Labels
+
+ + { + if (isChecked) { + setSubLabels(undefined); + } + }} + /> +
+
+ {allSubLabels.map((item) => ( + { + if (isChecked) { + const updatedLabels = subLabels ? [...subLabels] : []; + + updatedLabels.push(item); + setSubLabels(updatedLabels); + } else { + const updatedLabels = subLabels ? [...subLabels] : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setSubLabels(updatedLabels); + } + } + }} + /> + ))} +
+
+ ); +} + +type SearchTypeContentProps = { + searchSources: SearchSource[] | undefined; + setSearchSources: (sources: SearchSource[] | undefined) => void; +}; +export function SearchTypeContent({ + searchSources, + setSearchSources, +}: SearchTypeContentProps) { + return ( + <> +
+ +
Search Sources
+
+ { + const updatedSources = searchSources ? [...searchSources] : []; + + if (isChecked) { + updatedSources.push("thumbnail"); + setSearchSources(updatedSources); + } else { + if (updatedSources.length > 1) { + const index = updatedSources.indexOf("thumbnail"); + if (index !== -1) updatedSources.splice(index, 1); + setSearchSources(updatedSources); + } + } + }} + /> + { + const updatedSources = searchSources ? [...searchSources] : []; + + if (isChecked) { + updatedSources.push("description"); + setSearchSources(updatedSources); + } else { + if (updatedSources.length > 1) { + const index = updatedSources.indexOf("description"); + if (index !== -1) updatedSources.splice(index, 1); + setSearchSources(updatedSources); + } + } + }} + /> +
+
+ + ); +} diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 03a60a8d0..ffbef1060 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -275,7 +275,7 @@ export default function Explore() {
Search Unavailable
- {embeddingsReindexing && ( + {embeddingsReindexing && allModelsLoaded && ( <>
Search can be used after tracked object embeddings have @@ -384,6 +384,7 @@ export default function Explore() { searchFilter={searchFilter} searchResults={searchResults} isLoading={(isLoadingInitialData || isLoadingMore) ?? true} + hasMore={!isReachingEnd} setSearch={setSearch} setSimilaritySearch={(search) => { setSearchFilter({ @@ -395,7 +396,7 @@ export default function Explore() { setSearchFilter={setSearchFilter} onUpdateFilter={setSearchFilter} loadMore={loadMore} - hasMore={!isReachingEnd} + refresh={mutate} /> )} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 6d147b332..4ba29dd08 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -29,16 +29,18 @@ import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { PolygonType } from "@/types/canvas"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import scrollIntoView from "scroll-into-view-if-needed"; -import GeneralSettingsView from "@/views/settings/GeneralSettingsView"; import CameraSettingsView from "@/views/settings/CameraSettingsView"; import ObjectSettingsView from "@/views/settings/ObjectSettingsView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import AuthenticationView from "@/views/settings/AuthenticationView"; import NotificationView from "@/views/settings/NotificationsSettingsView"; +import SearchSettingsView from "@/views/settings/SearchSettingsView"; +import UiSettingsView from "@/views/settings/UiSettingsView"; const allSettingsViews = [ - "general", + "UI settings", + "search settings", "camera settings", "masks / zones", "motion tuner", @@ -49,7 +51,7 @@ const allSettingsViews = [ type SettingsType = (typeof allSettingsViews)[number]; export default function Settings() { - const [page, setPage] = useState("general"); + const [page, setPage] = useState("UI settings"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const tabsRef = useRef(null); @@ -140,7 +142,7 @@ export default function Settings() { {Object.values(settingsViews).map((item) => (
- {page == "general" && } + {page == "UI settings" && } + {page == "search settings" && ( + + )} {page == "debug" && ( )} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index fe889ed9d..76d9cfa67 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -27,6 +27,8 @@ export const ATTRIBUTE_LABELS = [ "ups", ]; +export type SearchModelSize = "small" | "large"; + export interface CameraConfig { audio: { enabled: boolean; @@ -340,6 +342,7 @@ export interface FrigateConfig { path: string | null; width: number; colormap: { [key: string]: [number, number, number] }; + attributes_map: { [key: string]: [string] }; }; motion: Record | null; @@ -417,7 +420,8 @@ export interface FrigateConfig { semantic_search: { enabled: boolean; - model_size: string; + reindex: boolean; + model_size: SearchModelSize; }; snapshots: { diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index e64affa36..9427cdcff 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -1,8 +1,9 @@ import SearchThumbnail from "@/components/card/SearchThumbnail"; import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; import ActivityIndicator from "@/components/indicators/activity-indicator"; -import Chip from "@/components/indicators/Chip"; -import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog"; +import SearchDetailDialog, { + SearchTab, +} from "@/components/overlay/detail/SearchDetailDialog"; import { Toaster } from "@/components/ui/sonner"; import { Tooltip, @@ -14,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobileOnly } from "react-device-detect"; -import { LuColumns, LuImage, LuSearchX, LuText } from "react-icons/lu"; +import { LuColumns, LuSearchX } from "react-icons/lu"; import useSWR from "swr"; import ExploreView from "../explore/ExploreView"; import useKeyboardListener, { @@ -25,7 +26,6 @@ import InputWithTags from "@/components/input/InputWithTags"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { isEqual } from "lodash"; import { formatDateToLocaleString } from "@/utils/dateUtil"; -import { TooltipPortal } from "@radix-ui/react-tooltip"; import { Slider } from "@/components/ui/slider"; import { Popover, @@ -33,6 +33,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { usePersistence } from "@/hooks/use-persistence"; +import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter"; type SearchViewProps = { search: string; @@ -40,12 +41,13 @@ type SearchViewProps = { searchFilter?: SearchFilter; searchResults?: SearchResult[]; isLoading: boolean; + hasMore: boolean; setSearch: (search: string) => void; setSimilaritySearch: (search: SearchResult) => void; setSearchFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void; loadMore: () => void; - hasMore: boolean; + refresh: () => void; }; export default function SearchView({ search, @@ -53,12 +55,13 @@ export default function SearchView({ searchFilter, searchResults, isLoading, + hasMore, setSearch, setSimilaritySearch, setSearchFilter, onUpdateFilter, loadMore, - hasMore, + refresh, }: SearchViewProps) { const contentRef = useRef(null); const { data: config } = useSWR("config", { @@ -70,15 +73,17 @@ export default function SearchView({ const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4); const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]); - const gridClassName = cn("grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", { - "sm:grid-cols-2": effectiveColumnCount <= 2, - "sm:grid-cols-3": effectiveColumnCount === 3, - "sm:grid-cols-4": effectiveColumnCount === 4, - "sm:grid-cols-5": effectiveColumnCount === 5, - "sm:grid-cols-6": effectiveColumnCount === 6, - "sm:grid-cols-7": effectiveColumnCount === 7, - "sm:grid-cols-8": effectiveColumnCount >= 8, - }); + const gridClassName = cn( + "grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", + isMobileOnly && "grid-cols-2", + { + "sm:grid-cols-2": effectiveColumnCount <= 2, + "sm:grid-cols-3": effectiveColumnCount === 3, + "sm:grid-cols-4": effectiveColumnCount === 4, + "sm:grid-cols-5": effectiveColumnCount === 5, + "sm:grid-cols-6": effectiveColumnCount === 6, + }, + ); // suggestions values @@ -161,16 +166,25 @@ export default function SearchView({ // detail const [searchDetail, setSearchDetail] = useState(); + const [page, setPage] = useState("details"); // search interaction const [selectedIndex, setSelectedIndex] = useState(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); - const onSelectSearch = useCallback((item: SearchResult, index: number) => { - setSearchDetail(item); - setSelectedIndex(index); - }, []); + const onSelectSearch = useCallback( + (item: SearchResult, index: number, page: SearchTab = "details") => { + setPage(page); + setSearchDetail(item); + setSelectedIndex(index); + }, + [], + ); + + useEffect(() => { + setSelectedIndex(0); + }, [searchTerm, searchFilter]); // update search detail when results change @@ -187,21 +201,6 @@ export default function SearchView({ } }, [searchResults, searchDetail]); - // confidence score - - const zScoreToConfidence = (score: number) => { - // Normalizing is not needed for similarity searches - // Sigmoid function for normalized: 1 / (1 + e^x) - // Cosine for similarity - if (searchFilter) { - const notNormalized = searchFilter?.search_type?.includes("similarity"); - - const confidence = notNormalized ? 1 - score : 1 / (1 + Math.exp(score)); - - return Math.round(confidence * 100); - } - }; - const hasExistingSearch = useMemo( () => searchResults != undefined || searchFilter != undefined, [searchResults, searchFilter], @@ -310,7 +309,9 @@ export default function SearchView({ setSimilaritySearch(searchDetail)) } @@ -388,47 +389,31 @@ export default function SearchView({ >
onSelectSearch(value, index)} + /> +
+
+
+ { if (config?.semantic_search.enabled) { setSimilaritySearch(value); } }} - onClick={() => onSelectSearch(value, index)} + refreshResults={refresh} + showObjectLifecycle={() => + onSelectSearch(value, index, "object lifecycle") + } /> - {(searchTerm || - searchFilter?.search_type?.includes("similarity")) && ( -
- - - - {value.search_source == "thumbnail" ? ( - - ) : ( - - )} - {zScoreToConfidence(value.search_distance)}% - - - - - Matched {value.search_source} at{" "} - {zScoreToConfidence(value.search_distance)}% - - - -
- )}
-
); })} @@ -467,7 +452,7 @@ export default function SearchView({ setColumnCount(value)} - max={8} + max={6} min={2} step={1} className="flex-grow" diff --git a/web/src/views/settings/SearchSettingsView.tsx b/web/src/views/settings/SearchSettingsView.tsx new file mode 100644 index 000000000..a08816675 --- /dev/null +++ b/web/src/views/settings/SearchSettingsView.tsx @@ -0,0 +1,288 @@ +import Heading from "@/components/ui/heading"; +import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig"; +import useSWR from "swr"; +import axios from "axios"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useCallback, useContext, useEffect, useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; +import { Separator } from "@/components/ui/separator"; +import { Link } from "react-router-dom"; +import { LuExternalLink } from "react-icons/lu"; +import { StatusBarMessagesContext } from "@/context/statusbar-provider"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; + +type SearchSettingsViewProps = { + setUnsavedChanges: React.Dispatch>; +}; + +type SearchSettings = { + enabled?: boolean; + reindex?: boolean; + model_size?: SearchModelSize; +}; + +export default function SearchSettingsView({ + setUnsavedChanges, +}: SearchSettingsViewProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + const [changedValue, setChangedValue] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + + const [searchSettings, setSearchSettings] = useState({ + enabled: undefined, + reindex: undefined, + model_size: undefined, + }); + + const [origSearchSettings, setOrigSearchSettings] = useState({ + enabled: undefined, + reindex: undefined, + model_size: undefined, + }); + + useEffect(() => { + if (config) { + if (searchSettings?.enabled == undefined) { + setSearchSettings({ + enabled: config.semantic_search.enabled, + reindex: config.semantic_search.reindex, + model_size: config.semantic_search.model_size, + }); + } + + setOrigSearchSettings({ + enabled: config.semantic_search.enabled, + reindex: config.semantic_search.reindex, + model_size: config.semantic_search.model_size, + }); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); + + const handleSearchConfigChange = (newConfig: Partial) => { + setSearchSettings((prevConfig) => ({ ...prevConfig, ...newConfig })); + setUnsavedChanges(true); + setChangedValue(true); + }; + + const saveToConfig = useCallback(async () => { + setIsLoading(true); + + axios + .put( + `config/set?semantic_search.enabled=${searchSettings.enabled}&semantic_search.reindex=${searchSettings.reindex}&semantic_search.model_size=${searchSettings.model_size}`, + ) + .then((res) => { + if (res.status === 200) { + toast.success("Search settings have been saved.", { + position: "top-center", + }); + setChangedValue(false); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, [ + updateConfig, + searchSettings.enabled, + searchSettings.reindex, + searchSettings.model_size, + ]); + + const onCancel = useCallback(() => { + setSearchSettings(origSearchSettings); + setChangedValue(false); + removeMessage("search_settings", "search_settings"); + }, [origSearchSettings, removeMessage]); + + useEffect(() => { + if (changedValue) { + addMessage( + "search_settings", + `Unsaved search settings changes`, + undefined, + "search_settings", + ); + } else { + removeMessage("search_settings", "search_settings"); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changedValue]); + + useEffect(() => { + document.title = "Search Settings - Frigate"; + }, []); + + if (!config) { + return ; + } + + return ( +
+ +
+ + Search Settings + + + + 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. +

+ +
+ + Read the Documentation + + +
+
+
+ +
+
+ { + handleSearchConfigChange({ enabled: isChecked }); + }} + /> +
+ +
+
+
+
+ { + handleSearchConfigChange({ reindex: isChecked }); + }} + /> +
+ +
+
+
+ Re-indexing will reprocess all thumbnails and descriptions (if + enabled) and apply the embeddings on each startup.{" "} + Don't forget to disable the option after restarting! +
+
+
+
+
Model Size
+
+

+ The size of the model used for semantic search embeddings. +

+
    +
  • + Using small employs a quantized version of the + model that uses less RAM and runs faster on CPU with a very + negligible difference in embedding quality. +
  • +
  • + Using large employs the full Jina model and will + automatically run on the GPU if applicable. +
  • +
+
+
+ +
+
+ + +
+ + +
+
+
+ ); +} diff --git a/web/src/views/settings/GeneralSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx similarity index 99% rename from web/src/views/settings/GeneralSettingsView.tsx rename to web/src/views/settings/UiSettingsView.tsx index 0cb7689f6..c212073c1 100644 --- a/web/src/views/settings/GeneralSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -22,7 +22,7 @@ import { const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; const WEEK_STARTS_ON = ["Sunday", "Monday"]; -export default function GeneralSettingsView() { +export default function UiSettingsView() { const { data: config } = useSWR("config"); const clearStoredLayouts = useCallback(() => {