Semantic Search Triggers (#18969)

* semantic trigger test

* database and model

* config

* embeddings maintainer and trigger post-processor

* api to create, edit, delete triggers

* frontend and i18n keys

* use thumbnail and description for trigger types

* image picker tweaks

* initial sync

* thumbnail file management

* clean up logs and use saved thumbnail on frontend

* publish mqtt messages

* webpush changes to enable trigger notifications

* add enabled switch

* add triggers from explore

* renaming and deletion fixes

* fix typing

* UI updates and add last triggering event time and link

* log exception instead of return in endpoint

* highlight entry in UI when triggered

* save and delete thumbnails directly

* remove alert action for now and add descriptions

* tweaks

* clean up

* fix types

* docs

* docs tweaks

* docs

* reuse enum
This commit is contained in:
Josh Hawkins 2025-07-07 09:03:57 -05:00 committed by GitHub
parent 2b4a773f9b
commit 57bb0cc397
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2736 additions and 62 deletions

View File

@ -840,6 +840,23 @@ cameras:
# By default the cameras are sorted alphabetically.
order: 0
# Optional: Configuration for triggers to automate actions based on semantic search results.
triggers:
# Required: Unique identifier for the trigger (generated automatically from nickname if not specified).
trigger_name:
# Required: Enable or disable the trigger. (default: shown below)
enabled: true
# Type of trigger, either `thumbnail` for image-based matching or `description` for text-based matching. (default: none)
type: thumbnail
# Reference data for matching, either an event ID for `thumbnail` or a text string for `description`. (default: none)
data: 1751565549.853251-b69j73
# Similarity threshold for triggering. (default: none)
threshold: 0.7
# List of actions to perform when the trigger fires. (default: none)
# Available options: `notification` (send a webpush notification)
actions:
- notification
# Optional: Configuration for AI generated tracked object descriptions
genai:
# Optional: Enable AI description generation (default: shown below)

View File

@ -102,3 +102,41 @@ See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_
4. Make your search language and tone closely match exactly what you're looking for. If you are using thumbnail search, **phrase your query as an image caption**. Searching for "red car" may not work as well as "red sedan driving down a residential street on a sunny day".
5. Semantic search on thumbnails tends to return better results when matching large subjects that take up most of the frame. Small things like "cat" tend to not work well.
6. Experiment! Find a tracked object you want to test and start typing keywords and phrases to see what works for you.
## Triggers
Triggers utilize semantic search to automate actions when a tracked object matches a specified image or description. Triggers can be configured so that Frigate executes a specific actions when a tracked object's image or description matches a predefined image or text, based on a similarity threshold. Triggers are managed per camera and can be configured via the Frigate UI in the Settings page under the Triggers tab.
### Configuration
Triggers are defined within the `semantic_search` configuration for each camera in your Frigate configuration file or through the UI. Each trigger consists of a `type` (either `thumbnail` or `description`), a `data` field (the reference image event ID or text), a `threshold` for similarity matching, and a list of `actions` to perform when the trigger fires.
#### Managing Triggers in the UI
1. Navigate to the **Settings** page and select the **Triggers** tab.
2. Choose a camera from the dropdown menu to view or manage its triggers.
3. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one.
4. In the **Create Trigger** dialog:
- Enter a **Name** for the trigger (e.g., "red_car_alert").
- Select the **Type** (`Thumbnail` or `Description`).
- For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold.
- For `Description`, enter text to trigger this action when a similar tracked object description is detected.
- Set the **Threshold** for similarity matching.
- Select **Actions** to perform when the trigger fires.
5. Save the trigger to update the configuration and store the embedding in the database.
When a trigger fires, the UI highlights the trigger with a blue outline for 3 seconds for easy identification.
### Usage and Best Practices
1. **Thumbnail Triggers**: Select a representative image (event ID) from the Explore page that closely matches the object you want to detect. For best results, choose images where the object is prominent and fills most of the frame.
2. **Description Triggers**: Write concise, specific text descriptions (e.g., "Person in a red jacket") that align with the tracked objects description. Avoid vague terms to improve matching accuracy.
3. **Threshold Tuning**: Adjust the threshold to balance sensitivity and specificity. A higher threshold (e.g., 0.8) requires closer matches, reducing false positives but potentially missing similar objects. A lower threshold (e.g., 0.6) is more inclusive but may trigger more often.
4. **Using Explore**: Use the context menu or right-click / long-press on a tracked object in the Grid View in Explore to quickly add a trigger based on the tracked object's thumbnail.
5. **Editing triggers**: For the best experience, triggers should be edited via the UI. However, Frigate will ensure triggers edited in the config will be synced with triggers created and edited in the UI.
### Notes
- Triggers rely on the same Jina AI CLIP models (V1 or V2) used for semantic search. Ensure `semantic_search` is enabled and properly configured.
- Reindexing embeddings (via the UI or `reindex: True`) does not affect trigger configurations but may update the embeddings used for matching.
- For optimal performance, use a system with sufficient RAM (8GB minimum, 16GB recommended) and a GPU for `large` model configurations, as described in the Semantic Search requirements.

View File

@ -192,6 +192,20 @@ Message published for each changed review item. The first message is published w
}
```
### `frigate/triggers`
Message published when a trigger defined in a camera's `semantic_search` configuration fires.
```json
{
"name": "car_trigger",
"camera": "driveway",
"event_id": "1751565549.853251-b69j73",
"type": "thumbnail",
"score": 0.85
}
```
### `frigate/stats`
Same data available at `/api/stats` published at a configurable interval.

View File

@ -2,6 +2,8 @@ from typing import List, Optional, Union
from pydantic import BaseModel, Field
from frigate.config.classification import TriggerType
class EventsSubLabelBody(BaseModel):
subLabel: str = Field(title="Sub label", max_length=100)
@ -45,3 +47,9 @@ class EventsDeleteBody(BaseModel):
class SubmitPlusBody(BaseModel):
include_annotation: int = Field(default=1)
class TriggerEmbeddingBody(BaseModel):
type: TriggerType
data: str
threshold: float = Field(default=0.5, ge=0.0, le=1.0)

View File

@ -1,5 +1,6 @@
"""Event apis."""
import base64
import datetime
import logging
import os
@ -10,6 +11,7 @@ from pathlib import Path
from urllib.parse import unquote
import cv2
import numpy as np
from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import JSONResponse
@ -34,6 +36,7 @@ from frigate.api.defs.request.events_body import (
EventsLPRBody,
EventsSubLabelBody,
SubmitPlusBody,
TriggerEmbeddingBody,
)
from frigate.api.defs.response.event_response import (
EventCreateResponse,
@ -44,11 +47,12 @@ from frigate.api.defs.response.event_response import (
from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags
from frigate.comms.event_metadata_updater import EventMetadataTypeEnum
from frigate.const import CLIPS_DIR
from frigate.const import CLIPS_DIR, TRIGGER_DIR
from frigate.embeddings import EmbeddingsContext
from frigate.models import Event, ReviewSegment, Timeline
from frigate.models import Event, ReviewSegment, Timeline, Trigger
from frigate.track.object_processing import TrackedObject
from frigate.util.builtin import get_tz_modifiers
from frigate.util.path import get_event_thumbnail_bytes
logger = logging.getLogger(__name__)
@ -1255,6 +1259,38 @@ def regenerate_description(
)
@router.post(
"/description/generate",
response_model=GenericResponse,
# dependencies=[Depends(require_role(["admin"]))],
)
def generate_description_embedding(
request: Request,
body: EventsDescriptionBody,
):
new_description = body.description
# If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
if len(new_description) > 0:
result = context.generate_description_embedding(
new_description,
)
return JSONResponse(
content=(
{
"success": True,
"message": f"Embedding for description is {result}"
if result
else "Failed to generate embedding",
}
),
status_code=200,
)
def delete_single_event(event_id: str, request: Request) -> dict:
try:
event = Event.get(Event.id == event_id)
@ -1403,3 +1439,397 @@ def end_event(request: Request, event_id: str, body: EventsEndBody):
content=({"success": True, "message": "Event successfully ended."}),
status_code=200,
)
@router.post(
"/trigger/embedding",
response_model=dict,
dependencies=[Depends(require_role(["admin"]))],
)
def create_trigger_embedding(
request: Request,
body: TriggerEmbeddingBody,
camera: str,
name: str,
):
try:
if not request.app.frigate_config.semantic_search.enabled:
return JSONResponse(
content={
"success": False,
"message": "Semantic search is not enabled",
},
status_code=400,
)
# Check if trigger already exists
if (
Trigger.select()
.where(Trigger.camera == camera, Trigger.name == name)
.exists()
):
return JSONResponse(
content={
"success": False,
"message": f"Trigger {camera}:{name} already exists",
},
status_code=400,
)
context: EmbeddingsContext = request.app.embeddings
# Generate embedding based on type
embedding = None
if body.type == "description":
embedding = context.generate_description_embedding(body.data)
elif body.type == "thumbnail":
try:
event: Event = Event.get(Event.id == body.data)
except DoesNotExist:
# TODO: check triggers directory for image
return JSONResponse(
content={
"success": False,
"message": f"Failed to fetch event for {body.type} trigger",
},
status_code=400,
)
# Skip the event if not an object
if event.data.get("type") != "object":
return
if thumbnail := get_event_thumbnail_bytes(event):
cursor = context.db.execute_sql(
"""
SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?
""",
[body.data],
)
row = cursor.fetchone() if cursor else None
if row:
query_embedding = row[0]
embedding = np.frombuffer(query_embedding, dtype=np.float32)
else:
# Extract valid thumbnail
thumbnail = get_event_thumbnail_bytes(event)
if thumbnail is None:
return JSONResponse(
content={
"success": False,
"message": f"Failed to get thumbnail for {body.data} for {body.type} trigger",
},
status_code=400,
)
embedding = context.generate_image_embedding(
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
)
if embedding is None:
return JSONResponse(
content={
"success": False,
"message": f"Failed to generate embedding for {body.type} trigger",
},
status_code=400,
)
if body.type == "thumbnail":
# Save image to the triggers directory
try:
os.makedirs(os.path.join(TRIGGER_DIR, camera), exist_ok=True)
with open(
os.path.join(TRIGGER_DIR, camera, f"{body.data}.webp"), "wb"
) as f:
f.write(thumbnail)
logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera}."
)
except Exception as e:
logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera}: {e}"
)
Trigger.create(
camera=camera,
name=name,
type=body.type,
data=body.data,
threshold=body.threshold,
model=request.app.frigate_config.semantic_search.model,
embedding=np.array(embedding, dtype=np.float32).tobytes(),
triggering_event_id="",
last_triggered=None,
)
return JSONResponse(
content={
"success": True,
"message": f"Trigger created successfully for {camera}:{name}",
},
status_code=200,
)
except Exception as e:
return JSONResponse(
content={
"success": False,
"message": f"Error creating trigger embedding: {str(e)}",
},
status_code=500,
)
@router.put(
"/trigger/embedding/{camera}/{name}",
response_model=dict,
dependencies=[Depends(require_role(["admin"]))],
)
def update_trigger_embedding(
request: Request,
camera: str,
name: str,
body: TriggerEmbeddingBody,
):
try:
if not request.app.frigate_config.semantic_search.enabled:
return JSONResponse(
content={
"success": False,
"message": "Semantic search is not enabled",
},
status_code=400,
)
context: EmbeddingsContext = request.app.embeddings
# Generate embedding based on type
embedding = None
if body.type == "description":
embedding = context.generate_description_embedding(body.data)
elif body.type == "thumbnail":
webp_file = body.data + ".webp"
webp_path = os.path.join(TRIGGER_DIR, camera, webp_file)
try:
event: Event = Event.get(Event.id == body.data)
# Skip the event if not an object
if event.data.get("type") != "object":
return JSONResponse(
content={
"success": False,
"message": f"Event {body.data} is not a tracked object for {body.type} trigger",
},
status_code=400,
)
# Extract valid thumbnail
thumbnail = get_event_thumbnail_bytes(event)
with open(webp_path, "wb") as f:
f.write(thumbnail)
except DoesNotExist:
# check triggers directory for image
if not os.path.exists(webp_path):
return JSONResponse(
content={
"success": False,
"message": f"Failed to fetch event for {body.type} trigger",
},
status_code=400,
)
else:
# Load the image from the triggers directory
with open(webp_path, "rb") as f:
thumbnail = f.read()
embedding = context.generate_image_embedding(
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
)
if embedding is None:
return JSONResponse(
content={
"success": False,
"message": f"Failed to generate embedding for {body.type} trigger",
},
status_code=400,
)
# Check if trigger exists for upsert
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name)
if trigger:
# Update existing trigger
if trigger.data != body.data: # Delete old thumbnail only if data changes
try:
os.remove(os.path.join(TRIGGER_DIR, camera, f"{trigger.data}.webp"))
logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}."
)
except Exception as e:
logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}: {e}"
)
Trigger.update(
data=body.data,
model=request.app.frigate_config.semantic_search.model,
embedding=np.array(embedding, dtype=np.float32).tobytes(),
threshold=body.threshold,
triggering_event_id="",
last_triggered=None,
).where(Trigger.camera == camera, Trigger.name == name).execute()
else:
# Create new trigger (for rename case)
Trigger.create(
camera=camera,
name=name,
type=body.type,
data=body.data,
threshold=body.threshold,
model=request.app.frigate_config.semantic_search.model,
embedding=np.array(embedding, dtype=np.float32).tobytes(),
triggering_event_id="",
last_triggered=None,
)
if body.type == "thumbnail":
# Save image to the triggers directory
try:
os.makedirs(os.path.join(TRIGGER_DIR, camera), exist_ok=True)
with open(
os.path.join(TRIGGER_DIR, camera, f"{body.data}.webp"), "wb"
) as f:
f.write(thumbnail)
logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera}."
)
except Exception as e:
logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera}: {e}"
)
return JSONResponse(
content={
"success": True,
"message": f"Trigger updated successfully for {camera}:{name}",
},
status_code=200,
)
except Exception as e:
return JSONResponse(
content={
"success": False,
"message": f"Error updating trigger embedding: {str(e)}",
},
status_code=500,
)
@router.delete(
"/trigger/embedding/{camera}/{name}",
response_model=dict,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_trigger_embedding(
request: Request,
camera: str,
name: str,
):
try:
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name)
if trigger is None:
return JSONResponse(
content={
"success": False,
"message": f"Trigger {camera}:{name} not found",
},
status_code=500,
)
deleted = (
Trigger.delete()
.where(Trigger.camera == camera, Trigger.name == name)
.execute()
)
if deleted == 0:
return JSONResponse(
content={
"success": False,
"message": f"Error deleting trigger {camera}:{name}",
},
status_code=401,
)
try:
os.remove(os.path.join(TRIGGER_DIR, camera, f"{trigger.data}.webp"))
logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}."
)
except Exception as e:
logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}: {e}"
)
return JSONResponse(
content={
"success": True,
"message": f"Trigger deleted successfully for {camera}:{name}",
},
status_code=200,
)
except Exception as e:
return JSONResponse(
content={
"success": False,
"message": f"Error deleting trigger embedding: {str(e)}",
},
status_code=500,
)
@router.get(
"/triggers/status/{camera_name}",
response_model=dict,
dependencies=[Depends(require_role(["admin"]))],
)
def get_triggers_status(
camera_name: str,
):
try:
# Fetch all triggers for the specified camera
triggers = Trigger.select().where(Trigger.camera == camera_name)
# Prepare the response with trigger status
status = {
trigger.name: {
"last_triggered": trigger.last_triggered.timestamp()
if trigger.last_triggered
else None,
"triggering_event_id": trigger.triggering_event_id
if trigger.triggering_event_id
else None,
}
for trigger in triggers
}
if not status:
return JSONResponse(
content={
"success": False,
"message": f"No triggers found for camera {camera_name}",
},
status_code=404,
)
return {"success": True, "triggers": status}
except Exception as ex:
logger.exception(ex)
return JSONResponse(
content=({"success": False, "message": "Error fetching trigger status"}),
status_code=400,
)

View File

@ -38,6 +38,7 @@ from frigate.const import (
MODEL_CACHE_DIR,
RECORD_DIR,
THUMB_DIR,
TRIGGER_DIR,
)
from frigate.data_processing.types import DataProcessorMetrics
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
@ -55,6 +56,7 @@ from frigate.models import (
Regions,
ReviewSegment,
Timeline,
Trigger,
User,
)
from frigate.object_detection.base import ObjectDetectProcess
@ -121,6 +123,9 @@ class FrigateApp:
if self.config.face_recognition.enabled:
dirs.append(FACE_DIR)
if self.config.semantic_search.enabled:
dirs.append(TRIGGER_DIR)
for d in dirs:
if not os.path.exists(d) and not os.path.islink(d):
logger.info(f"Creating directory: {d}")
@ -286,6 +291,7 @@ class FrigateApp:
ReviewSegment,
Timeline,
User,
Trigger,
]
self.db.bind(models)

View File

@ -186,6 +186,28 @@ class WebPushClient(Communicator): # type: ignore[misc]
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_alert(decoded)
if topic == "triggers":
decoded = json.loads(payload)
camera = decoded["camera"]
name = decoded["name"]
# ensure notifications are enabled and the specific trigger has
# notification action enabled
if (
not self.config.cameras[camera].notifications.enabled
or name not in self.config.cameras[camera].semantic_search.triggers
or "notification"
not in self.config.cameras[camera]
.semantic_search.triggers[name]
.actions
):
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_trigger(decoded)
elif topic == "notification_test":
if not self.config.notifications.enabled and not any(
cam.notifications.enabled for cam in self.config.cameras.values()
@ -264,6 +286,23 @@ class WebPushClient(Communicator): # type: ignore[misc]
except Exception as e:
logger.error(f"Error processing notification: {str(e)}")
def _within_cooldown(self, camera: str) -> bool:
now = datetime.datetime.now().timestamp()
if now - self.last_notification_time < self.config.notifications.cooldown:
logger.debug(
f"Skipping notification for {camera} - in global cooldown period"
)
return True
if (
now - self.last_camera_notification_time[camera]
< self.config.cameras[camera].notifications.cooldown
):
logger.debug(
f"Skipping notification for {camera} - in camera-specific cooldown period"
)
return True
return False
def send_notification_test(self) -> None:
if not self.config.notifications.email:
return
@ -290,24 +329,7 @@ class WebPushClient(Communicator): # type: ignore[misc]
camera: str = payload["after"]["camera"]
current_time = datetime.datetime.now().timestamp()
# Check global cooldown period
if (
current_time - self.last_notification_time
< self.config.notifications.cooldown
):
logger.debug(
f"Skipping notification for {camera} - in global cooldown period"
)
return
# Check camera-specific cooldown period
if (
current_time - self.last_camera_notification_time[camera]
< self.config.cameras[camera].notifications.cooldown
):
logger.debug(
f"Skipping notification for {camera} - in camera-specific cooldown period"
)
if self._within_cooldown(camera):
return
self.check_registrations()
@ -362,6 +384,48 @@ class WebPushClient(Communicator): # type: ignore[misc]
self.cleanup_registrations()
def send_trigger(self, payload: dict[str, Any]) -> None:
if not self.config.notifications.email:
return
camera: str = payload["camera"]
current_time = datetime.datetime.now().timestamp()
if self._within_cooldown(camera):
return
self.check_registrations()
self.last_camera_notification_time[camera] = current_time
self.last_notification_time = current_time
trigger_type = payload["type"]
event_id = payload["event_id"]
name = payload["name"]
score = payload["score"]
title = f"{name.replace('_', ' ')} triggered on {titlecase(camera.replace('_', ' '))}"
message = f"{titlecase(trigger_type)} trigger fired for {titlecase(camera.replace('_', ' '))} with score {score:.2f}"
image = f"clips/triggers/{camera}/{event_id}.webp"
direct_url = f"/explore?event_id={event_id}"
ttl = 0
logger.debug(f"Sending push notification for {camera}, trigger name {name}")
for user in self.web_pushers:
self.send_push_notification(
user=user,
payload=payload,
title=title,
message=message,
direct_url=direct_url,
image=image,
ttl=ttl,
)
self.cleanup_registrations()
def stop(self) -> None:
logger.info("Closing notification queue")
self.notification_thread.join()

View File

@ -22,6 +22,7 @@ from ..classification import (
AudioTranscriptionConfig,
CameraFaceRecognitionConfig,
CameraLicensePlateRecognitionConfig,
CameraSemanticSearchConfig,
)
from .audio import AudioConfig
from .birdseye import BirdseyeCameraConfig
@ -91,6 +92,10 @@ class CameraConfig(FrigateBaseModel):
review: ReviewConfig = Field(
default_factory=ReviewConfig, title="Review configuration."
)
semantic_search: CameraSemanticSearchConfig = Field(
default_factory=CameraSemanticSearchConfig,
title="Semantic search configuration.",
)
snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig, title="Snapshot configuration."
)

View File

@ -23,6 +23,7 @@ class CameraConfigUpdateEnum(str, Enum):
record = "record"
remove = "remove" # for removing a camera
review = "review"
semantic_search = "semantic_search" # for semantic search triggers
snapshots = "snapshots"
zones = "zones"
@ -106,6 +107,8 @@ class CameraConfigUpdateSubscriber:
config.record = updated_config
elif update_type == CameraConfigUpdateEnum.review:
config.review = updated_config
elif update_type == CameraConfigUpdateEnum.semantic_search:
config.semantic_search = updated_config
elif update_type == CameraConfigUpdateEnum.snapshots:
config.snapshots = updated_config
elif update_type == CameraConfigUpdateEnum.zones:

View File

@ -10,6 +10,7 @@ __all__ = [
"CameraLicensePlateRecognitionConfig",
"FaceRecognitionConfig",
"SemanticSearchConfig",
"CameraSemanticSearchConfig",
"LicensePlateRecognitionConfig",
]
@ -24,6 +25,15 @@ class EnrichmentsDeviceEnum(str, Enum):
CPU = "CPU"
class TriggerType(str, Enum):
THUMBNAIL = "thumbnail"
DESCRIPTION = "description"
class TriggerAction(str, Enum):
NOTIFICATION = "notification"
class AudioTranscriptionConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable audio transcription.")
language: str = Field(
@ -113,6 +123,32 @@ class SemanticSearchConfig(FrigateBaseModel):
)
class TriggerConfig(FrigateBaseModel):
enabled: bool = Field(default=True, title="Enable this trigger")
type: TriggerType = Field(default=TriggerType.DESCRIPTION, title="Type of trigger")
data: str = Field(title="Trigger content (text phrase or image ID)")
threshold: float = Field(
title="Confidence score required to run the trigger",
default=0.8,
gt=0.0,
le=1.0,
)
actions: Optional[List[TriggerAction]] = Field(
default=[], title="Actions to perform when trigger is matched"
)
model_config = ConfigDict(extra="forbid", protected_namespaces=())
class CameraSemanticSearchConfig(FrigateBaseModel):
triggers: Optional[Dict[str, TriggerConfig]] = Field(
default=None,
title="Trigger actions on tracked objects that match existing thumbnails or descriptions",
)
model_config = ConfigDict(extra="forbid", protected_namespaces=())
class FaceRecognitionConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable face recognition.")
model_size: str = Field(

View File

@ -11,6 +11,7 @@ EXPORT_DIR = f"{BASE_DIR}/exports"
FACE_DIR = f"{CLIPS_DIR}/faces"
THUMB_DIR = f"{CLIPS_DIR}/thumbs"
RECORD_DIR = f"{BASE_DIR}/recordings"
TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
CACHE_DIR = "/tmp/cache"
FRIGATE_LOCALHOST = "http://127.0.0.1:5000"

View File

@ -0,0 +1,233 @@
"""Post time processor to trigger actions based on similar embeddings."""
import datetime
import json
import logging
import os
from typing import Any
import cv2
import numpy as np
from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR
from frigate.data_processing.types import PostProcessDataEnum
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.embeddings.util import ZScoreNormalization
from frigate.models import Event, Trigger
from frigate.util.builtin import cosine_distance
from frigate.util.path import get_event_thumbnail_bytes
from ..post.api import PostProcessorApi
from ..types import DataProcessorMetrics
logger = logging.getLogger(__name__)
WRITE_DEBUG_IMAGES = False
class SemanticTriggerProcessor(PostProcessorApi):
def __init__(
self,
db: SqliteVecQueueDatabase,
config: FrigateConfig,
requestor: InterProcessRequestor,
metrics: DataProcessorMetrics,
embeddings,
):
super().__init__(config, metrics, None)
self.db = db
self.embeddings = embeddings
self.requestor = requestor
self.trigger_embeddings: list[np.ndarray] = []
self.thumb_stats = ZScoreNormalization()
self.desc_stats = ZScoreNormalization()
# load stats from disk
try:
with open(os.path.join(CONFIG_DIR, ".search_stats.json"), "r") as f:
data = json.loads(f.read())
self.thumb_stats.from_dict(data["thumb_stats"])
self.desc_stats.from_dict(data["desc_stats"])
except FileNotFoundError:
pass
def process_data(
self, data: dict[str, Any], data_type: PostProcessDataEnum
) -> None:
event_id = data["event_id"]
camera = data["camera"]
process_type = data["type"]
if self.config.cameras[camera].semantic_search.triggers is None:
return
triggers = (
Trigger.select(
Trigger.camera,
Trigger.name,
Trigger.data,
Trigger.type,
Trigger.embedding,
Trigger.threshold,
)
.where(Trigger.camera == camera)
.dicts()
.iterator()
)
for trigger in triggers:
if (
trigger["name"]
not in self.config.cameras[camera].semantic_search.triggers
or not self.config.cameras[camera]
.semantic_search.triggers[trigger["name"]]
.enabled
):
logger.debug(
f"Trigger {trigger['name']} is disabled for camera {camera}"
)
continue
logger.debug(
f"Processing {trigger['type']} trigger for {event_id} on {trigger['camera']}: {trigger['name']}"
)
trigger_embedding = np.frombuffer(trigger["embedding"], dtype=np.float32)
# Get embeddings based on type
thumbnail_embedding = None
description_embedding = None
if process_type == "image":
cursor = self.db.execute_sql(
"""
SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?
""",
[event_id],
)
row = cursor.fetchone() if cursor else None
if row:
thumbnail_embedding = np.frombuffer(row[0], dtype=np.float32)
if process_type == "text":
cursor = self.db.execute_sql(
"""
SELECT description_embedding FROM vec_descriptions WHERE id = ?
""",
[event_id],
)
row = cursor.fetchone() if cursor else None
if row:
description_embedding = np.frombuffer(row[0], dtype=np.float32)
# Skip processing if we don't have any embeddings
if thumbnail_embedding is None and description_embedding is None:
logger.debug(f"No embeddings found for {event_id}")
return
# Determine which embedding to compare based on trigger type
if (
trigger["type"] in ["text", "thumbnail"]
and thumbnail_embedding is not None
):
data_embedding = thumbnail_embedding
normalized_distance = self.thumb_stats.normalize(
[cosine_distance(data_embedding, trigger_embedding)],
save_stats=False,
)[0]
elif trigger["type"] == "description" and description_embedding is not None:
data_embedding = description_embedding
normalized_distance = self.desc_stats.normalize(
[cosine_distance(data_embedding, trigger_embedding)],
save_stats=False,
)[0]
else:
continue
similarity = 1 - normalized_distance
logger.debug(
f"Trigger {trigger['name']} ({trigger['data'] if trigger['type'] == 'text' or trigger['type'] == 'description' else 'image'}): "
f"normalized distance: {normalized_distance:.4f}, "
f"similarity: {similarity:.4f}, threshold: {trigger['threshold']}"
)
# Check if similarity meets threshold
if similarity >= trigger["threshold"]:
logger.info(
f"Trigger {trigger['name']} activated with similarity {similarity:.4f}"
)
# Update the trigger's last_triggered and triggering_event_id
Trigger.update(
last_triggered=datetime.datetime.now(), triggering_event_id=event_id
).where(
Trigger.camera == camera, Trigger.name == trigger["name"]
).execute()
# Always publish MQTT message
self.requestor.send_data(
"triggers",
json.dumps(
{
"name": trigger["name"],
"camera": camera,
"event_id": event_id,
"type": trigger["type"],
"score": similarity,
}
),
)
if (
self.config.cameras[camera]
.semantic_search.triggers[trigger["name"]]
.actions
):
# TODO: handle actions for the trigger
# notifications already handled by webpush
pass
if WRITE_DEBUG_IMAGES:
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return
# Skip the event if not an object
if event.data.get("type") != "object":
return
thumbnail_bytes = get_event_thumbnail_bytes(event)
nparr = np.frombuffer(thumbnail_bytes, np.uint8)
thumbnail = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
font_scale = 0.5
font = cv2.FONT_HERSHEY_SIMPLEX
cv2.putText(
thumbnail,
f"{similarity:.4f}",
(10, 30),
font,
fontScale=font_scale,
color=(0, 255, 0),
thickness=2,
)
current_time = int(datetime.datetime.now().timestamp())
cv2.imwrite(
f"debug/frames/trigger-{event_id}_{current_time}.jpg",
thumbnail,
)
def handle_request(self, topic, request_data):
return None
def expire_object(self, object_id, camera):
pass

View File

@ -287,3 +287,15 @@ class EmbeddingsContext:
return self.requestor.send_data(
EmbeddingsRequestEnum.transcribe_audio.value, {"event": event}
)
def generate_description_embedding(self, text: str) -> None:
return self.requestor.send_data(
EmbeddingsRequestEnum.embed_description.value,
{"id": None, "description": text, "upsert": False},
)
def generate_image_embedding(self, event_id: str, thumbnail: bytes) -> None:
return self.requestor.send_data(
EmbeddingsRequestEnum.embed_thumbnail.value,
{"id": str(event_id), "thumbnail": str(thumbnail), "upsert": False},
)

View File

@ -6,20 +6,25 @@ import os
import threading
import time
from numpy import ndarray
import numpy as np
from peewee import DoesNotExist, IntegrityError
from playhouse.shortcuts import model_to_dict
from frigate.comms.embeddings_updater import (
EmbeddingsRequestEnum,
)
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.config.classification import SemanticSearchModelEnum
from frigate.const import (
CONFIG_DIR,
TRIGGER_DIR,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_MODEL_STATE,
)
from frigate.data_processing.types import DataProcessorMetrics
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.models import Event
from frigate.models import Event, Trigger
from frigate.types import ModelStatusTypesEnum
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
from frigate.util.path import get_event_thumbnail_bytes
@ -165,7 +170,7 @@ class Embeddings:
def embed_thumbnail(
self, event_id: str, thumbnail: bytes, upsert: bool = True
) -> ndarray:
) -> np.ndarray:
"""Embed thumbnail and optionally insert into DB.
@param: event_id in Events DB
@ -192,7 +197,7 @@ class Embeddings:
def batch_embed_thumbnail(
self, event_thumbs: dict[str, bytes], upsert: bool = True
) -> list[ndarray]:
) -> list[np.ndarray]:
"""Embed thumbnails and optionally insert into DB.
@param: event_thumbs Map of Event IDs in DB to thumbnail bytes in jpg format
@ -225,7 +230,7 @@ class Embeddings:
def embed_description(
self, event_id: str, description: str, upsert: bool = True
) -> ndarray:
) -> np.ndarray:
start = datetime.datetime.now().timestamp()
embedding = self.text_embedding([description])[0]
@ -245,7 +250,7 @@ class Embeddings:
def batch_embed_description(
self, event_descriptions: dict[str, str], upsert: bool = True
) -> ndarray:
) -> np.ndarray:
start = datetime.datetime.now().timestamp()
# upsert embeddings one by one to avoid token limit
embeddings = []
@ -401,3 +406,224 @@ class Embeddings:
with self.reindex_lock:
self.reindex_running = False
self.reindex_thread = None
def sync_triggers(self) -> None:
for camera in self.config.cameras.values():
# Get all existing triggers for this camera
existing_triggers = {
trigger.name: trigger
for trigger in Trigger.select().where(Trigger.camera == camera.name)
}
# Get all configured trigger names
configured_trigger_names = set(camera.semantic_search.triggers or {})
# Create or update triggers from config
for trigger_name, trigger in (
camera.semantic_search.triggers or {}
).items():
if trigger_name in existing_triggers:
existing_trigger = existing_triggers[trigger_name]
needs_embedding_update = False
thumbnail_missing = False
# Check if data has changed or thumbnail is missing for thumbnail type
if trigger.type == "thumbnail":
thumbnail_path = os.path.join(
TRIGGER_DIR, camera.name, f"{trigger.data}.webp"
)
try:
event = Event.get(Event.id == trigger.data)
if event.data.get("type") != "object":
logger.warning(
f"Event {trigger.data} is not a tracked object for {trigger.type} trigger"
)
continue # Skip if not an object
# Check if thumbnail needs to be updated (data changed or missing)
if (
existing_trigger.data != trigger.data
or not os.path.exists(thumbnail_path)
):
thumbnail = get_event_thumbnail_bytes(event)
if not thumbnail:
logger.warning(
f"Unable to retrieve thumbnail for event ID {trigger.data} for {trigger_name}."
)
continue
self.write_trigger_thumbnail(
camera.name, trigger.data, thumbnail
)
thumbnail_missing = True
except DoesNotExist:
logger.warning(
f"Event ID {trigger.data} for trigger {trigger_name} does not exist."
)
continue
# Update existing trigger if data has changed
if (
existing_trigger.type != trigger.type
or existing_trigger.data != trigger.data
or existing_trigger.threshold != trigger.threshold
):
existing_trigger.type = trigger.type
existing_trigger.data = trigger.data
existing_trigger.threshold = trigger.threshold
needs_embedding_update = True
# Check if embedding is missing or needs update
if (
not existing_trigger.embedding
or needs_embedding_update
or thumbnail_missing
):
existing_trigger.embedding = self._calculate_trigger_embedding(
trigger
)
needs_embedding_update = True
if needs_embedding_update:
existing_trigger.save()
else:
# Create new trigger
try:
try:
event: Event = Event.get(Event.id == trigger.data)
except DoesNotExist:
logger.warning(
f"Event ID {trigger.data} for trigger {trigger_name} does not exist."
)
continue
# Skip the event if not an object
if event.data.get("type") != "object":
logger.warning(
f"Event ID {trigger.data} for trigger {trigger_name} is not a tracked object."
)
continue
thumbnail = get_event_thumbnail_bytes(event)
if not thumbnail:
logger.warning(
f"Unable to retrieve thumbnail for event ID {trigger.data} for {trigger_name}."
)
continue
self.write_trigger_thumbnail(
camera.name, trigger.data, thumbnail
)
# Calculate embedding for new trigger
embedding = self._calculate_trigger_embedding(trigger)
Trigger.create(
camera=camera.name,
name=trigger_name,
type=trigger.type,
data=trigger.data,
threshold=trigger.threshold,
model=self.config.semantic_search.model,
embedding=embedding,
triggering_event_id="",
last_triggered=None,
)
except IntegrityError:
pass # Handle duplicate creation attempts
# Remove triggers that are no longer in config
triggers_to_remove = (
set(existing_triggers.keys()) - configured_trigger_names
)
if triggers_to_remove:
Trigger.delete().where(
Trigger.camera == camera.name, Trigger.name.in_(triggers_to_remove)
).execute()
for trigger_name in triggers_to_remove:
self.remove_trigger_thumbnail(camera.name, trigger_name)
def write_trigger_thumbnail(
self, camera: str, event_id: str, thumbnail: bytes
) -> None:
"""Write the thumbnail to the trigger directory."""
try:
os.makedirs(os.path.join(TRIGGER_DIR, camera), exist_ok=True)
with open(os.path.join(TRIGGER_DIR, camera, f"{event_id}.webp"), "wb") as f:
f.write(thumbnail)
logger.debug(
f"Writing thumbnail for trigger with data {event_id} in {camera}."
)
except Exception as e:
logger.error(
f"Failed to write thumbnail for trigger with data {event_id} in {camera}: {e}"
)
def remove_trigger_thumbnail(self, camera: str, event_id: str) -> None:
"""Write the thumbnail to the trigger directory."""
try:
os.remove(os.path.join(TRIGGER_DIR, camera, f"{event_id}.webp"))
logger.debug(
f"Deleted thumbnail for trigger with data {event_id} in {camera}."
)
except Exception as e:
logger.error(
f"Failed to delete thumbnail for trigger with data {event_id} in {camera}: {e}"
)
def _calculate_trigger_embedding(self, trigger) -> bytes:
"""Calculate embedding for a trigger based on its type and data."""
if trigger.type == "description":
logger.debug(f"Generating embedding for trigger description {trigger.name}")
embedding = self.requestor.send_data(
EmbeddingsRequestEnum.embed_description.value,
{"id": None, "description": trigger.data, "upsert": False},
)
return embedding.astype(np.float32).tobytes()
elif trigger.type == "thumbnail":
# For image triggers, trigger.data should be an image ID
# Try to get embedding from vec_thumbnails table first
cursor = self.db.execute_sql(
"SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?",
[trigger.data],
)
row = cursor.fetchone() if cursor else None
if row:
return row[0] # Already in bytes format
else:
logger.debug(
f"No thumbnail embedding found for image ID: {trigger.data}, generating from saved trigger thumbnail"
)
try:
with open(
os.path.join(
TRIGGER_DIR, trigger.camera, f"{trigger.data}.webp"
),
"rb",
) as f:
thumbnail = f.read()
except Exception as e:
logger.error(
f"Failed to read thumbnail for trigger {trigger.name} with ID {trigger.data}: {e}"
)
return b""
logger.debug(
f"Generating embedding for trigger thumbnail {trigger.name} with ID {trigger.data}"
)
embedding = self.requestor.send_data(
EmbeddingsRequestEnum.embed_thumbnail.value,
{
"id": str(trigger.data),
"thumbnail": str(thumbnail),
"upsert": False,
},
)
return embedding.astype(np.float32).tobytes()
else:
logger.warning(f"Unknown trigger type: {trigger.type}")
return b""

View File

@ -14,7 +14,10 @@ import numpy as np
from peewee import DoesNotExist
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsResponder
from frigate.comms.embeddings_updater import (
EmbeddingsRequestEnum,
EmbeddingsResponder,
)
from frigate.comms.event_metadata_updater import (
EventMetadataPublisher,
EventMetadataSubscriber,
@ -46,6 +49,7 @@ from frigate.data_processing.post.audio_transcription import (
from frigate.data_processing.post.license_plate import (
LicensePlatePostProcessor,
)
from frigate.data_processing.post.semantic_trigger import SemanticTriggerProcessor
from frigate.data_processing.real_time.api import RealTimeProcessorApi
from frigate.data_processing.real_time.bird import BirdRealTimeProcessor
from frigate.data_processing.real_time.custom_classification import (
@ -60,7 +64,7 @@ from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataE
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
from frigate.genai import get_genai_client
from frigate.models import Event, Recordings
from frigate.models import Event, Recordings, Trigger
from frigate.types import TrackedObjectUpdateTypesEnum
from frigate.util.builtin import serialize
from frigate.util.image import (
@ -93,7 +97,11 @@ class EmbeddingMaintainer(threading.Thread):
self.config_updater = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
[CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove],
[
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.semantic_search,
],
)
# Configure Frigate DB
@ -109,7 +117,7 @@ class EmbeddingMaintainer(threading.Thread):
),
load_vec_extension=True,
)
models = [Event, Recordings]
models = [Event, Recordings, Trigger]
db.bind(models)
if config.semantic_search.enabled:
@ -119,6 +127,9 @@ class EmbeddingMaintainer(threading.Thread):
if config.semantic_search.reindex:
self.embeddings.reindex()
# Sync semantic search triggers in db with config
self.embeddings.sync_triggers()
# create communication for updating event descriptions
self.requestor = InterProcessRequestor()
@ -211,6 +222,17 @@ class EmbeddingMaintainer(threading.Thread):
AudioTranscriptionPostProcessor(self.config, self.requestor, metrics)
)
if self.config.semantic_search.enabled:
self.post_processors.append(
SemanticTriggerProcessor(
db,
self.config,
self.requestor,
metrics,
self.embeddings,
)
)
self.stop_event = stop_event
self.tracked_events: dict[str, list[Any]] = {}
self.early_request_sent: dict[str, bool] = {}
@ -387,33 +409,6 @@ class EmbeddingMaintainer(threading.Thread):
event_id, camera, updated_db = ended
camera_config = self.config.cameras[camera]
# call any defined post processors
for processor in self.post_processors:
if isinstance(processor, LicensePlatePostProcessor):
recordings_available = self.recordings_available_through.get(camera)
if (
recordings_available is not None
and event_id in self.detected_license_plates
and self.config.cameras[camera].type != "lpr"
):
processor.process_data(
{
"event_id": event_id,
"camera": camera,
"recordings_available": self.recordings_available_through[
camera
],
"obj_data": self.detected_license_plates[event_id][
"obj_data"
],
},
PostProcessDataEnum.recording,
)
elif isinstance(processor, AudioTranscriptionPostProcessor):
continue
else:
processor.process_data(event_id, PostProcessDataEnum.event_id)
# expire in realtime processors
for processor in self.realtime_processors:
processor.expire_object(event_id, camera)
@ -450,6 +445,41 @@ class EmbeddingMaintainer(threading.Thread):
):
self._process_genai_description(event, camera_config, thumbnail)
# call any defined post processors
for processor in self.post_processors:
if isinstance(processor, LicensePlatePostProcessor):
recordings_available = self.recordings_available_through.get(camera)
if (
recordings_available is not None
and event_id in self.detected_license_plates
and self.config.cameras[camera].type != "lpr"
):
processor.process_data(
{
"event_id": event_id,
"camera": camera,
"recordings_available": self.recordings_available_through[
camera
],
"obj_data": self.detected_license_plates[event_id][
"obj_data"
],
},
PostProcessDataEnum.recording,
)
elif isinstance(processor, AudioTranscriptionPostProcessor):
continue
elif isinstance(processor, SemanticTriggerProcessor):
processor.process_data(
{"event_id": event_id, "camera": camera, "type": "image"},
PostProcessDataEnum.tracked_object,
)
else:
processor.process_data(
{"event_id": event_id, "camera": camera},
PostProcessDataEnum.tracked_object,
)
# Delete tracked events based on the event_id
if event_id in self.tracked_events:
del self.tracked_events[event_id]
@ -658,6 +688,16 @@ class EmbeddingMaintainer(threading.Thread):
if self.config.semantic_search.enabled:
self.embeddings.embed_description(event.id, description)
# Check semantic trigger for this description
for processor in self.post_processors:
if isinstance(processor, SemanticTriggerProcessor):
processor.process_data(
{"event_id": event.id, "camera": event.camera, "type": "text"},
PostProcessDataEnum.tracked_object,
)
else:
continue
logger.debug(
"Generated description for %s (%d images): %s",
event.id,

View File

@ -1,6 +1,8 @@
from peewee import (
BlobField,
BooleanField,
CharField,
CompositeKey,
DateTimeField,
FloatField,
ForeignKeyField,
@ -132,3 +134,18 @@ class User(Model): # type: ignore[misc]
)
password_hash = CharField(null=False, max_length=120)
notification_tokens = JSONField()
class Trigger(Model): # type: ignore[misc]
camera = CharField(max_length=20)
name = CharField()
type = CharField(max_length=10)
data = TextField()
threshold = FloatField()
model = CharField(max_length=30)
embedding = BlobField()
triggering_event_id = CharField(max_length=30)
last_triggered = DateTimeField()
class Meta:
primary_key = CompositeKey("camera", "name")

View File

@ -428,3 +428,19 @@ def sanitize_float(value):
if isinstance(value, (int, float)) and not math.isfinite(value):
return 0.0
return value
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return 1 - cosine_distance(a, b)
def cosine_distance(a: np.ndarray, b: np.ndarray) -> float:
"""Returns cosine distance to match sqlite-vec's calculation."""
dot = np.dot(a, b)
a_mag = np.dot(a, a) # ||a||^2
b_mag = np.dot(b, b) # ||b||^2
if a_mag == 0 or b_mag == 0:
return 1.0
return 1.0 - (dot / (np.sqrt(a_mag) * np.sqrt(b_mag)))

View File

@ -0,0 +1,50 @@
"""Peewee migrations -- 031_create_trigger_table.py.
This migration creates the Trigger table to track semantic search triggers for cameras.
Some examples (model - class or model_name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
"""
CREATE TABLE IF NOT EXISTS trigger (
camera VARCHAR(20) NOT NULL,
name VARCHAR NOT NULL,
type VARCHAR(10) NOT NULL,
model VARCHAR(30) NOT NULL,
data TEXT NOT NULL,
threshold REAL,
embedding BLOB,
triggering_event_id VARCHAR(30),
last_triggered DATETIME,
PRIMARY KEY (camera, name)
)
"""
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql("DROP TABLE IF EXISTS trigger")

View File

@ -109,5 +109,12 @@
"markAsReviewed": "Mark as reviewed",
"deleteNow": "Delete Now"
}
},
"imagePicker": {
"selectImage": "Select a tracked object's thumbnail",
"search": {
"placeholder": "Search by label or sub label..."
},
"noImages": "No thumbnails found for this camera"
}
}

View File

@ -175,6 +175,10 @@
"label": "Find similar",
"aria": "Find similar tracked objects"
},
"addTrigger": {
"label": "Add trigger",
"aria": "Add a trigger for this tracked object"
},
"audioTranscription": {
"label": "Transcribe",
"aria": "Request audio transcription"

View File

@ -644,5 +644,100 @@
"success": "Frigate+ settings have been saved. Restart Frigate to apply changes.",
"error": "Failed to save config changes: {{errorMessage}}"
}
},
"triggers": {
"documentTitle": "Triggers",
"management": {
"title": "Trigger Management",
"desc": "Manage triggers for {{camera}}. Use the thumbnail type to trigger on similar thumbnails to your selected tracked object, and the description type to trigger on similar descriptions to text you specify."
},
"addTrigger": "Add Trigger",
"table": {
"name": "Name",
"type": "Type",
"content": "Content",
"threshold": "Threshold",
"actions": "Actions",
"noTriggers": "No triggers configured for this camera.",
"edit": "Edit",
"deleteTrigger": "Delete Trigger",
"lastTriggered": "Last triggered"
},
"type": {
"thumbnail": "Thumbnail",
"description": "Description"
},
"actions": {
"alert": "Mark as Alert",
"notification": "Send Notification"
},
"dialog": {
"createTrigger": {
"title": "Create Trigger",
"desc": "Create a trigger for camera {{camera}}"
},
"editTrigger": {
"title": "Edit Trigger",
"desc": "Edit the settings for trigger on camera {{camera}}"
},
"deleteTrigger": {
"title": "Delete Trigger",
"desc": "Are you sure you want to delete the trigger <strong>{{triggerName}}</strong>? This action cannot be undone."
},
"form": {
"name": {
"title": "Name",
"placeholder": "Enter trigger name",
"error": {
"minLength": "Name must be at least 2 characters long.",
"invalidCharacters": "Name can only contain letters, numbers, underscores, and hyphens.",
"alreadyExists": "A trigger with this name already exists for this camera."
}
},
"enabled": {
"description": "Enable or disable this trigger"
},
"type": {
"title": "Type",
"placeholder": "Select trigger type"
},
"content": {
"title": "Content",
"imagePlaceholder": "Select an image",
"textPlaceholder": "Enter text content",
"imageDesc": "Select an image to trigger this action when a similar image is detected.",
"textDesc": "Enter text to trigger this action when a similar tracked object description is detected.",
"error": {
"required": "Content is required."
}
},
"threshold": {
"title": "Threshold",
"error": {
"min": "Threshold must be at least 0",
"max": "Threshold must be at most 1"
}
},
"actions": {
"title": "Actions",
"desc": "By default, Frigate fires an MQTT message for all triggers. Choose an additional action to perform when this trigger fires.",
"error": {
"min": "At least one action must be selected."
}
}
}
},
"toast": {
"success": {
"createTrigger": "Trigger {{name}} created successfully.",
"updateTrigger": "Trigger {{name}} updated successfully.",
"deleteTrigger": "Trigger {{name}} deleted successfully."
},
"error": {
"createTriggerFailed": "Failed to create trigger: {{errorMessage}}",
"updateTriggerFailed": "Failed to update trigger: {{errorMessage}}",
"deleteTriggerFailed": "Failed to delete trigger: {{errorMessage}}"
}
}
}
}

View File

@ -9,6 +9,7 @@ import {
ModelState,
ToggleableSetting,
TrackedObjectUpdateReturnType,
TriggerStatus,
} from "@/types/ws";
import { FrigateStats } from "@/types/stats";
import { createContainer } from "react-tracked";
@ -572,3 +573,13 @@ export function useNotificationTest(): {
} = useWs("notification_test", "notification_test");
return { payload: payload as string, send };
}
export function useTriggers(): { payload: TriggerStatus } {
const {
value: { payload },
} = useWs("triggers", "");
const parsed = payload
? JSON.parse(payload as string)
: { name: "", camera: "", event_id: "", type: "", score: 0 };
return { payload: useDeepMemo(parsed) };
}

View File

@ -15,6 +15,7 @@ type SearchThumbnailProps = {
refreshResults: () => void;
showObjectLifecycle: () => void;
showSnapshot: () => void;
addTrigger: () => void;
};
export default function SearchThumbnailFooter({
@ -24,6 +25,7 @@ export default function SearchThumbnailFooter({
refreshResults,
showObjectLifecycle,
showSnapshot,
addTrigger,
}: SearchThumbnailProps) {
const { t } = useTranslation(["views/search"]);
const { data: config } = useSWR<FrigateConfig>("config");
@ -61,6 +63,7 @@ export default function SearchThumbnailFooter({
refreshResults={refreshResults}
showObjectLifecycle={showObjectLifecycle}
showSnapshot={showSnapshot}
addTrigger={addTrigger}
/>
</div>
</div>

View File

@ -41,6 +41,7 @@ import {
import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next";
import { BsFillLightningFill } from "react-icons/bs";
type SearchResultActionsProps = {
searchResult: SearchResult;
@ -48,6 +49,7 @@ type SearchResultActionsProps = {
refreshResults: () => void;
showObjectLifecycle: () => void;
showSnapshot: () => void;
addTrigger: () => void;
isContextMenu?: boolean;
children?: ReactNode;
};
@ -58,6 +60,7 @@ export default function SearchResultActions({
refreshResults,
showObjectLifecycle,
showSnapshot,
addTrigger,
isContextMenu = false,
children,
}: SearchResultActionsProps) {
@ -138,6 +141,16 @@ export default function SearchResultActions({
<span>{t("itemMenu.findSimilar.label")}</span>
</MenuItem>
)}
{config?.semantic_search?.enabled &&
searchResult.data.type == "object" && (
<MenuItem
aria-label={t("itemMenu.addTrigger.aria")}
onClick={addTrigger}
>
<BsFillLightningFill className="mr-2 size-4" />
<span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem>
)}
{isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&

View File

@ -0,0 +1,416 @@
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import useSWR from "swr";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig";
import ImagePicker from "@/components/overlay/ImagePicker";
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "../ui/textarea";
type CreateTriggerDialogProps = {
show: boolean;
trigger: Trigger | null;
selectedCamera: string;
isLoading: boolean;
onCreate: (
enabled: boolean,
name: string,
type: TriggerType,
data: string,
threshold: number,
actions: TriggerAction[],
) => void;
onEdit: (trigger: Trigger) => void;
onCancel: () => void;
};
export default function CreateTriggerDialog({
show,
trigger,
selectedCamera,
isLoading,
onCreate,
onEdit,
onCancel,
}: CreateTriggerDialogProps) {
const { t } = useTranslation("views/settings");
const { data: config } = useSWR<FrigateConfig>("config");
const existingTriggerNames = useMemo(() => {
if (
!config ||
!selectedCamera ||
!config.cameras[selectedCamera]?.semantic_search?.triggers
) {
return [];
}
return Object.keys(config.cameras[selectedCamera].semantic_search.triggers);
}, [config, selectedCamera]);
const formSchema = z.object({
enabled: z.boolean(),
name: z
.string()
.min(2, t("triggers.dialog.form.name.error.minLength"))
.regex(
/^[a-zA-Z0-9_-]+$/,
t("triggers.dialog.form.name.error.invalidCharacters"),
)
.refine(
(value) =>
!existingTriggerNames.includes(value) || value === trigger?.name,
t("triggers.dialog.form.name.error.alreadyExists"),
),
type: z.enum(["thumbnail", "description"]),
data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
threshold: z
.number()
.min(0, t("triggers.dialog.form.threshold.error.min"))
.max(1, t("triggers.dialog.form.threshold.error.max")),
actions: z.array(z.enum(["notification"])),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
enabled: trigger?.enabled ?? true,
name: trigger?.name ?? "",
type: trigger?.type ?? "description",
data: trigger?.data ?? "",
threshold: trigger?.threshold ?? 0.5,
actions: trigger?.actions ?? [],
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (trigger) {
onEdit({ ...values });
} else {
onCreate(
values.enabled,
values.name,
values.type,
values.data,
values.threshold,
values.actions,
);
}
};
useEffect(() => {
if (!show) {
form.reset({
enabled: true,
name: "",
type: "description",
data: "",
threshold: 0.5,
actions: [],
});
} else if (trigger) {
form.reset(
{
enabled: trigger.enabled,
name: trigger.name,
type: trigger.type,
data: trigger.data,
threshold: trigger.threshold,
actions: trigger.actions,
},
{ keepDirty: false, keepTouched: false }, // Reset validation state
);
// Trigger validation to ensure isValid updates
// form.trigger();
}
}, [show, trigger, form]);
const handleCancel = () => {
form.reset();
onCancel();
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{t(
trigger
? "triggers.dialog.editTrigger.title"
: "triggers.dialog.createTrigger.title",
)}
</DialogTitle>
<DialogDescription>
{t(
trigger
? "triggers.dialog.editTrigger.desc"
: "triggers.dialog.createTrigger.desc",
{ camera: selectedCamera },
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 py-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("triggers.dialog.form.name.title")}</FormLabel>
<FormControl>
<Input
placeholder={t("triggers.dialog.form.name.placeholder")}
className="h-10"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-base">
{t("enabled", { ns: "common" })}
</FormLabel>
<div className="text-sm text-muted-foreground">
{t("triggers.dialog.form.enabled.description")}
</div>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("triggers.dialog.form.type.title")}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="h-10">
<SelectValue
placeholder={t(
"triggers.dialog.form.type.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="thumbnail">
{t("triggers.type.thumbnail")}
</SelectItem>
<SelectItem value="description">
{t("triggers.type.description")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("triggers.dialog.form.content.title")}
</FormLabel>
{form.watch("type") === "thumbnail" ? (
<>
<FormControl>
<ImagePicker
selectedImageId={field.value}
setSelectedImageId={field.onChange}
camera={selectedCamera}
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.content.imageDesc")}
</FormDescription>
</>
) : (
<>
<FormControl>
<Textarea
placeholder={t(
"triggers.dialog.form.content.textPlaceholder",
)}
{...field}
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.content.textDesc")}
</FormDescription>
</>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="threshold"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("triggers.dialog.form.threshold.title")}
</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min="0"
max="1"
placeholder="0.50"
className="h-10"
{...field}
onChange={(e) => {
const value = parseFloat(e.target.value);
field.onChange(isNaN(value) ? 0 : value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="actions"
render={() => (
<FormItem>
<FormLabel>
{t("triggers.dialog.form.actions.title")}
</FormLabel>
<div className="space-y-2">
{["notification"].map((action) => (
<div key={action} className="flex items-center space-x-2">
<FormControl>
<Checkbox
checked={form
.watch("actions")
.includes(action as TriggerAction)}
onCheckedChange={(checked) => {
const currentActions = form.getValues("actions");
if (checked) {
form.setValue("actions", [
...currentActions,
action as TriggerAction,
]);
} else {
form.setValue(
"actions",
currentActions.filter((a) => a !== action),
);
}
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{t(`triggers.actions.${action}`)}
</FormLabel>
</div>
))}
</div>
<FormDescription>
{t("triggers.dialog.form.actions.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex gap-2 pt-2 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" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,80 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trans } from "react-i18next";
import ActivityIndicator from "@/components/indicators/activity-indicator";
type DeleteTriggerDialogProps = {
show: boolean;
triggerName: string;
isLoading: boolean;
onCancel: () => void;
onDelete: () => void;
};
export default function DeleteTriggerDialog({
show,
triggerName,
isLoading,
onCancel,
onDelete,
}: DeleteTriggerDialogProps) {
const { t } = useTranslation("views/settings");
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("triggers.dialog.deleteTrigger.title")}</DialogTitle>
<DialogDescription>
<Trans
ns={"views/settings"}
values={{ triggerName }}
components={{ strong: <span className="font-medium" /> }}
>
triggers.dialog.deleteTrigger.desc
</Trans>
</DialogDescription>
</DialogHeader>
<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={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={onDelete}
disabled={isLoading}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.delete", { ns: "common" })}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -60,7 +60,7 @@ export default function DeleteUserDialog({
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1"
className="flex flex-1 text-white"
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}

View File

@ -0,0 +1,172 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { IoClose } from "react-icons/io5";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import Heading from "@/components/ui/heading";
import { cn } from "@/lib/utils";
import { Event } from "@/types/event";
import { useApiHost } from "@/api";
import { isDesktop, isMobile } from "react-device-detect";
type ImagePickerProps = {
selectedImageId?: string;
setSelectedImageId?: (id: string) => void;
camera: string;
};
export default function ImagePicker({
selectedImageId,
setSelectedImageId,
camera,
}: ImagePickerProps) {
const { t } = useTranslation(["components/dialog"]);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState("");
const { data: events } = useSWR<Event[]>(
`events?camera=${camera}&limit=100`,
{
revalidateOnFocus: false,
},
);
const apiHost = useApiHost();
const images = useMemo(() => {
if (!events) return [];
return events.filter(
(event) =>
(event.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
(event.sub_label &&
event.sub_label.toLowerCase().includes(searchTerm.toLowerCase())) ||
searchTerm === "") &&
event.camera === camera,
);
}, [events, searchTerm, camera]);
const selectedImage = useMemo(
() => images.find((img) => img.id === selectedImageId),
[images, selectedImageId],
);
const handleImageSelect = useCallback(
(id: string) => {
if (setSelectedImageId) {
setSelectedImageId(id);
}
setSearchTerm("");
setOpen(false);
},
[setSelectedImageId],
);
return (
<div ref={containerRef}>
<Dialog
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<DialogTrigger asChild>
{!selectedImageId ? (
<Button
className="mt-2 w-full text-muted-foreground"
aria-label={t("imagePicker.selectImage")}
>
{t("imagePicker.selectImage")}
</Button>
) : (
<div className="hover:cursor-pointer">
<div className="my-3 flex w-full flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center gap-2">
<img
src={
selectedImage
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
}
alt={selectedImage?.label || "Selected image"}
className="h-8 w-8 rounded object-cover"
/>
<div className="text-sm smart-capitalize">
{selectedImage?.label || selectedImageId}
{selectedImage?.sub_label
? ` (${selectedImage.sub_label})`
: ""}
</div>
</div>
<IoClose
className="mx-2 hover:cursor-pointer"
onClick={() => {
if (setSelectedImageId) {
setSelectedImageId("");
}
}}
/>
</div>
</div>
)}
</DialogTrigger>
<DialogTitle className="sr-only">
{t("imagePicker.selectImage")}
</DialogTitle>
<DialogContent
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-3xl",
isMobile && "px-4",
)}
>
<div className="mb-3 flex flex-row items-center justify-between">
<Heading as="h4">{t("imagePicker.selectImage")}</Heading>
<span tabIndex={0} className="sr-only" />
</div>
<Input
type="text"
placeholder={t("imagePicker.search.placeholder")}
className="text-md mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="scrollbar-container flex h-full flex-col overflow-y-auto">
<div className="grid grid-cols-3 gap-2 pr-1">
{images.length === 0 ? (
<div className="col-span-3 text-center text-sm text-muted-foreground">
{t("imagePicker.noImages")}
</div>
) : (
images.map((image) => (
<div
key={image.id}
className={cn(
"flex flex-row items-center justify-center rounded-lg p-1 hover:cursor-pointer",
selectedImageId === image.id
? "bg-selected text-white"
: "hover:bg-secondary-foreground",
)}
>
<img
src={`${apiHost}api/events/${image.id}/thumbnail.webp`}
alt={image.label}
className="rounded object-cover"
onClick={() => handleImageSelect(image.id)}
/>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -476,6 +476,7 @@ function LibrarySelector({
</Button>
<Button
variant="destructive"
className="text-white"
onClick={() => {
if (confirmDelete) {
handleDeleteFace(confirmDelete);

View File

@ -45,6 +45,7 @@ import { isInIframe } from "@/utils/isIFrame";
import { isPWA } from "@/utils/isPWA";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next";
import TriggerView from "@/views/settings/TriggerView";
const allSettingsViews = [
"ui",
@ -52,6 +53,7 @@ const allSettingsViews = [
"cameras",
"masksAndZones",
"motionTuner",
"triggers",
"debug",
"users",
"notifications",
@ -171,7 +173,7 @@ export default function Settings() {
}
}
// don't clear url params if we're creating a new object mask
return !searchParams.has("object_mask");
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
});
useSearchEffect("camera", (camera: string) => {
@ -179,8 +181,8 @@ export default function Settings() {
if (cameraNames.includes(camera)) {
setSelectedCamera(camera);
}
// don't clear url params if we're creating a new object mask
return !searchParams.has("object_mask");
// don't clear url params if we're creating a new object mask or trigger
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
});
useEffect(() => {
@ -229,7 +231,8 @@ export default function Settings() {
{(page == "debug" ||
page == "cameras" ||
page == "masksAndZones" ||
page == "motionTuner") && (
page == "motionTuner" ||
page == "triggers") && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
{page == "masksAndZones" && (
<ZoneMaskFilterButton
@ -274,6 +277,12 @@ export default function Settings() {
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page === "triggers" && (
<TriggerView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "users" && <AuthenticationView />}
{page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} />

View File

@ -1,4 +1,5 @@
import { IconName } from "@/components/icons/IconPicker";
import { TriggerAction, TriggerType } from "./trigger";
export interface UiConfig {
timezone?: string;
@ -220,6 +221,17 @@ export interface CameraConfig {
rtmp: {
enabled: boolean;
};
semantic_search: {
triggers: {
[triggerName: string]: {
enabled: boolean;
type: TriggerType;
data: string;
threshold: number;
actions: TriggerAction[];
};
};
};
snapshots: {
bounding_box: boolean;
clean_copy: boolean;

11
web/src/types/trigger.ts Normal file
View File

@ -0,0 +1,11 @@
export type TriggerType = "thumbnail" | "description";
export type TriggerAction = "notification";
export type Trigger = {
enabled: boolean;
name: string;
type: TriggerType;
data: string;
threshold: number;
actions: TriggerAction[];
};

View File

@ -105,3 +105,11 @@ export type TrackedObjectUpdateReturnType = {
timestamp?: number;
text?: string;
} | null;
export type TriggerStatus = {
name: string;
camera: string;
event_id: string;
type: string;
score: number;
};

View File

@ -431,6 +431,7 @@ function LibrarySelector({
</Button>
<Button
variant="destructive"
className="text-white"
onClick={() => {
if (confirmDelete) {
handleDeleteFace(confirmDelete);

View File

@ -218,6 +218,7 @@ function ExploreThumbnailImage({
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const navigate = useNavigate();
const handleFindSimilar = () => {
if (config?.semantic_search.enabled) {
@ -233,6 +234,12 @@ function ExploreThumbnailImage({
onSelectSearch(event, false, "snapshot");
};
const handleAddTrigger = () => {
navigate(
`/settings?page=triggers&camera=${event.camera}&event_id=${event.id}`,
);
};
return (
<SearchResultActions
searchResult={event}
@ -240,6 +247,7 @@ function ExploreThumbnailImage({
refreshResults={mutate}
showObjectLifecycle={handleShowObjectLifecycle}
showSnapshot={handleShowSnapshot}
addTrigger={handleAddTrigger}
isContextMenu={true}
>
<div className="relative size-full">

View File

@ -32,6 +32,7 @@ import Chip from "@/components/indicators/Chip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import SearchActionGroup from "@/components/filter/SearchActionGroup";
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
type SearchViewProps = {
search: string;
@ -76,6 +77,7 @@ export default function SearchView({
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const navigate = useNavigate();
// grid
@ -648,6 +650,16 @@ export default function SearchView({
showSnapshot={() =>
onSelectSearch(value, false, "snapshot")
}
addTrigger={() => {
if (
config?.semantic_search.enabled &&
value.data.type == "object"
) {
navigate(
`/settings?page=triggers&camera=${value.camera}&event_id=${value.id}`,
);
}
}}
/>
</div>
</div>

View File

@ -0,0 +1,595 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Toaster, toast } from "sonner";
import useSWR from "swr";
import axios from "axios";
import { Button } from "@/components/ui/button";
import Heading from "@/components/ui/heading";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { LuPlus, LuTrash, LuPencil, LuSearch } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
import { FrigateConfig } from "@/types/frigateConfig";
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { Link } from "react-router-dom";
import { useTriggers } from "@/api/ws";
type ConfigSetBody = {
requires_restart: number;
config_data: {
cameras: {
[key: string]: {
semantic_search?: {
triggers?: {
[key: string]:
| {
enabled: boolean;
type: string;
data: string;
threshold: number;
actions: string[];
}
| "";
};
};
};
};
};
update_topic?: string;
};
type TriggerEmbeddingBody = {
type: TriggerType;
data: string;
threshold: number;
};
type TriggerViewProps = {
selectedCamera: string;
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function TriggerView({
selectedCamera,
setUnsavedChanges,
}: TriggerViewProps) {
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const { data: trigger_status, mutate } = useSWR(
`/triggers/status/${selectedCamera}`,
{
revalidateOnFocus: false,
},
);
const [showCreate, setShowCreate] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [selectedTrigger, setSelectedTrigger] = useState<Trigger | null>(null);
const [triggeredTrigger, setTriggeredTrigger] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const triggers = useMemo(() => {
if (
!config ||
!selectedCamera ||
!config.cameras[selectedCamera]?.semantic_search?.triggers
) {
return [];
}
return Object.entries(
config.cameras[selectedCamera].semantic_search.triggers,
).map(([name, trigger]) => ({
enabled: trigger.enabled,
name,
type: trigger.type,
data: trigger.data,
threshold: trigger.threshold,
actions: trigger.actions,
}));
}, [config, selectedCamera]);
// watch websocket for updates
const { payload: triggers_status_ws } = useTriggers();
useEffect(() => {
if (!triggers_status_ws) return;
mutate();
setTriggeredTrigger(triggers_status_ws.name);
const target = document.querySelector(
`#trigger-${triggers_status_ws.name}`,
);
if (target) {
target.scrollIntoView({
block: "center",
behavior: "smooth",
inline: "nearest",
});
const ring = target.querySelector(".trigger-ring");
if (ring) {
ring.classList.add(`outline-selected`);
ring.classList.remove("outline-transparent");
const timeout = setTimeout(() => {
ring.classList.remove(`outline-selected`);
ring.classList.add("outline-transparent");
}, 3000);
return () => clearTimeout(timeout);
}
}
}, [triggers_status_ws, selectedCamera, mutate]);
useEffect(() => {
document.title = t("triggers.documentTitle");
}, [t]);
const saveToConfig = useCallback(
(trigger: Trigger, isEdit: boolean) => {
setIsLoading(true);
const { enabled, name, type, data, threshold, actions } = trigger;
const embeddingBody: TriggerEmbeddingBody = { type, data, threshold };
const embeddingUrl = isEdit
? `/trigger/embedding/${selectedCamera}/${name}`
: `/trigger/embedding?camera=${selectedCamera}&name=${name}`;
const embeddingMethod = isEdit ? axios.put : axios.post;
embeddingMethod(embeddingUrl, embeddingBody)
.then((embeddingResponse) => {
if (embeddingResponse.data.success) {
const configBody: ConfigSetBody = {
requires_restart: 0,
config_data: {
cameras: {
[selectedCamera]: {
semantic_search: {
triggers: {
[name]: {
enabled,
type,
data,
threshold,
actions,
},
},
},
},
},
},
update_topic: `config/cameras/${selectedCamera}/semantic_search`,
};
return axios
.put("config/set", configBody)
.then((configResponse) => {
if (configResponse.status === 200) {
updateConfig();
toast.success(
t(
isEdit
? "triggers.toast.success.updateTrigger"
: "triggers.toast.success.createTrigger",
{ name },
),
{ position: "top-center" },
);
setUnsavedChanges(false);
} else {
throw new Error(configResponse.statusText);
}
});
} else {
throw new Error(embeddingResponse.data.message);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
setShowCreate(false);
});
},
[t, updateConfig, selectedCamera, setUnsavedChanges],
);
const onCreate = useCallback(
(
enabled: boolean,
name: string,
type: TriggerType,
data: string,
threshold: number,
actions: TriggerAction[],
) => {
setUnsavedChanges(true);
saveToConfig({ enabled, name, type, data, threshold, actions }, false);
},
[saveToConfig, setUnsavedChanges],
);
const onEdit = useCallback(
(trigger: Trigger) => {
setUnsavedChanges(true);
setIsLoading(true);
if (selectedTrigger?.name && selectedTrigger.name !== trigger.name) {
// Handle rename: delete old trigger, update config, then save new trigger
axios
.delete(
`/trigger/embedding/${selectedCamera}/${selectedTrigger.name}`,
)
.then((embeddingResponse) => {
if (!embeddingResponse.data.success) {
throw new Error(embeddingResponse.data.message);
}
const deleteConfigBody: ConfigSetBody = {
requires_restart: 0,
config_data: {
cameras: {
[selectedCamera]: {
semantic_search: {
triggers: {
[selectedTrigger.name]: "",
},
},
},
},
},
update_topic: `config/cameras/${selectedCamera}/semantic_search`,
};
return axios.put("config/set", deleteConfigBody);
})
.then((configResponse) => {
if (configResponse.status !== 200) {
throw new Error(configResponse.statusText);
}
// Save new trigger
saveToConfig(trigger, false);
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
setIsLoading(false);
});
} else {
// Regular update without rename
saveToConfig(trigger, true);
}
setSelectedTrigger(null);
},
[t, saveToConfig, selectedCamera, selectedTrigger, setUnsavedChanges],
);
const onDelete = useCallback(
(name: string) => {
setUnsavedChanges(true);
setIsLoading(true);
axios
.delete(`/trigger/embedding/${selectedCamera}/${name}`)
.then((embeddingResponse) => {
if (embeddingResponse.data.success) {
const configBody: ConfigSetBody = {
requires_restart: 0,
config_data: {
cameras: {
[selectedCamera]: {
semantic_search: {
triggers: {
[name]: "",
},
},
},
},
},
update_topic: `config/cameras/${selectedCamera}/semantic_search`,
};
return axios
.put("config/set", configBody)
.then((configResponse) => {
if (configResponse.status === 200) {
updateConfig();
toast.success(
t("triggers.toast.success.deleteTrigger", { name }),
{
position: "top-center",
},
);
setUnsavedChanges(false);
} else {
throw new Error(configResponse.statusText);
}
});
} else {
throw new Error(embeddingResponse.data.message);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("triggers.toast.error.deleteTriggerFailed", { errorMessage }),
{ position: "top-center" },
);
})
.finally(() => {
setShowDelete(false);
setIsLoading(false);
});
},
[t, updateConfig, selectedCamera, setUnsavedChanges],
);
useEffect(() => {
if (selectedCamera) {
setSelectedTrigger(null);
setShowCreate(false);
setShowDelete(false);
setUnsavedChanges(false);
}
}, [selectedCamera, setUnsavedChanges]);
// for adding a trigger with event id via explore context menu
useSearchEffect("event_id", (eventId: string) => {
if (!config || isLoading) {
return false;
}
setShowCreate(true);
setSelectedTrigger({
enabled: true,
name: "",
type: "thumbnail",
data: eventId,
threshold: 0.5,
actions: [],
});
return true;
});
if (!config || !selectedCamera) {
return (
<div className="flex h-full w-full items-center justify-center">
<ActivityIndicator />
</div>
);
}
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">
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<Heading as="h3" className="my-2">
{t("triggers.management.title")}
</Heading>
<p className="text-sm text-muted-foreground">
{t("triggers.management.desc", { camera: selectedCamera })}
</p>
</div>
<Button
className="flex items-center gap-2 self-start sm:self-auto"
aria-label={t("triggers.addTrigger")}
variant="default"
onClick={() => {
setSelectedTrigger(null);
setShowCreate(true);
}}
disabled={isLoading}
>
<LuPlus className="size-4" />
{t("triggers.addTrigger")}
</Button>
</div>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
<div className="h-full overflow-auto p-0">
{triggers.length === 0 ? (
<div className="flex h-24 items-center justify-center">
<p className="text-center text-muted-foreground">
{t("triggers.table.noTriggers")}
</p>
</div>
) : (
<div className="space-y-2">
{triggers.map((trigger) => (
<div
key={trigger.name}
id={`trigger-${trigger.name}`}
className="relative flex items-center justify-between rounded-lg border border-border bg-background p-4 transition-all"
>
<div
className={cn(
"trigger-ring pointer-events-none absolute inset-0 z-10 size-full rounded-md outline outline-[3px] -outline-offset-[2.8px] duration-500",
triggeredTrigger === trigger.name
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
/>
<div className="min-w-0 flex-1">
<h3
className={cn(
"truncate text-lg font-medium",
!trigger.enabled && "opacity-60",
)}
>
{trigger.name}
</h3>
<div
className={cn(
"mt-1 flex flex-col gap-1 text-sm text-muted-foreground md:flex-row md:items-center md:gap-3",
!trigger.enabled && "opacity-60",
)}
>
<div>
<Badge
variant={
trigger.type === "thumbnail"
? "default"
: "outline"
}
className={
trigger.type === "thumbnail"
? "bg-primary/20 text-primary hover:bg-primary/30"
: ""
}
>
{t(`triggers.type.${trigger.type}`)}
</Badge>
</div>
<Link
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
className={cn(
"text-sm",
!trigger_status?.triggers[trigger.name]
?.triggering_event_id && "pointer-events-none",
)}
>
<div className="flex flex-row items-center">
{t("triggers.table.lastTriggered")}:{" "}
{trigger_status &&
trigger_status.triggers[trigger.name]
?.last_triggered
? formatUnixTimestampToDateTime(
trigger_status.triggers[trigger.name]
?.last_triggered,
{
timezone: config.ui.timezone,
date_format:
config.ui.time_format == "24hour"
? t(
"time.formattedTimestamp2.24hour",
{
ns: "common",
},
)
: t(
"time.formattedTimestamp2.12hour",
{
ns: "common",
},
),
time_style: "medium",
date_style: "medium",
},
)
: "Never"}
<Tooltip>
<TooltipTrigger>
<LuSearch className="ml-2 size-3.5" />
</TooltipTrigger>
<TooltipContent>
{t("details.item.button.viewInExplore", {
ns: "views/explore",
})}
</TooltipContent>
</Tooltip>
</div>
</Link>
</div>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => {
setSelectedTrigger(trigger);
setShowCreate(true);
}}
disabled={isLoading}
>
<LuPencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.edit")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 w-8 p-0 text-white"
onClick={() => {
setSelectedTrigger(trigger);
setShowDelete(true);
}}
disabled={isLoading}
>
<LuTrash className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.deleteTrigger")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
<CreateTriggerDialog
show={showCreate}
trigger={selectedTrigger}
selectedCamera={selectedCamera}
isLoading={isLoading}
onCreate={onCreate}
onEdit={onEdit}
onCancel={() => {
setShowCreate(false);
setSelectedTrigger(null);
setUnsavedChanges(false);
}}
/>
<DeleteTriggerDialog
show={showDelete}
triggerName={selectedTrigger?.name ?? ""}
isLoading={isLoading}
onCancel={() => {
setShowDelete(false);
setSelectedTrigger(null);
setUnsavedChanges(false);
}}
onDelete={() => onDelete(selectedTrigger?.name ?? "")}
/>
</div>
);
}