import { baseUrl } from "@/api/baseUrl"; import TimeAgo from "@/components/dynamic/TimeAgo"; import AddFaceIcon from "@/components/icons/AddFaceIcon"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog"; import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; 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 useContextMenu from "@/hooks/use-contextmenu"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { cn } from "@/lib/utils"; import { FaceLibraryData, RecognizedFaceData } from "@/types/face"; import { FrigateConfig } from "@/types/frigateConfig"; import axios from "axios"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu"; import { toast } from "sonner"; import useSWR from "swr"; export default function FaceLibrary() { const { t } = useTranslation(["views/faceLibrary"]); const { data: config } = useSWR("config"); // title useEffect(() => { document.title = t("documentTitle"); }, [t]); const [page, setPage] = useState(); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); // 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 [addFace, setAddFace] = useState(false); const onUploadImage = useCallback( (file: File) => { const formData = new FormData(); formData.append("file", file); axios .post(`faces/${pageToggle}/register`, formData, { headers: { "Content-Type": "multipart/form-data", }, }) .then((resp) => { if (resp.status == 200) { setUpload(false); refreshFaces(); toast.success(t("toast.success.uploadedImage"), { position: "top-center", }); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error(t("toast.error.uploadingImageFailed", { errorMessage }), { position: "top-center", }); }); }, [pageToggle, refreshFaces, t], ); // face multiselect const [selectedFaces, setSelectedFaces] = useState([]); const onClickFace = useCallback( (imageId: string, ctrl: boolean) => { if (selectedFaces.length == 0 && !ctrl) { return; } const index = selectedFaces.indexOf(imageId); if (index != -1) { if (selectedFaces.length == 1) { setSelectedFaces([]); } else { const copy = [ ...selectedFaces.slice(0, index), ...selectedFaces.slice(index + 1), ]; setSelectedFaces(copy); } } else { const copy = [...selectedFaces]; copy.push(imageId); setSelectedFaces(copy); } }, [selectedFaces, setSelectedFaces], ); const onDelete = useCallback( (name: string, ids: string[]) => { axios .post(`/faces/${name}/delete`, { ids }) .then((resp) => { setSelectedFaces([]); if (resp.status == 200) { toast.success(t("toast.success.deletedFace"), { position: "top-center", }); if (faceImages.length == 1) { // face has been deleted setPageToggle(""); } refreshFaces(); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), { position: "top-center", }); }); }, [faceImages, refreshFaces, setPageToggle, t], ); // keyboard useKeyboardListener(["a", "Escape"], (key, modifiers) => { if (modifiers.repeat || !modifiers.down) { return; } switch (key) { case "a": if (modifiers.ctrl) { setSelectedFaces([...trainImages]); } break; case "Escape": setSelectedFaces([]); break; } }); if (!config) { return ; } return (
{selectedFaces?.length > 0 ? (
) : (
{pageToggle != "train" && ( )}
)}
{pageToggle && (pageToggle == "train" ? ( ) : ( ))}
); } type LibrarySelectorProps = { pageToggle: string | undefined; faceData?: FaceLibraryData; faces: string[]; trainImages: string[]; setPageToggle: (toggle: string | undefined) => void; }; function LibrarySelector({ pageToggle, faceData, faces, trainImages, setPageToggle, }: LibrarySelectorProps) { const { t } = useTranslation(["views/faceLibrary"]); return isDesktop ? (
{ if (value) { setPageToggle(value); } }} > {trainImages.length > 0 && ( <>
{t("train.title")}
|
)} {Object.values(faces).map((face) => (
{face} ({faceData?.[face].length})
))}
) : ( {Object.values(faces).map((face) => ( setPageToggle(face)} > {face} ({faceData?.[face].length}) ))} ); } type TrainingGridProps = { config: FrigateConfig; attemptImages: string[]; faceNames: string[]; selectedFaces: string[]; onClickFace: (image: string, ctrl: boolean) => void; onRefresh: () => void; }; function TrainingGrid({ config, attemptImages, faceNames, selectedFaces, onClickFace, onRefresh, }: TrainingGridProps) { const { t } = useTranslation(["views/faceLibrary"]); // face data const [selectedEvent, setSelectedEvent] = useState(); const formattedDate = useFormattedTimestamp( selectedEvent?.timestamp ?? 0, config?.ui.time_format == "24hour" ? t("time.formattedTimestampWithYear.24hour", { ns: "common" }) : t("time.formattedTimestampWithYear.12hour", { ns: "common" }), config?.ui.timezone, ); return ( <> { if (!open) { setSelectedEvent(undefined); } }} > {t("details.face")} {t("details.faceDesc")}
{t("details.person")}
{selectedEvent?.name}
{t("details.confidence")}
{(selectedEvent?.score || 0) * 100}%
{t("details.timestamp")}
{formattedDate}
{attemptImages.map((image: string) => ( { if (meta) { onClickFace(image, meta); } else { setSelectedEvent(data); } }} onRefresh={onRefresh} /> ))}
); } type FaceAttemptProps = { image: string; faceNames: string[]; threshold: number; selected: boolean; onClick: (data: RecognizedFaceData, meta: boolean) => void; onRefresh: () => void; }; function FaceAttempt({ image, faceNames, threshold, selected, onClick, onRefresh, }: FaceAttemptProps) { const { t } = useTranslation(["views/faceLibrary"]); const data = useMemo(() => { const parts = image.split("-"); return { timestamp: Number.parseFloat(parts[0]), eventId: `${parts[0]}-${parts[1]}`, name: parts[2], score: Number.parseFloat(parts[3]), }; }, [image]); // interaction const imgRef = useRef(null); useContextMenu(imgRef, () => { onClick(data, true); }); // api calls const onTrainAttempt = useCallback( (trainName: string) => { axios .post(`/faces/train/${trainName}/classify`, { training_file: image }) .then((resp) => { if (resp.status == 200) { toast.success(t("toast.success.trainedFace"), { position: "top-center", }); onRefresh(); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error(t("toast.error.trainFailed", { errorMessage }), { position: "top-center", }); }); }, [image, onRefresh, t], ); const onReprocess = useCallback(() => { axios .post(`/faces/reprocess`, { training_file: image }) .then((resp) => { if (resp.status == 200) { toast.success(t("toast.success.updatedFaceScore"), { position: "top-center", }); onRefresh(); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error(t("toast.error.updateFaceScoreFailed", { errorMessage }), { position: "top-center", }); }); }, [image, onRefresh, t]); return (
onClick(data, e.metaKey || e.ctrlKey)} />
{data.name}
= threshold ? "text-success" : "text-danger", )} > {data.score * 100}%
{t("trainFaceAs")} {faceNames.map((faceName) => ( onTrainAttempt(faceName)} > {faceName} ))} {t("trainFace")} onReprocess()} /> {t("button.reprocessFace")}
); } type FaceGridProps = { faceImages: string[]; pageToggle: string; onDelete: (name: string, ids: string[]) => void; }; function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) { return (
{faceImages.map((image: string) => ( ))}
); } type FaceImageProps = { name: string; image: string; onDelete: (name: string, ids: string[]) => void; }; function FaceImage({ name, image, onDelete }: FaceImageProps) { const { t } = useTranslation(["views/faceLibrary"]); return (
{name}
onDelete(name, [image])} /> {t("button.deleteFaceAttempts")}
); }