mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-05 13:07:44 +03:00
Merge branch 'blakeblackshear:dev' into dev
This commit is contained in:
commit
07622b9502
@ -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 \
|
||||
|
||||
@ -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/';
|
||||
|
||||
10
docker/main/rootfs/usr/local/nginx/get_base_path.py
Normal file
10
docker/main/rootfs/usr/local/nginx/get_base_path.py
Normal 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))
|
||||
@ -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 }}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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"] = (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
web/src/components/indicators/StepIndicator.tsx
Normal file
28
web/src/components/indicators/StepIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
web/src/components/input/ImageEntry.tsx
Normal file
58
web/src/components/input/ImageEntry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
web/src/components/input/TextEntry.tsx
Normal file
68
web/src/components/input/TextEntry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
web/src/components/overlay/detail/FaceCreateWizardDialog.tsx
Normal file
168
web/src/components/overlay/detail/FaceCreateWizardDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
229
web/src/views/settings/FrigatePlusSettingsView.tsx
Normal file
229
web/src/views/settings/FrigatePlusSettingsView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user