mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 02:29:19 +03:00
Compare commits
9 Commits
eb2ea3d5af
...
619afb9e07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
619afb9e07 | ||
|
|
dd9497baf2 | ||
|
|
e930492ccc | ||
|
|
b2c7840c29 | ||
|
|
df27e04c0f | ||
|
|
ef07563d0a | ||
|
|
a705f254e5 | ||
|
|
9fe9345263 | ||
|
|
7f172ca2c6 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,6 +3,8 @@ __pycache__
|
|||||||
.mypy_cache
|
.mypy_cache
|
||||||
*.swp
|
*.swp
|
||||||
debug
|
debug
|
||||||
|
.claude/*
|
||||||
|
.mcp.json
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
config/*
|
config/*
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
# NVidia TensorRT Support (amd64 only)
|
# Nvidia ONNX Runtime GPU Support
|
||||||
--extra-index-url 'https://pypi.nvidia.com'
|
--extra-index-url 'https://pypi.nvidia.com'
|
||||||
cython==3.0.*; platform_machine == 'x86_64'
|
cython==3.0.*; platform_machine == 'x86_64'
|
||||||
nvidia_cuda_cupti_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-cuda-cupti-cu12==12.9.79; platform_machine == 'x86_64'
|
||||||
nvidia-cublas-cu12==12.5.3.*; platform_machine == 'x86_64'
|
nvidia-cublas-cu12==12.9.1.*; platform_machine == 'x86_64'
|
||||||
nvidia-cudnn-cu12==9.3.0.*; platform_machine == 'x86_64'
|
nvidia-cudnn-cu12==9.19.0.*; platform_machine == 'x86_64'
|
||||||
nvidia-cufft-cu12==11.2.3.*; platform_machine == 'x86_64'
|
nvidia-cufft-cu12==11.4.1.*; platform_machine == 'x86_64'
|
||||||
nvidia-curand-cu12==10.3.6.*; platform_machine == 'x86_64'
|
nvidia-curand-cu12==10.3.10.*; platform_machine == 'x86_64'
|
||||||
nvidia_cuda_nvcc_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-cuda-nvcc-cu12==12.9.86; platform_machine == 'x86_64'
|
||||||
nvidia-cuda-nvrtc-cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-cuda-nvrtc-cu12==12.9.86; platform_machine == 'x86_64'
|
||||||
nvidia_cuda_runtime_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-cuda-runtime-cu12==12.9.79; platform_machine == 'x86_64'
|
||||||
nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64'
|
nvidia-cusolver-cu12==11.7.5.*; platform_machine == 'x86_64'
|
||||||
nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
|
nvidia-cusparse-cu12==12.5.10.*; platform_machine == 'x86_64'
|
||||||
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
|
nvidia-nccl-cu12==2.29.7; platform_machine == 'x86_64'
|
||||||
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-nvjitlink-cu12==12.9.86; platform_machine == 'x86_64'
|
||||||
onnx==1.16.*; platform_machine == 'x86_64'
|
onnx==1.16.*; platform_machine == 'x86_64'
|
||||||
onnxruntime-gpu==1.22.*; platform_machine == 'x86_64'
|
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
|
||||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||||
|
|||||||
@ -76,6 +76,40 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
|
|||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### GenAI Provider
|
||||||
|
|
||||||
|
Frigate can use a GenAI provider for semantic search embeddings when that provider has the `embeddings` role. Currently, only **llama.cpp** supports multimodal embeddings (both text and images).
|
||||||
|
|
||||||
|
To use llama.cpp for semantic search:
|
||||||
|
|
||||||
|
1. Configure a GenAI provider in your config with `embeddings` in its `roles`.
|
||||||
|
2. Set `semantic_search.model` to the GenAI config key (e.g. `default`).
|
||||||
|
3. Start the llama.cpp server with `--embeddings` and `--mmproj` for image support:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
genai:
|
||||||
|
default:
|
||||||
|
provider: llamacpp
|
||||||
|
base_url: http://localhost:8080
|
||||||
|
model: your-model-name
|
||||||
|
roles:
|
||||||
|
- embeddings
|
||||||
|
- vision
|
||||||
|
- tools
|
||||||
|
|
||||||
|
semantic_search:
|
||||||
|
enabled: True
|
||||||
|
model: default
|
||||||
|
```
|
||||||
|
|
||||||
|
The llama.cpp server must be started with `--embeddings` for the embeddings API, and a multi-modal embeddings model. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
Switching between Jina models and a GenAI provider requires reindexing. Embeddings from different backends are incompatible.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
### GPU Acceleration
|
### GPU Acceleration
|
||||||
|
|
||||||
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation.
|
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation.
|
||||||
|
|||||||
@ -159,7 +159,8 @@ Published when a license plate is recognized on a car object. See the [License P
|
|||||||
"plate": "123ABC",
|
"plate": "123ABC",
|
||||||
"score": 0.95,
|
"score": 0.95,
|
||||||
"camera": "driveway_cam",
|
"camera": "driveway_cam",
|
||||||
"timestamp": 1607123958.748393
|
"timestamp": 1607123958.748393,
|
||||||
|
"plate_box": [917, 487, 1029, 529] // box coordinates of the detected license plate in the frame
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""Camera apis."""
|
"""Camera apis."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@ -11,7 +12,9 @@ import httpx
|
|||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from filelock import FileLock, Timeout
|
||||||
from onvif import ONVIFCamera, ONVIFError
|
from onvif import ONVIFCamera, ONVIFError
|
||||||
|
from ruamel.yaml import YAML
|
||||||
from zeep.exceptions import Fault, TransportError
|
from zeep.exceptions import Fault, TransportError
|
||||||
from zeep.transports import AsyncTransport
|
from zeep.transports import AsyncTransport
|
||||||
|
|
||||||
@ -21,8 +24,14 @@ from frigate.api.auth import (
|
|||||||
require_role,
|
require_role,
|
||||||
)
|
)
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.config.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.camera.updater import (
|
||||||
|
CameraConfigUpdateEnum,
|
||||||
|
CameraConfigUpdateTopic,
|
||||||
|
)
|
||||||
from frigate.util.builtin import clean_camera_user_pass
|
from frigate.util.builtin import clean_camera_user_pass
|
||||||
|
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||||
|
from frigate.util.config import find_config_file
|
||||||
from frigate.util.image import run_ffmpeg_snapshot
|
from frigate.util.image import run_ffmpeg_snapshot
|
||||||
from frigate.util.services import ffprobe_stream
|
from frigate.util.services import ffprobe_stream
|
||||||
|
|
||||||
@ -995,3 +1004,154 @@ async def onvif_probe(
|
|||||||
await onvif_camera.close()
|
await onvif_camera.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error closing ONVIF camera session: {e}")
|
logger.debug(f"Error closing ONVIF camera session: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/cameras/{camera_name}",
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
|
async def delete_camera(
|
||||||
|
request: Request,
|
||||||
|
camera_name: str,
|
||||||
|
delete_exports: bool = Query(default=False),
|
||||||
|
):
|
||||||
|
"""Delete a camera and all its associated data.
|
||||||
|
|
||||||
|
Removes the camera from config, stops processes, and cleans up
|
||||||
|
all database entries and media files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
camera_name: Name of the camera to delete
|
||||||
|
delete_exports: Whether to also delete exports for this camera
|
||||||
|
"""
|
||||||
|
frigate_config: FrigateConfig = request.app.frigate_config
|
||||||
|
|
||||||
|
if camera_name not in frigate_config.cameras:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": f"Camera {camera_name} not found",
|
||||||
|
},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
old_camera_config = frigate_config.cameras[camera_name]
|
||||||
|
config_file = find_config_file()
|
||||||
|
lock = FileLock(f"{config_file}.lock", timeout=5)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with lock:
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
old_raw_config = f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yaml = YAML()
|
||||||
|
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||||
|
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
data = yaml.load(f)
|
||||||
|
|
||||||
|
# Remove camera from config
|
||||||
|
if "cameras" in data and camera_name in data["cameras"]:
|
||||||
|
del data["cameras"][camera_name]
|
||||||
|
|
||||||
|
# Remove camera from auth roles
|
||||||
|
auth = data.get("auth", {})
|
||||||
|
if auth and "roles" in auth:
|
||||||
|
empty_roles = []
|
||||||
|
for role_name, cameras_list in auth["roles"].items():
|
||||||
|
if (
|
||||||
|
isinstance(cameras_list, list)
|
||||||
|
and camera_name in cameras_list
|
||||||
|
):
|
||||||
|
cameras_list.remove(camera_name)
|
||||||
|
# Custom roles can't be empty; mark for removal
|
||||||
|
if not cameras_list and role_name not in (
|
||||||
|
"admin",
|
||||||
|
"viewer",
|
||||||
|
):
|
||||||
|
empty_roles.append(role_name)
|
||||||
|
for role_name in empty_roles:
|
||||||
|
del auth["roles"][role_name]
|
||||||
|
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(data, f)
|
||||||
|
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
new_raw_config = f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = FrigateConfig.parse(new_raw_config)
|
||||||
|
except Exception:
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
f.write(old_raw_config)
|
||||||
|
logger.exception(
|
||||||
|
"Config error after removing camera %s",
|
||||||
|
camera_name,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Error parsing config after camera removal",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Error updating config to remove camera %s: %s", camera_name, e
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Error updating config",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update runtime config
|
||||||
|
request.app.frigate_config = config
|
||||||
|
request.app.genai_manager.update_config(config)
|
||||||
|
|
||||||
|
# Publish removal to stop ffmpeg processes and clean up runtime state
|
||||||
|
request.app.config_publisher.publish_update(
|
||||||
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name),
|
||||||
|
old_camera_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Timeout:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Another process is currently updating the config",
|
||||||
|
},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up database entries
|
||||||
|
counts, export_paths = await asyncio.to_thread(
|
||||||
|
cleanup_camera_db, camera_name, delete_exports
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up media files in background thread
|
||||||
|
await asyncio.to_thread(
|
||||||
|
cleanup_camera_files, camera_name, export_paths if delete_exports else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Best-effort go2rtc stream removal
|
||||||
|
try:
|
||||||
|
requests.delete(
|
||||||
|
"http://127.0.0.1:1984/api/streams",
|
||||||
|
params={"src": camera_name},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": True,
|
||||||
|
"message": f"Camera {camera_name} has been deleted",
|
||||||
|
"cleanup": counts,
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
from pydantic import ConfigDict, Field
|
from pydantic import ConfigDict, Field
|
||||||
|
|
||||||
@ -173,10 +173,10 @@ class SemanticSearchConfig(FrigateBaseModel):
|
|||||||
title="Reindex on startup",
|
title="Reindex on startup",
|
||||||
description="Trigger a full reindex of historical tracked objects into the embeddings database.",
|
description="Trigger a full reindex of historical tracked objects into the embeddings database.",
|
||||||
)
|
)
|
||||||
model: Optional[SemanticSearchModelEnum] = Field(
|
model: Optional[Union[SemanticSearchModelEnum, str]] = Field(
|
||||||
default=SemanticSearchModelEnum.jinav1,
|
default=SemanticSearchModelEnum.jinav1,
|
||||||
title="Semantic search model",
|
title="Semantic search model or GenAI provider name",
|
||||||
description="The embeddings model to use for semantic search (for example 'jinav1').",
|
description="The embeddings model to use for semantic search (for example 'jinav1'), or the name of a GenAI provider with the embeddings role.",
|
||||||
)
|
)
|
||||||
model_size: str = Field(
|
model_size: str = Field(
|
||||||
default="small",
|
default="small",
|
||||||
|
|||||||
@ -61,6 +61,7 @@ from .classification import (
|
|||||||
FaceRecognitionConfig,
|
FaceRecognitionConfig,
|
||||||
LicensePlateRecognitionConfig,
|
LicensePlateRecognitionConfig,
|
||||||
SemanticSearchConfig,
|
SemanticSearchConfig,
|
||||||
|
SemanticSearchModelEnum,
|
||||||
)
|
)
|
||||||
from .database import DatabaseConfig
|
from .database import DatabaseConfig
|
||||||
from .env import EnvVars
|
from .env import EnvVars
|
||||||
@ -592,6 +593,24 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
role_to_name[role] = name
|
role_to_name[role] = name
|
||||||
|
|
||||||
|
# validate semantic_search.model when it is a GenAI provider name
|
||||||
|
if (
|
||||||
|
self.semantic_search.enabled
|
||||||
|
and isinstance(self.semantic_search.model, str)
|
||||||
|
and not isinstance(self.semantic_search.model, SemanticSearchModelEnum)
|
||||||
|
):
|
||||||
|
if self.semantic_search.model not in self.genai:
|
||||||
|
raise ValueError(
|
||||||
|
f"semantic_search.model '{self.semantic_search.model}' is not a "
|
||||||
|
"valid GenAI config key. Must match a key in genai config."
|
||||||
|
)
|
||||||
|
genai_cfg = self.genai[self.semantic_search.model]
|
||||||
|
if GenAIRoleEnum.embeddings not in genai_cfg.roles:
|
||||||
|
raise ValueError(
|
||||||
|
f"GenAI provider '{self.semantic_search.model}' must have "
|
||||||
|
"'embeddings' in its roles for semantic search."
|
||||||
|
)
|
||||||
|
|
||||||
# set default min_score for object attributes
|
# set default min_score for object attributes
|
||||||
for attribute in self.model.all_attributes:
|
for attribute in self.model.all_attributes:
|
||||||
if not self.objects.filters.get(attribute):
|
if not self.objects.filters.get(attribute):
|
||||||
|
|||||||
@ -1225,6 +1225,8 @@ class LicensePlateProcessingMixin:
|
|||||||
logger.debug(f"{camera}: License plate area below minimum threshold.")
|
logger.debug(f"{camera}: License plate area below minimum threshold.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
plate_box = license_plate
|
||||||
|
|
||||||
license_plate_frame = rgb[
|
license_plate_frame = rgb[
|
||||||
license_plate[1] : license_plate[3],
|
license_plate[1] : license_plate[3],
|
||||||
license_plate[0] : license_plate[2],
|
license_plate[0] : license_plate[2],
|
||||||
@ -1341,6 +1343,20 @@ class LicensePlateProcessingMixin:
|
|||||||
logger.debug(f"{camera}: License plate is less than min_area")
|
logger.debug(f"{camera}: License plate is less than min_area")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Scale back to original car coordinates and then to frame
|
||||||
|
plate_box_in_car = (
|
||||||
|
license_plate[0] // 2,
|
||||||
|
license_plate[1] // 2,
|
||||||
|
license_plate[2] // 2,
|
||||||
|
license_plate[3] // 2,
|
||||||
|
)
|
||||||
|
plate_box = (
|
||||||
|
left + plate_box_in_car[0],
|
||||||
|
top + plate_box_in_car[1],
|
||||||
|
left + plate_box_in_car[2],
|
||||||
|
top + plate_box_in_car[3],
|
||||||
|
)
|
||||||
|
|
||||||
license_plate_frame = car[
|
license_plate_frame = car[
|
||||||
license_plate[1] : license_plate[3],
|
license_plate[1] : license_plate[3],
|
||||||
license_plate[0] : license_plate[2],
|
license_plate[0] : license_plate[2],
|
||||||
@ -1404,6 +1420,8 @@ class LicensePlateProcessingMixin:
|
|||||||
0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2
|
0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
plate_box = tuple(int(x) for x in expanded_box)
|
||||||
|
|
||||||
# Crop using the expanded box
|
# Crop using the expanded box
|
||||||
license_plate_frame = license_plate_frame[
|
license_plate_frame = license_plate_frame[
|
||||||
int(expanded_box[1]) : int(expanded_box[3]),
|
int(expanded_box[1]) : int(expanded_box[3]),
|
||||||
@ -1611,6 +1629,7 @@ class LicensePlateProcessingMixin:
|
|||||||
"id": id,
|
"id": id,
|
||||||
"camera": camera,
|
"camera": camera,
|
||||||
"timestamp": start,
|
"timestamp": start,
|
||||||
|
"plate_box": plate_box,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -50,3 +50,16 @@ class PostProcessorApi(ABC):
|
|||||||
None if request was not handled, otherwise return response.
|
None if request was not handled, otherwise return response.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
|
"""Handle a config change notification.
|
||||||
|
|
||||||
|
Called for every config update published under ``config/``.
|
||||||
|
Processors should override this to check the topic and act only
|
||||||
|
on changes relevant to them. Default is a no-op.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: The config topic that changed.
|
||||||
|
payload: The updated configuration object.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|||||||
@ -12,7 +12,6 @@ from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
|||||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.classification import LicensePlateRecognitionConfig
|
|
||||||
from frigate.data_processing.common.license_plate.mixin import (
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
WRITE_DEBUG_IMAGES,
|
WRITE_DEBUG_IMAGES,
|
||||||
LicensePlateProcessingMixin,
|
LicensePlateProcessingMixin,
|
||||||
@ -48,10 +47,15 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
|||||||
self.sub_label_publisher = sub_label_publisher
|
self.sub_label_publisher = sub_label_publisher
|
||||||
super().__init__(config, metrics, model_runner)
|
super().__init__(config, metrics, model_runner)
|
||||||
|
|
||||||
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
CONFIG_UPDATE_TOPIC = "config/lpr"
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
"""Update LPR config at runtime."""
|
"""Update LPR config at runtime."""
|
||||||
self.lpr_config = lpr_config
|
if topic != self.CONFIG_UPDATE_TOPIC:
|
||||||
logger.debug("LPR config updated dynamically")
|
return
|
||||||
|
|
||||||
|
self.lpr_config = payload
|
||||||
|
logger.debug("LPR post-processor config updated dynamically")
|
||||||
|
|
||||||
def process_data(
|
def process_data(
|
||||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||||
|
|||||||
@ -61,3 +61,16 @@ class RealTimeProcessorApi(ABC):
|
|||||||
None.
|
None.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
|
"""Handle a config change notification.
|
||||||
|
|
||||||
|
Called for every config update published under ``config/``.
|
||||||
|
Processors should override this to check the topic and act only
|
||||||
|
on changes relevant to them. Default is a no-op.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: The config topic that changed.
|
||||||
|
payload: The updated configuration object.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|||||||
@ -169,6 +169,16 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
)
|
)
|
||||||
self.detected_birds[obj_data["id"]] = score
|
self.detected_birds[obj_data["id"]] = score
|
||||||
|
|
||||||
|
CONFIG_UPDATE_TOPIC = "config/classification"
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
|
"""Update bird classification config at runtime."""
|
||||||
|
if topic != self.CONFIG_UPDATE_TOPIC:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.config.classification = payload
|
||||||
|
logger.debug("Bird classification config updated dynamically")
|
||||||
|
|
||||||
def handle_request(self, topic, request_data):
|
def handle_request(self, topic, request_data):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,6 @@ from frigate.comms.event_metadata_updater import (
|
|||||||
)
|
)
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.classification import FaceRecognitionConfig
|
|
||||||
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
||||||
from frigate.data_processing.common.face.model import (
|
from frigate.data_processing.common.face.model import (
|
||||||
ArcFaceRecognizer,
|
ArcFaceRecognizer,
|
||||||
@ -96,9 +95,21 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
|
|
||||||
self.recognizer.build()
|
self.recognizer.build()
|
||||||
|
|
||||||
def update_config(self, face_config: FaceRecognitionConfig) -> None:
|
CONFIG_UPDATE_TOPIC = "config/face_recognition"
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
"""Update face recognition config at runtime."""
|
"""Update face recognition config at runtime."""
|
||||||
self.face_config = face_config
|
if topic != self.CONFIG_UPDATE_TOPIC:
|
||||||
|
return
|
||||||
|
|
||||||
|
previous_min_area = self.config.face_recognition.min_area
|
||||||
|
self.config.face_recognition = payload
|
||||||
|
self.face_config = payload
|
||||||
|
|
||||||
|
for camera_config in self.config.cameras.values():
|
||||||
|
if camera_config.face_recognition.min_area == previous_min_area:
|
||||||
|
camera_config.face_recognition.min_area = payload.min_area
|
||||||
|
|
||||||
logger.debug("Face recognition config updated dynamically")
|
logger.debug("Face recognition config updated dynamically")
|
||||||
|
|
||||||
def __download_models(self, path: str) -> None:
|
def __download_models(self, path: str) -> None:
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import numpy as np
|
|||||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.classification import LicensePlateRecognitionConfig
|
|
||||||
from frigate.data_processing.common.license_plate.mixin import (
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
LicensePlateProcessingMixin,
|
LicensePlateProcessingMixin,
|
||||||
)
|
)
|
||||||
@ -41,9 +40,21 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
|||||||
self.camera_current_cars: dict[str, list[str]] = {}
|
self.camera_current_cars: dict[str, list[str]] = {}
|
||||||
super().__init__(config, metrics)
|
super().__init__(config, metrics)
|
||||||
|
|
||||||
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
CONFIG_UPDATE_TOPIC = "config/lpr"
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
"""Update LPR config at runtime."""
|
"""Update LPR config at runtime."""
|
||||||
self.lpr_config = lpr_config
|
if topic != self.CONFIG_UPDATE_TOPIC:
|
||||||
|
return
|
||||||
|
|
||||||
|
previous_min_area = self.config.lpr.min_area
|
||||||
|
self.config.lpr = payload
|
||||||
|
self.lpr_config = payload
|
||||||
|
|
||||||
|
for camera_config in self.config.cameras.values():
|
||||||
|
if camera_config.lpr.min_area == previous_min_area:
|
||||||
|
camera_config.lpr.min_area = payload.min_area
|
||||||
|
|
||||||
logger.debug("LPR config updated dynamically")
|
logger.debug("LPR config updated dynamically")
|
||||||
|
|
||||||
def process_frame(
|
def process_frame(
|
||||||
|
|||||||
@ -21,7 +21,8 @@ from frigate.const import (
|
|||||||
REPLAY_DIR,
|
REPLAY_DIR,
|
||||||
THUMB_DIR,
|
THUMB_DIR,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
from frigate.models import Recordings
|
||||||
|
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||||
from frigate.util.config import find_config_file
|
from frigate.util.config import find_config_file
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -357,43 +358,13 @@ class DebugReplayManager:
|
|||||||
|
|
||||||
def _cleanup_db(self, camera_name: str) -> None:
|
def _cleanup_db(self, camera_name: str) -> None:
|
||||||
"""Defensively remove any database rows for the replay camera."""
|
"""Defensively remove any database rows for the replay camera."""
|
||||||
try:
|
cleanup_camera_db(camera_name)
|
||||||
Event.delete().where(Event.camera == camera_name).execute()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete replay events: %s", e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
Timeline.delete().where(Timeline.camera == camera_name).execute()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete replay timeline: %s", e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
Recordings.delete().where(Recordings.camera == camera_name).execute()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete replay recordings: %s", e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete replay review segments: %s", e)
|
|
||||||
|
|
||||||
def _cleanup_files(self, camera_name: str) -> None:
|
def _cleanup_files(self, camera_name: str) -> None:
|
||||||
"""Remove filesystem artifacts for the replay camera."""
|
"""Remove filesystem artifacts for the replay camera."""
|
||||||
dirs_to_clean = [
|
cleanup_camera_files(camera_name)
|
||||||
os.path.join(RECORD_DIR, camera_name),
|
|
||||||
os.path.join(CLIPS_DIR, camera_name),
|
|
||||||
os.path.join(THUMB_DIR, camera_name),
|
|
||||||
]
|
|
||||||
|
|
||||||
for dir_path in dirs_to_clean:
|
# Remove replay-specific cache directory
|
||||||
if os.path.exists(dir_path):
|
|
||||||
try:
|
|
||||||
shutil.rmtree(dir_path)
|
|
||||||
logger.debug("Removed replay directory: %s", dir_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to remove %s: %s", dir_path, e)
|
|
||||||
|
|
||||||
# Remove replay clip and any related files
|
|
||||||
if os.path.exists(REPLAY_DIR):
|
if os.path.exists(REPLAY_DIR):
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(REPLAY_DIR)
|
shutil.rmtree(REPLAY_DIR)
|
||||||
|
|||||||
@ -28,6 +28,7 @@ from frigate.types import ModelStatusTypesEnum
|
|||||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
||||||
from frigate.util.file import get_event_thumbnail_bytes
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
|
|
||||||
|
from .genai_embedding import GenAIEmbedding
|
||||||
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
|
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
|
||||||
from .onnx.jina_v2_embedding import JinaV2Embedding
|
from .onnx.jina_v2_embedding import JinaV2Embedding
|
||||||
|
|
||||||
@ -73,6 +74,7 @@ class Embeddings:
|
|||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
db: SqliteVecQueueDatabase,
|
db: SqliteVecQueueDatabase,
|
||||||
metrics: DataProcessorMetrics,
|
metrics: DataProcessorMetrics,
|
||||||
|
genai_manager=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.db = db
|
self.db = db
|
||||||
@ -104,7 +106,27 @@ class Embeddings:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
|
model_cfg = self.config.semantic_search.model
|
||||||
|
|
||||||
|
if not isinstance(model_cfg, SemanticSearchModelEnum):
|
||||||
|
# GenAI provider
|
||||||
|
embeddings_client = (
|
||||||
|
genai_manager.embeddings_client if genai_manager else None
|
||||||
|
)
|
||||||
|
if not embeddings_client:
|
||||||
|
raise ValueError(
|
||||||
|
f"semantic_search.model is '{model_cfg}' (GenAI provider) but "
|
||||||
|
"no embeddings client is configured. Ensure the GenAI provider "
|
||||||
|
"has 'embeddings' in its roles."
|
||||||
|
)
|
||||||
|
self.embedding = GenAIEmbedding(embeddings_client)
|
||||||
|
self.text_embedding = lambda input_data: self.embedding(
|
||||||
|
input_data, embedding_type="text"
|
||||||
|
)
|
||||||
|
self.vision_embedding = lambda input_data: self.embedding(
|
||||||
|
input_data, embedding_type="vision"
|
||||||
|
)
|
||||||
|
elif model_cfg == SemanticSearchModelEnum.jinav2:
|
||||||
# Single JinaV2Embedding instance for both text and vision
|
# Single JinaV2Embedding instance for both text and vision
|
||||||
self.embedding = JinaV2Embedding(
|
self.embedding = JinaV2Embedding(
|
||||||
model_size=self.config.semantic_search.model_size,
|
model_size=self.config.semantic_search.model_size,
|
||||||
@ -118,7 +140,8 @@ class Embeddings:
|
|||||||
self.vision_embedding = lambda input_data: self.embedding(
|
self.vision_embedding = lambda input_data: self.embedding(
|
||||||
input_data, embedding_type="vision"
|
input_data, embedding_type="vision"
|
||||||
)
|
)
|
||||||
else: # Default to jinav1
|
else:
|
||||||
|
# Default to jinav1
|
||||||
self.text_embedding = JinaV1TextEmbedding(
|
self.text_embedding = JinaV1TextEmbedding(
|
||||||
model_size=config.semantic_search.model_size,
|
model_size=config.semantic_search.model_size,
|
||||||
requestor=self.requestor,
|
requestor=self.requestor,
|
||||||
@ -136,8 +159,11 @@ class Embeddings:
|
|||||||
self.metrics.text_embeddings_eps.value = self.text_eps.eps()
|
self.metrics.text_embeddings_eps.value = self.text_eps.eps()
|
||||||
|
|
||||||
def get_model_definitions(self):
|
def get_model_definitions(self):
|
||||||
# Version-specific models
|
model_cfg = self.config.semantic_search.model
|
||||||
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
|
if not isinstance(model_cfg, SemanticSearchModelEnum):
|
||||||
|
# GenAI provider: no ONNX models to download
|
||||||
|
models = []
|
||||||
|
elif model_cfg == SemanticSearchModelEnum.jinav2:
|
||||||
models = [
|
models = [
|
||||||
"jinaai/jina-clip-v2-tokenizer",
|
"jinaai/jina-clip-v2-tokenizer",
|
||||||
"jinaai/jina-clip-v2-model_fp16.onnx"
|
"jinaai/jina-clip-v2-model_fp16.onnx"
|
||||||
@ -312,11 +338,12 @@ class Embeddings:
|
|||||||
# Get total count of events to process
|
# Get total count of events to process
|
||||||
total_events = Event.select().count()
|
total_events = Event.select().count()
|
||||||
|
|
||||||
batch_size = (
|
if not isinstance(self.config.semantic_search.model, SemanticSearchModelEnum):
|
||||||
4
|
batch_size = 1
|
||||||
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2
|
elif self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
|
||||||
else 32
|
batch_size = 4
|
||||||
)
|
else:
|
||||||
|
batch_size = 32
|
||||||
current_page = 1
|
current_page = 1
|
||||||
|
|
||||||
totals = {
|
totals = {
|
||||||
|
|||||||
89
frigate/embeddings/genai_embedding.py
Normal file
89
frigate/embeddings/genai_embedding.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"""GenAI-backed embeddings for semantic search."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frigate.genai import GenAIClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EMBEDDING_DIM = 768
|
||||||
|
|
||||||
|
|
||||||
|
class GenAIEmbedding:
|
||||||
|
"""Embedding adapter that delegates to a GenAI provider's embed API.
|
||||||
|
|
||||||
|
Provides the same interface as JinaV2Embedding for semantic search:
|
||||||
|
__call__(inputs, embedding_type) -> list[np.ndarray]. Output embeddings are
|
||||||
|
normalized to 768 dimensions for Frigate's sqlite-vec schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: "GenAIClient") -> None:
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
inputs: list[str] | list[bytes] | list[Image.Image],
|
||||||
|
embedding_type: str = "text",
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
"""Generate embeddings for text or images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
inputs: List of strings (text) or bytes/PIL images (vision).
|
||||||
|
embedding_type: "text" or "vision".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of 768-dim numpy float32 arrays.
|
||||||
|
"""
|
||||||
|
if not inputs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if embedding_type == "text":
|
||||||
|
texts = [str(x) for x in inputs]
|
||||||
|
embeddings = self.client.embed(texts=texts)
|
||||||
|
elif embedding_type == "vision":
|
||||||
|
images: list[bytes] = []
|
||||||
|
for inp in inputs:
|
||||||
|
if isinstance(inp, bytes):
|
||||||
|
images.append(inp)
|
||||||
|
elif isinstance(inp, Image.Image):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
inp.convert("RGB").save(buf, format="JPEG")
|
||||||
|
images.append(buf.getvalue())
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"GenAIEmbedding: skipping unsupported vision input type %s",
|
||||||
|
type(inp).__name__,
|
||||||
|
)
|
||||||
|
if not images:
|
||||||
|
return []
|
||||||
|
embeddings = self.client.embed(images=images)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid embedding_type '{embedding_type}'. Must be 'text' or 'vision'."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for emb in embeddings:
|
||||||
|
arr = np.asarray(emb, dtype=np.float32)
|
||||||
|
if arr.ndim > 1:
|
||||||
|
# Some providers return token-level embeddings; pool to one vector.
|
||||||
|
arr = arr.mean(axis=0)
|
||||||
|
arr = arr.flatten()
|
||||||
|
if arr.size != EMBEDDING_DIM:
|
||||||
|
if arr.size > EMBEDDING_DIM:
|
||||||
|
arr = arr[:EMBEDDING_DIM]
|
||||||
|
else:
|
||||||
|
arr = np.pad(
|
||||||
|
arr,
|
||||||
|
(0, EMBEDDING_DIM - arr.size),
|
||||||
|
mode="constant",
|
||||||
|
constant_values=0,
|
||||||
|
)
|
||||||
|
result.append(arr)
|
||||||
|
return result
|
||||||
@ -96,16 +96,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
CameraConfigUpdateEnum.semantic_search,
|
CameraConfigUpdateEnum.semantic_search,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.classification_config_subscriber = ConfigSubscriber(
|
self.enrichment_config_subscriber = ConfigSubscriber("config/")
|
||||||
"config/classification/custom/"
|
|
||||||
)
|
|
||||||
self.bird_classification_config_subscriber = ConfigSubscriber(
|
|
||||||
"config/classification", exact=True
|
|
||||||
)
|
|
||||||
self.face_recognition_config_subscriber = ConfigSubscriber(
|
|
||||||
"config/face_recognition", exact=True
|
|
||||||
)
|
|
||||||
self.lpr_config_subscriber = ConfigSubscriber("config/lpr", exact=True)
|
|
||||||
|
|
||||||
# Configure Frigate DB
|
# Configure Frigate DB
|
||||||
db = SqliteVecQueueDatabase(
|
db = SqliteVecQueueDatabase(
|
||||||
@ -123,8 +114,10 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
models = [Event, Recordings, ReviewSegment, Trigger]
|
models = [Event, Recordings, ReviewSegment, Trigger]
|
||||||
db.bind(models)
|
db.bind(models)
|
||||||
|
|
||||||
|
self.genai_manager = GenAIClientManager(config)
|
||||||
|
|
||||||
if config.semantic_search.enabled:
|
if config.semantic_search.enabled:
|
||||||
self.embeddings = Embeddings(config, db, metrics)
|
self.embeddings = Embeddings(config, db, metrics, self.genai_manager)
|
||||||
|
|
||||||
# Check if we need to re-index events
|
# Check if we need to re-index events
|
||||||
if config.semantic_search.reindex:
|
if config.semantic_search.reindex:
|
||||||
@ -151,7 +144,6 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
|
|
||||||
self.detected_license_plates: dict[str, dict[str, Any]] = {}
|
self.detected_license_plates: dict[str, dict[str, Any]] = {}
|
||||||
self.genai_manager = GenAIClientManager(config)
|
|
||||||
|
|
||||||
# model runners to share between realtime and post processors
|
# model runners to share between realtime and post processors
|
||||||
if self.config.lpr.enabled:
|
if self.config.lpr.enabled:
|
||||||
@ -279,10 +271,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
"""Maintain a SQLite-vec database for semantic search."""
|
"""Maintain a SQLite-vec database for semantic search."""
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
self.config_updater.check_for_updates()
|
self.config_updater.check_for_updates()
|
||||||
self._check_classification_config_updates()
|
self._check_enrichment_config_updates()
|
||||||
self._check_bird_classification_config_updates()
|
|
||||||
self._check_face_recognition_config_updates()
|
|
||||||
self._check_lpr_config_updates()
|
|
||||||
self._process_requests()
|
self._process_requests()
|
||||||
self._process_updates()
|
self._process_updates()
|
||||||
self._process_recordings_updates()
|
self._process_recordings_updates()
|
||||||
@ -293,10 +282,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self._process_event_metadata()
|
self._process_event_metadata()
|
||||||
|
|
||||||
self.config_updater.stop()
|
self.config_updater.stop()
|
||||||
self.classification_config_subscriber.stop()
|
self.enrichment_config_subscriber.stop()
|
||||||
self.bird_classification_config_subscriber.stop()
|
|
||||||
self.face_recognition_config_subscriber.stop()
|
|
||||||
self.lpr_config_subscriber.stop()
|
|
||||||
self.event_subscriber.stop()
|
self.event_subscriber.stop()
|
||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
self.recordings_subscriber.stop()
|
self.recordings_subscriber.stop()
|
||||||
@ -307,11 +293,29 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.requestor.stop()
|
self.requestor.stop()
|
||||||
logger.info("Exiting embeddings maintenance...")
|
logger.info("Exiting embeddings maintenance...")
|
||||||
|
|
||||||
def _check_classification_config_updates(self) -> None:
|
def _check_enrichment_config_updates(self) -> None:
|
||||||
"""Check for classification config updates and add/remove processors."""
|
"""Check for enrichment config updates and delegate to processors."""
|
||||||
topic, model_config = self.classification_config_subscriber.check_for_update()
|
topic, payload = self.enrichment_config_subscriber.check_for_update()
|
||||||
|
|
||||||
if topic:
|
if topic is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Custom classification add/remove requires managing the processor list
|
||||||
|
if topic.startswith("config/classification/custom/"):
|
||||||
|
self._handle_custom_classification_update(topic, payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Broadcast to all processors — each decides if the topic is relevant
|
||||||
|
for processor in self.realtime_processors:
|
||||||
|
processor.update_config(topic, payload)
|
||||||
|
|
||||||
|
for processor in self.post_processors:
|
||||||
|
processor.update_config(topic, payload)
|
||||||
|
|
||||||
|
def _handle_custom_classification_update(
|
||||||
|
self, topic: str, model_config: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle add/remove of custom classification processors."""
|
||||||
model_name = topic.split("/")[-1]
|
model_name = topic.split("/")[-1]
|
||||||
|
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
@ -333,7 +337,8 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully removed classification processor for model: {model_name}"
|
f"Successfully removed classification processor for model: {model_name}"
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
|
|
||||||
self.config.classification.custom[model_name] = model_config
|
self.config.classification.custom[model_name] = model_config
|
||||||
|
|
||||||
# Check if processor already exists
|
# Check if processor already exists
|
||||||
@ -369,62 +374,6 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
|
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_bird_classification_config_updates(self) -> None:
|
|
||||||
"""Check for bird classification config updates."""
|
|
||||||
topic, classification_config = (
|
|
||||||
self.bird_classification_config_subscriber.check_for_update()
|
|
||||||
)
|
|
||||||
|
|
||||||
if topic is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.config.classification = classification_config
|
|
||||||
logger.debug("Applied dynamic bird classification config update")
|
|
||||||
|
|
||||||
def _check_face_recognition_config_updates(self) -> None:
|
|
||||||
"""Check for face recognition config updates."""
|
|
||||||
topic, face_config = self.face_recognition_config_subscriber.check_for_update()
|
|
||||||
|
|
||||||
if topic is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
previous_min_area = self.config.face_recognition.min_area
|
|
||||||
self.config.face_recognition = face_config
|
|
||||||
|
|
||||||
for camera_config in self.config.cameras.values():
|
|
||||||
if camera_config.face_recognition.min_area == previous_min_area:
|
|
||||||
camera_config.face_recognition.min_area = face_config.min_area
|
|
||||||
|
|
||||||
for processor in self.realtime_processors:
|
|
||||||
if isinstance(processor, FaceRealTimeProcessor):
|
|
||||||
processor.update_config(face_config)
|
|
||||||
|
|
||||||
logger.debug("Applied dynamic face recognition config update")
|
|
||||||
|
|
||||||
def _check_lpr_config_updates(self) -> None:
|
|
||||||
"""Check for LPR config updates."""
|
|
||||||
topic, lpr_config = self.lpr_config_subscriber.check_for_update()
|
|
||||||
|
|
||||||
if topic is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
previous_min_area = self.config.lpr.min_area
|
|
||||||
self.config.lpr = lpr_config
|
|
||||||
|
|
||||||
for camera_config in self.config.cameras.values():
|
|
||||||
if camera_config.lpr.min_area == previous_min_area:
|
|
||||||
camera_config.lpr.min_area = lpr_config.min_area
|
|
||||||
|
|
||||||
for processor in self.realtime_processors:
|
|
||||||
if isinstance(processor, LicensePlateRealTimeProcessor):
|
|
||||||
processor.update_config(lpr_config)
|
|
||||||
|
|
||||||
for processor in self.post_processors:
|
|
||||||
if isinstance(processor, LicensePlatePostProcessor):
|
|
||||||
processor.update_config(lpr_config)
|
|
||||||
|
|
||||||
logger.debug("Applied dynamic LPR config update")
|
|
||||||
|
|
||||||
def _process_requests(self) -> None:
|
def _process_requests(self) -> None:
|
||||||
"""Process embeddings requests"""
|
"""Process embeddings requests"""
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
||||||
@ -304,6 +305,25 @@ Guidelines:
|
|||||||
"""Get the context window size for this provider in tokens."""
|
"""Get the context window size for this provider in tokens."""
|
||||||
return 4096
|
return 4096
|
||||||
|
|
||||||
|
def embed(
|
||||||
|
self,
|
||||||
|
texts: list[str] | None = None,
|
||||||
|
images: list[bytes] | None = None,
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
"""Generate embeddings for text and/or images.
|
||||||
|
|
||||||
|
Returns list of numpy arrays (one per input). Expected dimension is 768
|
||||||
|
for Frigate semantic search compatibility.
|
||||||
|
|
||||||
|
Providers that support embeddings should override this method.
|
||||||
|
"""
|
||||||
|
logger.warning(
|
||||||
|
"%s does not support embeddings. "
|
||||||
|
"This method should be overridden by the provider implementation.",
|
||||||
|
self.__class__.__name__,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
def chat_with_tools(
|
def chat_with_tools(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
"""llama.cpp Provider for Frigate AI."""
|
"""llama.cpp Provider for Frigate AI."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import numpy as np
|
||||||
import requests
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from frigate.config import GenAIProviderEnum
|
from frigate.config import GenAIProviderEnum
|
||||||
from frigate.genai import GenAIClient, register_genai_provider
|
from frigate.genai import GenAIClient, register_genai_provider
|
||||||
@ -15,6 +18,20 @@ from frigate.genai.utils import parse_tool_calls_from_message
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||||
|
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(img_bytes))
|
||||||
|
if img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=85)
|
||||||
|
return buf.getvalue()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to convert image to JPEG: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@register_genai_provider(GenAIProviderEnum.llamacpp)
|
@register_genai_provider(GenAIProviderEnum.llamacpp)
|
||||||
class LlamaCppClient(GenAIClient):
|
class LlamaCppClient(GenAIClient):
|
||||||
"""Generative AI client for Frigate using llama.cpp server."""
|
"""Generative AI client for Frigate using llama.cpp server."""
|
||||||
@ -176,6 +193,110 @@ class LlamaCppClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
return result if result else None
|
return result if result else None
|
||||||
|
|
||||||
|
def embed(
|
||||||
|
self,
|
||||||
|
texts: list[str] | None = None,
|
||||||
|
images: list[bytes] | None = None,
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
"""Generate embeddings via llama.cpp /embeddings endpoint.
|
||||||
|
|
||||||
|
Supports batch requests. Uses content format with prompt_string and
|
||||||
|
multimodal_data for images (PR #15108). Server must be started with
|
||||||
|
--embeddings and --mmproj for multimodal support.
|
||||||
|
"""
|
||||||
|
if self.provider is None:
|
||||||
|
logger.warning(
|
||||||
|
"llama.cpp provider has not been initialized. Check your llama.cpp configuration."
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
texts = texts or []
|
||||||
|
images = images or []
|
||||||
|
if not texts and not images:
|
||||||
|
return []
|
||||||
|
|
||||||
|
EMBEDDING_DIM = 768
|
||||||
|
|
||||||
|
content = []
|
||||||
|
for text in texts:
|
||||||
|
content.append({"prompt_string": text})
|
||||||
|
for img in images:
|
||||||
|
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||||
|
jpeg_bytes = _to_jpeg(img)
|
||||||
|
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||||
|
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||||
|
# prompt_string must contain <__media__> placeholder for image tokenization
|
||||||
|
content.append(
|
||||||
|
{
|
||||||
|
"prompt_string": "<__media__>\n",
|
||||||
|
"multimodal_data": [encoded],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.provider}/embeddings",
|
||||||
|
json={"model": self.genai_config.model, "content": content},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
items = result.get("data", result) if isinstance(result, dict) else result
|
||||||
|
if not isinstance(items, list):
|
||||||
|
logger.warning("llama.cpp embeddings returned unexpected format")
|
||||||
|
return []
|
||||||
|
|
||||||
|
embeddings = []
|
||||||
|
for item in items:
|
||||||
|
emb = item.get("embedding") if isinstance(item, dict) else None
|
||||||
|
if emb is None:
|
||||||
|
logger.warning("llama.cpp embeddings item missing embedding field")
|
||||||
|
continue
|
||||||
|
arr = np.array(emb, dtype=np.float32)
|
||||||
|
if arr.ndim > 1:
|
||||||
|
# llama.cpp can return token-level embeddings; pool per item
|
||||||
|
arr = arr.mean(axis=0)
|
||||||
|
arr = arr.flatten()
|
||||||
|
orig_dim = arr.size
|
||||||
|
if orig_dim != EMBEDDING_DIM:
|
||||||
|
if orig_dim > EMBEDDING_DIM:
|
||||||
|
arr = arr[:EMBEDDING_DIM]
|
||||||
|
logger.debug(
|
||||||
|
"Truncated llama.cpp embedding from %d to %d dimensions",
|
||||||
|
orig_dim,
|
||||||
|
EMBEDDING_DIM,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
arr = np.pad(
|
||||||
|
arr,
|
||||||
|
(0, EMBEDDING_DIM - orig_dim),
|
||||||
|
mode="constant",
|
||||||
|
constant_values=0,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Padded llama.cpp embedding from %d to %d dimensions",
|
||||||
|
orig_dim,
|
||||||
|
EMBEDDING_DIM,
|
||||||
|
)
|
||||||
|
embeddings.append(arr)
|
||||||
|
return embeddings
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning("llama.cpp embeddings request timed out")
|
||||||
|
return []
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
error_detail = str(e)
|
||||||
|
if hasattr(e, "response") and e.response is not None:
|
||||||
|
try:
|
||||||
|
error_detail = f"{str(e)} - Response: {e.response.text[:500]}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.warning("llama.cpp embeddings error: %s", error_detail)
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Unexpected error in llama.cpp embeddings: %s", str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
def chat_with_tools(
|
def chat_with_tools(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
|
|||||||
153
frigate/util/camera_cleanup.py
Normal file
153
frigate/util/camera_cleanup.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"""Utilities for cleaning up camera data from database and filesystem."""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from frigate.const import CLIPS_DIR, RECORD_DIR, THUMB_DIR
|
||||||
|
from frigate.models import (
|
||||||
|
Event,
|
||||||
|
Export,
|
||||||
|
Previews,
|
||||||
|
Recordings,
|
||||||
|
Regions,
|
||||||
|
ReviewSegment,
|
||||||
|
Timeline,
|
||||||
|
Trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_camera_db(
|
||||||
|
camera_name: str, delete_exports: bool = False
|
||||||
|
) -> tuple[dict[str, int], list[str]]:
|
||||||
|
"""Remove all database rows for a camera.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
camera_name: The camera name to clean up
|
||||||
|
delete_exports: Whether to also delete export records
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (deletion counts dict, list of export file paths to remove)
|
||||||
|
"""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
export_paths: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["events"] = Event.delete().where(Event.camera == camera_name).execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete events for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["timeline"] = (
|
||||||
|
Timeline.delete().where(Timeline.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete timeline for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["recordings"] = (
|
||||||
|
Recordings.delete().where(Recordings.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete recordings for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["review_segments"] = (
|
||||||
|
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to delete review segments for camera %s: %s", camera_name, e
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["previews"] = (
|
||||||
|
Previews.delete().where(Previews.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete previews for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["regions"] = (
|
||||||
|
Regions.delete().where(Regions.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete regions for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["triggers"] = (
|
||||||
|
Trigger.delete().where(Trigger.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete triggers for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
if delete_exports:
|
||||||
|
try:
|
||||||
|
exports = Export.select(Export.video_path, Export.thumb_path).where(
|
||||||
|
Export.camera == camera_name
|
||||||
|
)
|
||||||
|
for export in exports:
|
||||||
|
export_paths.append(export.video_path)
|
||||||
|
export_paths.append(export.thumb_path)
|
||||||
|
|
||||||
|
counts["exports"] = (
|
||||||
|
Export.delete().where(Export.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete exports for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
return counts, export_paths
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_camera_files(
|
||||||
|
camera_name: str, export_paths: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Remove filesystem artifacts for a camera.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
camera_name: The camera name to clean up
|
||||||
|
export_paths: Optional list of export file paths to remove
|
||||||
|
"""
|
||||||
|
dirs_to_clean = [
|
||||||
|
os.path.join(RECORD_DIR, camera_name),
|
||||||
|
os.path.join(CLIPS_DIR, camera_name),
|
||||||
|
os.path.join(THUMB_DIR, camera_name),
|
||||||
|
os.path.join(CLIPS_DIR, "previews", camera_name),
|
||||||
|
]
|
||||||
|
|
||||||
|
for dir_path in dirs_to_clean:
|
||||||
|
if os.path.exists(dir_path):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(dir_path)
|
||||||
|
logger.debug("Removed directory: %s", dir_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to remove %s: %s", dir_path, e)
|
||||||
|
|
||||||
|
# Remove event snapshot files
|
||||||
|
for snapshot in glob.glob(os.path.join(CLIPS_DIR, f"{camera_name}-*.jpg")):
|
||||||
|
try:
|
||||||
|
os.remove(snapshot)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to remove snapshot %s: %s", snapshot, e)
|
||||||
|
|
||||||
|
# Remove review thumbnail files
|
||||||
|
for thumb in glob.glob(
|
||||||
|
os.path.join(CLIPS_DIR, "review", f"thumb-{camera_name}-*.webp")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
os.remove(thumb)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to remove review thumbnail %s: %s", thumb, e)
|
||||||
|
|
||||||
|
# Remove export files if requested
|
||||||
|
if export_paths:
|
||||||
|
for path in export_paths:
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
logger.debug("Removed export file: %s", path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to remove export file %s: %s", path, e)
|
||||||
@ -422,6 +422,18 @@
|
|||||||
"cameraManagement": {
|
"cameraManagement": {
|
||||||
"title": "Manage Cameras",
|
"title": "Manage Cameras",
|
||||||
"addCamera": "Add New Camera",
|
"addCamera": "Add New Camera",
|
||||||
|
"deleteCamera": "Delete Camera",
|
||||||
|
"deleteCameraDialog": {
|
||||||
|
"title": "Delete Camera",
|
||||||
|
"description": "Deleting a camera will permanently remove all recordings, tracked objects, and configuration for that camera. Any go2rtc streams associated with this camera may still need to be manually removed.",
|
||||||
|
"selectPlaceholder": "Choose camera...",
|
||||||
|
"confirmTitle": "Are you sure?",
|
||||||
|
"confirmWarning": "Deleting <strong>{{cameraName}}</strong> cannot be undone.",
|
||||||
|
"deleteExports": "Also delete exports for this camera",
|
||||||
|
"confirmButton": "Delete Permanently",
|
||||||
|
"success": "Camera {{cameraName}} deleted successfully",
|
||||||
|
"error": "Failed to delete camera {{cameraName}}"
|
||||||
|
},
|
||||||
"editCamera": "Edit Camera:",
|
"editCamera": "Edit Camera:",
|
||||||
"selectCamera": "Select a Camera",
|
"selectCamera": "Select a Camera",
|
||||||
"backToSettings": "Back to Camera Settings",
|
"backToSettings": "Back to Camera Settings",
|
||||||
|
|||||||
@ -30,10 +30,22 @@ const detect: SectionConfigOverrides = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
restartRequired: [
|
||||||
|
"fps",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"min_initialized",
|
||||||
|
"max_disappeared",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
restartRequired: [
|
||||||
|
"fps",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"min_initialized",
|
||||||
|
"max_disappeared",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,12 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const motion: SectionConfigOverrides = {
|
const motion: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/motion_detection",
|
sectionDocs: "/configuration/motion_detection",
|
||||||
|
fieldDocs: {
|
||||||
|
lightning_threshold:
|
||||||
|
"/configuration/motion_detection#lightning_threshold",
|
||||||
|
skip_motion_threshold:
|
||||||
|
"/configuration/motion_detection#skip_motion_on_large_scene_changes",
|
||||||
|
},
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"enabled",
|
"enabled",
|
||||||
@ -20,6 +26,16 @@ const motion: SectionConfigOverrides = {
|
|||||||
sensitivity: ["enabled", "threshold", "contour_area"],
|
sensitivity: ["enabled", "threshold", "contour_area"],
|
||||||
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
||||||
},
|
},
|
||||||
|
uiSchema: {
|
||||||
|
skip_motion_threshold: {
|
||||||
|
"ui:widget": "optionalField",
|
||||||
|
"ui:options": {
|
||||||
|
innerWidget: "range",
|
||||||
|
step: 0.05,
|
||||||
|
suppressMultiSchema: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
||||||
advancedFields: [
|
advancedFields: [
|
||||||
"lightning_threshold",
|
"lightning_threshold",
|
||||||
@ -58,7 +74,7 @@ const motion: SectionConfigOverrides = {
|
|||||||
"frame_alpha",
|
"frame_alpha",
|
||||||
"frame_height",
|
"frame_height",
|
||||||
],
|
],
|
||||||
advancedFields: ["lightning_threshold"],
|
advancedFields: ["lightning_threshold", "skip_motion_threshold"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
|||||||
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
||||||
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||||
|
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||||
|
|
||||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||||
@ -73,6 +74,7 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
audioLabels: AudioLabelSwitchesWidget,
|
audioLabels: AudioLabelSwitchesWidget,
|
||||||
zoneNames: ZoneSwitchesWidget,
|
zoneNames: ZoneSwitchesWidget,
|
||||||
timezoneSelect: TimezoneSelectWidget,
|
timezoneSelect: TimezoneSelectWidget,
|
||||||
|
optionalField: OptionalFieldWidget,
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
// Optional Field Widget - wraps any inner widget with an enable/disable switch
|
||||||
|
// Used for nullable fields where None means "disabled" (not the same as 0)
|
||||||
|
|
||||||
|
import type { WidgetProps } from "@rjsf/utils";
|
||||||
|
import { getWidget } from "@rjsf/utils";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getNonNullSchema } from "../fields/nullableUtils";
|
||||||
|
|
||||||
|
export function OptionalFieldWidget(props: WidgetProps) {
|
||||||
|
const { id, value, disabled, readonly, onChange, schema, options, registry } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
const innerWidgetName = (options.innerWidget as string) || undefined;
|
||||||
|
const isEnabled = value !== undefined && value !== null;
|
||||||
|
|
||||||
|
// Extract the non-null branch from anyOf [Type, null]
|
||||||
|
const innerSchema = getNonNullSchema(schema) ?? schema;
|
||||||
|
|
||||||
|
const InnerWidget = getWidget(innerSchema, innerWidgetName, registry.widgets);
|
||||||
|
|
||||||
|
const getDefaultValue = () => {
|
||||||
|
if (innerSchema.default !== undefined && innerSchema.default !== null) {
|
||||||
|
return innerSchema.default;
|
||||||
|
}
|
||||||
|
if (innerSchema.minimum !== undefined) {
|
||||||
|
return innerSchema.minimum;
|
||||||
|
}
|
||||||
|
if (innerSchema.type === "integer" || innerSchema.type === "number") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (innerSchema.type === "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (checked: boolean) => {
|
||||||
|
onChange(checked ? getDefaultValue() : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const innerProps: WidgetProps = {
|
||||||
|
...props,
|
||||||
|
schema: innerSchema,
|
||||||
|
disabled: disabled || readonly || !isEnabled,
|
||||||
|
value: isEnabled ? value : getDefaultValue(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id={`${id}-toggle`}
|
||||||
|
checked={isEnabled}
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn("flex-1", !isEnabled && "pointer-events-none opacity-40")}
|
||||||
|
>
|
||||||
|
<InnerWidget {...innerProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -155,7 +155,7 @@ export default function SearchResultActions({
|
|||||||
</a>
|
</a>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{searchResult.has_snapshot && (
|
{searchResult.has_snapshot && searchResult?.data?.type === "object" && (
|
||||||
<MenuItem aria-label={t("itemMenu.downloadSnapshot.aria")}>
|
<MenuItem aria-label={t("itemMenu.downloadSnapshot.aria")}>
|
||||||
<a
|
<a
|
||||||
className="flex items-center"
|
className="flex items-center"
|
||||||
@ -167,6 +167,7 @@ export default function SearchResultActions({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{searchResult.has_snapshot &&
|
{searchResult.has_snapshot &&
|
||||||
|
searchResult?.data?.type === "object" &&
|
||||||
config?.cameras[searchResult.camera].snapshots.clean_copy && (
|
config?.cameras[searchResult.camera].snapshots.clean_copy && (
|
||||||
<MenuItem aria-label={t("itemMenu.downloadCleanSnapshot.aria")}>
|
<MenuItem aria-label={t("itemMenu.downloadCleanSnapshot.aria")}>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@ -81,7 +81,7 @@ export default function DetailActionsMenu({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{search.has_snapshot && (
|
{search.has_snapshot && search?.data?.type === "object" && (
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<a
|
<a
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -95,7 +95,8 @@ export default function DetailActionsMenu({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{search.has_snapshot &&
|
{search.has_snapshot &&
|
||||||
config?.cameras[search.camera].snapshots.clean_copy && (
|
config?.cameras[search.camera].snapshots.clean_copy &&
|
||||||
|
search?.data?.type === "object" && (
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<a
|
<a
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
215
web/src/components/overlay/dialog/DeleteCameraDialog.tsx
Normal file
215
web/src/components/overlay/dialog/DeleteCameraDialog.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Trans } from "react-i18next";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
type DeleteCameraDialogProps = {
|
||||||
|
show: boolean;
|
||||||
|
cameras: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteCameraDialog({
|
||||||
|
show,
|
||||||
|
cameras,
|
||||||
|
onClose,
|
||||||
|
onDeleted,
|
||||||
|
}: DeleteCameraDialogProps) {
|
||||||
|
const { t } = useTranslation(["views/settings", "common"]);
|
||||||
|
const [phase, setPhase] = useState<"select" | "confirm">("select");
|
||||||
|
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
||||||
|
const [deleteExports, setDeleteExports] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (isDeleting) return;
|
||||||
|
setPhase("select");
|
||||||
|
setSelectedCamera("");
|
||||||
|
setDeleteExports(false);
|
||||||
|
onClose();
|
||||||
|
}, [isDeleting, onClose]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
setPhase("confirm");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setPhase("select");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
if (!selectedCamera || isDeleting) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(
|
||||||
|
`cameras/${selectedCamera}?delete_exports=${deleteExports}`,
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
t("cameraManagement.deleteCameraDialog.success", {
|
||||||
|
cameraName: selectedCamera,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
setPhase("select");
|
||||||
|
setSelectedCamera("");
|
||||||
|
setDeleteExports(false);
|
||||||
|
onDeleted();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
axios.isAxiosError(error) &&
|
||||||
|
(error.response?.data?.message || error.response?.data?.detail)
|
||||||
|
? error.response?.data?.message || error.response?.data?.detail
|
||||||
|
: t("cameraManagement.deleteCameraDialog.error", {
|
||||||
|
cameraName: selectedCamera,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.error(errorMessage, { position: "top-center" });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [selectedCamera, deleteExports, isDeleting, onDeleted, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={show} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
{phase === "select" ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("cameraManagement.deleteCameraDialog.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("cameraManagement.deleteCameraDialog.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Select value={selectedCamera} onValueChange={setSelectedCamera}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"cameraManagement.deleteCameraDialog.selectPlaceholder",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{cameras.map((camera) => (
|
||||||
|
<SelectItem key={camera} value={camera}>
|
||||||
|
{camera}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||||
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label={t("button.cancel", { ns: "common" })}
|
||||||
|
onClick={handleClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
aria-label={t("button.delete", { ns: "common" })}
|
||||||
|
className="flex flex-1 text-white"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!selectedCamera}
|
||||||
|
>
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("cameraManagement.deleteCameraDialog.confirmTitle")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans
|
||||||
|
ns="views/settings"
|
||||||
|
values={{ cameraName: selectedCamera }}
|
||||||
|
components={{ strong: <span className="font-medium" /> }}
|
||||||
|
>
|
||||||
|
cameraManagement.deleteCameraDialog.confirmWarning
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="delete-exports"
|
||||||
|
checked={deleteExports}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDeleteExports(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="delete-exports" className="cursor-pointer">
|
||||||
|
{t("cameraManagement.deleteCameraDialog.deleteExports")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||||
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label={t("button.back", { ns: "common" })}
|
||||||
|
onClick={handleBack}
|
||||||
|
type="button"
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex flex-1 text-white"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ActivityIndicator />
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"cameraManagement.deleteCameraDialog.confirmButton",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t("cameraManagement.deleteCameraDialog.confirmButton")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
/** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */
|
||||||
|
export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
|
||||||
|
|
||||||
export const supportedLanguageKeys = [
|
export const supportedLanguageKeys = [
|
||||||
"en",
|
"en",
|
||||||
"es",
|
"es",
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { toast } from "sonner";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import useSWRInfinite from "swr/infinite";
|
import useSWRInfinite from "swr/infinite";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import { JINA_EMBEDDING_MODELS } from "@/lib/const";
|
||||||
|
|
||||||
const API_LIMIT = 25;
|
const API_LIMIT = 25;
|
||||||
|
|
||||||
@ -293,7 +294,12 @@ export default function Explore() {
|
|||||||
const modelVersion = config?.semantic_search.model || "jinav1";
|
const modelVersion = config?.semantic_search.model || "jinav1";
|
||||||
const modelSize = config?.semantic_search.model_size || "small";
|
const modelSize = config?.semantic_search.model_size || "small";
|
||||||
|
|
||||||
// Text model state
|
// GenAI providers have no local models to download
|
||||||
|
const isGenaiEmbeddings =
|
||||||
|
typeof modelVersion === "string" &&
|
||||||
|
!(JINA_EMBEDDING_MODELS as readonly string[]).includes(modelVersion);
|
||||||
|
|
||||||
|
// Text model state (skipped for GenAI - no local models)
|
||||||
const { payload: textModelState } = useModelState(
|
const { payload: textModelState } = useModelState(
|
||||||
modelVersion === "jinav1"
|
modelVersion === "jinav1"
|
||||||
? "jinaai/jina-clip-v1-text_model_fp16.onnx"
|
? "jinaai/jina-clip-v1-text_model_fp16.onnx"
|
||||||
@ -328,6 +334,10 @@ export default function Explore() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allModelsLoaded = useMemo(() => {
|
const allModelsLoaded = useMemo(() => {
|
||||||
|
if (isGenaiEmbeddings) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
textModelState === "downloaded" &&
|
textModelState === "downloaded" &&
|
||||||
textTokenizerState === "downloaded" &&
|
textTokenizerState === "downloaded" &&
|
||||||
@ -335,6 +345,7 @@ export default function Explore() {
|
|||||||
visionFeatureExtractorState === "downloaded"
|
visionFeatureExtractorState === "downloaded"
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
|
isGenaiEmbeddings,
|
||||||
textModelState,
|
textModelState,
|
||||||
textTokenizerState,
|
textTokenizerState,
|
||||||
visionModelState,
|
visionModelState,
|
||||||
@ -358,10 +369,11 @@ export default function Explore() {
|
|||||||
!defaultViewLoaded ||
|
!defaultViewLoaded ||
|
||||||
(config?.semantic_search.enabled &&
|
(config?.semantic_search.enabled &&
|
||||||
(!reindexState ||
|
(!reindexState ||
|
||||||
!textModelState ||
|
(!isGenaiEmbeddings &&
|
||||||
|
(!textModelState ||
|
||||||
!textTokenizerState ||
|
!textTokenizerState ||
|
||||||
!visionModelState ||
|
!visionModelState ||
|
||||||
!visionFeatureExtractorState))
|
!visionFeatureExtractorState))))
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
|||||||
@ -1284,7 +1284,7 @@ function MotionReview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{motionPreviewsCamera && selectedMotionPreviewCamera ? (
|
{selectedMotionPreviewCamera && (
|
||||||
<>
|
<>
|
||||||
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
|
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
|
||||||
<Button
|
<Button
|
||||||
@ -1465,10 +1465,15 @@ function MotionReview({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
)}
|
||||||
<div className="no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
className={cn(
|
||||||
|
"no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4",
|
||||||
|
selectedMotionPreviewCamera && "hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={selectedMotionPreviewCamera ? undefined : contentRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"no-scrollbar grid w-full grid-cols-1",
|
"no-scrollbar grid w-full grid-cols-1",
|
||||||
isMobile && "landscape:grid-cols-2",
|
isMobile && "landscape:grid-cols-2",
|
||||||
@ -1562,7 +1567,6 @@ function MotionReview({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{!selectedMotionPreviewCamera && (
|
{!selectedMotionPreviewCamera && (
|
||||||
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
||||||
{motionData ? (
|
{motionData ? (
|
||||||
|
|||||||
@ -624,6 +624,9 @@ export default function MotionPreviewsPane({
|
|||||||
const [hasVisibilityData, setHasVisibilityData] = useState(false);
|
const [hasVisibilityData, setHasVisibilityData] = useState(false);
|
||||||
const clipObserver = useRef<IntersectionObserver | null>(null);
|
const clipObserver = useRef<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
|
const [mountedClips, setMountedClips] = useState<Set<string>>(new Set());
|
||||||
|
const mountObserver = useRef<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
const recordingTimeRange = useMemo(() => {
|
const recordingTimeRange = useMemo(() => {
|
||||||
if (!motionRanges.length) {
|
if (!motionRanges.length) {
|
||||||
return null;
|
return null;
|
||||||
@ -788,15 +791,56 @@ export default function MotionPreviewsPane({
|
|||||||
};
|
};
|
||||||
}, [scrollContainer]);
|
}, [scrollContainer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearClipIds = new Set<string>();
|
||||||
|
mountObserver.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const clipId = (entry.target as HTMLElement).dataset.clipId;
|
||||||
|
|
||||||
|
if (!clipId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
nearClipIds.add(clipId);
|
||||||
|
} else {
|
||||||
|
nearClipIds.delete(clipId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setMountedClips(new Set(nearClipIds));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: scrollContainer,
|
||||||
|
rootMargin: "200% 0px",
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
scrollContainer
|
||||||
|
.querySelectorAll<HTMLElement>("[data-clip-id]")
|
||||||
|
.forEach((node) => {
|
||||||
|
mountObserver.current?.observe(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountObserver.current?.disconnect();
|
||||||
|
};
|
||||||
|
}, [scrollContainer]);
|
||||||
|
|
||||||
const clipRef = useCallback((node: HTMLElement | null) => {
|
const clipRef = useCallback((node: HTMLElement | null) => {
|
||||||
if (!clipObserver.current) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (node) {
|
clipObserver.current?.observe(node);
|
||||||
clipObserver.current.observe(node);
|
mountObserver.current?.observe(node);
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// no op
|
// no op
|
||||||
}
|
}
|
||||||
@ -864,12 +908,17 @@ export default function MotionPreviewsPane({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
||||||
{clipData.map(
|
{clipData.map(
|
||||||
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => (
|
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
|
||||||
|
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
|
||||||
|
const isMounted = mountedClips.has(clipId);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
|
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
|
||||||
data-clip-id={`${camera.name}-${range.start_time}-${range.end_time}-${idx}`}
|
data-clip-id={clipId}
|
||||||
ref={clipRef}
|
ref={clipRef}
|
||||||
>
|
>
|
||||||
|
{isMounted ? (
|
||||||
<MotionPreviewClip
|
<MotionPreviewClip
|
||||||
cameraName={camera.name}
|
cameraName={camera.name}
|
||||||
range={range}
|
range={range}
|
||||||
@ -880,15 +929,17 @@ export default function MotionPreviewsPane({
|
|||||||
nonMotionAlpha={nonMotionAlpha}
|
nonMotionAlpha={nonMotionAlpha}
|
||||||
isVisible={
|
isVisible={
|
||||||
windowVisible &&
|
windowVisible &&
|
||||||
(visibleClips.includes(
|
(visibleClips.includes(clipId) ||
|
||||||
`${camera.name}-${range.start_time}-${range.end_time}-${idx}`,
|
|
||||||
) ||
|
|
||||||
(!hasVisibilityData && idx < 8))
|
(!hasVisibilityData && idx < 8))
|
||||||
}
|
}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-video rounded-lg bg-black md:rounded-2xl" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import { LuSearchX } from "react-icons/lu";
|
import { LuSearchX } from "react-icons/lu";
|
||||||
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
|
|
||||||
type ExploreViewProps = {
|
type ExploreViewProps = {
|
||||||
setSearchDetail: (search: SearchResult | undefined) => void;
|
setSearchDetail: (search: SearchResult | undefined) => void;
|
||||||
@ -149,6 +150,9 @@ function ThumbnailRow({
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-background_alt p-2 md:px-4">
|
<div className="rounded-lg bg-background_alt p-2 md:px-4">
|
||||||
<div className="flex flex-row items-center text-lg smart-capitalize">
|
<div className="flex flex-row items-center text-lg smart-capitalize">
|
||||||
|
{labelType === "audio"
|
||||||
|
? getIconForLabel("", labelType, "size-4 mr-1")
|
||||||
|
: null}
|
||||||
{getTranslatedLabel(label, labelType)}
|
{getTranslatedLabel(label, labelType)}
|
||||||
{searchResults && (
|
{searchResults && (
|
||||||
<span className="ml-3 text-sm text-secondary-foreground">
|
<span className="ml-3 text-sm text-secondary-foreground">
|
||||||
|
|||||||
@ -13,7 +13,8 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
||||||
|
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
@ -45,6 +46,7 @@ export default function CameraManagementView({
|
|||||||
undefined,
|
undefined,
|
||||||
); // Track camera being edited
|
); // Track camera being edited
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
// State for restart dialog when enabling a disabled camera
|
// State for restart dialog when enabling a disabled camera
|
||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
@ -98,6 +100,7 @@ export default function CameraManagementView({
|
|||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="w-full max-w-5xl space-y-6">
|
<div className="w-full max-w-5xl space-y-6">
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
onClick={() => setShowWizard(true)}
|
onClick={() => setShowWizard(true)}
|
||||||
@ -106,6 +109,17 @@ export default function CameraManagementView({
|
|||||||
<LuPlus className="h-4 w-4" />
|
<LuPlus className="h-4 w-4" />
|
||||||
{t("cameraManagement.addCamera")}
|
{t("cameraManagement.addCamera")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{enabledCameras.length + disabledCameras.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
className="mb-2 flex max-w-48 items-center gap-2 text-white"
|
||||||
|
>
|
||||||
|
<LuTrash2 className="h-4 w-4" />
|
||||||
|
{t("cameraManagement.deleteCamera")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{enabledCameras.length > 0 && (
|
{enabledCameras.length > 0 && (
|
||||||
<SettingsGroupCard
|
<SettingsGroupCard
|
||||||
@ -221,6 +235,15 @@ export default function CameraManagementView({
|
|||||||
open={showWizard}
|
open={showWizard}
|
||||||
onClose={() => setShowWizard(false)}
|
onClose={() => setShowWizard(false)}
|
||||||
/>
|
/>
|
||||||
|
<DeleteCameraDialog
|
||||||
|
show={showDeleteDialog}
|
||||||
|
cameras={[...enabledCameras, ...disabledCameras]}
|
||||||
|
onClose={() => setShowDeleteDialog(false)}
|
||||||
|
onDeleted={() => {
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
updateConfig();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<RestartDialog
|
<RestartDialog
|
||||||
isOpen={restartDialogOpen}
|
isOpen={restartDialogOpen}
|
||||||
onClose={() => setRestartDialogOpen(false)}
|
onClose={() => setRestartDialogOpen(false)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user