import { baseUrl } from "@/api/baseUrl"; import AddFaceIcon from "@/components/icons/AddFaceIcon"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { Toaster } from "@/components/ui/sonner"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; import axios from "axios"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LuImagePlus, LuTrash2, LuUserPlus, LuEdit } from "react-icons/lu"; import { toast } from "sonner"; import useSWR from "swr"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; export default function FaceLibrary() { const { data: config } = useSWR("config"); // title useEffect(() => { document.title = "Face Library - Frigate"; }, []); const [page, setPage] = useState(); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const tabsRef = useRef(null); // face data const { data: faceData, mutate: refreshFaces } = useSWR("faces"); const faces = useMemo( () => faceData ? Object.keys(faceData).filter((face) => face != "train") : [], [faceData], ); const faceImages = useMemo( () => (pageToggle && faceData ? faceData[pageToggle] : []), [pageToggle, faceData], ); const trainImages = useMemo( () => faceData?.["train"] || [], [faceData], ); useEffect(() => { if (!pageToggle) { if (trainImages.length > 0) { setPageToggle("train"); } else if (faces) { setPageToggle(faces[0]); } } else if (pageToggle == "train" && trainImages.length == 0) { setPageToggle(faces[0]); } // we need to listen on the value of the faces list // eslint-disable-next-line react-hooks/exhaustive-deps }, [trainImages, faces]); // upload const [upload, setUpload] = useState(false); const onUploadImage = useCallback( (file: File) => { const formData = new FormData(); formData.append("file", file); axios .post(`faces/${pageToggle}`, formData, { headers: { "Content-Type": "multipart/form-data", }, }) .then((resp) => { if (resp.status == 200) { setUpload(false); refreshFaces(); toast.success( "Successfully uploaded image. View the file in the /exports folder.", { position: "top-center" }, ); } }) .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", }); } }); }, [pageToggle, refreshFaces], ); const [newFaceDialog, setNewFaceDialog] = useState(false); const [isCreatingFace, setIsCreatingFace] = useState(false); const [newFaceName, setNewFaceName] = useState(""); const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { createNewFace(); } }; const createNewFace = useCallback(async () => { if (!newFaceName.trim()) { toast.error("Face name cannot be empty", { position: "top-center" }); return; } setIsCreatingFace(true); try { const formData = new FormData(); const emptyBlob = new Blob([], { type: 'image/webp' }); formData.append('file', emptyBlob, 'empty.webp'); const resp = await axios.post(`/faces/${newFaceName}`, formData, { headers: { 'Content-Type': 'multipart/form-data', }, }); if (resp.status === 200) { setNewFaceDialog(false); setNewFaceName(""); refreshFaces(); toast.success("Successfully created new face", { position: "top-center" }); } } catch (error) { toast.error( `Failed to create face: ${error.response?.data?.message || error.message}`, { position: "top-center" } ); } finally { setIsCreatingFace(false); } }, [newFaceName, refreshFaces]); const [renameDialog, setRenameDialog] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [renameData, setRenameData] = useState<{ oldName: string; newName: string }>({ oldName: '', newName: '' }); const renameFace = useCallback(async () => { if (!renameData.newName.trim()) { toast.error("Face name cannot be empty", { position: "top-center" }); return; } setIsRenaming(true); try { const formData = new FormData(); const emptyBlob = new Blob([], { type: 'image/webp' }); formData.append('file', emptyBlob, 'empty.webp'); await axios.post(`/faces/${renameData.newName}`, formData); const oldFaceImages = faceData[renameData.oldName] || []; for (const image of oldFaceImages) { const response = await fetch(`${baseUrl}clips/faces/${renameData.oldName}/${image}`); const blob = await response.blob(); const formData = new FormData(); formData.append('file', blob, image); await axios.post(`/faces/${renameData.newName}`, formData); } await axios.post(`/faces/${renameData.oldName}/delete`, { ids: oldFaceImages }); setRenameDialog(false); setRenameData({ oldName: '', newName: '' }); refreshFaces(); toast.success("Successfully renamed face", { position: "top-center" }); } catch (error) { toast.error( `Failed to rename face: ${error.response?.data?.message || error.message}`, { position: "top-center" } ); } finally { setIsRenaming(false); } }, [renameData, faceData, refreshFaces]); if (!config) { return ; } return (
Create New Face
setNewFaceName(e.target.value)} onKeyPress={handleKeyPress} disabled={isCreatingFace} />
Rename Face
setRenameData(prev => ({ ...prev, newName: e.target.value }))} onKeyPress={(e) => e.key === 'Enter' && renameFace()} disabled={isRenaming} />
{ if (value) { setPageToggle(value); } }} > {trainImages.length > 0 && ( <>
Train
|
)} {Object.values(faces).map((item) => (
{item} ({faceData[item].length})
Rename Face
))}
{pageToggle && (pageToggle == "train" ? ( ) : ( ))}
); } type TrainingGridProps = { config: FrigateConfig; attemptImages: string[]; faceNames: string[]; onRefresh: () => void; }; function TrainingGrid({ config, attemptImages, faceNames, onRefresh, }: TrainingGridProps) { return (
{attemptImages.map((image: string) => ( ))}
); } type FaceAttemptProps = { image: string; faceNames: string[]; threshold: number; onRefresh: () => void; }; function FaceAttempt({ image, faceNames, threshold, onRefresh, }: FaceAttemptProps) { const data = useMemo(() => { const parts = image.split("-"); return { eventId: `${parts[0]}-${parts[1]}`, name: parts[2], score: parts[3], }; }, [image]); const onTrainAttempt = useCallback( (trainName: string) => { axios .post(`/faces/train/${trainName}/classify`, { training_file: image }) .then((resp) => { if (resp.status == 200) { toast.success(`Successfully trained face.`, { position: "top-center", }); onRefresh(); } }) .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", }); } }); }, [image, onRefresh], ); const onDelete = useCallback(() => { axios .post(`/faces/train/delete`, { ids: [image] }) .then((resp) => { if (resp.status == 200) { toast.success(`Successfully deleted face.`, { position: "top-center", }); onRefresh(); } }) .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", }); } }); }, [image, onRefresh]); return (
{data.name}
= threshold ? "text-success" : "text-danger", )} > {Number.parseFloat(data.score) * 100}%
Train Face as: {faceNames.map((faceName) => ( onTrainAttempt(faceName)} > {faceName} ))} Train Face as Person Delete Face Attempt
); } type FaceGridProps = { faceImages: string[]; pageToggle: string; onRefresh: () => void; }; function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) { return (
{faceImages.map((image: string) => ( ))}
); } type FaceImageProps = { name: string; image: string; onRefresh: () => void; }; function FaceImage({ name, image, onRefresh }: FaceImageProps) { const onDelete = useCallback(() => { axios .post(`/faces/${name}/delete`, { ids: [image] }) .then((resp) => { if (resp.status == 200) { toast.success(`Successfully deleted face.`, { position: "top-center", }); onRefresh(); } }) .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", }); } }); }, [name, image, onRefresh]); return (
{name}
Delete Face Attempt
); }