* face library i18n fixes

* face library i18n fixes

* add ability to use ctrl/cmd S to save in the config editor

* Use datetime as ID

* Update metrics inference speed to start with 0 ms

* fix android formatted thumbnail

* ensure role is comma separated and stripped correctly

* improve face library deletion

- add a confirmation dialog
- add ability to select all / delete faces in collections

* Implement lazy loading for video previews

* Force GPU for large embedding model

* GPU is required

* settings i18n fixes

* Don't delete train tab

* webpush debugging logs

* Fix incorrectly copying zones

* copy path data

* Ensure that cache dir exists for Frigate+

* face docs update

* Add description to upload image step to clarify the image

* Clean up

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins
2025-05-09 08:36:44 -05:00
committed by GitHub
parent 52d94231c7
commit 8094dd4075
27 changed files with 402 additions and 195 deletions

View File

@@ -128,13 +128,18 @@ export default function CreateFaceWizardDialog({
</TextEntry>
)}
{step == 1 && (
<ImageEntry onSave={onUploadImage}>
<div className="flex justify-end py-2">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
<>
<div className="px-8 py-2 text-center text-sm text-secondary-foreground">
{t("steps.description.uploadFace", { name })}
</div>
</ImageEntry>
<ImageEntry onSave={onUploadImage}>
<div className="flex justify-end py-2">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
</div>
</ImageEntry>
</>
)}
{step == 2 && (
<div className="mt-2">

View File

@@ -23,6 +23,7 @@ import {
import { useTranslation } from "react-i18next";
type PreviewPlayerProps = {
previewRef?: (ref: HTMLDivElement | null) => void;
className?: string;
camera: string;
timeRange: TimeRange;
@@ -30,16 +31,19 @@ type PreviewPlayerProps = {
startTime?: number;
isScrubbing: boolean;
forceAspect?: number;
isVisible?: boolean;
onControllerReady: (controller: PreviewController) => void;
onClick?: () => void;
};
export default function PreviewPlayer({
previewRef,
className,
camera,
timeRange,
cameraPreviews,
startTime,
isScrubbing,
isVisible = true,
onControllerReady,
onClick,
}: PreviewPlayerProps) {
@@ -54,6 +58,7 @@ export default function PreviewPlayer({
if (currentPreview) {
return (
<PreviewVideoPlayer
visibilityRef={previewRef}
className={className}
camera={camera}
timeRange={timeRange}
@@ -61,6 +66,7 @@ export default function PreviewPlayer({
initialPreview={currentPreview}
startTime={startTime}
isScrubbing={isScrubbing}
isVisible={isVisible}
currentHourFrame={currentHourFrame}
onControllerReady={onControllerReady}
onClick={onClick}
@@ -110,6 +116,7 @@ export abstract class PreviewController {
}
type PreviewVideoPlayerProps = {
visibilityRef?: (ref: HTMLDivElement | null) => void;
className?: string;
camera: string;
timeRange: TimeRange;
@@ -117,12 +124,14 @@ type PreviewVideoPlayerProps = {
initialPreview?: Preview;
startTime?: number;
isScrubbing: boolean;
isVisible: boolean;
currentHourFrame?: string;
onControllerReady: (controller: PreviewVideoController) => void;
onClick?: () => void;
setCurrentHourFrame: (src: string | undefined) => void;
};
function PreviewVideoPlayer({
visibilityRef,
className,
camera,
timeRange,
@@ -130,6 +139,7 @@ function PreviewVideoPlayer({
initialPreview,
startTime,
isScrubbing,
isVisible,
currentHourFrame,
onControllerReady,
onClick,
@@ -267,11 +277,13 @@ function PreviewVideoPlayer({
return (
<div
ref={visibilityRef}
className={cn(
"relative flex w-full justify-center overflow-hidden rounded-lg bg-black md:rounded-2xl",
onClick && "cursor-pointer",
className,
)}
data-camera={camera}
onClick={onClick}
>
<img
@@ -286,45 +298,48 @@ function PreviewVideoPlayer({
previewRef.current?.load();
}}
/>
<video
ref={previewRef}
className={`absolute size-full ${currentHourFrame ? "invisible" : "visible"}`}
preload="auto"
autoPlay
playsInline
muted
disableRemotePlayback
onSeeked={onPreviewSeeked}
onLoadedData={() => {
if (firstLoad) {
setFirstLoad(false);
}
if (controller) {
controller.previewReady();
} else {
previewRef.current?.pause();
}
if (previewRef.current) {
setVideoSize([
previewRef.current.videoWidth,
previewRef.current.videoHeight,
]);
if (startTime && currentPreview) {
previewRef.current.currentTime = startTime - currentPreview.start;
{isVisible && (
<video
ref={previewRef}
className={`absolute size-full ${currentHourFrame ? "invisible" : "visible"}`}
preload="auto"
autoPlay
playsInline
muted
disableRemotePlayback
onSeeked={onPreviewSeeked}
onLoadedData={() => {
if (firstLoad) {
setFirstLoad(false);
}
}
}}
>
{currentPreview != undefined && (
<source
src={`${baseUrl}${currentPreview.src.substring(1)}`}
type={currentPreview.type}
/>
)}
</video>
if (controller) {
controller.previewReady();
} else {
previewRef.current?.pause();
}
if (previewRef.current) {
setVideoSize([
previewRef.current.videoWidth,
previewRef.current.videoHeight,
]);
if (startTime && currentPreview) {
previewRef.current.currentTime =
startTime - currentPreview.start;
}
}
}}
>
{currentPreview != undefined && (
<source
src={`${baseUrl}${currentPreview.src.substring(1)}`}
type={currentPreview.type}
/>
)}
</video>
)}
{cameraPreviews && !currentPreview && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl">
{t("noPreviewFoundFor", { camera: camera.replaceAll("_", " ") })}

View File

@@ -143,6 +143,12 @@ function ConfigEditor() {
scrollBeyondLastLine: false,
theme: (systemTheme || theme) == "dark" ? "vs-dark" : "vs-light",
});
editorRef.current?.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
() => {
onHandleSaveConfig("saveonly");
},
);
} else if (editorRef.current) {
editorRef.current.setModel(modelRef.current);
}
@@ -158,7 +164,7 @@ function ConfigEditor() {
}
schemaConfiguredRef.current = false;
};
}, [config, apiHost, systemTheme, theme]);
}, [config, apiHost, systemTheme, theme, onHandleSaveConfig]);
// monitoring state

View File

@@ -6,7 +6,17 @@ import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizard
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
import FaceSelectionDialog from "@/components/overlay/FaceSelectionDialog";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
@@ -44,7 +54,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
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 { Trans, useTranslation } from "react-i18next";
import {
LuFolderCheck,
LuImagePlus,
@@ -165,6 +175,11 @@ export default function FaceLibrary() {
[selectedFaces, setSelectedFaces],
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState<{
name: string;
ids: string[];
} | null>(null);
const onDelete = useCallback(
(name: string, ids: string[], isName: boolean = false) => {
axios
@@ -191,7 +206,7 @@ export default function FaceLibrary() {
if (faceImages.length == 1) {
// face has been deleted
setPageToggle("");
setPageToggle("train");
}
refreshFaces();
@@ -244,29 +259,32 @@ export default function FaceLibrary() {
// keyboard
useKeyboardListener(
page === "train" ? ["a", "Escape"] : [],
(key, modifiers) => {
if (modifiers.repeat || !modifiers.down) {
return;
}
useKeyboardListener(["a", "Escape"], (key, modifiers) => {
if (modifiers.repeat || !modifiers.down) {
return;
}
switch (key) {
case "a":
if (modifiers.ctrl) {
if (selectedFaces.length) {
setSelectedFaces([]);
} else {
setSelectedFaces([...trainImages]);
}
switch (key) {
case "a":
if (modifiers.ctrl) {
if (selectedFaces.length) {
setSelectedFaces([]);
} else {
setSelectedFaces([
...(pageToggle === "train" ? trainImages : faceImages),
]);
}
break;
case "Escape":
setSelectedFaces([]);
break;
}
},
);
}
break;
case "Escape":
setSelectedFaces([]);
break;
}
});
useEffect(() => {
setSelectedFaces([]);
}, [pageToggle]);
if (!config) {
return <ActivityIndicator />;
@@ -276,6 +294,41 @@ export default function FaceLibrary() {
<div className="flex size-full flex-col p-2">
<Toaster />
<AlertDialog
open={!!deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteFaceAttempts.title")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Trans
ns="views/faceLibrary"
values={{ count: deleteDialogOpen?.ids.length }}
>
deleteFaceAttempts.desc
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={() => {
if (deleteDialogOpen) {
onDelete(deleteDialogOpen.name, deleteDialogOpen.ids);
setDeleteDialogOpen(null);
}
}}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<UploadImageDialog
open={upload}
title={t("uploadFaceImage.title")}
@@ -314,7 +367,9 @@ export default function FaceLibrary() {
</div>
<Button
className="flex gap-2"
onClick={() => onDelete("train", selectedFaces)}
onClick={() =>
setDeleteDialogOpen({ name: pageToggle, ids: selectedFaces })
}
>
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
{isDesktop && t("button.deleteFaceAttempts")}
@@ -335,7 +390,13 @@ export default function FaceLibrary() {
</div>
)}
</div>
{pageToggle &&
{pageToggle && faceImages.length === 0 && pageToggle !== "train" ? (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderCheck className="size-16" />
No faces available
</div>
) : (
pageToggle &&
(pageToggle == "train" ? (
<TrainingGrid
config={config}
@@ -349,9 +410,12 @@ export default function FaceLibrary() {
<FaceGrid
faceImages={faceImages}
pageToggle={pageToggle}
selectedFaces={selectedFaces}
onClickFaces={onClickFaces}
onDelete={onDelete}
/>
))}
))
)}
</div>
);
}
@@ -443,7 +507,7 @@ function LibrarySelector({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="flex justify-between smart-capitalize">
{pageToggle || t("selectFace")}
{pageToggle == "train" ? t("train.title") : pageToggle}
<span className="ml-2 text-primary-variant">
({(pageToggle && faceData?.[pageToggle]?.length) || 0})
</span>
@@ -467,7 +531,7 @@ function LibrarySelector({
<>
<DropdownMenuSeparator />
<div className="mb-1 ml-1.5 text-xs text-secondary-foreground">
Collections
{t("collections")}
</div>
</>
)}
@@ -644,7 +708,7 @@ function TrainingGrid({
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">{t("details.person")}</div>
<div className="text-sm smart-capitalize">
{selectedEvent?.sub_label ?? "Unknown"}
{selectedEvent?.sub_label ?? t("details.unknown")}
</div>
</div>
{selectedEvent?.data.sub_label_score && (
@@ -793,7 +857,7 @@ function FaceAttemptGroup({
Person
{event?.sub_label
? `: ${event.sub_label} (${Math.round((event.data.sub_label_score || 0) * 100)}%)`
: ": Unknown"}
: ": " + t("details.unknown")}
</div>
{event && (
<Tooltip>
@@ -968,7 +1032,9 @@ function FaceAttempt({
<div className="select-none p-2">
<div className="flex w-full flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start text-xs text-primary-variant">
<div className="smart-capitalize">{data.name}</div>
<div className="smart-capitalize">
{data.name == "unknown" ? t("details.unknown") : data.name}
</div>
<div
className={cn(
"",
@@ -1007,16 +1073,36 @@ function FaceAttempt({
type FaceGridProps = {
faceImages: string[];
pageToggle: string;
selectedFaces: string[];
onClickFaces: (images: string[], ctrl: boolean) => void;
onDelete: (name: string, ids: string[]) => void;
};
function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
const sortedFaces = useMemo(() => faceImages.sort().reverse(), [faceImages]);
function FaceGrid({
faceImages,
pageToggle,
selectedFaces,
onClickFaces,
onDelete,
}: FaceGridProps) {
const sortedFaces = useMemo(
() => (faceImages || []).sort().reverse(),
[faceImages],
);
if (sortedFaces.length === 0) {
return (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderCheck className="size-16" />
No faces available
</div>
);
}
return (
<div
className={cn(
"scrollbar-container gap-2 overflow-y-scroll",
isDesktop ? "flex flex-wrap" : "grid grid-cols-2",
"scrollbar-container gap-2 overflow-y-scroll p-1",
isDesktop ? "flex flex-wrap" : "grid grid-cols-2 md:grid-cols-4",
)}
>
{sortedFaces.map((image: string) => (
@@ -1024,6 +1110,8 @@ function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
key={image}
name={pageToggle}
image={image}
selected={selectedFaces.includes(image)}
onClickFaces={onClickFaces}
onDelete={onDelete}
/>
))}
@@ -1034,22 +1122,44 @@ function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
type FaceImageProps = {
name: string;
image: string;
selected: boolean;
onClickFaces: (images: string[], ctrl: boolean) => void;
onDelete: (name: string, ids: string[]) => void;
};
function FaceImage({ name, image, onDelete }: FaceImageProps) {
function FaceImage({
name,
image,
selected,
onClickFaces,
onDelete,
}: FaceImageProps) {
const { t } = useTranslation(["views/faceLibrary"]);
return (
<div className="relative flex flex-col rounded-lg">
<div
className={cn(
"flex cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]",
selected
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
onClick={(e) => {
e.stopPropagation();
onClickFaces([image], e.ctrlKey || e.metaKey);
}}
>
<div
className={cn(
"w-full overflow-hidden rounded-t-lg *:text-card-foreground",
"w-full overflow-hidden p-2 *:text-card-foreground",
isMobile && "flex justify-center",
)}
>
<img className="h-40" src={`${baseUrl}clips/faces/${name}/${image}`} />
<img
className="h-40 rounded-lg"
src={`${baseUrl}clips/faces/${name}/${image}`}
/>
</div>
<div className="rounded-b-lg bg-card p-2">
<div className="rounded-b-lg bg-card p-3">
<div className="flex w-full flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start text-xs text-primary-variant">
<div className="smart-capitalize">{name}</div>
@@ -1059,7 +1169,10 @@ function FaceImage({ name, image, onDelete }: FaceImageProps) {
<TooltipTrigger>
<LuTrash2
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={() => onDelete(name, [image])}
onClick={(e) => {
e.stopPropagation();
onDelete(name, [image]);
}}
/>
</TooltipTrigger>
<TooltipContent>{t("button.deleteFaceAttempts")}</TooltipContent>

View File

@@ -385,6 +385,55 @@ export function RecordingView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewRowRef.current?.scrollWidth, previewRowRef.current?.scrollHeight]);
// visibility listener for lazy loading
const [visiblePreviews, setVisiblePreviews] = useState<string[]>([]);
const visiblePreviewObserver = useRef<IntersectionObserver | null>(null);
useEffect(() => {
const visibleCameras = new Set<string>();
visiblePreviewObserver.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const camera = (entry.target as HTMLElement).dataset.camera;
if (!camera) {
return;
}
if (entry.isIntersecting) {
visibleCameras.add(camera);
} else {
visibleCameras.delete(camera);
}
setVisiblePreviews([...visibleCameras]);
});
},
{ threshold: 0.1 },
);
return () => {
visiblePreviewObserver.current?.disconnect();
};
}, []);
const previewRef = useCallback(
(node: HTMLElement | null) => {
if (!visiblePreviewObserver.current) {
return;
}
try {
if (node) visiblePreviewObserver.current.observe(node);
} catch (e) {
// no op
}
},
// we need to listen on the value of the ref
// eslint-disable-next-line react-hooks/exhaustive-deps
[visiblePreviewObserver.current],
);
return (
<div ref={contentRef} className="flex size-full flex-col pt-2">
<Toaster closeButton={true} />
@@ -631,12 +680,14 @@ export function RecordingView({
}}
>
<PreviewPlayer
previewRef={previewRef}
className="size-full"
camera={cam}
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTime={startTime}
isScrubbing={scrubbing}
isVisible={visiblePreviews.includes(cam)}
onControllerReady={(controller) => {
previewRefs.current[cam] = controller;
controller.scrubToTimestamp(startTime);

View File

@@ -230,7 +230,9 @@ export default function CameraSettingsView({
if (changedValue) {
addMessage(
"camera_settings",
`Unsaved review classification settings for ${capitalizeFirstLetter(selectedCamera)}`,
t("camera.reviewClassification.unsavedChanges", {
camera: selectedCamera,
}),
undefined,
`review_classification_settings_${selectedCamera}`,
);

View File

@@ -220,7 +220,7 @@ export default function ClassificationSettingsView({
if (changedValue) {
addMessage(
"search_settings",
`Unsaved Classification settings changes`,
t("classification.unsavedChanges"),
undefined,
"search_settings",
);

View File

@@ -176,7 +176,7 @@ export default function FrigatePlusSettingsView({
if (changedValue) {
addMessage(
"plus_settings",
`Unsaved Frigate+ settings changes`,
t("frigatePlus.unsavedChanges"),
undefined,
"plus_settings",
);

View File

@@ -167,7 +167,7 @@ export default function MotionTunerView({
if (changedValue) {
addMessage(
"motion_tuner",
`Unsaved motion tuner changes (${selectedCamera})`,
t("motionDetectionTuner.unsavedChanges", { camera: selectedCamera }),
undefined,
`motion_tuner_${selectedCamera}`,
);

View File

@@ -105,7 +105,7 @@ export default function NotificationView({
if (changedValue) {
addMessage(
"notification_settings",
`Unsaved notification settings`,
t("notification.unsavedChanges"),
undefined,
`notification_settings`,
);
@@ -128,7 +128,7 @@ export default function NotificationView({
if (registration) {
addMessage(
"notification_settings",
"Unsaved Notification Registrations",
t("notification.unsavedRegistrations"),
undefined,
"registration",
);