Compare commits

...

13 Commits

Author SHA1 Message Date
dependabot[bot]
9a46ce3441
Merge 5199e10086 into dd9497baf2 2026-03-09 12:02:14 +00:00
Josh Hawkins
dd9497baf2
Add ability to delete cameras (#22336)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* refactor camera cleanup code to generic util

* add api endpoint for deleting a camera

* frontend

* i18n

* clean up
2026-03-08 16:23:48 -06:00
Josh Hawkins
e930492ccc
Publish license plate box coordinates (#22337)
* publish the detected plate's box coordinates in tracked_object_update

* docs
2026-03-08 16:22:38 -06:00
Josh Hawkins
b2c7840c29
Refactor enrichment confg updater (#22325)
* enrichment updater and enum

* update_config stubs

* config updaters in enrichments

* update maintainer

* formatting

* simplify enrichment config updates to use single subscriber with topic-based routing
2026-03-08 14:14:18 -06:00
Josh Hawkins
df27e04c0f
Frontend updates (#22327)
* add optional field widget

adds a switch to enable nullable fields like skip_motion_threshold

* config field updates

add skip_motion_threshold optional switch
add fps back to detect restart required

* don't use ternary operator when displaying motion previews

the main previews were being unnecessarily unmounted

* lazy mount motion preview clips to reduce DOM overhead
2026-03-08 12:27:53 -05:00
Nicolas Mowen
ef07563d0a
Update onnx deps to support 50 series GPUs (#22324)
* Update support for newer Nvidia GPUs

* Update

* Cleanup

* Update gitignore
2026-03-08 11:45:29 -05:00
Nicolas Mowen
a705f254e5
Support using GenAI for embeddings / semantic search (#22323)
* Support GenAI for embeddings

* Add embed API support

* Add support for embedding via genai

* Basic docs

* undo

* Fix sending images

* Don't require download check

* Set model

* Handle emb correctly

* Clarification

* Cleanup

* Cleanup
2026-03-08 10:55:00 -05:00
Josh Hawkins
acdfed40a9
Improve annotation offset UX (#22310)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* keep nav buttons visible

nav buttons would be hidden when closing and reopening dialog after selecting the tracking details pane

* better ux in tracking details

actually pause the video and seek when annotation offset changes to make it easier to visually line up the bounding box

* improve detail stream ux

* update dummy camera docs

* fix docs link
2026-03-07 07:50:00 -06:00
Josh Hawkins
889dfca36c
Frontend fixes (#22309)
* prevent unnecessary reloads in useUserPersistence hook

* always render ProtectedRoute (handling undefined roles internally) and add Suspense fallback

* add missing i18n namespaces

react 19 enforces Suspense more strictly, so components using useTranslation() with unloaded namespaces would suspend, blanking the content behind the empty Suspense fallback

* add missing namespace

* remove unneeded

* remove modal from actions dropdown
2026-03-07 06:43:00 -07:00
Josh Hawkins
dda9f7bfed
apply filters after clustering (#22308)
apply length and format filters to the clustered representative plate rather than individual OCR readings, so noisy variants still contribute to clustering even when they don't pass on their own
2026-03-07 06:42:27 -07:00
Josh Hawkins
c2e667c0dd
Add dynamic configuration for more fields (#22295)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* face recognition dynamic config

* lpr dynamic config

* safe changes for birdseye dynamic config

* bird classification dynamic config

* always assign new config to stats emitter to make telemetry fields dynamic

* add wildcard support for camera config updates in config_set

* update restart required fields for global sections

* add test

* fix rebase issue

* collapsible settings sidebar

use the preexisting control available with shadcn's sidebar (cmd/ctrl-B) to give users more space to set masks/zones on smaller screens

* dynamic ffmpeg

* ensure previews dir exists

when ffmpeg processes restart, there's a brief window where the preview frame generation pipeline is torn down and restarted. before these changes, ffmpeg only restarted on crash/stall recovery or full Frigate restart. Now that ffmpeg restarts happen on-demand via config changes, there's a higher chance a frontend request hits the preview_mp4 or preview_gif endpoints during that brief restart window when the directory might not exist yet. The existing os.listdir() call would throw FileNotFoundError without a directory existence check. this fix just checks if the directory exists and returns 404 if not, exactly how preview_thumbnail already handles the same scenario a few lines below

* global ffmpeg section

* clean up

* tweak

* fix test
2026-03-06 13:45:39 -07:00
Josh Hawkins
c9bd907721
Frontend fixes (#22294)
* fix useImageLoaded hook running on every render

* fix volume not applying for all cameras

* Fix maximum update depth exceeded errors on Review page

- use-overlay-state: use refs for location to keep setter identity
  stable across renders, preventing cascading re-render loops when
  effects depend on the setter. Add Object.is bail-out guard to skip
  redundant navigate calls. Move setPersistedValue after bail-out to
  avoid unnecessary IndexedDB writes.

* don't try to fetch previews when motion search dialog is open

* revert unneeded changes

re-rendering was caused by the overlay state hook, not this one

* filter dicts to only use id field in sync recordings
2026-03-06 13:41:15 -07:00
dependabot[bot]
5199e10086
Update pandas requirement from ==2.2.* to ==2.3.* in /docker/main
Updates the requirements on [pandas](https://github.com/pandas-dev/pandas) to permit the latest version.
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v2.2.0dev0...v2.3.1)

---
updated-dependencies:
- dependency-name: pandas
  dependency-version: 2.3.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-19 19:05:41 +00:00
71 changed files with 2372 additions and 736 deletions

4
.gitignore vendored
View File

@ -3,6 +3,8 @@ __pycache__
.mypy_cache
*.swp
debug
.claude/*
.mcp.json
.vscode/*
!.vscode/launch.json
config/*
@ -19,4 +21,4 @@ web/.env
core
!/web/**/*.ts
.idea/*
.ipynb_checkpoints
.ipynb_checkpoints

View File

@ -19,7 +19,7 @@ tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
mypy == 1.6.1
onvif-zeep-async == 4.0.*
paho-mqtt == 2.1.*
pandas == 2.2.*
pandas == 2.3.*
peewee == 3.17.*
peewee_migrate == 1.14.*
psutil == 7.1.*

View File

@ -1,18 +1,18 @@
# NVidia TensorRT Support (amd64 only)
# Nvidia ONNX Runtime GPU Support
--extra-index-url 'https://pypi.nvidia.com'
cython==3.0.*; platform_machine == 'x86_64'
nvidia_cuda_cupti_cu12==12.5.82; platform_machine == 'x86_64'
nvidia-cublas-cu12==12.5.3.*; platform_machine == 'x86_64'
nvidia-cudnn-cu12==9.3.0.*; platform_machine == 'x86_64'
nvidia-cufft-cu12==11.2.3.*; platform_machine == 'x86_64'
nvidia-curand-cu12==10.3.6.*; platform_machine == 'x86_64'
nvidia_cuda_nvcc_cu12==12.5.82; platform_machine == 'x86_64'
nvidia-cuda-nvrtc-cu12==12.5.82; platform_machine == 'x86_64'
nvidia_cuda_runtime_cu12==12.5.82; platform_machine == 'x86_64'
nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64'
nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
nvidia-cuda-cupti-cu12==12.9.79; platform_machine == 'x86_64'
nvidia-cublas-cu12==12.9.1.*; platform_machine == 'x86_64'
nvidia-cudnn-cu12==9.19.0.*; platform_machine == 'x86_64'
nvidia-cufft-cu12==11.4.1.*; platform_machine == 'x86_64'
nvidia-curand-cu12==10.3.10.*; platform_machine == 'x86_64'
nvidia-cuda-nvcc-cu12==12.9.86; platform_machine == 'x86_64'
nvidia-cuda-nvrtc-cu12==12.9.86; platform_machine == 'x86_64'
nvidia-cuda-runtime-cu12==12.9.79; platform_machine == 'x86_64'
nvidia-cusolver-cu12==11.7.5.*; platform_machine == 'x86_64'
nvidia-cusparse-cu12==12.5.10.*; platform_machine == 'x86_64'
nvidia-nccl-cu12==2.29.7; platform_machine == 'x86_64'
nvidia-nvjitlink-cu12==12.9.86; 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'

View File

@ -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
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.

View File

@ -159,7 +159,8 @@ Published when a license plate is recognized on a car object. See the [License P
"plate": "123ABC",
"score": 0.95,
"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
}
```

View File

@ -3,17 +3,67 @@ id: dummy-camera
title: Analyzing Object Detection
---
When investigating object detection or tracking problems, it can be helpful to replay an exported video as a temporary "dummy" camera. This lets you reproduce issues locally, iterate on configuration (detections, zones, enrichment settings), and capture logs and clips for analysis.
Frigate provides several tools for investigating object detection and tracking behavior: reviewing recorded detections through the UI, using the built-in Debug Replay feature, and manually setting up a dummy camera for advanced scenarios.
## When to use
## Reviewing Detections in the UI
- Replaying an exported clip to reproduce incorrect detections
- Testing configuration changes (model settings, trackers, filters) against a known clip
- Gathering deterministic logs and recordings for debugging or issue reports
Before setting up a replay, you can often diagnose detection issues by reviewing existing recordings directly in the Frigate UI.
## Example Config
### Detail View (History)
Place the clip you want to replay in a location accessible to Frigate (for example `/media/frigate/` or the repository `debug/` folder when developing). Then add a temporary camera to your `config/config.yml` like this:
The **Detail Stream** view in History shows recorded video with detection overlays (bounding boxes, path points, and zone highlights) drawn on top. Select a review item to see its tracked objects and lifecycle events. Clicking a lifecycle event seeks the video to that point so you can see exactly what the detector saw.
### Tracking Details (Explore)
In **Explore**, clicking a thumbnail opens the **Tracking Details** pane, which shows the full lifecycle of a single tracked object: every detection, zone entry/exit, and attribute change. The video plays back with the bounding box overlaid, letting you step through the object's entire lifecycle.
### Annotation Offset
Both views support an **Annotation Offset** setting (`detect.annotation_offset` in your camera config) that shifts the detection overlay in time relative to the recorded video. This compensates for the timing drift between the `detect` and `record` pipelines.
These streams use fundamentally different clocks with different buffering and latency characteristics, so the detection data and the recorded video are never perfectly synchronized. The annotation offset shifts the overlay to visually align the bounding boxes with the objects in the recorded video.
#### Why the offset varies between clips
The base timing drift between detect and record is roughly constant for a given camera, so a single offset value works well on average. However, you may notice the alignment is not pixel-perfect in every clip. This is normal and caused by several factors:
- **Keyframe-constrained seeking**: When the browser seeks to a timestamp, it can only land on the nearest keyframe. Each recording segment has keyframes at different positions relative to the detection timestamps, so the same offset may land slightly early in one clip and slightly late in another.
- **Segment boundary trimming**: When a recording range starts mid-segment, the video is trimmed to the requested start point. This trim may not align with a keyframe, shifting the effective reference point.
- **Capture-time jitter**: Network buffering, camera buffer flushes, and ffmpeg's own buffering mean the system-clock timestamp and the corresponding recorded frame are not always offset by exactly the same amount.
The per-clip variation is typically quite low and is mostly an artifact of keyframe granularity rather than a change in the true drift. A "perfect" alignment would require per-frame, keyframe-aware offset compensation, which is not practical. Treat the annotation offset as a best-effort average for your camera.
## Debug Replay
Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time.
### When to use
- Reproducing a detection or tracking issue from a specific time range
- Testing configuration changes (model settings, zones, filters, motion) against a known clip
- Gathering logs and debug overlays for a bug report
:::note
Only one replay session can be active at a time. If a session is already running, you will be prompted to navigate to it or stop it first.
:::
### Variables to consider
- The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking.
- Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
- Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs.
Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior.
## Manual Dummy Camera
For advanced scenarios — such as testing with a clip from a different source, debugging ffmpeg behavior, or running a clip through a completely custom configuration — you can set up a dummy camera manually.
### Example config
Place the clip you want to replay in a location accessible to Frigate (for example `/media/frigate/` or the repository `debug/` folder when developing). Then add a temporary camera to your `config/config.yml`:
```yaml
cameras:
@ -32,10 +82,10 @@ cameras:
enabled: false
```
- `-re -stream_loop -1` tells `ffmpeg` to play the file in realtime and loop indefinitely, which is useful for long debugging sessions.
- `-fflags +genpts` helps generate presentation timestamps when they are missing in the file.
- `-re -stream_loop -1` tells ffmpeg to play the file in real time and loop indefinitely.
- `-fflags +genpts` generates presentation timestamps when they are missing in the file.
## Steps
### Steps
1. Export or copy the clip you want to replay to the Frigate host (e.g., `/media/frigate/` or `debug/clips/`). Depending on what you are looking to debug, it is often helpful to add some "pre-capture" time (where the tracked object is not yet visible) to the clip when exporting.
2. Add the temporary camera to `config/config.yml` (example above). Use a unique name such as `test` or `replay_camera` so it's easy to remove later.
@ -45,16 +95,8 @@ cameras:
5. Iterate on camera or enrichment settings (model, fps, zones, filters) and re-check the replay until the behavior is resolved.
6. Remove the temporary camera from your config after debugging to avoid spurious telemetry or recordings.
## Variables to consider in object tracking
### Troubleshooting
- The exported video will not always line up exactly with how it originally ran through Frigate (or even with the last loop). Different frames may be used on replay, which can change detections and tracking.
- Motion detection depends on the frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
- Object detection is not deterministic: models and post-processing can yield different results across runs, so you may not get identical detections or track IDs every time.
When debugging, treat the replay as a close approximation rather than a byte-for-byte replay. Capture multiple runs, enable recording if helpful, and examine logs and saved event clips to understand variability.
## Troubleshooting
- No video: verify the path is correct and accessible from the Frigate process/container.
- FFmpeg errors: check the log output for ffmpeg-specific flags and adjust `input_args` accordingly for your file/container. You may also need to disable hardware acceleration (`hwaccel_args: ""`) for the dummy camera.
- No detections: confirm the camera `roles` include `detect`, and model/detector configuration is enabled.
- **No video**: verify the file path is correct and accessible from the Frigate process/container.
- **FFmpeg errors**: check the log output and adjust `input_args` for your file format. You may also need to disable hardware acceleration (`hwaccel_args: ""`) for the dummy camera.
- **No detections**: confirm the camera `roles` include `detect` and that the model/detector configuration is enabled.

View File

@ -589,23 +589,38 @@ def config_set(request: Request, body: AppConfigSetBody):
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
if request.app.stats_emitter is not None:
request.app.stats_emitter.config = config
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
if field == "add":
settings = config.cameras[camera]
elif field == "remove":
settings = old_config.cameras[camera]
if camera == "*":
# Wildcard: fan out update to all cameras
enum_value = CameraConfigUpdateEnum[field]
for camera_name in config.cameras:
settings = config.get_nested_object(
f"config/cameras/{camera_name}/{field}"
)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(enum_value, camera_name),
settings,
)
else:
settings = config.get_nested_object(body.update_topic)
if field == "add":
settings = config.cameras[camera]
elif field == "remove":
settings = old_config.cameras[camera]
else:
settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum[field], camera
),
settings,
)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum[field], camera
),
settings,
)
else:
# Generic handling for global config updates
settings = config.get_nested_object(body.update_topic)

View File

@ -1,5 +1,6 @@
"""Camera apis."""
import asyncio
import json
import logging
import re
@ -11,7 +12,9 @@ import httpx
import requests
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import JSONResponse
from filelock import FileLock, Timeout
from onvif import ONVIFCamera, ONVIFError
from ruamel.yaml import YAML
from zeep.exceptions import Fault, TransportError
from zeep.transports import AsyncTransport
@ -21,8 +24,14 @@ from frigate.api.auth import (
require_role,
)
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.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.services import ffprobe_stream
@ -995,3 +1004,154 @@ async def onvif_probe(
await onvif_camera.close()
except Exception as 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,
)

View File

@ -1281,6 +1281,13 @@ def preview_gif(
else:
# need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
if not os.path.isdir(preview_dir):
return JSONResponse(
content={"success": False, "message": "Preview not found"},
status_code=404,
)
file_start = f"preview_{camera_name}"
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
@ -1456,6 +1463,13 @@ def preview_mp4(
else:
# need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
if not os.path.isdir(preview_dir):
return JSONResponse(
content={"success": False, "message": "Preview not found"},
status_code=404,
)
file_start = f"preview_{camera_name}"
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"

View File

@ -242,6 +242,14 @@ class CameraConfig(FrigateBaseModel):
def create_ffmpeg_cmds(self):
if "_ffmpeg_cmds" in self:
return
self._build_ffmpeg_cmds()
def recreate_ffmpeg_cmds(self):
"""Force regeneration of ffmpeg commands from current config."""
self._build_ffmpeg_cmds()
def _build_ffmpeg_cmds(self):
"""Build ffmpeg commands from the current ffmpeg config."""
ffmpeg_cmds = []
for ffmpeg_input in self.ffmpeg.inputs:
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)

View File

@ -17,6 +17,7 @@ class CameraConfigUpdateEnum(str, Enum):
birdseye = "birdseye"
detect = "detect"
enabled = "enabled"
ffmpeg = "ffmpeg"
motion = "motion" # includes motion and motion masks
notifications = "notifications"
objects = "objects"
@ -91,6 +92,9 @@ class CameraConfigUpdateSubscriber:
if update_type == CameraConfigUpdateEnum.audio:
config.audio = updated_config
elif update_type == CameraConfigUpdateEnum.ffmpeg:
config.ffmpeg = updated_config
config.recreate_ffmpeg_cmds()
elif update_type == CameraConfigUpdateEnum.audio_transcription:
config.audio_transcription = updated_config
elif update_type == CameraConfigUpdateEnum.birdseye:

View File

@ -1,5 +1,5 @@
from enum import Enum
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Union
from pydantic import ConfigDict, Field
@ -173,10 +173,10 @@ class SemanticSearchConfig(FrigateBaseModel):
title="Reindex on startup",
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,
title="Semantic search model",
description="The embeddings model to use for semantic search (for example 'jinav1').",
title="Semantic search model or GenAI provider name",
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(
default="small",

View File

@ -61,6 +61,7 @@ from .classification import (
FaceRecognitionConfig,
LicensePlateRecognitionConfig,
SemanticSearchConfig,
SemanticSearchModelEnum,
)
from .database import DatabaseConfig
from .env import EnvVars
@ -592,6 +593,24 @@ class FrigateConfig(FrigateBaseModel):
)
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
for attribute in self.model.all_attributes:
if not self.objects.filters.get(attribute):

View File

@ -401,35 +401,10 @@ class LicensePlateProcessingMixin:
all_confidences.append(flat_confidences)
all_areas.append(combined_area)
# Step 3: Filter and sort the combined plates
# Step 3: Sort the combined plates
if all_license_plates:
filtered_data = []
for plate, conf_list, area in zip(
all_license_plates, all_confidences, all_areas
):
if len(plate) < self.lpr_config.min_plate_length:
logger.debug(
f"{camera}: Filtered out '{plate}' due to length ({len(plate)} < {self.lpr_config.min_plate_length})"
)
continue
if self.lpr_config.format:
try:
if not re.fullmatch(self.lpr_config.format, plate):
logger.debug(
f"{camera}: Filtered out '{plate}' due to format mismatch"
)
continue
except re.error:
# Skip format filtering if regex is invalid
logger.error(
f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}"
)
filtered_data.append((plate, conf_list, area))
sorted_data = sorted(
filtered_data,
zip(all_license_plates, all_confidences, all_areas),
key=lambda x: (x[2], len(x[0]), sum(x[1]) / len(x[1]) if x[1] else 0),
reverse=True,
)
@ -1250,6 +1225,8 @@ class LicensePlateProcessingMixin:
logger.debug(f"{camera}: License plate area below minimum threshold.")
return
plate_box = license_plate
license_plate_frame = rgb[
license_plate[1] : license_plate[3],
license_plate[0] : license_plate[2],
@ -1366,6 +1343,20 @@ class LicensePlateProcessingMixin:
logger.debug(f"{camera}: License plate is less than min_area")
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[1] : license_plate[3],
license_plate[0] : license_plate[2],
@ -1429,6 +1420,8 @@ class LicensePlateProcessingMixin:
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
license_plate_frame = license_plate_frame[
int(expanded_box[1]) : int(expanded_box[3]),
@ -1557,6 +1550,27 @@ class LicensePlateProcessingMixin:
f"{camera}: Clustering changed top plate '{top_plate}' (conf: {avg_confidence:.3f}) to rep '{rep_plate}' (conf: {rep_conf:.3f})"
)
# Apply length and format filters to the clustered representative
# rather than individual OCR readings, so noisy variants still
# contribute to clustering even when they don't pass on their own.
if len(rep_plate) < self.lpr_config.min_plate_length:
logger.debug(
f"{camera}: Filtered out clustered plate '{rep_plate}' due to length ({len(rep_plate)} < {self.lpr_config.min_plate_length})"
)
return
if self.lpr_config.format:
try:
if not re.fullmatch(self.lpr_config.format, rep_plate):
logger.debug(
f"{camera}: Filtered out clustered plate '{rep_plate}' due to format mismatch"
)
return
except re.error:
logger.error(
f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}"
)
# Update stored rep
self.detected_license_plates[id].update(
{
@ -1615,6 +1629,7 @@ class LicensePlateProcessingMixin:
"id": id,
"camera": camera,
"timestamp": start,
"plate_box": plate_box,
}
),
)

View File

@ -50,3 +50,16 @@ class PostProcessorApi(ABC):
None if request was not handled, otherwise return response.
"""
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

View File

@ -47,6 +47,16 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
self.sub_label_publisher = sub_label_publisher
super().__init__(config, metrics, model_runner)
CONFIG_UPDATE_TOPIC = "config/lpr"
def update_config(self, topic: str, payload: Any) -> None:
"""Update LPR config at runtime."""
if topic != self.CONFIG_UPDATE_TOPIC:
return
self.lpr_config = payload
logger.debug("LPR post-processor config updated dynamically")
def process_data(
self, data: dict[str, Any], data_type: PostProcessDataEnum
) -> None:

View File

@ -61,3 +61,16 @@ class RealTimeProcessorApi(ABC):
None.
"""
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

View File

@ -169,6 +169,16 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
)
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):
return None

View File

@ -95,6 +95,23 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
self.recognizer.build()
CONFIG_UPDATE_TOPIC = "config/face_recognition"
def update_config(self, topic: str, payload: Any) -> None:
"""Update face recognition config at runtime."""
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")
def __download_models(self, path: str) -> None:
try:
file_name = os.path.basename(path)

View File

@ -40,6 +40,23 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
self.camera_current_cars: dict[str, list[str]] = {}
super().__init__(config, metrics)
CONFIG_UPDATE_TOPIC = "config/lpr"
def update_config(self, topic: str, payload: Any) -> None:
"""Update LPR config at runtime."""
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")
def process_frame(
self,
obj_data: dict[str, Any],

View File

@ -21,7 +21,8 @@ from frigate.const import (
REPLAY_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
logger = logging.getLogger(__name__)
@ -357,43 +358,13 @@ class DebugReplayManager:
def _cleanup_db(self, camera_name: str) -> None:
"""Defensively remove any database rows for the replay camera."""
try:
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)
cleanup_camera_db(camera_name)
def _cleanup_files(self, camera_name: str) -> None:
"""Remove filesystem artifacts for the replay camera."""
dirs_to_clean = [
os.path.join(RECORD_DIR, camera_name),
os.path.join(CLIPS_DIR, camera_name),
os.path.join(THUMB_DIR, camera_name),
]
cleanup_camera_files(camera_name)
for dir_path in dirs_to_clean:
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
# Remove replay-specific cache directory
if os.path.exists(REPLAY_DIR):
try:
shutil.rmtree(REPLAY_DIR)

View File

@ -28,6 +28,7 @@ from frigate.types import ModelStatusTypesEnum
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
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_v2_embedding import JinaV2Embedding
@ -73,6 +74,7 @@ class Embeddings:
config: FrigateConfig,
db: SqliteVecQueueDatabase,
metrics: DataProcessorMetrics,
genai_manager=None,
) -> None:
self.config = config
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
self.embedding = JinaV2Embedding(
model_size=self.config.semantic_search.model_size,
@ -118,7 +140,8 @@ class Embeddings:
self.vision_embedding = lambda input_data: self.embedding(
input_data, embedding_type="vision"
)
else: # Default to jinav1
else:
# Default to jinav1
self.text_embedding = JinaV1TextEmbedding(
model_size=config.semantic_search.model_size,
requestor=self.requestor,
@ -136,8 +159,11 @@ class Embeddings:
self.metrics.text_embeddings_eps.value = self.text_eps.eps()
def get_model_definitions(self):
# Version-specific models
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
model_cfg = self.config.semantic_search.model
if not isinstance(model_cfg, SemanticSearchModelEnum):
# GenAI provider: no ONNX models to download
models = []
elif model_cfg == SemanticSearchModelEnum.jinav2:
models = [
"jinaai/jina-clip-v2-tokenizer",
"jinaai/jina-clip-v2-model_fp16.onnx"
@ -312,11 +338,12 @@ class Embeddings:
# Get total count of events to process
total_events = Event.select().count()
batch_size = (
4
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2
else 32
)
if not isinstance(self.config.semantic_search.model, SemanticSearchModelEnum):
batch_size = 1
elif self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
batch_size = 4
else:
batch_size = 32
current_page = 1
totals = {

View 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

View File

@ -96,9 +96,7 @@ class EmbeddingMaintainer(threading.Thread):
CameraConfigUpdateEnum.semantic_search,
],
)
self.classification_config_subscriber = ConfigSubscriber(
"config/classification/custom/"
)
self.enrichment_config_subscriber = ConfigSubscriber("config/")
# Configure Frigate DB
db = SqliteVecQueueDatabase(
@ -116,8 +114,10 @@ class EmbeddingMaintainer(threading.Thread):
models = [Event, Recordings, ReviewSegment, Trigger]
db.bind(models)
self.genai_manager = GenAIClientManager(config)
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
if config.semantic_search.reindex:
@ -144,7 +144,6 @@ class EmbeddingMaintainer(threading.Thread):
self.frame_manager = SharedMemoryFrameManager()
self.detected_license_plates: dict[str, dict[str, Any]] = {}
self.genai_manager = GenAIClientManager(config)
# model runners to share between realtime and post processors
if self.config.lpr.enabled:
@ -272,7 +271,7 @@ class EmbeddingMaintainer(threading.Thread):
"""Maintain a SQLite-vec database for semantic search."""
while not self.stop_event.is_set():
self.config_updater.check_for_updates()
self._check_classification_config_updates()
self._check_enrichment_config_updates()
self._process_requests()
self._process_updates()
self._process_recordings_updates()
@ -283,7 +282,7 @@ class EmbeddingMaintainer(threading.Thread):
self._process_event_metadata()
self.config_updater.stop()
self.classification_config_subscriber.stop()
self.enrichment_config_subscriber.stop()
self.event_subscriber.stop()
self.event_end_subscriber.stop()
self.recordings_subscriber.stop()
@ -294,67 +293,86 @@ class EmbeddingMaintainer(threading.Thread):
self.requestor.stop()
logger.info("Exiting embeddings maintenance...")
def _check_classification_config_updates(self) -> None:
"""Check for classification config updates and add/remove processors."""
topic, model_config = self.classification_config_subscriber.check_for_update()
def _check_enrichment_config_updates(self) -> None:
"""Check for enrichment config updates and delegate to processors."""
topic, payload = self.enrichment_config_subscriber.check_for_update()
if topic:
model_name = topic.split("/")[-1]
if topic is None:
return
if model_config is None:
self.realtime_processors = [
processor
for processor in self.realtime_processors
if not (
isinstance(
processor,
(
CustomStateClassificationProcessor,
CustomObjectClassificationProcessor,
),
)
and processor.model_config.name == model_name
)
]
# Custom classification add/remove requires managing the processor list
if topic.startswith("config/classification/custom/"):
self._handle_custom_classification_update(topic, payload)
return
logger.info(
f"Successfully removed classification processor for model: {model_name}"
)
else:
self.config.classification.custom[model_name] = model_config
# Broadcast to all processors — each decides if the topic is relevant
for processor in self.realtime_processors:
processor.update_config(topic, payload)
# Check if processor already exists
for processor in self.realtime_processors:
if isinstance(
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]
if model_config is None:
self.realtime_processors = [
processor
for processor in self.realtime_processors
if not (
isinstance(
processor,
(
CustomStateClassificationProcessor,
CustomObjectClassificationProcessor,
),
):
if processor.model_config.name == model_name:
logger.debug(
f"Classification processor for model {model_name} already exists, skipping"
)
return
if model_config.state_config is not None:
processor = CustomStateClassificationProcessor(
self.config, model_config, self.requestor, self.metrics
)
else:
processor = CustomObjectClassificationProcessor(
self.config,
model_config,
self.event_metadata_publisher,
self.requestor,
self.metrics,
)
self.realtime_processors.append(processor)
logger.info(
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
and processor.model_config.name == model_name
)
]
logger.info(
f"Successfully removed classification processor for model: {model_name}"
)
return
self.config.classification.custom[model_name] = model_config
# Check if processor already exists
for processor in self.realtime_processors:
if isinstance(
processor,
(
CustomStateClassificationProcessor,
CustomObjectClassificationProcessor,
),
):
if processor.model_config.name == model_name:
logger.debug(
f"Classification processor for model {model_name} already exists, skipping"
)
return
if model_config.state_config is not None:
processor = CustomStateClassificationProcessor(
self.config, model_config, self.requestor, self.metrics
)
else:
processor = CustomObjectClassificationProcessor(
self.config,
model_config,
self.event_metadata_publisher,
self.requestor,
self.metrics,
)
self.realtime_processors.append(processor)
logger.info(
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
)
def _process_requests(self) -> None:
"""Process embeddings requests"""

View File

@ -7,6 +7,7 @@ import os
import re
from typing import Any, Optional
import numpy as np
from playhouse.shortcuts import model_to_dict
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
@ -304,6 +305,25 @@ Guidelines:
"""Get the context window size for this provider in tokens."""
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(
self,
messages: list[dict[str, Any]],

View File

@ -1,12 +1,15 @@
"""llama.cpp Provider for Frigate AI."""
import base64
import io
import json
import logging
from typing import Any, Optional
import httpx
import numpy as np
import requests
from PIL import Image
from frigate.config import GenAIProviderEnum
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__)
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)
class LlamaCppClient(GenAIClient):
"""Generative AI client for Frigate using llama.cpp server."""
@ -176,6 +193,110 @@ class LlamaCppClient(GenAIClient):
)
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(
self,
messages: list[dict[str, Any]],

View File

@ -273,17 +273,13 @@ class BirdsEyeFrameManager:
stop_event: mp.Event,
):
self.config = config
self.mode = config.birdseye.mode
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
self.frame_shape = (height, width)
self.yuv_shape = (height * 3 // 2, width)
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
self.stop_event = stop_event
self.inactivity_threshold = config.birdseye.inactivity_threshold
if config.birdseye.layout.max_cameras:
self.last_refresh_time = 0
self.last_refresh_time = 0
# initialize the frame as black and with the Frigate logo
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
@ -426,7 +422,7 @@ class BirdsEyeFrameManager:
and self.config.cameras[cam].enabled
and cam_data["last_active_frame"] > 0
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
< self.inactivity_threshold
< self.config.birdseye.inactivity_threshold
]
)
logger.debug(f"Active cameras: {active_cameras}")

View File

@ -15,6 +15,7 @@ from ws4py.server.wsgirefserver import (
)
from ws4py.server.wsgiutils import WebSocketWSGIApplication
from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.ws import WebSocket
from frigate.config import FrigateConfig
@ -138,6 +139,7 @@ class OutputProcess(FrigateProcess):
CameraConfigUpdateEnum.record,
],
)
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
birdseye: Birdseye | None = None
@ -167,6 +169,20 @@ class OutputProcess(FrigateProcess):
websocket_thread.start()
while not self.stop_event.is_set():
update_topic, birdseye_config = (
birdseye_config_subscriber.check_for_update()
)
if update_topic is not None:
previous_global_mode = self.config.birdseye.mode
self.config.birdseye = birdseye_config
for camera_config in self.config.cameras.values():
if camera_config.birdseye.mode == previous_global_mode:
camera_config.birdseye.mode = birdseye_config.mode
logger.debug("Applied dynamic birdseye config update")
# check if there is an updated config
updates = config_subscriber.check_for_updates()
@ -297,6 +313,7 @@ class OutputProcess(FrigateProcess):
birdseye.stop()
config_subscriber.stop()
birdseye_config_subscriber.stop()
websocket_server.manager.close_all()
websocket_server.manager.stop()
websocket_server.manager.join()

View File

@ -0,0 +1,261 @@
"""Tests for the config_set endpoint's wildcard camera propagation."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdatePublisher,
CameraConfigUpdateTopic,
)
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestConfigSetWildcardPropagation(BaseTestHttp):
"""Test that wildcard camera updates fan out to all cameras."""
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
},
"back_yard": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 720,
"width": 1280,
"fps": 10,
},
},
},
}
def _create_app_with_publisher(self):
"""Create app with a mocked config publisher."""
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
return {"username": username, "role": role}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app, mock_publisher
def _write_config_file(self):
"""Write the minimal config to a temp YAML file and return the path."""
yaml = ruamel.yaml.YAML()
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
yaml.dump(self.minimal_config, f)
f.close()
return f.name
@patch("frigate.api.app.find_config_file")
def test_wildcard_detect_update_fans_out_to_all_cameras(self, mock_find_config):
"""config/cameras/*/detect fans out to all cameras."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"detect": {"fps": 15}},
"update_topic": "config/cameras/*/detect",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertTrue(data["success"])
# Verify publish_update called for each camera
self.assertEqual(mock_publisher.publish_update.call_count, 2)
published_cameras = set()
for c in mock_publisher.publish_update.call_args_list:
topic = c[0][0]
self.assertIsInstance(topic, CameraConfigUpdateTopic)
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
published_cameras.add(topic.camera)
self.assertEqual(published_cameras, {"front_door", "back_yard"})
# Global publisher should NOT be called for wildcard
mock_publisher.publisher.publish.assert_not_called()
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_wildcard_motion_update_fans_out(self, mock_find_config):
"""config/cameras/*/motion fans out to all cameras."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"motion": {"threshold": 30}},
"update_topic": "config/cameras/*/motion",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
published_cameras = set()
for c in mock_publisher.publish_update.call_args_list:
topic = c[0][0]
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.motion)
published_cameras.add(topic.camera)
self.assertEqual(published_cameras, {"front_door", "back_yard"})
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_camera_specific_topic_only_updates_one_camera(self, mock_find_config):
"""config/cameras/front_door/detect only updates front_door."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {
"cameras": {"front_door": {"detect": {"fps": 20}}}
},
"update_topic": "config/cameras/front_door/detect",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
# Only one camera updated
self.assertEqual(mock_publisher.publish_update.call_count, 1)
topic = mock_publisher.publish_update.call_args[0][0]
self.assertEqual(topic.camera, "front_door")
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
# Global publisher should NOT be called
mock_publisher.publisher.publish.assert_not_called()
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_wildcard_sends_merged_per_camera_config(self, mock_find_config):
"""Wildcard fan-out sends each camera's own merged config."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"detect": {"fps": 15}},
"update_topic": "config/cameras/*/detect",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
for c in mock_publisher.publish_update.call_args_list:
camera_detect_config = c[0][1]
self.assertIsNotNone(camera_detect_config)
self.assertTrue(hasattr(camera_detect_config, "fps"))
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_non_camera_global_topic_uses_generic_publish(self, mock_find_config):
"""Non-camera topics (e.g. config/live) use the generic publisher."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"live": {"height": 720}},
"update_topic": "config/live",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
# Global topic publisher called
mock_publisher.publisher.publish.assert_called_once()
# Camera-level publish_update NOT called
mock_publisher.publish_update.assert_not_called()
finally:
os.unlink(config_path)
if __name__ == "__main__":
unittest.main()

View 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)

View File

@ -151,7 +151,9 @@ def sync_recordings(
max_inserts = 1000
for batch in chunked(recordings_to_delete, max_inserts):
RecordingsToDelete.insert_many(batch).execute()
RecordingsToDelete.insert_many(
[{"id": r["id"]} for r in batch]
).execute()
try:
deleted = (

View File

@ -214,7 +214,11 @@ class CameraWatchdog(threading.Thread):
self.config_subscriber = CameraConfigUpdateSubscriber(
None,
{config.name: config},
[CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.record],
[
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.ffmpeg,
CameraConfigUpdateEnum.record,
],
)
self.requestor = InterProcessRequestor()
self.was_enabled = self.config.enabled
@ -254,9 +258,13 @@ class CameraWatchdog(threading.Thread):
self._last_record_status = status
self._last_status_update_time = now
def _check_config_updates(self) -> dict[str, list[str]]:
"""Check for config updates and return the update dict."""
return self.config_subscriber.check_for_updates()
def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state."""
self.config_subscriber.check_for_updates()
self._check_config_updates()
return self.config.enabled
def reset_capture_thread(
@ -317,7 +325,24 @@ class CameraWatchdog(threading.Thread):
# 1 second watchdog loop
while not self.stop_event.wait(1):
enabled = self._update_enabled_state()
updates = self._check_config_updates()
# Handle ffmpeg config changes by restarting all ffmpeg processes
if "ffmpeg" in updates and self.config.enabled:
self.logger.debug(
"FFmpeg config updated for %s, restarting ffmpeg processes",
self.config.name,
)
self.stop_all_ffmpeg()
self.start_all_ffmpeg()
self.latest_valid_segment_time = 0
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(timezone.utc)
last_restart_time = datetime.now().timestamp()
continue
enabled = self.config.enabled
if enabled != self.was_enabled:
if enabled:
self.logger.debug(f"Enabling camera {self.config.name}")

View File

@ -422,6 +422,18 @@
"cameraManagement": {
"title": "Manage Cameras",
"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:",
"selectCamera": "Select a Camera",
"backToSettings": "Back to Camera Settings",

View File

@ -11,7 +11,6 @@ import { Redirect } from "./components/navigation/Redirect";
import { cn } from "./lib/utils";
import { isPWA } from "./utils/isPWA";
import ProtectedRoute from "@/components/auth/ProtectedRoute";
import { AuthProvider } from "@/context/auth-context";
import useSWR from "swr";
import { FrigateConfig } from "./types/frigateConfig";
import ActivityIndicator from "@/components/indicators/activity-indicator";
@ -39,13 +38,11 @@ function App() {
return (
<Providers>
<AuthProvider>
<BrowserRouter basename={window.baseUrl}>
<Wrapper>
{config?.safe_mode ? <SafeAppView /> : <DefaultAppView />}
</Wrapper>
</BrowserRouter>
</AuthProvider>
<BrowserRouter basename={window.baseUrl}>
<Wrapper>
{config?.safe_mode ? <SafeAppView /> : <DefaultAppView />}
</Wrapper>
</BrowserRouter>
</Providers>
);
}
@ -85,17 +82,13 @@ function DefaultAppView() {
: "bottom-8 left-[52px]",
)}
>
<Suspense>
<Suspense
fallback={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
>
<Routes>
<Route
element={
mainRouteRoles ? (
<ProtectedRoute requiredRoles={mainRouteRoles} />
) : (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)
}
>
<Route element={<ProtectedRoute requiredRoles={mainRouteRoles} />}>
<Route index element={<Live />} />
<Route path="/review" element={<Events />} />
<Route path="/explore" element={<Explore />} />

View File

@ -10,7 +10,7 @@ import {
export default function ProtectedRoute({
requiredRoles,
}: {
requiredRoles: string[];
requiredRoles?: string[];
}) {
const { auth } = useContext(AuthContext);
@ -36,6 +36,13 @@ export default function ProtectedRoute({
);
}
// Wait for config to provide required roles
if (!requiredRoles) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
);
}
if (auth.isLoading) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />

View File

@ -25,14 +25,7 @@ const audio: SectionConfigOverrides = {
},
},
global: {
restartRequired: [
"enabled",
"listen",
"filters",
"min_volume",
"max_not_heard",
"num_threads",
],
restartRequired: ["num_threads"],
},
camera: {
restartRequired: ["num_threads"],

View File

@ -28,10 +28,7 @@ const birdseye: SectionConfigOverrides = {
"width",
"height",
"quality",
"mode",
"layout.scaling_factor",
"inactivity_threshold",
"layout.max_cameras",
"idle_heartbeat_fps",
],
uiSchema: {

View File

@ -3,7 +3,7 @@ import type { SectionConfigOverrides } from "./types";
const classification: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/custom_classification/object_classification",
restartRequired: ["bird.enabled", "bird.threshold"],
restartRequired: ["bird.enabled"],
hiddenFields: ["custom"],
advancedFields: [],
},

View File

@ -31,18 +31,21 @@ const detect: SectionConfigOverrides = {
},
global: {
restartRequired: [
"enabled",
"fps",
"width",
"height",
"fps",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
},
camera: {
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
restartRequired: [
"fps",
"width",
"height",
"min_initialized",
"max_disappeared",
],
},
};

View File

@ -32,18 +32,7 @@ const faceRecognition: SectionConfigOverrides = {
"blur_confidence_filter",
"device",
],
restartRequired: [
"enabled",
"model_size",
"unknown_score",
"detection_threshold",
"recognition_threshold",
"min_area",
"min_faces",
"save_attempts",
"blur_confidence_filter",
"device",
],
restartRequired: ["enabled", "model_size", "device"],
},
};

View File

@ -116,16 +116,7 @@ const ffmpeg: SectionConfigOverrides = {
},
},
global: {
restartRequired: [
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
restartRequired: [],
fieldOrder: [
"hwaccel_args",
"path",
@ -162,17 +153,7 @@ const ffmpeg: SectionConfigOverrides = {
fieldGroups: {
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
},
restartRequired: [
"inputs",
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
restartRequired: [],
},
};

View File

@ -40,21 +40,7 @@ const lpr: SectionConfigOverrides = {
"device",
"replace_rules",
],
restartRequired: [
"enabled",
"model_size",
"detection_threshold",
"min_area",
"recognition_threshold",
"min_plate_length",
"format",
"match_distance",
"known_plates",
"enhancement",
"debug_save_plates",
"device",
"replace_rules",
],
restartRequired: ["model_size", "enhancement", "device"],
uiSchema: {
format: {
"ui:options": { size: "md" },

View File

@ -3,6 +3,12 @@ import type { SectionConfigOverrides } from "./types";
const motion: SectionConfigOverrides = {
base: {
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: [],
fieldOrder: [
"enabled",
@ -20,6 +26,16 @@ const motion: SectionConfigOverrides = {
sensitivity: ["enabled", "threshold", "contour_area"],
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"],
advancedFields: [
"lightning_threshold",
@ -31,18 +47,7 @@ const motion: SectionConfigOverrides = {
],
},
global: {
restartRequired: [
"enabled",
"threshold",
"lightning_threshold",
"skip_motion_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
restartRequired: ["frame_height"],
},
camera: {
restartRequired: ["frame_height"],
@ -69,7 +74,7 @@ const motion: SectionConfigOverrides = {
"frame_alpha",
"frame_height",
],
advancedFields: ["lightning_threshold"],
advancedFields: ["lightning_threshold", "skip_motion_threshold"],
},
};

View File

@ -83,7 +83,7 @@ const objects: SectionConfigOverrides = {
},
},
global: {
restartRequired: ["track", "alert", "detect", "filters", "genai"],
restartRequired: [],
hiddenFields: [
"enabled_in_config",
"mask",

View File

@ -29,16 +29,7 @@ const record: SectionConfigOverrides = {
},
},
global: {
restartRequired: [
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
restartRequired: [],
},
camera: {
restartRequired: [],

View File

@ -44,7 +44,7 @@ const review: SectionConfigOverrides = {
},
},
global: {
restartRequired: ["alerts", "detections", "genai"],
restartRequired: [],
},
camera: {
restartRequired: [],

View File

@ -27,14 +27,7 @@ const snapshots: SectionConfigOverrides = {
},
},
global: {
restartRequired: [
"enabled",
"bounding_box",
"crop",
"quality",
"timestamp",
"retain",
],
restartRequired: [],
hiddenFields: ["enabled_in_config", "required_zones"],
},
camera: {

View File

@ -3,14 +3,7 @@ import type { SectionConfigOverrides } from "./types";
const telemetry: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
restartRequired: [
"network_interfaces",
"stats.amd_gpu_stats",
"stats.intel_gpu_stats",
"stats.intel_gpu_device",
"stats.network_bandwidth",
"version_check",
],
restartRequired: ["version_check"],
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: [],
},

View File

@ -56,6 +56,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
cameraUpdateTopicMap,
globalCameraDefaultSections,
buildOverrides,
buildConfigDataForPath,
sanitizeSectionData as sharedSanitizeSectionData,
@ -234,7 +235,10 @@ export function ConfigSection({
? cameraUpdateTopicMap[sectionPath]
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
: undefined
: `config/${sectionPath}`;
: globalCameraDefaultSections.has(sectionPath) &&
cameraUpdateTopicMap[sectionPath]
? `config/cameras/*/${cameraUpdateTopicMap[sectionPath]}`
: `config/${sectionPath}`;
// Default: show title for camera level (since it might be collapsible), hide for global
const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
@ -827,7 +831,7 @@ export function ConfigSection({
<div
className={cn(
"w-full border-t border-secondary bg-background pb-5 pt-0",
"w-full border-t border-secondary bg-background pt-0",
!noStickyButtons && "sticky bottom-0 z-50",
)}
>

View File

@ -26,6 +26,7 @@ import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
import { InputRolesWidget } from "./widgets/InputRolesWidget";
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@ -73,6 +74,7 @@ export const frigateTheme: FrigateTheme = {
audioLabels: AudioLabelSwitchesWidget,
zoneNames: ZoneSwitchesWidget,
timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget,
},
templates: {
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,

View File

@ -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>
);
}

View File

@ -20,7 +20,7 @@ export default function ActionsDropdown({
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
return (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="flex items-center gap-2"

View File

@ -7,10 +7,16 @@ import axios from "axios";
import { useSWRConfig } from "swr";
import { toast } from "sonner";
import { Trans, useTranslation } from "react-i18next";
import { LuInfo } from "react-icons/lu";
import { LuExternalLink, LuInfo, LuMinus, LuPlus } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom";
const OFFSET_MIN = -2500;
const OFFSET_MAX = 2500;
const OFFSET_STEP = 50;
type Props = {
className?: string;
@ -19,6 +25,7 @@ type Props = {
export default function AnnotationOffsetSlider({ className }: Props) {
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
const isAdmin = useIsAdmin();
const { getLocaleDocUrl } = useDocDomain();
const { mutate } = useSWRConfig();
const { t } = useTranslation(["views/explore"]);
const [isSaving, setIsSaving] = useState(false);
@ -32,6 +39,16 @@ export default function AnnotationOffsetSlider({ className }: Props) {
[setAnnotationOffset],
);
const stepOffset = useCallback(
(delta: number) => {
setAnnotationOffset((prev) => {
const next = prev + delta;
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
});
},
[setAnnotationOffset],
);
const reset = useCallback(() => {
setAnnotationOffset(0);
}, [setAnnotationOffset]);
@ -72,11 +89,18 @@ export default function AnnotationOffsetSlider({ className }: Props) {
return (
<div
className={cn(
"flex flex-col gap-0.5",
"flex flex-col gap-1.5",
isMobile && "landscape:gap-3",
className,
)}
>
<div className="flex items-center gap-2 text-sm">
<span>{t("trackingDetails.annotationSettings.offset.label")}:</span>
<span className="font-mono tabular-nums text-primary-variant">
{annotationOffset > 0 ? "+" : ""}
{annotationOffset}ms
</span>
</div>
<div
className={cn(
"flex items-center gap-3",
@ -84,57 +108,81 @@ export default function AnnotationOffsetSlider({ className }: Props) {
"landscape:flex-col landscape:items-start landscape:gap-4",
)}
>
<div className="flex max-w-28 flex-row items-center gap-2 text-sm md:max-w-48">
<span className="max-w-24 md:max-w-44">
{t("trackingDetails.annotationSettings.offset.label")}:
</span>
<span className="text-primary-variant">{annotationOffset}</span>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="size-8 shrink-0"
aria-label="-50ms"
onClick={() => stepOffset(-OFFSET_STEP)}
disabled={annotationOffset <= OFFSET_MIN}
>
<LuMinus className="size-4" />
</Button>
<div className="w-full flex-1 landscape:flex">
<Slider
value={[annotationOffset]}
min={-2500}
max={2500}
step={50}
min={OFFSET_MIN}
max={OFFSET_MAX}
step={OFFSET_STEP}
onValueChange={handleChange}
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={reset}>
{t("button.reset", { ns: "common" })}
</Button>
{isAdmin && (
<Button size="sm" onClick={save} disabled={isSaving}>
{isSaving
? t("button.saving", { ns: "common" })
: t("button.save", { ns: "common" })}
</Button>
)}
</div>
<Button
type="button"
variant="outline"
size="icon"
className="size-8 shrink-0"
aria-label="+50ms"
onClick={() => stepOffset(OFFSET_STEP)}
disabled={annotationOffset >= OFFSET_MAX}
>
<LuPlus className="size-4" />
</Button>
</div>
<div
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground",
isMobile && "landscape:flex-col landscape:items-start",
)}
>
<div className="flex items-start gap-1.5 text-xs text-muted-foreground">
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
className="mt-px shrink-0 focus:outline-none"
aria-label={t("trackingDetails.annotationSettings.offset.tips")}
>
<LuInfo className="size-4" />
<LuInfo className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{t("trackingDetails.annotationSettings.offset.tips")}
<div className="mt-2 flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl(
"troubleshooting/dummy-camera#annotation-offset",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="ghost" onClick={reset}>
{t("button.reset", { ns: "common" })}
</Button>
{isAdmin && (
<Button size="sm" onClick={save} disabled={isSaving}>
{isSaving
? t("button.saving", { ns: "common" })
: t("button.save", { ns: "common" })}
</Button>
)}
</div>
</div>
);
}

View File

@ -1,31 +1,23 @@
import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { LuExternalLink } from "react-icons/lu";
import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import useSWR from "swr";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useIsAdmin } from "@/hooks/use-is-admin";
const OFFSET_MIN = -2500;
const OFFSET_MAX = 2500;
const OFFSET_STEP = 50;
type AnnotationSettingsPaneProps = {
event: Event;
annotationOffset: number;
@ -45,93 +37,69 @@ export function AnnotationSettingsPane({
const [isLoading, setIsLoading] = useState(false);
const formSchema = z.object({
annotationOffset: z.coerce.number().optional().or(z.literal("")),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
annotationOffset: annotationOffset,
const handleSliderChange = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
setAnnotationOffset(values[0]);
},
});
const saveToConfig = useCallback(
async (annotation_offset: number | string) => {
if (!config || !event) {
return;
}
axios
.put(
`config/set?cameras.${event?.camera}.detect.annotation_offset=${annotation_offset}`,
{
requires_restart: 0,
},
)
.then((res) => {
if (res.status === 200) {
toast.success(
t("trackingDetails.annotationSettings.offset.toast.success", {
camera: event?.camera,
}),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.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);
});
},
[updateConfig, config, event, t],
[setAnnotationOffset],
);
function onSubmit(values: z.infer<typeof formSchema>) {
if (!values || values.annotationOffset == null || !config) {
return;
}
const stepOffset = useCallback(
(delta: number) => {
setAnnotationOffset((prev) => {
const next = prev + delta;
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
});
},
[setAnnotationOffset],
);
const reset = useCallback(() => {
setAnnotationOffset(0);
}, [setAnnotationOffset]);
const saveToConfig = useCallback(async () => {
if (!config || !event) return;
setIsLoading(true);
saveToConfig(values.annotationOffset);
}
function onApply(values: z.infer<typeof formSchema>) {
if (
!values ||
values.annotationOffset === null ||
values.annotationOffset === "" ||
!config
) {
return;
try {
const res = await axios.put(
`config/set?cameras.${event.camera}.detect.annotation_offset=${annotationOffset}`,
{ requires_restart: 0 },
);
if (res.status === 200) {
toast.success(
t("trackingDetails.annotationSettings.offset.toast.success", {
camera: event.camera,
}),
{ position: "top-center" },
);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
{ position: "top-center" },
);
}
} catch (error: unknown) {
const err = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
err?.response?.data?.message ||
err?.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
position: "top-center",
});
} finally {
setIsLoading(false);
}
setAnnotationOffset(values.annotationOffset ?? 0);
}
}, [annotationOffset, config, event, updateConfig, t]);
return (
<div className="p-4">
@ -140,91 +108,100 @@ export function AnnotationSettingsPane({
</div>
<Separator className="mb-4 flex bg-secondary" />
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-3"
>
<FormField
control={form.control}
name="annotationOffset"
render={({ field }) => (
<>
<FormItem className="flex flex-row items-start justify-between space-x-2">
<div className="flex flex-col gap-1">
<FormLabel>
{t("trackingDetails.annotationSettings.offset.label")}
</FormLabel>
<FormDescription>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<FormMessage />
</FormDescription>
</div>
<div className="flex flex-col gap-3">
<div className="min-w-24">
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0"
{...field}
/>
</FormControl>
</div>
</div>
</FormItem>
<div className="mt-1 text-sm text-secondary-foreground">
{t("trackingDetails.annotationSettings.offset.tips")}
<div className="mt-2 flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl("configuration/reference")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</>
)}
/>
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
variant="default"
aria-label={t("button.apply", { ns: "common" })}
type="button"
onClick={form.handleSubmit(onApply)}
>
{t("button.apply", { ns: "common" })}
</Button>
{isAdmin && (
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
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 className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="text-sm font-medium">
{t("trackingDetails.annotationSettings.offset.label")}
</div>
</form>
</Form>
<div className="text-sm text-muted-foreground">
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
</div>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="icon"
className="size-8 shrink-0"
aria-label="-50ms"
onClick={() => stepOffset(-OFFSET_STEP)}
disabled={annotationOffset <= OFFSET_MIN}
>
<LuMinus className="size-4" />
</Button>
<Slider
value={[annotationOffset]}
min={OFFSET_MIN}
max={OFFSET_MAX}
step={OFFSET_STEP}
onValueChange={handleSliderChange}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
className="size-8 shrink-0"
aria-label="+50ms"
onClick={() => stepOffset(OFFSET_STEP)}
disabled={annotationOffset >= OFFSET_MAX}
>
<LuPlus className="size-4" />
</Button>
</div>
<div className="flex items-center justify-between">
<span className="font-mono text-sm tabular-nums text-primary-variant">
{annotationOffset > 0 ? "+" : ""}
{annotationOffset}ms
</span>
<Button type="button" variant="ghost" size="sm" onClick={reset}>
{t("button.reset", { ns: "common" })}
</Button>
</div>
<div className="text-sm text-secondary-foreground">
{t("trackingDetails.annotationSettings.offset.tips")}
<div className="mt-2 flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl(
"troubleshooting/dummy-camera#annotation-offset",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
{isAdmin && (
<>
<Separator className="bg-secondary" />
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
onClick={saveToConfig}
>
{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>
);
}

View File

@ -323,6 +323,7 @@ function DialogContentComponent({
<TrackingDetails
className={cn(isDesktop ? "size-full" : "flex flex-col gap-4")}
event={search as unknown as Event}
isAnnotationSettingsOpen={isPopoverOpen}
tabs={
isDesktop ? (
<TabsWithActions
@ -495,6 +496,15 @@ export default function SearchDetailDialog({
}
}, [search]);
useEffect(() => {
if (!isDesktop || !onPrevious || !onNext) {
setShowNavigationButtons(false);
return;
}
setShowNavigationButtons(isOpen);
}, [isOpen, onNext, onPrevious]);
// show/hide annotation settings is handled inside TabsWithActions
const searchTabs = useMemo(() => {

View File

@ -47,12 +47,14 @@ type TrackingDetailsProps = {
event: Event;
fullscreen?: boolean;
tabs?: React.ReactNode;
isAnnotationSettingsOpen?: boolean;
};
export function TrackingDetails({
className,
event,
tabs,
isAnnotationSettingsOpen = false,
}: TrackingDetailsProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const { t } = useTranslation(["views/explore"]);
@ -69,6 +71,14 @@ export function TrackingDetails({
// user (eg, clicking a lifecycle row). When null we display `currentTime`.
const [manualOverride, setManualOverride] = useState<number | null>(null);
// Capture the annotation offset used for building the video source URL.
// This only updates when the event changes, NOT on every slider drag,
// so the HLS player doesn't reload while the user is adjusting the offset.
const sourceOffsetRef = useRef(annotationOffset);
useEffect(() => {
sourceOffsetRef.current = annotationOffset;
}, [event.id]); // eslint-disable-line react-hooks/exhaustive-deps
// event.start_time is detect time, convert to record, then subtract padding
const [currentTime, setCurrentTime] = useState(
(event.start_time ?? 0) + annotationOffset / 1000 - REVIEW_PADDING,
@ -90,14 +100,19 @@ export function TrackingDetails({
const { data: config } = useSWR<FrigateConfig>("config");
// Fetch recording segments for the event's time range to handle motion-only gaps
// Fetch recording segments for the event's time range to handle motion-only gaps.
// Use the source offset (stable per event) so recordings don't refetch on every
// slider drag while adjusting annotation offset.
const eventStartRecord = useMemo(
() => (event.start_time ?? 0) + annotationOffset / 1000,
[event.start_time, annotationOffset],
() => (event.start_time ?? 0) + sourceOffsetRef.current / 1000,
// eslint-disable-next-line react-hooks/exhaustive-deps
[event.start_time, event.id],
);
const eventEndRecord = useMemo(
() => (event.end_time ?? Date.now() / 1000) + annotationOffset / 1000,
[event.end_time, annotationOffset],
() =>
(event.end_time ?? Date.now() / 1000) + sourceOffsetRef.current / 1000,
// eslint-disable-next-line react-hooks/exhaustive-deps
[event.end_time, event.id],
);
const { data: recordings } = useSWR<Recording[]>(
@ -298,6 +313,53 @@ export function TrackingDetails({
setSelectedObjectIds([event.id]);
}, [event.id, setSelectedObjectIds]);
// When the annotation settings popover is open, pin the video to a specific
// lifecycle event (detect-stream timestamp). As the user drags the offset
// slider, the video re-seeks to show the recording frame at
// pinnedTimestamp + newOffset, while the bounding box stays fixed at the
// pinned detect timestamp. This lets the user visually align the box to
// the car in the video.
const pinnedDetectTimestampRef = useRef<number | null>(null);
const wasAnnotationOpenRef = useRef(false);
// On popover open: pause, pin first lifecycle item, and seek.
useEffect(() => {
if (isAnnotationSettingsOpen && !wasAnnotationOpenRef.current) {
if (videoRef.current && displaySource === "video") {
videoRef.current.pause();
}
if (eventSequence && eventSequence.length > 0) {
pinnedDetectTimestampRef.current = eventSequence[0].timestamp;
}
}
if (!isAnnotationSettingsOpen) {
pinnedDetectTimestampRef.current = null;
}
wasAnnotationOpenRef.current = isAnnotationSettingsOpen;
}, [isAnnotationSettingsOpen, displaySource, eventSequence]);
// When the pinned timestamp or offset changes, re-seek the video and
// explicitly update currentTime so the overlay shows the pinned event's box.
useEffect(() => {
const pinned = pinnedDetectTimestampRef.current;
if (!isAnnotationSettingsOpen || pinned == null) return;
if (!videoRef.current || displaySource !== "video") return;
const targetTimeRecord = pinned + annotationOffset / 1000;
const relativeTime = timestampToVideoTime(targetTimeRecord);
videoRef.current.currentTime = relativeTime;
// Explicitly update currentTime state so the overlay's effectiveCurrentTime
// resolves back to the pinned detect timestamp:
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned
setCurrentTime(targetTimeRecord);
}, [
isAnnotationSettingsOpen,
annotationOffset,
displaySource,
timestampToVideoTime,
]);
const handleLifecycleClick = useCallback(
(item: TrackingDetailsSequence) => {
if (!videoRef.current && !imgRef.current) return;
@ -453,19 +515,23 @@ export function TrackingDetails({
const videoSource = useMemo(() => {
// event.start_time and event.end_time are in DETECT stream time
// Convert to record stream time, then create video clip with padding
const eventStartRecord = event.start_time + annotationOffset / 1000;
const eventEndRecord =
(event.end_time ?? Date.now() / 1000) + annotationOffset / 1000;
const startTime = eventStartRecord - REVIEW_PADDING;
const endTime = eventEndRecord + REVIEW_PADDING;
// Convert to record stream time, then create video clip with padding.
// Use sourceOffsetRef (stable per event) so the HLS player doesn't
// reload while the user is dragging the annotation offset slider.
const sourceOffset = sourceOffsetRef.current;
const eventStartRec = event.start_time + sourceOffset / 1000;
const eventEndRec =
(event.end_time ?? Date.now() / 1000) + sourceOffset / 1000;
const startTime = eventStartRec - REVIEW_PADDING;
const endTime = eventEndRec + REVIEW_PADDING;
const playlist = `${baseUrl}vod/clip/${event.camera}/start/${startTime}/end/${endTime}/index.m3u8`;
return {
playlist,
startPosition: 0,
};
}, [event, annotationOffset]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event]);
// Determine camera aspect ratio category
const cameraAspect = useMemo(() => {

View 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>
);
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TrackingDetailsSequence } from "@/types/timeline";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useDetailStream } from "@/context/detail-stream-context";
@ -33,6 +33,7 @@ import { MdAutoAwesome } from "react-icons/md";
import { isPWA } from "@/utils/isPWA";
import { isInIframe } from "@/utils/isIFrame";
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
import { Separator } from "../ui/separator";
type DetailStreamProps = {
reviewItems?: ReviewSegment[];
@ -49,7 +50,8 @@ export default function DetailStream({
}: DetailStreamProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation("views/events");
const { annotationOffset } = useDetailStream();
const { annotationOffset, selectedObjectIds, setSelectedObjectIds } =
useDetailStream();
const scrollRef = useRef<HTMLDivElement>(null);
const [activeReviewId, setActiveReviewId] = useState<string | undefined>(
@ -67,9 +69,69 @@ export default function DetailStream({
true,
);
const onSeekCheckPlaying = (timestamp: number) => {
onSeek(timestamp, isPlaying);
};
// When the settings panel opens, pin to the nearest review with detections
// so the user can visually align the bounding box using the offset slider
const pinnedDetectTimestampRef = useRef<number | null>(null);
const wasControlsExpandedRef = useRef(false);
const selectedBeforeExpandRef = useRef<string[]>([]);
const onSeekCheckPlaying = useCallback(
(timestamp: number) => {
onSeek(timestamp, isPlaying);
},
[onSeek, isPlaying],
);
useEffect(() => {
if (controlsExpanded && !wasControlsExpandedRef.current) {
selectedBeforeExpandRef.current = selectedObjectIds;
const items = (reviewItems ?? []).filter(
(r) => r.data?.detections?.length > 0,
);
if (items.length > 0) {
// Pick the nearest review to current effective time
let nearest = items[0];
let minDiff = Math.abs(effectiveTime - nearest.start_time);
for (const r of items) {
const diff = Math.abs(effectiveTime - r.start_time);
if (diff < minDiff) {
nearest = r;
minDiff = diff;
}
}
const nearestId = `review-${nearest.id ?? nearest.start_time ?? Math.floor(nearest.start_time ?? 0)}`;
setActiveReviewId(nearestId);
const detectionId = nearest.data.detections[0];
setSelectedObjectIds([detectionId]);
// Use the detection's actual start timestamp (parsed from its ID)
// rather than review.start_time, which can be >10ms away from any
// lifecycle event and would fail the bounding-box TOLERANCE check.
const detectTimestamp = parseFloat(detectionId);
pinnedDetectTimestampRef.current = detectTimestamp;
const recordTime = detectTimestamp + annotationOffset / 1000;
onSeek(recordTime, false);
}
}
if (!controlsExpanded && wasControlsExpandedRef.current) {
pinnedDetectTimestampRef.current = null;
setSelectedObjectIds(selectedBeforeExpandRef.current);
}
wasControlsExpandedRef.current = controlsExpanded;
// Only trigger on expand/collapse transition
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlsExpanded]);
// Re-seek on annotation offset change while settings panel is open
useEffect(() => {
const pinned = pinnedDetectTimestampRef.current;
if (!controlsExpanded || pinned == null) return;
const recordTime = pinned + annotationOffset / 1000;
onSeek(recordTime, false);
}, [controlsExpanded, annotationOffset, onSeek]);
// Ensure we initialize the active review when reviewItems first arrive.
// This helps when the component mounts while the video is already
@ -214,6 +276,12 @@ export default function DetailStream({
/>
<div className="relative flex h-full flex-col">
{controlsExpanded && (
<div
className="absolute inset-0 z-20 cursor-pointer bg-black/50"
onClick={() => setControlsExpanded(false)}
/>
)}
<div
ref={scrollRef}
className="scrollbar-container flex-1 overflow-y-auto overflow-x-hidden pb-14"
@ -267,8 +335,9 @@ export default function DetailStream({
)}
</button>
{controlsExpanded && (
<div className="space-y-3 px-3 pb-3">
<div className="space-y-4 px-3 pb-5 pt-2">
<AnnotationOffsetSlider />
<Separator />
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">

View File

@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useMemo } from "react";
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { usePersistence } from "./use-persistence";
import { useUserPersistence } from "./use-user-persistence";
@ -12,20 +12,28 @@ export function useOverlayState<S>(
const location = useLocation();
const navigate = useNavigate();
const currentLocationState = useMemo(() => location.state, [location]);
const locationRef = useRef(location);
locationRef.current = location;
const setOverlayStateValue = useCallback(
(value: S, replace: boolean = false) => {
const newLocationState = { ...currentLocationState };
const loc = locationRef.current;
const currentValue = loc.state?.[key] as S | undefined;
if (Object.is(currentValue, value)) {
return;
}
const newLocationState = { ...loc.state };
newLocationState[key] = value;
navigate(location.pathname + (preserveSearch ? location.search : ""), {
navigate(loc.pathname + (preserveSearch ? loc.search : ""), {
state: newLocationState,
replace,
});
},
// we know that these deps are correct
// locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, currentLocationState, navigate],
[key, navigate, preserveSearch],
);
const overlayStateValue = useMemo<S | undefined>(
@ -47,7 +55,9 @@ export function usePersistedOverlayState<S extends string>(
] {
const location = useLocation();
const navigate = useNavigate();
const currentLocationState = useMemo(() => location.state, [location]);
const locationRef = useRef(location);
locationRef.current = location;
// currently selected value
@ -63,14 +73,21 @@ export function usePersistedOverlayState<S extends string>(
const setOverlayStateValue = useCallback(
(value: S | undefined, replace: boolean = false) => {
const loc = locationRef.current;
const currentValue = loc.state?.[key] as S | undefined;
if (Object.is(currentValue, value)) {
return;
}
setPersistedValue(value);
const newLocationState = { ...currentLocationState };
const newLocationState = { ...loc.state };
newLocationState[key] = value;
navigate(location.pathname, { state: newLocationState, replace });
navigate(loc.pathname, { state: newLocationState, replace });
},
// we know that these deps are correct
// locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, currentLocationState, navigate],
[key, navigate, setPersistedValue],
);
return [
@ -98,7 +115,9 @@ export function useUserPersistedOverlayState<S extends string>(
const { auth } = useContext(AuthContext);
const location = useLocation();
const navigate = useNavigate();
const currentLocationState = useMemo(() => location.state, [location]);
const locationRef = useRef(location);
locationRef.current = location;
// currently selected value from URL state
const overlayStateValue = useMemo<S | undefined>(
@ -112,14 +131,21 @@ export function useUserPersistedOverlayState<S extends string>(
const setOverlayStateValue = useCallback(
(value: S | undefined, replace: boolean = false) => {
const loc = locationRef.current;
const currentValue = loc.state?.[key] as S | undefined;
if (Object.is(currentValue, value)) {
return;
}
setPersistedValue(value);
const newLocationState = { ...currentLocationState };
const newLocationState = { ...loc.state };
newLocationState[key] = value;
navigate(location.pathname, { state: newLocationState, replace });
navigate(loc.pathname, { state: newLocationState, replace });
},
// we know that these deps are correct
// locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, currentLocationState, navigate, setPersistedValue],
[key, navigate, setPersistedValue],
);
// Don't return a value until auth has finished loading
@ -142,17 +168,21 @@ export function useHashState<S extends string>(): [
const location = useLocation();
const navigate = useNavigate();
const locationRef = useRef(location);
locationRef.current = location;
const setHash = useCallback(
(value: S | undefined) => {
const loc = locationRef.current;
if (!value) {
navigate(location.pathname);
navigate(loc.pathname);
} else {
navigate(`${location.pathname}#${value}`, { state: location.state });
navigate(`${loc.pathname}#${value}`, { state: loc.state });
}
},
// we know that these deps are correct
// locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
[location, navigate],
[navigate],
);
const hash = useMemo(

View File

@ -116,6 +116,11 @@ export function useUserPersistence<S>(
return;
}
// Skip reload if we're already loaded for this key
if (loadedKeyRef.current === namespacedKey) {
return;
}
// Reset state when key changes - this prevents stale writes
loadedKeyRef.current = null;
migrationAttemptedRef.current = false;

View File

@ -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 = [
"en",
"es",

View File

@ -23,6 +23,7 @@ import { toast } from "sonner";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { JINA_EMBEDDING_MODELS } from "@/lib/const";
const API_LIMIT = 25;
@ -293,7 +294,12 @@ export default function Explore() {
const modelVersion = config?.semantic_search.model || "jinav1";
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(
modelVersion === "jinav1"
? "jinaai/jina-clip-v1-text_model_fp16.onnx"
@ -328,6 +334,10 @@ export default function Explore() {
);
const allModelsLoaded = useMemo(() => {
if (isGenaiEmbeddings) {
return true;
}
return (
textModelState === "downloaded" &&
textTokenizerState === "downloaded" &&
@ -335,6 +345,7 @@ export default function Explore() {
visionFeatureExtractorState === "downloaded"
);
}, [
isGenaiEmbeddings,
textModelState,
textTokenizerState,
visionModelState,
@ -358,10 +369,11 @@ export default function Explore() {
!defaultViewLoaded ||
(config?.semantic_search.enabled &&
(!reindexState ||
!textModelState ||
!textTokenizerState ||
!visionModelState ||
!visionFeatureExtractorState))
(!isGenaiEmbeddings &&
(!textModelState ||
!textTokenizerState ||
!visionModelState ||
!visionFeatureExtractorState))))
) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />

View File

@ -479,14 +479,7 @@ const CAMERA_SELECT_BUTTON_PAGES = [
"regionGrid",
];
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
const LARGE_BOTTOM_MARGIN_PAGES = [
"masksAndZones",
"motionTuner",
"mediaSync",
"regionGrid",
];
const ALLOWED_VIEWS_FOR_VIEWER = ["profileSettings", "notifications"];
// keys for camera sections
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
@ -1362,9 +1355,9 @@ export default function Settings() {
)}
</div>
</div>
<SidebarProvider>
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0">
<SidebarContent className="scrollbar-container mb-24 overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
<SidebarProvider className="relative h-full min-h-0 flex-1">
<Sidebar variant="inset" className="absolute h-full pl-0 pt-0">
<SidebarContent className="scrollbar-container overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
<SidebarMenu>
{settingsGroups.map((group) => {
const filteredItems = group.items.filter((item) =>
@ -1452,8 +1445,7 @@ export default function Settings() {
<SidebarInset>
<div
className={cn(
"scrollbar-container mb-16 flex-1 overflow-y-auto p-2 pr-0",
LARGE_BOTTOM_MARGIN_PAGES.includes(pageToggle) && "mb-24",
"scrollbar-container flex-1 overflow-y-auto pl-2 pr-0 pt-2",
)}
>
{(() => {

View File

@ -54,6 +54,20 @@ export const cameraUpdateTopicMap: Record<string, string> = {
ui: "ui",
};
// Sections where global config serves as the default for per-camera config.
// Global updates to these sections are fanned out to all cameras via wildcard.
export const globalCameraDefaultSections = new Set([
"detect",
"objects",
"motion",
"record",
"snapshots",
"review",
"audio",
"notifications",
"ffmpeg",
]);
// ---------------------------------------------------------------------------
// buildOverrides — pure recursive diff of current vs stored config & defaults
// ---------------------------------------------------------------------------
@ -476,6 +490,9 @@ export function prepareSectionSavePayload(opts: {
if (level === "camera" && cameraName) {
const topic = cameraUpdateTopicMap[sectionPath];
updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
} else if (globalCameraDefaultSections.has(sectionPath)) {
const topic = cameraUpdateTopicMap[sectionPath];
updateTopic = topic ? `config/cameras/*/${topic}` : `config/${sectionPath}`;
} else {
updateTopic = `config/${sectionPath}`;
}

View File

@ -40,23 +40,31 @@ i18n
"common",
"objects",
"audio",
"components/auth",
"components/camera",
"components/dialog",
"components/filter",
"components/icons",
"components/input",
"components/player",
"views/events",
"views/chat",
"views/classificationModel",
"views/configEditor",
"views/events",
"views/explore",
"views/exports",
"views/faceLibrary",
"views/live",
"views/motionSearch",
"views/recording",
"views/replay",
"views/search",
"views/settings",
"views/system",
"views/exports",
"views/explore",
"config/global",
"config/cameras",
"config/validation",
"config/global",
"config/groups",
"config/validation",
],
defaultNS: "common",

View File

@ -1284,7 +1284,7 @@ function MotionReview({
return (
<>
{motionPreviewsCamera && selectedMotionPreviewCamera ? (
{selectedMotionPreviewCamera && (
<>
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
<Button
@ -1465,104 +1465,108 @@ 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
ref={contentRef}
className={cn(
"no-scrollbar grid w-full grid-cols-1",
isMobile && "landscape:grid-cols-2",
reviewCameras.length > 3 &&
isMobile &&
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
isDesktop && "grid-cols-2 lg:grid-cols-3",
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
)}
>
{reviewCameras.map((camera) => {
let grow;
let spans;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
grow = "aspect-wide";
spans = "sm:col-span-2";
} else if (aspectRatio < 1) {
grow = "h-full aspect-tall";
spans = "md:row-span-2";
} else {
grow = "aspect-video";
}
const detectionType = getDetectionType(camera.name);
return (
<div key={camera.name} className={`relative ${spans}`}>
{motionData ? (
<>
<PreviewPlayer
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
camera={camera.name}
timeRange={currentTimeRange}
startTime={previewStart}
cameraPreviews={relevantPreviews}
isScrubbing={scrubbing}
onControllerReady={(controller) => {
videoPlayersRef.current[camera.name] = controller;
}}
onClick={() =>
onOpenRecording({
camera: camera.name,
startTime: Math.min(
currentTime,
Date.now() / 1000 - 30,
),
severity: "significant_motion",
})
}
/>
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
/>
<div className="absolute bottom-2 right-2 z-30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<BlurredIconButton
aria-label={t("motionSearch.openMenu")}
onClick={(e) => e.stopPropagation()}
>
<FiMoreVertical className="size-5" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setMotionPreviewsCamera(camera.name);
}}
>
{t("motionPreviews.menuItem")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setMotionSearchCamera(camera.name);
}}
>
{t("motionSearch.menuItem")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
) : (
<Skeleton
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
/>
)}
</div>
);
})}
</div>
</div>
)}
<div
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(
"no-scrollbar grid w-full grid-cols-1",
isMobile && "landscape:grid-cols-2",
reviewCameras.length > 3 &&
isMobile &&
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
isDesktop && "grid-cols-2 lg:grid-cols-3",
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
)}
>
{reviewCameras.map((camera) => {
let grow;
let spans;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
grow = "aspect-wide";
spans = "sm:col-span-2";
} else if (aspectRatio < 1) {
grow = "h-full aspect-tall";
spans = "md:row-span-2";
} else {
grow = "aspect-video";
}
const detectionType = getDetectionType(camera.name);
return (
<div key={camera.name} className={`relative ${spans}`}>
{motionData ? (
<>
<PreviewPlayer
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
camera={camera.name}
timeRange={currentTimeRange}
startTime={previewStart}
cameraPreviews={relevantPreviews}
isScrubbing={scrubbing}
onControllerReady={(controller) => {
videoPlayersRef.current[camera.name] = controller;
}}
onClick={() =>
onOpenRecording({
camera: camera.name,
startTime: Math.min(
currentTime,
Date.now() / 1000 - 30,
),
severity: "significant_motion",
})
}
/>
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
/>
<div className="absolute bottom-2 right-2 z-30">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<BlurredIconButton
aria-label={t("motionSearch.openMenu")}
onClick={(e) => e.stopPropagation()}
>
<FiMoreVertical className="size-5" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setMotionPreviewsCamera(camera.name);
}}
>
{t("motionPreviews.menuItem")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setMotionSearchCamera(camera.name);
}}
>
{t("motionSearch.menuItem")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
) : (
<Skeleton
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
/>
)}
</div>
);
})}
</div>
</div>
{!selectedMotionPreviewCamera && (
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
{motionData ? (

View File

@ -624,6 +624,9 @@ export default function MotionPreviewsPane({
const [hasVisibilityData, setHasVisibilityData] = useState(false);
const clipObserver = useRef<IntersectionObserver | null>(null);
const [mountedClips, setMountedClips] = useState<Set<string>>(new Set());
const mountObserver = useRef<IntersectionObserver | null>(null);
const recordingTimeRange = useMemo(() => {
if (!motionRanges.length) {
return null;
@ -788,15 +791,56 @@ export default function MotionPreviewsPane({
};
}, [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) => {
if (!clipObserver.current) {
if (!node) {
return;
}
try {
if (node) {
clipObserver.current.observe(node);
}
clipObserver.current?.observe(node);
mountObserver.current?.observe(node);
} catch {
// no op
}
@ -864,31 +908,38 @@ 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">
{clipData.map(
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => (
<div
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}`}
ref={clipRef}
>
<MotionPreviewClip
cameraName={camera.name}
range={range}
playbackRate={playbackRate}
preview={preview}
fallbackFrameTimes={fallbackFrameTimes}
motionHeatmap={motionHeatmap}
nonMotionAlpha={nonMotionAlpha}
isVisible={
windowVisible &&
(visibleClips.includes(
`${camera.name}-${range.start_time}-${range.end_time}-${idx}`,
) ||
(!hasVisibilityData && idx < 8))
}
onSeek={onSeek}
/>
</div>
),
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
const isMounted = mountedClips.has(clipId);
return (
<div
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
data-clip-id={clipId}
ref={clipRef}
>
{isMounted ? (
<MotionPreviewClip
cameraName={camera.name}
range={range}
playbackRate={playbackRate}
preview={preview}
fallbackFrameTimes={fallbackFrameTimes}
motionHeatmap={motionHeatmap}
nonMotionAlpha={nonMotionAlpha}
isVisible={
windowVisible &&
(visibleClips.includes(clipId) ||
(!hasVisibilityData && idx < 8))
}
onSeek={onSeek}
/>
) : (
<div className="aspect-video rounded-lg bg-black md:rounded-2xl" />
)}
</div>
);
},
)}
</div>
)}

View File

@ -632,9 +632,10 @@ export default function DraggableGridLayout({
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name]}
setVolumeState={(value) =>
setVolumeStates({
setVolumeStates((prev) => ({
...prev,
[camera.name]: value,
})
}))
}
muteAll={muteAll}
unmuteAll={unmuteAll}

View File

@ -131,12 +131,10 @@ export default function MotionSearchView({
);
// Camera previews defer until dialog is closed
const allPreviews = useCameraPreviews(
isSearchDialogOpen ? { after: 0, before: 0 } : timeRange,
{
camera: selectedCamera ?? undefined,
},
);
const allPreviews = useCameraPreviews(timeRange, {
camera: selectedCamera ?? undefined,
fetchPreviews: !isSearchDialogOpen,
});
// ROI state
const [polygonPoints, setPolygonPoints] = useState<number[][]>([]);

View File

@ -13,7 +13,8 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
import CameraEditForm from "@/components/settings/CameraEditForm";
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 { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
@ -45,6 +46,7 @@ export default function CameraManagementView({
undefined,
); // Track camera being edited
const [showWizard, setShowWizard] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// State for restart dialog when enabling a disabled camera
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
@ -98,14 +100,26 @@ export default function CameraManagementView({
</Heading>
<div className="w-full max-w-5xl space-y-6">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</Button>
<div className="flex gap-2">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</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 && (
<SettingsGroupCard
@ -221,6 +235,15 @@ export default function CameraManagementView({
open={showWizard}
onClose={() => setShowWizard(false)}
/>
<DeleteCameraDialog
show={showDeleteDialog}
cameras={[...enabledCameras, ...disabledCameras]}
onClose={() => setShowDeleteDialog(false)}
onDeleted={() => {
setShowDeleteDialog(false);
updateConfig();
}}
/>
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}

View File

@ -210,7 +210,7 @@ export default function UiSettingsView() {
];
return (
<div className="flex size-full flex-col md:pb-8">
<div className="flex size-full flex-col">
<Toaster position="top-center" closeButton={true} />
<Heading as="h4" className="mb-3">
{t("general.title")}