Merge branch 'blakeblackshear:dev' into dev

This commit is contained in:
GuoQing Liu 2025-03-18 16:04:34 +08:00 committed by GitHub
commit 07622b9502
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1010 additions and 256 deletions

View File

@ -79,6 +79,11 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null
fi
# build templates for optional FRIGATE_BASE_PATH environment variable
python3 /usr/local/nginx/get_base_path.py | \
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
-out /usr/local/nginx/conf/base_path.conf
# build templates for optional TLS support
python3 /usr/local/nginx/get_tls_settings.py | \
tempio -template /usr/local/nginx/templates/listen.gotmpl \

View File

@ -96,6 +96,7 @@ http {
gzip_types application/vnd.apple.mpegurl;
include auth_location.conf;
include base_path.conf;
location /vod/ {
include auth_request.conf;
@ -299,6 +300,18 @@ http {
add_header Cache-Control "public";
}
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
access_log off;
expires 1y;
add_header Cache-Control "public";
default_type application/json;
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types application/json;
sub_filter '"start_url": "/"' '"start_url" : "$http_x_ingress_path"';
sub_filter '"src": "/' '"src": "$http_x_ingress_path/';
}
sub_filter 'href="/BASE_PATH/' 'href="$http_x_ingress_path/';
sub_filter 'url(/BASE_PATH/' 'url($http_x_ingress_path/';
sub_filter '"/BASE_PATH/dist/' '"$http_x_ingress_path/dist/';

View File

@ -0,0 +1,10 @@
"""Prints the base path as json to stdout."""
import json
import os
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
result: dict[str, any] = {"base_path": base_path}
print(json.dumps(result))

View File

@ -0,0 +1,19 @@
{{ if .base_path }}
location = {{ .base_path }} {
return 302 {{ .base_path }}/;
}
location ^~ {{ .base_path }}/ {
# remove base_url from the path before passing upstream
rewrite ^{{ .base_path }}/(.*) /$1 break;
proxy_pass $scheme://127.0.0.1:8971;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Ingress-Path {{ .base_path }};
access_log off;
}
{{ end }}

View File

@ -172,6 +172,38 @@ listen [::]:8971 ipv6only=off ssl;
listen [::]:5000 ipv6only=off;
```
## Base path
By default, Frigate runs at the root path (`/`). However some setups require to run Frigate under a custom path prefix (e.g. `/frigate`), especially when Frigate is located behind a reverse proxy that requires path-based routing.
### Set Base Path via HTTP Header
The preferred way to configure the base path is through the `X-Ingress-Path` HTTP header, which needs to be set to the desired base path in an upstream reverse proxy.
For example, in Nginx:
```
location /frigate {
proxy_set_header X-Ingress-Path /frigate;
proxy_pass http://frigate_backend;
}
```
### Set Base Path via Environment Variable
When it is not feasible to set the base path via a HTTP header, it can also be set via the `FRIGATE_BASE_PATH` environment variable in the Docker Compose file.
For example:
```
services:
frigate:
image: blakeblackshear/frigate:latest
environment:
- FRIGATE_BASE_PATH=/frigate
```
This can be used for example to access Frigate via a Tailscale agent (https), by simply forwarding all requests to the base path (http):
```
tailscale serve --https=443 --bg --set-path /frigate http://localhost:5000/frigate
```
## Custom Dependencies
### Custom ffmpeg build

View File

@ -22,3 +22,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
### Can I keep using my Frigate+ models even if I do not renew my subscription?
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
### Why can't I submit images to Frigate+?
If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that you've enabled both snapshots and `clean_copy` snapshots for the cameras you'd like to submit images for. Note that `clean_copy` is enabled by default when snapshots are enabled.
```yaml
snapshots:
enabled: true
clean_copy: true
```

View File

@ -9,6 +9,7 @@ import traceback
from datetime import datetime, timedelta
from functools import reduce
from io import StringIO
from pathlib import Path as FilePath
from typing import Any, Optional
import aiofiles
@ -174,6 +175,22 @@ def config(request: Request):
config["model"]["all_attributes"] = config_obj.model.all_attributes
config["model"]["non_logo_attributes"] = config_obj.model.non_logo_attributes
# Add model plus data if plus is enabled
if config["plus"]["enabled"]:
model_path = config.get("model", {}).get("path")
if model_path:
model_json_path = FilePath(model_path).with_suffix(".json")
try:
with open(model_json_path, "r") as f:
model_plus_data = json.load(f)
config["model"]["plus"] = model_plus_data
except FileNotFoundError:
config["model"]["plus"] = None
except json.JSONDecodeError:
config["model"]["plus"] = None
else:
config["model"]["plus"] = None
# use merged labelamp
for detector_config in config["detectors"].values():
detector_config["model"]["labelmap"] = (

View File

@ -6,6 +6,7 @@ import random
import shutil
import string
import cv2
from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filename
@ -14,9 +15,11 @@ from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.config.camera import DetectConfig
from frigate.const import FACE_DIR
from frigate.embeddings import EmbeddingsContext
from frigate.models import Event
from frigate.util.path import get_event_snapshot
logger = logging.getLogger(__name__)
@ -87,16 +90,27 @@ def train_face(request: Request, name: str, body: dict = None):
)
json: dict[str, any] = body or {}
training_file = os.path.join(
FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}"
)
training_file_name = sanitize_filename(json.get("training_file", ""))
training_file = os.path.join(FACE_DIR, f"train/{training_file_name}")
event_id = json.get("event_id")
if not training_file or not os.path.isfile(training_file):
if not training_file_name and not event_id:
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid filename or no file exists: {training_file}",
"message": "A training file or event_id must be passed.",
}
),
status_code=400,
)
if training_file_name and not os.path.isfile(training_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid filename or no file exists: {training_file_name}",
}
),
status_code=404,
@ -106,7 +120,36 @@ def train_face(request: Request, name: str, body: dict = None):
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
new_name = f"{sanitized_name}-{rand_id}.webp"
new_file = os.path.join(FACE_DIR, f"{sanitized_name}/{new_name}")
shutil.move(training_file, new_file)
if training_file_name:
shutil.move(training_file, new_file)
else:
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid event_id or no event exists: {event_id}",
}
),
status_code=404,
)
snapshot = get_event_snapshot(event)
face_box = event.data["attributes"][0]["box"]
detect_config: DetectConfig = request.app.frigate_config.cameras[
event.camera
].detect
# crop onto the face box minus the bounding box itself
x1 = int(face_box[0] * detect_config.width) + 2
y1 = int(face_box[1] * detect_config.height) + 2
x2 = x1 + int(face_box[2] * detect_config.width) - 4
y2 = y1 + int(face_box[3] * detect_config.height) - 4
face = snapshot[y1:y2, x1:x2]
cv2.imwrite(new_file, face)
context: EmbeddingsContext = request.app.embeddings
context.clear_face_classifier()
@ -115,7 +158,7 @@ def train_face(request: Request, name: str, body: dict = None):
content=(
{
"success": True,
"message": f"Successfully saved {training_file} as {new_name}.",
"message": f"Successfully saved {training_file_name} as {new_name}.",
}
),
status_code=200,

View File

@ -701,6 +701,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
for k, v in event["data"].items()
if k
in [
"attributes",
"type",
"score",
"top_score",

View File

@ -27,6 +27,8 @@ from .api import RealTimeProcessorApi
logger = logging.getLogger(__name__)
MAX_DETECTION_HEIGHT = 1080
MAX_FACE_ATTEMPTS = 100
MIN_MATCHING_FACES = 2
@ -88,7 +90,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"),
config="",
input_size=(320, 320),
score_threshold=self.face_config.detection_threshold,
score_threshold=0.5,
nms_threshold=0.3,
)
self.landmark_detector = cv2.face.createFacemarkLBF()
@ -212,11 +214,23 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
self.face_recognizer = None
self.label_map = {}
def __detect_face(self, input: np.ndarray) -> tuple[int, int, int, int]:
def __detect_face(
self, input: np.ndarray, threshold: float
) -> tuple[int, int, int, int]:
"""Detect faces in input image."""
if not self.face_detector:
return None
# YN face detector fails at extreme definitions
# this rescales to a size that can properly detect faces
# still retaining plenty of detail
if input.shape[0] > MAX_DETECTION_HEIGHT:
scale_factor = MAX_DETECTION_HEIGHT / input.shape[0]
new_width = int(scale_factor * input.shape[1])
input = cv2.resize(input, (new_width, MAX_DETECTION_HEIGHT))
else:
scale_factor = 1
self.face_detector.setInputSize((input.shape[1], input.shape[0]))
faces = self.face_detector.detect(input)
@ -226,11 +240,14 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
face = None
for _, potential_face in enumerate(faces[1]):
if potential_face[-1] < threshold:
continue
raw_bbox = potential_face[0:4].astype(np.uint16)
x: int = max(raw_bbox[0], 0)
y: int = max(raw_bbox[1], 0)
w: int = raw_bbox[2]
h: int = raw_bbox[3]
x: int = int(max(raw_bbox[0], 0) / scale_factor)
y: int = int(max(raw_bbox[1], 0) / scale_factor)
w: int = int(raw_bbox[2] / scale_factor)
h: int = int(raw_bbox[3] / scale_factor)
bbox = (x, y, x + w, y + h)
if face is None or area(bbox) > area(face):
@ -300,7 +317,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
left, top, right, bottom = person_box
person = rgb[top:bottom, left:right]
face_box = self.__detect_face(person)
face_box = self.__detect_face(person, self.face_config.detection_threshold)
if not face_box:
logger.debug("Detected no faces for person object.")
@ -406,7 +423,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
),
cv2.IMREAD_COLOR,
)
face_box = self.__detect_face(img)
# detect faces with lower confidence since we expect the face
# to be visible in uploaded images
face_box = self.__detect_face(img, 0.5)
if not face_box:
return {
@ -463,6 +483,16 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
)
shutil.move(current_file, new_file)
files = sorted(
os.listdir(folder),
key=lambda f: os.path.getctime(os.path.join(folder, f)),
reverse=True,
)
# delete oldest face image if maximum is reached
if len(files) > MAX_FACE_ATTEMPTS:
os.unlink(os.path.join(folder, files[-1]))
def expire_object(self, object_id: str):
if object_id in self.detected_faces:
self.detected_faces.pop(object_id)

View File

@ -4,6 +4,9 @@ import base64
import os
from pathlib import Path
import cv2
from numpy import ndarray
from frigate.const import CLIPS_DIR, THUMB_DIR
from frigate.models import Event
@ -21,6 +24,11 @@ def get_event_thumbnail_bytes(event: Event) -> bytes | None:
return None
def get_event_snapshot(event: Event) -> ndarray:
media_name = f"{event.camera}-{event.id}"
return cv2.imread(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
### Deletion

View File

@ -64,6 +64,7 @@
"button": {
"apply": "Apply",
"reset": "Reset",
"done": "Done",
"enabled": "Enabled",
"enable": "Enable",
"disabled": "Disabled",
@ -94,7 +95,8 @@
"play": "Play",
"unselect": "Unselect",
"export": "Export",
"deleteNow": "Delete Now"
"deleteNow": "Delete Now",
"next": "Next"
},
"menu": {
"system": "System",

View File

@ -1,4 +1,7 @@
{
"description": {
"addFace": "Walk through adding a new face to the Face Library."
},
"documentTitle": "Face Library - Frigate",
"uploadFaceImage": {
"title": "Upload Face Image",
@ -6,7 +9,8 @@
},
"createFaceLibrary": {
"title": "Create Face Library",
"desc": "Create a new face library"
"desc": "Create a new face library",
"nextSteps": "It is recommended to use the Train tab to select and train images for each person as they are detected. When building a strong foundation it is strongly recommended to only train on images that are straight-on. Ignore images from cameras that recognize faces from an angle."
},
"train": {
"title": "Train",
@ -19,12 +23,13 @@
"uploadImage": "Upload Image",
"reprocessFace": "Reprocess Face"
},
"readTheDocs": "Read the documentation to view more details on refining images for the Face Library",
"trainFaceAs": "Train Face as:",
"trainFaceAsPerson": "Train Face as Person",
"trainFace": "Train Face",
"toast": {
"success": {
"uploadedImage": "Successfully uploaded image.",
"addFaceLibrary": "Successfully add face library.",
"addFaceLibrary": "{{name}} has successfully been added to the Face Library!",
"deletedFace": "Successfully deleted face.",
"trainedFace": "Successfully trained face.",
"updatedFaceScore": "Successfully updated face score."

View File

@ -7,7 +7,8 @@
"masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate",
"object": "Object Settings - Frigate",
"general": "General Settings - Frigate"
"general": "General Settings - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate"
},
"menu": {
"uiSettings": "UI Settings",
@ -17,7 +18,8 @@
"motionTuner": "Motion Tuner",
"debug": "Debug",
"users": "Users",
"notifications": "Notifications"
"notifications": "Notifications",
"frigateplus": "Frigate+"
},
"dialog": {
"unsavedChanges": {
@ -515,5 +517,36 @@
"registerFailed": "Failed to save notification registration."
}
}
},
"frigatePlus": {
"title": "Frigate+ Settings",
"apiKey": {
"title": "Frigate+ API Key",
"validated": "Frigate+ API key is detected and validated",
"notValidated": "Frigate+ API key is not detected or not validated",
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
"plusLink": "Read more about Frigate+"
},
"snapshotConfig": {
"title": "Snapshot Configuration",
"desc": "Submitting to Frigate+ requires both snapshots and <code>clean_copy</code> snapshots to be enabled in your config.",
"documentation": "Read the documentation",
"cleanCopyWarning": "Some cameras have snapshots enabled but have the clean copy disabled. You need to enable <code>clean_copy</code> in your snapshot config to be able to submit images from these cameras to Frigate+.",
"table": {
"camera": "Camera",
"snapshots": "Snapshots",
"cleanCopySnapshots": "<code>clean_copy</code> Snapshots"
}
},
"modelInfo": {
"title": "Model Information",
"modelType": "Model Type",
"trainDate": "Train Date",
"baseModel": "Base Model",
"supportedDetectors": "Supported Detectors",
"cameras": "Cameras",
"loading": "Loading model information...",
"error": "Failed to load model information"
}
}
}

View File

@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
type StepIndicatorProps = {
steps: string[];
currentStep: number;
};
export default function StepIndicator({
steps,
currentStep,
}: StepIndicatorProps) {
return (
<div className="flex flex-row justify-evenly">
{steps.map((name, idx) => (
<div className="flex flex-col items-center gap-2">
<div
className={cn(
"flex size-16 items-center justify-center rounded-full",
currentStep == idx ? "bg-selected" : "border-2 border-selected",
)}
>
{idx + 1}
</div>
<div className="w-24 text-center md:w-24">{name}</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,58 @@
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useCallback } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
type ImageEntryProps = {
onSave: (file: File) => void;
children?: React.ReactNode;
};
export default function ImageEntry({ onSave, children }: ImageEntryProps) {
const formSchema = z.object({
file: z.instanceof(FileList, { message: "Please select an image file." }),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
});
const fileRef = form.register("file");
// upload handler
const onSubmit = useCallback(
(data: z.infer<typeof formSchema>) => {
if (!data["file"] || Object.keys(data.file).length == 0) {
return;
}
onSave(data["file"]["0"]);
},
[onSave],
);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="file"
render={() => (
<FormItem>
<FormControl>
<Input
className="aspect-video h-40 w-full"
type="file"
{...fileRef}
/>
</FormControl>
</FormItem>
)}
/>
{children}
</form>
</Form>
);
}

View File

@ -0,0 +1,68 @@
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useCallback } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
type TextEntryProps = {
defaultValue?: string;
placeholder?: string;
allowEmpty?: boolean;
onSave: (text: string) => void;
children?: React.ReactNode;
};
export default function TextEntry({
defaultValue,
placeholder,
allowEmpty,
onSave,
children,
}: TextEntryProps) {
const formSchema = z.object({
text: z.string(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { text: defaultValue },
});
const fileRef = form.register("text");
// upload handler
const onSubmit = useCallback(
(data: z.infer<typeof formSchema>) => {
if (!allowEmpty && !data["text"]) {
return;
}
onSave(data["text"]);
},
[onSave, allowEmpty],
);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="text"
render={() => (
<FormItem>
<FormControl>
<Input
className="aspect-video h-8 w-full"
placeholder={placeholder}
type="text"
{...fileRef}
/>
</FormControl>
</FormItem>
)}
/>
{children}
</form>
</Form>
);
}

View File

@ -0,0 +1,168 @@
import StepIndicator from "@/components/indicators/StepIndicator";
import ImageEntry from "@/components/input/ImageEntry";
import TextEntry from "@/components/input/TextEntry";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import axios from "axios";
import { useCallback, useState } from "react";
import { isDesktop } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import { Link } from "react-router-dom";
import { toast } from "sonner";
const STEPS = ["Enter Face Name", "Upload Face Image", "Next Steps"];
type CreateFaceWizardDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
onFinish: () => void;
};
export default function CreateFaceWizardDialog({
open,
setOpen,
onFinish,
}: CreateFaceWizardDialogProps) {
const { t } = useTranslation("views/faceLibrary");
// wizard
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const handleReset = useCallback(() => {
setStep(0);
setName("");
setOpen(false);
}, [setOpen]);
// data handling
const onUploadImage = useCallback(
(file: File) => {
const formData = new FormData();
formData.append("file", file);
axios
.post(`faces/${name}/register`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((resp) => {
if (resp.status == 200) {
setStep(2);
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",
});
});
},
[name, t],
);
// layout
const Overlay = isDesktop ? Dialog : MobilePage;
const Content = isDesktop ? DialogContent : MobilePageContent;
const Header = isDesktop ? DialogHeader : MobilePageHeader;
const Title = isDesktop ? DialogTitle : MobilePageTitle;
const Description = isDesktop ? DialogDescription : MobilePageDescription;
return (
<Overlay
open={open}
onOpenChange={(open) => {
if (!open) {
handleReset();
}
}}
>
<Content
className={cn("flex flex-col gap-4", isDesktop ? "max-w-[50%]" : "p-4")}
>
<Header>
<Title>{t("button.addFace")}</Title>
{isDesktop && <Description>{t("description.addFace")}</Description>}
</Header>
<StepIndicator steps={STEPS} currentStep={step} />
{step == 0 && (
<TextEntry
placeholder="Enter Face Name"
onSave={(name) => {
setName(name);
setStep(1);
}}
>
<div className="flex justify-end py-2">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
</div>
</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>
</ImageEntry>
)}
{step == 2 && (
<div>
{t("toast.success.addFaceLibrary", { name })}
<p className="py-4 text-sm text-secondary-foreground">
{t("createFaceLibrary.nextSteps")}
</p>
<div className="text-s my-4 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/face_recognition"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocs")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
<div className="flex justify-end">
<Button
variant="select"
onClick={() => {
onFinish();
handleReset();
}}
>
{t("button.done", { ns: "common" })}
</Button>
</div>
</div>
)}
</Content>
</Overlay>
);
}

View File

@ -57,6 +57,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
@ -69,11 +70,12 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { LuInfo } from "react-icons/lu";
import { LuInfo, LuSearch } from "react-icons/lu";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { FaPencilAlt } from "react-icons/fa";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import { useTranslation } from "react-i18next";
import { TbFaceId } from "react-icons/tb";
const SEARCH_TABS = [
"details",
@ -99,7 +101,7 @@ export default function SearchDetailDialog({
setSimilarity,
setInputFocused,
}: SearchDetailDialogProps) {
const { t } = useTranslation(["views/explore"]);
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@ -555,6 +557,48 @@ function ObjectDetailsTab({
[search, apiHost, mutate, setSearch, t],
);
// face training
const hasFace = useMemo(() => {
if (!config?.face_recognition.enabled || !search) {
return false;
}
return search.data.attributes?.find((attr) => attr.label == "face");
}, [config, search]);
const { data: faceData } = useSWR(hasFace ? "faces" : null);
const faceNames = useMemo<string[]>(
() =>
faceData ? Object.keys(faceData).filter((face) => face != "train") : [],
[faceData],
);
const onTrainFace = useCallback(
(trainName: string) => {
axios
.post(`/faces/train/${trainName}/classify`, { event_id: search.id })
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.trainedFace"), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.trainFailed", { errorMessage }), {
position: "top-center",
});
});
},
[search, t],
);
return (
<div className="flex flex-col gap-5">
<div className="flex w-full flex-row">
@ -563,7 +607,7 @@ function ObjectDetailsTab({
<div className="text-sm text-primary/40">{t("details.label")}</div>
<div className="flex flex-row items-center gap-2 text-sm capitalize">
{getIconForLabel(search.label, "size-4 text-primary")}
{t("{search.label}", { ns: "objects" })}
{t(search.label, { ns: "objects" })}
{search.sub_label && ` (${search.sub_label})`}
<Tooltip>
<TooltipTrigger asChild>
@ -673,20 +717,53 @@ function ObjectDetailsTab({
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
{config?.semantic_search.enabled && search.data.type == "object" && (
<Button
aria-label={t("itemMenu.findSimilar.aria")}
onClick={() => {
setSearch(undefined);
<div className="flex w-full flex-row gap-2">
{config?.semantic_search.enabled &&
search.data.type == "object" && (
<Button
className="w-full"
aria-label={t("itemMenu.findSimilar.aria")}
onClick={() => {
setSearch(undefined);
if (setSimilarity) {
setSimilarity();
}
}}
>
{t("itemMenu.findSimilar.label")}
</Button>
)}
if (setSimilarity) {
setSimilarity();
}
}}
>
<div className="flex gap-1">
<LuSearch />
{t("itemMenu.findSimilar.label")}
</div>
</Button>
)}
{hasFace && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-full">
<div className="flex gap-1">
<TbFaceId />
{t("trainFace", { ns: "views/faceLibrary" })}
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>
{t("trainFaceAs", { ns: "views/faceLibrary" })}
</DropdownMenuLabel>
{faceNames.map((faceName) => (
<DropdownMenuItem
key={faceName}
className="cursor-pointer capitalize"
onClick={() => onTrainFace(faceName)}
>
{faceName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-1.5">

View File

@ -1,3 +1,4 @@
import TextEntry from "@/components/input/TextEntry";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -7,15 +8,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useEffect } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
type TextEntryDialogProps = {
open: boolean;
title: string;
@ -35,35 +29,7 @@ export default function TextEntryDialog({
defaultValue = "",
allowEmpty = false,
}: TextEntryDialogProps) {
const formSchema = z.object({
text: z.string(),
});
const { t } = useTranslation("components/dialog");
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { text: defaultValue },
});
const fileRef = form.register("text");
// upload handler
const onSubmit = useCallback(
(data: z.infer<typeof formSchema>) => {
if (!allowEmpty && !data["text"]) {
return;
}
onSave(data["text"]);
},
[onSave, allowEmpty],
);
useEffect(() => {
if (open) {
form.reset({ text: defaultValue });
}
}, [open, defaultValue, form]);
const { t } = useTranslation("common");
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
@ -72,33 +38,20 @@ export default function TextEntryDialog({
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="text"
render={() => (
<FormItem>
<FormControl>
<Input
className="aspect-video h-8 w-full"
type="text"
{...fileRef}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="pt-4">
<Button type="button" onClick={() => setOpen(false)}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button variant="select" type="submit">
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</form>
</Form>
<TextEntry
defaultValue={defaultValue}
allowEmpty={allowEmpty}
onSave={onSave}
>
<DialogFooter className="pt-4">
<Button type="button" onClick={() => setOpen(false)}>
{t("button.cancel")}
</Button>
<Button variant="select" type="submit">
{t("button.save")}
</Button>
</DialogFooter>
</TextEntry>
</DialogContent>
</Dialog>
);

View File

@ -1,3 +1,4 @@
import ImageEntry from "@/components/input/ImageEntry";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -7,12 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useTranslation } from "react-i18next";
type UploadImageDialogProps = {
open: boolean;
@ -28,27 +24,7 @@ export default function UploadImageDialog({
setOpen,
onSave,
}: UploadImageDialogProps) {
const formSchema = z.object({
file: z.instanceof(FileList, { message: "Please select an image file." }),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
});
const fileRef = form.register("file");
// upload handler
const onSubmit = useCallback(
(data: z.infer<typeof formSchema>) => {
if (!data["file"] || Object.keys(data.file).length == 0) {
return;
}
onSave(data["file"]["0"]);
},
[onSave],
);
const { t } = useTranslation("common");
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
@ -57,31 +33,14 @@ export default function UploadImageDialog({
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="file"
render={() => (
<FormItem>
<FormControl>
<Input
className="aspect-video h-40 w-full"
type="file"
{...fileRef}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="pt-4">
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="select" type="submit">
Save
</Button>
</DialogFooter>
</form>
</Form>
<ImageEntry onSave={onSave}>
<DialogFooter className="pt-4">
<Button onClick={() => setOpen(false)}>{t("button.cancel")}</Button>
<Button variant="select" type="submit">
{t("button.save")}
</Button>
</DialogFooter>
</ImageEntry>
</DialogContent>
</Dialog>
);

View File

@ -1,7 +1,8 @@
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 TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
import { Button } from "@/components/ui/button";
import {
@ -25,6 +26,7 @@ import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner";
@ -115,42 +117,16 @@ export default function FaceLibrary() {
[pageToggle, refreshFaces, t],
);
const onAddName = useCallback(
(name: string) => {
axios
.post(`faces/${name}/create`, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((resp) => {
if (resp.status == 200) {
setAddFace(false);
refreshFaces();
toast.success(t("toast.success.addFaceLibrary"), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.addFaceLibraryFailed", { errorMessage }), {
position: "top-center",
});
});
},
[refreshFaces, t],
);
// face multiselect
const [selectedFaces, setSelectedFaces] = useState<string[]>([]);
const onClickFace = useCallback(
(imageId: string) => {
(imageId: string, ctrl: boolean) => {
if (selectedFaces.length == 0 && !ctrl) {
return;
}
const index = selectedFaces.indexOf(imageId);
if (index != -1) {
@ -172,33 +148,42 @@ export default function FaceLibrary() {
[selectedFaces, setSelectedFaces],
);
const onDelete = useCallback(() => {
axios
.post(`/faces/train/delete`, { ids: selectedFaces })
.then((resp) => {
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"), {
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",
});
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",
});
});
}, [selectedFaces, refreshFaces, t]);
},
[faceImages, refreshFaces, setPageToggle, t],
);
// keyboard
useKeyboardListener(["a"], (key, modifiers) => {
useKeyboardListener(["a", "Escape"], (key, modifiers) => {
if (modifiers.repeat || !modifiers.down) {
return;
}
@ -209,6 +194,9 @@ export default function FaceLibrary() {
setSelectedFaces([...trainImages]);
}
break;
case "Escape":
setSelectedFaces([]);
break;
}
});
@ -228,12 +216,10 @@ export default function FaceLibrary() {
onSave={onUploadImage}
/>
<TextEntryDialog
title={t("createFaceLibrary.title")}
description={t("createFaceLibrary.desc")}
<CreateFaceWizardDialog
open={addFace}
setOpen={setAddFace}
onSave={onAddName}
onFinish={refreshFaces}
/>
<div className="relative mb-2 flex h-11 w-full items-center justify-between">
@ -283,21 +269,24 @@ export default function FaceLibrary() {
</ScrollArea>
{selectedFaces?.length > 0 ? (
<div className="flex items-center justify-center gap-2">
<Button className="flex gap-2" onClick={() => onDelete()}>
<Button
className="flex gap-2"
onClick={() => onDelete("train", selectedFaces)}
>
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
{t("button.deleteFaceAttempts")}
{isDesktop && t("button.deleteFaceAttempts")}
</Button>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
{t("button.addFace")}
{isDesktop && t("button.addFace")}
</Button>
{pageToggle != "train" && (
<Button className="flex gap-2" onClick={() => setUpload(true)}>
<LuImagePlus className="size-7 rounded-md p-1 text-secondary-foreground" />
{t("button.uploadImage")}
{isDesktop && t("button.uploadImage")}
</Button>
)}
</div>
@ -317,7 +306,7 @@ export default function FaceLibrary() {
<FaceGrid
faceImages={faceImages}
pageToggle={pageToggle}
onRefresh={refreshFaces}
onDelete={onDelete}
/>
))}
</div>
@ -329,7 +318,7 @@ type TrainingGridProps = {
attemptImages: string[];
faceNames: string[];
selectedFaces: string[];
onClickFace: (image: string) => void;
onClickFace: (image: string, ctrl: boolean) => void;
onRefresh: () => void;
};
function TrainingGrid({
@ -349,7 +338,7 @@ function TrainingGrid({
faceNames={faceNames}
threshold={config.face_recognition.recognition_threshold}
selected={selectedFaces.includes(image)}
onClick={() => onClickFace(image)}
onClick={(meta) => onClickFace(image, meta)}
onRefresh={onRefresh}
/>
))}
@ -362,7 +351,7 @@ type FaceAttemptProps = {
faceNames: string[];
threshold: number;
selected: boolean;
onClick: () => void;
onClick: (meta: boolean) => void;
onRefresh: () => void;
};
function FaceAttempt({
@ -378,6 +367,7 @@ function FaceAttempt({
const parts = image.split("-");
return {
timestamp: Number.parseFloat(parts[0]),
eventId: `${parts[0]}-${parts[1]}`,
name: parts[2],
score: parts[3],
@ -439,10 +429,13 @@ function FaceAttempt({
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
onClick={onClick}
onClick={(e) => onClick(e.metaKey || e.ctrlKey)}
>
<div className="w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground">
<img className="size-40" src={`${baseUrl}clips/faces/train/${image}`} />
<div className="relative w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground">
<img className="size-44" src={`${baseUrl}clips/faces/train/${image}`} />
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
<TimeAgo time={data.timestamp * 1000} dense />
</div>
</div>
<div className="rounded-b-lg bg-card p-2">
<div className="flex w-full flex-row items-center justify-between gap-2">
@ -479,7 +472,7 @@ function FaceAttempt({
))}
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>{t("trainFaceAsPerson")}</TooltipContent>
<TooltipContent>{t("trainFace")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
@ -500,9 +493,9 @@ function FaceAttempt({
type FaceGridProps = {
faceImages: string[];
pageToggle: string;
onRefresh: () => void;
onDelete: (name: string, ids: string[]) => void;
};
function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) {
function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
return (
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll">
{faceImages.map((image: string) => (
@ -510,7 +503,7 @@ function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) {
key={image}
name={pageToggle}
image={image}
onRefresh={onRefresh}
onDelete={onDelete}
/>
))}
</div>
@ -520,31 +513,10 @@ function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) {
type FaceImageProps = {
name: string;
image: string;
onRefresh: () => void;
onDelete: (name: string, ids: string[]) => void;
};
function FaceImage({ name, image, onRefresh }: FaceImageProps) {
function FaceImage({ name, image, onDelete }: FaceImageProps) {
const { t } = useTranslation(["views/faceLibrary"]);
const onDelete = useCallback(() => {
axios
.post(`/faces/${name}/delete`, { ids: [image] })
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.deletedFace"), {
position: "top-center",
});
onRefresh();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
});
}, [name, image, onRefresh, t]);
return (
<div className="relative flex flex-col rounded-lg">
@ -561,7 +533,7 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
<TooltipTrigger>
<LuTrash2
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={onDelete}
onClick={() => onDelete(name, [image])}
/>
</TooltipTrigger>
<TooltipContent>{t("button.deleteFaceAttempts")}</TooltipContent>

View File

@ -1,20 +1,24 @@
import { UserAuthForm } from "@/components/auth/AuthForm";
import Logo from "@/components/Logo";
import { ThemeProvider } from "@/context/theme-provider";
import "@/utils/i18n";
import { LanguageProvider } from "@/context/language-provider";
function LoginPage() {
return (
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<div className="size-full overflow-hidden">
<div className="p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col items-center space-y-2">
<Logo className="mb-6 h-8 w-8" />
<LanguageProvider>
<div className="size-full overflow-hidden">
<div className="p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col items-center space-y-2">
<Logo className="mb-6 h-8 w-8" />
</div>
<UserAuthForm />
</div>
<UserAuthForm />
</div>
</div>
</div>
</LanguageProvider>
</ThemeProvider>
);
}

View File

@ -37,6 +37,7 @@ import AuthenticationView from "@/views/settings/AuthenticationView";
import NotificationView from "@/views/settings/NotificationsSettingsView";
import ClassificationSettingsView from "@/views/settings/ClassificationSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
@ -54,6 +55,7 @@ const allSettingsViews = [
"debug",
"users",
"notifications",
"frigateplus",
] as const;
type SettingsType = (typeof allSettingsViews)[number];
@ -279,6 +281,7 @@ export default function Settings() {
{page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} />
)}
{page == "frigateplus" && <FrigatePlusSettingsView />}
</div>
{confirmationDialogOpen && (
<AlertDialog

View File

@ -391,6 +391,12 @@ export interface FrigateConfig {
colormap: { [key: string]: [number, number, number] };
attributes_map: { [key: string]: [string] };
all_attributes: [string];
plus?: {
name: string;
trainDate: string;
baseModel: string;
supportedDetectors: string[];
};
};
motion: Record<string, unknown> | null;

View File

@ -50,6 +50,7 @@ export type SearchResult = {
score: number;
sub_label_score?: number;
region: number[];
attributes?: [{ box: number[]; label: string; score: number }];
box: number[];
area: number;
ratio: number;

View File

@ -0,0 +1,229 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import { useEffect } from "react";
import { Toaster } from "sonner";
import { Separator } from "../../components/ui/separator";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { CheckCircle2, XCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { IoIosWarning } from "react-icons/io";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
export default function FrigatePlusSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation("views/settings");
useEffect(() => {
document.title = t("documentTitle.frigatePlus");
}, [t]);
const needCleanSnapshots = () => {
if (!config) {
return false;
}
return Object.values(config.cameras).some(
(camera) => camera.snapshots.enabled && !camera.snapshots.clean_copy,
);
};
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
{t("frigatePlus.title")}
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("frigatePlus.apiKey.title")}
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<Label>
{config?.plus?.enabled
? t("frigatePlus.apiKey.validated")
: t("frigatePlus.apiKey.notValidated")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("frigatePlus.apiKey.desc")}</p>
{!config?.model.plus && (
<>
<div className="mt-2 flex items-center text-primary-variant">
<Link
to="https://frigate.video/plus"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("frigatePlus.apiKey.plusLink")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</>
)}
</div>
</div>
{config?.model.plus && (
<>
<Separator className="my-2 flex bg-secondary" />
<div className="mt-2 max-w-2xl">
<Heading as="h4" className="my-2">
{t("frigatePlus.modelInfo.title")}
</Heading>
<div className="mt-2 space-y-3">
{!config?.model?.plus && (
<p className="text-muted-foreground">
{t("frigatePlus.modelInfo.loading")}
</p>
)}
{config?.model?.plus === null && (
<p className="text-danger">
{t("frigatePlus.modelInfo.error")}
</p>
)}
{config?.model?.plus && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.modelType")}
</Label>
<p>{config.model.plus.name}</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.trainDate")}
</Label>
<p>
{new Date(
config.model.plus.trainDate,
).toLocaleString()}
</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.baseModel")}
</Label>
<p>{config.model.plus.baseModel}</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.supportedDetectors")}
</Label>
<p>
{config.model.plus.supportedDetectors.join(", ")}
</p>
</div>
</div>
)}
</div>
</div>
</>
)}
<Separator className="my-2 flex bg-secondary" />
<div className="mt-2 max-w-5xl">
<Heading as="h4" className="my-2">
{t("frigatePlus.snapshotConfig.title")}
</Heading>
<div className="mt-2 space-y-3">
<div className="my-2 text-sm text-muted-foreground">
<p>
<Trans ns="views/settings">
frigatePlus.snapshotConfig.desc
</Trans>
</p>
<div className="mt-2 flex items-center text-primary-variant">
<Link
to="https://docs.frigate.video/configuration/plus/faq"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("frigatePlus.snapshotConfig.documentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
{config && (
<div className="overflow-x-auto">
<table className="max-w-2xl text-sm">
<thead>
<tr className="border-b border-secondary">
<th className="px-4 py-2 text-left">
{t("frigatePlus.snapshotConfig.table.camera")}
</th>
<th className="px-4 py-2 text-center">
{t("frigatePlus.snapshotConfig.table.snapshots")}
</th>
<th className="px-4 py-2 text-center">
<Trans ns="views/settings">
frigatePlus.snapshotConfig.table.cleanCopySnapshots
</Trans>
</th>
</tr>
</thead>
<tbody>
{Object.entries(config.cameras).map(
([name, camera]) => (
<tr
key={name}
className="border-b border-secondary"
>
<td className="px-4 py-2">{name}</td>
<td className="px-4 py-2 text-center">
{camera.snapshots.enabled ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
<td className="px-4 py-2 text-center">
{camera.snapshots?.enabled &&
camera.snapshots?.clean_copy ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
{needCleanSnapshots() && (
<div className="mt-2 max-w-xl rounded-lg border border-secondary-foreground bg-secondary p-4 text-sm text-danger">
<div className="flex items-center gap-2">
<IoIosWarning className="mr-2 size-5 text-danger" />
<div className="max-w-[85%] text-sm">
<Trans ns="views/settings">
frigatePlus.snapshotConfig.cleanCopyWarning
</Trans>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -323,7 +323,7 @@ export default function MotionTunerView({
</div>
{cameraConfig ? (
<div className="flex md:h-dvh md:max-h-full md:w-7/12 md:grow">
<div className="flex max-h-[70%] md:h-dvh md:max-h-full md:w-7/12 md:grow">
<div className="size-full min-h-10">
<AutoUpdatingCameraImage
camera={cameraConfig.name}

View File

@ -296,7 +296,7 @@ export default function ObjectSettingsView({
</div>
{cameraConfig ? (
<div className="flex md:h-dvh md:max-h-full md:w-7/12 md:grow">
<div className="flex max-h-[70%] md:h-dvh md:max-h-full md:w-7/12 md:grow">
<div ref={containerRef} className="relative size-full min-h-10">
<AutoUpdatingCameraImage
camera={cameraConfig.name}