mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
Compare commits
9 Commits
334c8efce0
...
334399a260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
334399a260 | ||
|
|
ac63be9ea7 | ||
|
|
a8c741a8ce | ||
|
|
55e4d210cf | ||
|
|
28cb974e94 | ||
|
|
d16bacf96b | ||
|
|
ebd7e8010d | ||
|
|
e79a624a15 | ||
|
|
29e2c322e7 |
16
.github/copilot-instructions.md
vendored
16
.github/copilot-instructions.md
vendored
@ -324,12 +324,6 @@ try:
|
||||
value = await sensor.read()
|
||||
except Exception: # ❌ Too broad
|
||||
logger.error("Failed")
|
||||
|
||||
# Returning exceptions in JSON responses
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": str(e)},
|
||||
)
|
||||
```
|
||||
|
||||
### ✅ Use These Instead
|
||||
@ -359,16 +353,6 @@ try:
|
||||
value = await sensor.read()
|
||||
except SensorException as err: # ✅ Specific
|
||||
logger.exception("Failed to read sensor")
|
||||
|
||||
# Safe error responses
|
||||
except ValueError:
|
||||
logger.exception("Invalid parameters for API request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid request parameters",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Project-Specific Conventions
|
||||
|
||||
@ -75,4 +75,4 @@ Many providers also have a public facing chat interface for their models. Downlo
|
||||
|
||||
- OpenAI - [ChatGPT](https://chatgpt.com)
|
||||
- Gemini - [Google AI Studio](https://aistudio.google.com)
|
||||
- Ollama - [Open WebUI](https://docs.openwebui.com/)
|
||||
- Ollama - [Open WebUI](https://docs.openwebui.com/)
|
||||
@ -38,6 +38,7 @@ Remember that motion detection is just used to determine when object detection s
|
||||
The threshold value dictates how much of a change in a pixels luminance is required to be considered motion.
|
||||
|
||||
```yaml
|
||||
# default threshold value
|
||||
motion:
|
||||
# Optional: The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. (default: shown below)
|
||||
# Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive.
|
||||
@ -52,6 +53,7 @@ Watching the motion boxes in the debug view, increase the threshold until you on
|
||||
### Contour Area
|
||||
|
||||
```yaml
|
||||
# default contour_area value
|
||||
motion:
|
||||
# Optional: Minimum size in pixels in the resized motion image that counts as motion (default: shown below)
|
||||
# Increasing this value will prevent smaller areas of motion from being detected. Decreasing will
|
||||
@ -79,49 +81,27 @@ However, if the preferred day settings do not work well at night it is recommend
|
||||
|
||||
## Tuning For Large Changes In Motion
|
||||
|
||||
### Lightning Threshold
|
||||
|
||||
```yaml
|
||||
# default lightning_threshold:
|
||||
motion:
|
||||
# Optional: The percentage of the image used to detect lightning or
|
||||
# other substantial changes where motion detection needs to
|
||||
# recalibrate. (default: shown below)
|
||||
# Increasing this value will make motion detection more likely
|
||||
# to consider lightning or IR mode changes as valid motion.
|
||||
# Decreasing this value will make motion detection more likely
|
||||
# to ignore large amounts of motion such as a person
|
||||
# approaching a doorbell camera.
|
||||
# Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection
|
||||
# needs to recalibrate. (default: shown below)
|
||||
# Increasing this value will make motion detection more likely to consider lightning or ir mode changes as valid motion.
|
||||
# Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching
|
||||
# a doorbell camera.
|
||||
lightning_threshold: 0.8
|
||||
```
|
||||
|
||||
Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in a pause in object detection. `lightning_threshold` defines the percentage of the image used to detect these substantial changes. Increasing this value makes motion detection more likely to treat large changes (like IR mode switches) as valid motion. Decreasing it makes motion detection more likely to ignore large amounts of motion, such as a person approaching a doorbell camera.
|
||||
|
||||
Note that `lightning_threshold` does **not** stop motion-based recordings from being saved — it only prevents additional motion analysis after the threshold is exceeded, reducing false positive object detections during high-motion periods (e.g. storms or PTZ sweeps) without interfering with recordings.
|
||||
|
||||
:::warning
|
||||
|
||||
Some cameras, like doorbell cameras, may have missed detections when someone walks directly in front of the camera and the `lightning_threshold` causes motion detection to recalibrate. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed.
|
||||
Some cameras like doorbell cameras may have missed detections when someone walks directly in front of the camera and the lightning_threshold causes motion detection to be re-calibrated. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed.
|
||||
|
||||
:::
|
||||
|
||||
### Skip Motion On Large Scene Changes
|
||||
:::note
|
||||
|
||||
```yaml
|
||||
motion:
|
||||
# Optional: Fraction of the frame that must change in a single update
|
||||
# before Frigate will completely ignore any motion in that frame.
|
||||
# Values range between 0.0 and 1.0, leave unset (null) to disable.
|
||||
# Setting this to 0.7 would cause Frigate to **skip** reporting
|
||||
# motion boxes when more than 70% of the image appears to change
|
||||
# (e.g. during lightning storms, IR/color mode switches, or other
|
||||
# sudden lighting events).
|
||||
skip_motion_threshold: 0.7
|
||||
```
|
||||
|
||||
This option is handy when you want to prevent large transient changes from triggering recordings or object detection. It differs from `lightning_threshold` because it completely suppresses motion instead of just forcing a recalibration.
|
||||
|
||||
:::warning
|
||||
|
||||
When the skip threshold is exceeded, **no motion is reported** for that frame, meaning **nothing is recorded** for that frame. That means you can miss something important, like a PTZ camera auto-tracking an object or activity while the camera is moving. If you prefer to guarantee that every frame is saved, leave this unset and accept occasional recordings containing scene noise — they typically only take up a few megabytes and are quick to scan in the timeline UI.
|
||||
Lightning threshold does not stop motion based recordings from being saved.
|
||||
|
||||
:::
|
||||
|
||||
Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in a pause in object detection. This is done via the `lightning_threshold` configuration. It is defined as the percentage of the image used to detect lightning or other substantial changes where motion detection needs to recalibrate. Increasing this value will make motion detection more likely to consider lightning or IR mode changes as valid motion. Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera.
|
||||
|
||||
@ -480,16 +480,12 @@ motion:
|
||||
# Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive.
|
||||
# The value should be between 1 and 255.
|
||||
threshold: 30
|
||||
# Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection needs
|
||||
# to recalibrate and motion checks stop for that frame. Recordings are unaffected. (default: shown below)
|
||||
# Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection
|
||||
# needs to recalibrate. (default: shown below)
|
||||
# Increasing this value will make motion detection more likely to consider lightning or ir mode changes as valid motion.
|
||||
# Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera.
|
||||
# Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching
|
||||
# a doorbell camera.
|
||||
lightning_threshold: 0.8
|
||||
# Optional: Fraction of the frame that must change in a single update before motion boxes are completely
|
||||
# ignored. Values range between 0.0 and 1.0. When exceeded, no motion boxes are reported and **no motion
|
||||
# recording** is created for that frame. Leave unset (null) to disable this feature. Use with care on PTZ
|
||||
# cameras or other situations where you require guaranteed frame capture.
|
||||
skip_motion_threshold: None
|
||||
# Optional: Minimum size in pixels in the resized motion image that counts as motion (default: shown below)
|
||||
# Increasing this value will prevent smaller areas of motion from being detected. Decreasing will
|
||||
# make motion detection more sensitive to smaller moving objects.
|
||||
|
||||
@ -76,7 +76,7 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
|
||||
|
||||
:::
|
||||
|
||||
### GenAI Provider
|
||||
### GenAI Provider (llama.cpp)
|
||||
|
||||
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).
|
||||
|
||||
@ -102,7 +102,7 @@ semantic_search:
|
||||
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.
|
||||
The llama.cpp server must be started with `--embeddings` for the embeddings API, and `--mmproj <mmproj.gguf>` when using image embeddings. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details.
|
||||
|
||||
:::note
|
||||
|
||||
|
||||
@ -3,67 +3,17 @@ id: dummy-camera
|
||||
title: Analyzing Object Detection
|
||||
---
|
||||
|
||||
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 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.
|
||||
|
||||
## Reviewing Detections in the UI
|
||||
## When to use
|
||||
|
||||
Before setting up a replay, you can often diagnose detection issues by reviewing existing recordings directly in the Frigate 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
|
||||
|
||||
### Detail View (History)
|
||||
## Example Config
|
||||
|
||||
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`:
|
||||
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:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
@ -82,10 +32,10 @@ cameras:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
- `-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.
|
||||
- `-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.
|
||||
|
||||
### 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.
|
||||
@ -95,8 +45,16 @@ 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.
|
||||
|
||||
### Troubleshooting
|
||||
## Variables to consider in object tracking
|
||||
|
||||
- **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.
|
||||
- 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.
|
||||
|
||||
@ -49,13 +49,12 @@ from frigate.stats.prometheus import get_metrics, update_metrics
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
deep_merge,
|
||||
flatten_config_data,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import apply_section_update, find_config_file
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
get_nvidia_driver_info,
|
||||
@ -423,100 +422,9 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
)
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
"""Apply config changes in-memory only, without writing to YAML.
|
||||
|
||||
Used for temporary config changes like debug replay camera tuning.
|
||||
Updates the in-memory Pydantic config and publishes ZMQ updates,
|
||||
bypassing YAML parsing entirely.
|
||||
"""
|
||||
try:
|
||||
updates = {}
|
||||
if body.config_data:
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "No configuration data provided"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
# Group flat key paths into nested per-camera, per-section dicts
|
||||
grouped: dict[str, dict[str, dict]] = {}
|
||||
for key_path, value in updates.items():
|
||||
parts = key_path.split(".")
|
||||
if len(parts) < 3 or parts[0] != "cameras":
|
||||
continue
|
||||
|
||||
cam, section = parts[1], parts[2]
|
||||
grouped.setdefault(cam, {}).setdefault(section, {})
|
||||
|
||||
# Build nested dict from remaining path (e.g. "filters.person.threshold")
|
||||
target = grouped[cam][section]
|
||||
for part in parts[3:-1]:
|
||||
target = target.setdefault(part, {})
|
||||
if len(parts) > 3:
|
||||
target[parts[-1]] = value
|
||||
elif isinstance(value, dict):
|
||||
grouped[cam][section] = deep_merge(
|
||||
grouped[cam][section], value, override=True
|
||||
)
|
||||
else:
|
||||
grouped[cam][section] = value
|
||||
|
||||
# Apply each section update
|
||||
for cam_name, sections in grouped.items():
|
||||
camera_config = config.cameras.get(cam_name)
|
||||
if not camera_config:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Camera '{cam_name}' not found",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for section_name, update in sections.items():
|
||||
err = apply_section_update(camera_config, section_name, update)
|
||||
if err is not None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": err},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Publish ZMQ updates so processing threads pick up changes
|
||||
if body.update_topic and body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
settings = getattr(config.cameras.get(camera, None), field, None)
|
||||
|
||||
if settings is not None:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
|
||||
settings,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Config applied in-memory"},
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying config in-memory: {e}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Error applying config"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_set(request: Request, body: AppConfigSetBody):
|
||||
config_file = find_config_file()
|
||||
|
||||
if body.skip_save:
|
||||
return _config_set_in_memory(request, body)
|
||||
|
||||
lock = FileLock(f"{config_file}.lock", timeout=5)
|
||||
|
||||
try:
|
||||
@ -589,38 +497,23 @@ 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 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,
|
||||
)
|
||||
if field == "add":
|
||||
settings = config.cameras[camera]
|
||||
elif field == "remove":
|
||||
settings = old_config.cameras[camera]
|
||||
else:
|
||||
if field == "add":
|
||||
settings = config.cameras[camera]
|
||||
elif field == "remove":
|
||||
settings = old_config.cameras[camera]
|
||||
else:
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
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)
|
||||
|
||||
@ -32,12 +32,6 @@ from frigate.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory cache to track which clients we've logged for an anonymous access event.
|
||||
# Keyed by a hashed value combining remote address + user-agent. The value is
|
||||
# an expiration timestamp (float).
|
||||
FIRST_LOAD_TTL_SECONDS = 60 * 60 * 24 * 7 # 7 days
|
||||
_first_load_seen: dict[str, float] = {}
|
||||
|
||||
|
||||
def require_admin_by_default():
|
||||
"""
|
||||
@ -290,15 +284,6 @@ def get_remote_addr(request: Request):
|
||||
return remote_addr or "127.0.0.1"
|
||||
|
||||
|
||||
def _cleanup_first_load_seen() -> None:
|
||||
"""Cleanup expired entries in the in-memory first-load cache."""
|
||||
now = time.time()
|
||||
# Build list for removal to avoid mutating dict during iteration
|
||||
expired = [k for k, exp in _first_load_seen.items() if exp <= now]
|
||||
for k in expired:
|
||||
del _first_load_seen[k]
|
||||
|
||||
|
||||
def get_jwt_secret() -> str:
|
||||
jwt_secret = None
|
||||
# check env var
|
||||
@ -759,30 +744,10 @@ def profile(request: Request):
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
|
||||
response = JSONResponse(
|
||||
return JSONResponse(
|
||||
content={"username": username, "role": role, "allowed_cameras": allowed_cameras}
|
||||
)
|
||||
|
||||
if username == "anonymous":
|
||||
try:
|
||||
remote_addr = get_remote_addr(request)
|
||||
except Exception:
|
||||
remote_addr = (
|
||||
request.client.host if hasattr(request, "client") else "unknown"
|
||||
)
|
||||
|
||||
ua = request.headers.get("user-agent", "")
|
||||
key_material = f"{remote_addr}|{ua}"
|
||||
cache_key = hashlib.sha256(key_material.encode()).hexdigest()
|
||||
|
||||
_cleanup_first_load_seen()
|
||||
now = time.time()
|
||||
if cache_key not in _first_load_seen:
|
||||
_first_load_seen[cache_key] = now + FIRST_LOAD_TTL_SECONDS
|
||||
logger.info(f"Anonymous user access from {remote_addr} ua={ua[:200]}")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/logout",
|
||||
|
||||
@ -1,176 +0,0 @@
|
||||
"""Debug replay API endpoints."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.app])
|
||||
|
||||
|
||||
class DebugReplayStartBody(BaseModel):
|
||||
"""Request body for starting a debug replay session."""
|
||||
|
||||
camera: str = Field(title="Source camera name")
|
||||
start_time: float = Field(title="Start timestamp")
|
||||
end_time: float = Field(title="End timestamp")
|
||||
|
||||
|
||||
class DebugReplayStartResponse(BaseModel):
|
||||
"""Response for starting a debug replay session."""
|
||||
|
||||
success: bool
|
||||
replay_camera: str
|
||||
|
||||
|
||||
class DebugReplayStatusResponse(BaseModel):
|
||||
"""Response for debug replay status."""
|
||||
|
||||
active: bool
|
||||
replay_camera: str | None = None
|
||||
source_camera: str | None = None
|
||||
start_time: float | None = None
|
||||
end_time: float | None = None
|
||||
live_ready: bool = False
|
||||
|
||||
|
||||
class DebugReplayStopResponse(BaseModel):
|
||||
"""Response for stopping a debug replay session."""
|
||||
|
||||
success: bool
|
||||
|
||||
|
||||
@router.post(
|
||||
"/debug_replay/start",
|
||||
response_model=DebugReplayStartResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Start debug replay",
|
||||
description="Start a debug replay session from camera recordings.",
|
||||
)
|
||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
"""Start a debug replay session."""
|
||||
replay_manager = request.app.replay_manager
|
||||
|
||||
if replay_manager.active:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "A replay session is already active",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
try:
|
||||
replay_camera = await asyncio.to_thread(
|
||||
replay_manager.start,
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception("Invalid parameters for debug replay start request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid debug replay request parameters",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.exception("Error while starting debug replay session")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "An internal error occurred while starting debug replay",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return DebugReplayStartResponse(
|
||||
success=True,
|
||||
replay_camera=replay_camera,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/debug_replay/status",
|
||||
response_model=DebugReplayStatusResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Get debug replay status",
|
||||
description="Get the status of the current debug replay session.",
|
||||
)
|
||||
def get_debug_replay_status(request: Request):
|
||||
"""Get the current replay session status."""
|
||||
replay_manager = request.app.replay_manager
|
||||
|
||||
live_ready = False
|
||||
replay_camera = replay_manager.replay_camera_name
|
||||
|
||||
if replay_manager.active and replay_camera:
|
||||
frame_processor = request.app.detected_frames_processor
|
||||
frame = frame_processor.get_current_frame(replay_camera)
|
||||
|
||||
if frame is not None:
|
||||
frame_time = frame_processor.get_current_frame_time(replay_camera)
|
||||
camera_config = request.app.frigate_config.cameras.get(replay_camera)
|
||||
retry_interval = 10
|
||||
|
||||
if camera_config is not None:
|
||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
||||
|
||||
live_ready = datetime.now().timestamp() <= frame_time + retry_interval
|
||||
|
||||
return DebugReplayStatusResponse(
|
||||
active=replay_manager.active,
|
||||
replay_camera=replay_camera,
|
||||
source_camera=replay_manager.source_camera,
|
||||
start_time=replay_manager.start_ts,
|
||||
end_time=replay_manager.end_ts,
|
||||
live_ready=live_ready,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/debug_replay/stop",
|
||||
response_model=DebugReplayStopResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Stop debug replay",
|
||||
description="Stop the active debug replay session and clean up all artifacts.",
|
||||
)
|
||||
async def stop_debug_replay(request: Request):
|
||||
"""Stop the active replay session."""
|
||||
replay_manager = request.app.replay_manager
|
||||
|
||||
if not replay_manager.active:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "No active replay session"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
replay_manager.stop,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
)
|
||||
except (ValueError, RuntimeError, OSError) as e:
|
||||
logger.error("Error stopping replay: %s", e)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Failed to stop replay session due to an internal error.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return DebugReplayStopResponse(success=True)
|
||||
@ -7,7 +7,6 @@ class AppConfigSetBody(BaseModel):
|
||||
requires_restart: int = 1
|
||||
update_topic: str | None = None
|
||||
config_data: Optional[Dict[str, Any]] = None
|
||||
skip_save: bool = False
|
||||
|
||||
|
||||
class AppPutPasswordBody(BaseModel):
|
||||
|
||||
@ -11,7 +11,6 @@ class Tags(Enum):
|
||||
classification = "Classification"
|
||||
logs = "Logs"
|
||||
media = "Media"
|
||||
motion_search = "Motion Search"
|
||||
notifications = "Notifications"
|
||||
preview = "Preview"
|
||||
recordings = "Recordings"
|
||||
|
||||
@ -18,11 +18,9 @@ from frigate.api import (
|
||||
camera,
|
||||
chat,
|
||||
classification,
|
||||
debug_replay,
|
||||
event,
|
||||
export,
|
||||
media,
|
||||
motion_search,
|
||||
notification,
|
||||
preview,
|
||||
record,
|
||||
@ -34,7 +32,6 @@ from frigate.comms.event_metadata_updater import (
|
||||
)
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
@ -68,7 +65,6 @@ def create_fastapi_app(
|
||||
stats_emitter: StatsEmitter,
|
||||
event_metadata_updater: EventMetadataPublisher,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: DebugReplayManager,
|
||||
enforce_default_admin: bool = True,
|
||||
):
|
||||
logger.info("Starting FastAPI app")
|
||||
@ -136,9 +132,7 @@ def create_fastapi_app(
|
||||
app.include_router(export.router)
|
||||
app.include_router(event.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(motion_search.router)
|
||||
app.include_router(record.router)
|
||||
app.include_router(debug_replay.router)
|
||||
# App Properties
|
||||
app.frigate_config = frigate_config
|
||||
app.genai_manager = GenAIClientManager(frigate_config)
|
||||
@ -150,7 +144,6 @@ def create_fastapi_app(
|
||||
app.stats_emitter = stats_emitter
|
||||
app.event_metadata_updater = event_metadata_updater
|
||||
app.config_publisher = config_publisher
|
||||
app.replay_manager = replay_manager
|
||||
|
||||
if frigate_config.auth.enabled:
|
||||
secret = get_jwt_secret()
|
||||
|
||||
@ -24,7 +24,6 @@ from tzlocal import get_localzone_name
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
require_camera_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.query.media_query_parameters import (
|
||||
Extension,
|
||||
@ -1006,23 +1005,6 @@ def grid_snapshot(
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{camera_name}/region_grid", dependencies=[Depends(require_role("admin"))]
|
||||
)
|
||||
def clear_region_grid(request: Request, camera_name: str):
|
||||
"""Clear the region grid for a camera."""
|
||||
if camera_name not in request.app.frigate_config.cameras:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Camera not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
Regions.delete().where(Regions.camera == camera_name).execute()
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Region grid cleared"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/snapshot-clean.webp",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
@ -1281,13 +1263,6 @@ 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}"
|
||||
@ -1463,13 +1438,6 @@ 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}"
|
||||
|
||||
@ -1,292 +0,0 @@
|
||||
"""Motion search API for detecting changes within a region of interest."""
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.api.auth import require_camera_access
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.jobs.motion_search import (
|
||||
cancel_motion_search_job,
|
||||
get_motion_search_job,
|
||||
start_motion_search_job,
|
||||
)
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.motion_search])
|
||||
|
||||
|
||||
class MotionSearchRequest(BaseModel):
|
||||
"""Request body for motion search."""
|
||||
|
||||
start_time: float = Field(description="Start timestamp for the search range")
|
||||
end_time: float = Field(description="End timestamp for the search range")
|
||||
polygon_points: List[List[float]] = Field(
|
||||
description="List of [x, y] normalized coordinates (0-1) defining the ROI polygon"
|
||||
)
|
||||
threshold: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=255,
|
||||
description="Pixel difference threshold (1-255)",
|
||||
)
|
||||
min_area: float = Field(
|
||||
default=5.0,
|
||||
ge=0.1,
|
||||
le=100.0,
|
||||
description="Minimum change area as a percentage of the ROI",
|
||||
)
|
||||
frame_skip: int = Field(
|
||||
default=5,
|
||||
ge=1,
|
||||
le=30,
|
||||
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
|
||||
)
|
||||
parallel: bool = Field(
|
||||
default=False,
|
||||
description="Enable parallel scanning across segments",
|
||||
)
|
||||
max_results: int = Field(
|
||||
default=25,
|
||||
ge=1,
|
||||
le=200,
|
||||
description="Maximum number of search results to return",
|
||||
)
|
||||
|
||||
|
||||
class MotionSearchResult(BaseModel):
|
||||
"""A single search result with timestamp and change info."""
|
||||
|
||||
timestamp: float = Field(description="Timestamp where change was detected")
|
||||
change_percentage: float = Field(description="Percentage of ROI area that changed")
|
||||
|
||||
|
||||
class MotionSearchMetricsResponse(BaseModel):
|
||||
"""Metrics collected during motion search execution."""
|
||||
|
||||
segments_scanned: int = 0
|
||||
segments_processed: int = 0
|
||||
metadata_inactive_segments: int = 0
|
||||
heatmap_roi_skip_segments: int = 0
|
||||
fallback_full_range_segments: int = 0
|
||||
frames_decoded: int = 0
|
||||
wall_time_seconds: float = 0.0
|
||||
segments_with_errors: int = 0
|
||||
|
||||
|
||||
class MotionSearchStartResponse(BaseModel):
|
||||
"""Response when motion search job starts."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
job_id: str
|
||||
|
||||
|
||||
class MotionSearchStatusResponse(BaseModel):
|
||||
"""Response containing job status and results."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
status: str # "queued", "running", "success", "failed", or "cancelled"
|
||||
results: Optional[List[MotionSearchResult]] = None
|
||||
total_frames_processed: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
metrics: Optional[MotionSearchMetricsResponse] = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{camera_name}/search/motion",
|
||||
response_model=MotionSearchStartResponse,
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
summary="Start motion search job",
|
||||
description="""Starts an asynchronous search for significant motion changes within
|
||||
a user-defined Region of Interest (ROI) over a specified time range. Returns a job_id
|
||||
that can be used to poll for results.""",
|
||||
)
|
||||
async def start_motion_search(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
body: MotionSearchRequest,
|
||||
):
|
||||
"""Start an async motion search job."""
|
||||
config = request.app.frigate_config
|
||||
|
||||
if camera_name not in config.cameras:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"Camera {camera_name} not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate polygon has at least 3 points
|
||||
if len(body.polygon_points) < 3:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Polygon must have at least 3 points",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate time range
|
||||
if body.start_time >= body.end_time:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Start time must be before end time",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Start the job using the jobs module
|
||||
job_id = start_motion_search_job(
|
||||
config=config,
|
||||
camera_name=camera_name,
|
||||
start_time=body.start_time,
|
||||
end_time=body.end_time,
|
||||
polygon_points=body.polygon_points,
|
||||
threshold=body.threshold,
|
||||
min_area=body.min_area,
|
||||
frame_skip=body.frame_skip,
|
||||
parallel=body.parallel,
|
||||
max_results=body.max_results,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Search job started",
|
||||
"job_id": job_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{camera_name}/search/motion/{job_id}",
|
||||
response_model=MotionSearchStatusResponse,
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
summary="Get motion search job status",
|
||||
description="Returns the status and results (if complete) of a motion search job.",
|
||||
)
|
||||
async def get_motion_search_status_endpoint(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
job_id: str,
|
||||
):
|
||||
"""Get the status of a motion search job."""
|
||||
config = request.app.frigate_config
|
||||
|
||||
if camera_name not in config.cameras:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"Camera {camera_name} not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
job = get_motion_search_job(job_id)
|
||||
if not job:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Job not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
api_status = job.status
|
||||
|
||||
# Build response content
|
||||
response_content: dict[str, Any] = {
|
||||
"success": api_status != JobStatusTypesEnum.failed,
|
||||
"status": api_status,
|
||||
}
|
||||
|
||||
if api_status == JobStatusTypesEnum.failed:
|
||||
response_content["message"] = job.error_message or "Search failed"
|
||||
response_content["error_message"] = job.error_message
|
||||
elif api_status == JobStatusTypesEnum.cancelled:
|
||||
response_content["message"] = "Search cancelled"
|
||||
response_content["total_frames_processed"] = job.total_frames_processed
|
||||
elif api_status == JobStatusTypesEnum.success:
|
||||
response_content["message"] = "Search complete"
|
||||
if job.results:
|
||||
response_content["results"] = job.results.get("results", [])
|
||||
response_content["total_frames_processed"] = job.results.get(
|
||||
"total_frames_processed", job.total_frames_processed
|
||||
)
|
||||
else:
|
||||
response_content["results"] = []
|
||||
response_content["total_frames_processed"] = job.total_frames_processed
|
||||
else:
|
||||
response_content["message"] = "Job processing"
|
||||
response_content["total_frames_processed"] = job.total_frames_processed
|
||||
# Include partial results if available (streaming)
|
||||
if job.results:
|
||||
response_content["results"] = job.results.get("results", [])
|
||||
response_content["total_frames_processed"] = job.results.get(
|
||||
"total_frames_processed", job.total_frames_processed
|
||||
)
|
||||
|
||||
# Include metrics if available
|
||||
if job.metrics:
|
||||
response_content["metrics"] = job.metrics.to_dict()
|
||||
|
||||
return JSONResponse(content=response_content)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{camera_name}/search/motion/{job_id}/cancel",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
summary="Cancel motion search job",
|
||||
description="Cancels an active motion search job if it is still processing.",
|
||||
)
|
||||
async def cancel_motion_search_endpoint(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
job_id: str,
|
||||
):
|
||||
"""Cancel an active motion search job."""
|
||||
config = request.app.frigate_config
|
||||
|
||||
if camera_name not in config.cameras:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"Camera {camera_name} not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
job = get_motion_search_job(job_id)
|
||||
if not job:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Job not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Check if already finished
|
||||
api_status = job.status
|
||||
if api_status not in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running):
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Job already finished",
|
||||
"status": api_status,
|
||||
}
|
||||
)
|
||||
|
||||
# Request cancellation
|
||||
cancelled = cancel_motion_search_job(job_id)
|
||||
if cancelled:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Search cancelled",
|
||||
"status": "cancelled",
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Failed to cancel job",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
@ -261,7 +261,6 @@ async def recordings(
|
||||
Recordings.segment_size,
|
||||
Recordings.motion,
|
||||
Recordings.objects,
|
||||
Recordings.motion_heatmap,
|
||||
Recordings.duration,
|
||||
)
|
||||
.where(
|
||||
|
||||
@ -43,15 +43,10 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.data_processing.types import DataProcessorMetrics
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.debug_replay import (
|
||||
DebugReplayManager,
|
||||
cleanup_replay_cameras,
|
||||
)
|
||||
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
||||
from frigate.events.audio import AudioProcessor
|
||||
from frigate.events.cleanup import EventCleanup
|
||||
from frigate.events.maintainer import EventProcessor
|
||||
from frigate.jobs.motion_search import stop_all_motion_search_jobs
|
||||
from frigate.log import _stop_logging
|
||||
from frigate.models import (
|
||||
Event,
|
||||
@ -144,9 +139,6 @@ class FrigateApp:
|
||||
else:
|
||||
logger.debug(f"Skipping directory: {d}")
|
||||
|
||||
def init_debug_replay_manager(self) -> None:
|
||||
self.replay_manager = DebugReplayManager()
|
||||
|
||||
def init_camera_metrics(self) -> None:
|
||||
# create camera_metrics
|
||||
for camera_name in self.config.cameras.keys():
|
||||
@ -539,7 +531,6 @@ class FrigateApp:
|
||||
set_file_limit()
|
||||
|
||||
# Start frigate services.
|
||||
self.init_debug_replay_manager()
|
||||
self.init_camera_metrics()
|
||||
self.init_queues()
|
||||
self.init_database()
|
||||
@ -550,10 +541,6 @@ class FrigateApp:
|
||||
self.init_embeddings_manager()
|
||||
self.bind_database()
|
||||
self.check_db_data_migrations()
|
||||
|
||||
# Clean up any stale replay camera artifacts (filesystem + DB)
|
||||
cleanup_replay_cameras()
|
||||
|
||||
self.init_inter_process_communicator()
|
||||
self.start_detectors()
|
||||
self.init_dispatcher()
|
||||
@ -585,7 +572,6 @@ class FrigateApp:
|
||||
self.stats_emitter,
|
||||
self.event_metadata_updater,
|
||||
self.inter_config_updater,
|
||||
self.replay_manager,
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=5001,
|
||||
@ -600,9 +586,6 @@ class FrigateApp:
|
||||
# used by the docker healthcheck
|
||||
Path("/dev/shm/.frigate-is-stopping").touch()
|
||||
|
||||
# Cancel any running motion search jobs before setting stop_event
|
||||
stop_all_motion_search_jobs()
|
||||
|
||||
self.stop_event.set()
|
||||
|
||||
# set an end_time on entries without an end_time before exiting
|
||||
@ -654,7 +637,6 @@ class FrigateApp:
|
||||
self.record_cleanup.join()
|
||||
self.stats_emitter.join()
|
||||
self.frigate_watchdog.join()
|
||||
self.camera_maintainer.join()
|
||||
self.db.stop()
|
||||
|
||||
# Save embeddings stats to disk
|
||||
|
||||
@ -57,9 +57,6 @@ class CameraActivityManager:
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.camera_all_object_counts:
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
@ -127,11 +124,7 @@ class CameraActivityManager:
|
||||
any_changed = False
|
||||
|
||||
# run through each object and check what topics need to be updated
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
|
||||
for label in camera_config.objects.track:
|
||||
for label in self.config.cameras[camera].objects.track:
|
||||
if label in self.config.model.non_logo_attributes:
|
||||
continue
|
||||
|
||||
@ -181,9 +174,6 @@ class AudioActivityManager:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
for camera in new_activity.keys():
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.current_audio_detections:
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
@ -203,11 +193,7 @@ class AudioActivityManager:
|
||||
def compare_audio_activity(
|
||||
self, camera: str, new_detections: list[tuple[str, float]], now: float
|
||||
) -> None:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False
|
||||
|
||||
max_not_heard = camera_config.audio.max_not_heard
|
||||
max_not_heard = self.config.cameras[camera].audio.max_not_heard
|
||||
current = self.current_audio_detections[camera]
|
||||
|
||||
any_changed = False
|
||||
@ -236,7 +222,6 @@ class AudioActivityManager:
|
||||
None,
|
||||
"audio",
|
||||
{},
|
||||
None,
|
||||
),
|
||||
EventMetadataTypeEnum.manual_event_create.value,
|
||||
)
|
||||
|
||||
@ -55,20 +55,8 @@ class CameraMaintainer(threading.Thread):
|
||||
self.shm_count = self.__calculate_shm_frame_count()
|
||||
self.camera_processes: dict[str, mp.Process] = {}
|
||||
self.capture_processes: dict[str, mp.Process] = {}
|
||||
self.camera_stop_events: dict[str, MpEvent] = {}
|
||||
self.metrics_manager = metrics_manager
|
||||
|
||||
def __ensure_camera_stop_event(self, camera: str) -> MpEvent:
|
||||
camera_stop_event = self.camera_stop_events.get(camera)
|
||||
|
||||
if camera_stop_event is None:
|
||||
camera_stop_event = mp.Event()
|
||||
self.camera_stop_events[camera] = camera_stop_event
|
||||
else:
|
||||
camera_stop_event.clear()
|
||||
|
||||
return camera_stop_event
|
||||
|
||||
def __init_historical_regions(self) -> None:
|
||||
# delete region grids for removed or renamed cameras
|
||||
cameras = list(self.config.cameras.keys())
|
||||
@ -111,8 +99,6 @@ class CameraMaintainer(threading.Thread):
|
||||
logger.info(f"Camera processor not started for disabled camera {name}")
|
||||
return
|
||||
|
||||
camera_stop_event = self.__ensure_camera_stop_event(name)
|
||||
|
||||
if runtime:
|
||||
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False)
|
||||
@ -149,7 +135,7 @@ class CameraMaintainer(threading.Thread):
|
||||
self.camera_metrics[name],
|
||||
self.ptz_metrics[name],
|
||||
self.region_grids[name],
|
||||
camera_stop_event,
|
||||
self.stop_event,
|
||||
self.config.logger,
|
||||
)
|
||||
self.camera_processes[config.name] = camera_process
|
||||
@ -164,8 +150,6 @@ class CameraMaintainer(threading.Thread):
|
||||
logger.info(f"Capture process not started for disabled camera {name}")
|
||||
return
|
||||
|
||||
camera_stop_event = self.__ensure_camera_stop_event(name)
|
||||
|
||||
# pre-create shms
|
||||
count = 10 if runtime else self.shm_count
|
||||
for i in range(count):
|
||||
@ -176,7 +160,7 @@ class CameraMaintainer(threading.Thread):
|
||||
config,
|
||||
count,
|
||||
self.camera_metrics[name],
|
||||
camera_stop_event,
|
||||
self.stop_event,
|
||||
self.config.logger,
|
||||
)
|
||||
capture_process.daemon = True
|
||||
@ -186,36 +170,18 @@ class CameraMaintainer(threading.Thread):
|
||||
logger.info(f"Capture process started for {name}: {capture_process.pid}")
|
||||
|
||||
def __stop_camera_capture_process(self, camera: str) -> None:
|
||||
capture_process = self.capture_processes.get(camera)
|
||||
capture_process = self.capture_processes[camera]
|
||||
if capture_process is not None:
|
||||
logger.info(f"Waiting for capture process for {camera} to stop")
|
||||
camera_stop_event = self.camera_stop_events.get(camera)
|
||||
|
||||
if camera_stop_event is not None:
|
||||
camera_stop_event.set()
|
||||
|
||||
capture_process.join(timeout=10)
|
||||
if capture_process.is_alive():
|
||||
logger.warning(
|
||||
f"Capture process for {camera} didn't exit, forcing termination"
|
||||
)
|
||||
capture_process.terminate()
|
||||
capture_process.join()
|
||||
capture_process.terminate()
|
||||
capture_process.join()
|
||||
|
||||
def __stop_camera_process(self, camera: str) -> None:
|
||||
camera_process = self.camera_processes.get(camera)
|
||||
camera_process = self.camera_processes[camera]
|
||||
if camera_process is not None:
|
||||
logger.info(f"Waiting for process for {camera} to stop")
|
||||
camera_stop_event = self.camera_stop_events.get(camera)
|
||||
|
||||
if camera_stop_event is not None:
|
||||
camera_stop_event.set()
|
||||
|
||||
camera_process.join(timeout=10)
|
||||
if camera_process.is_alive():
|
||||
logger.warning(f"Process for {camera} didn't exit, forcing termination")
|
||||
camera_process.terminate()
|
||||
camera_process.join()
|
||||
camera_process.terminate()
|
||||
camera_process.join()
|
||||
logger.info(f"Closing frame queue for {camera}")
|
||||
empty_and_close_queue(self.camera_metrics[camera].frame_queue)
|
||||
|
||||
@ -233,12 +199,6 @@ class CameraMaintainer(threading.Thread):
|
||||
for update_type, updated_cameras in updates.items():
|
||||
if update_type == CameraConfigUpdateEnum.add.name:
|
||||
for camera in updated_cameras:
|
||||
if (
|
||||
camera in self.camera_processes
|
||||
or camera in self.capture_processes
|
||||
):
|
||||
continue
|
||||
|
||||
self.__start_camera_processor(
|
||||
camera,
|
||||
self.update_subscriber.camera_configs[camera],
|
||||
@ -250,22 +210,15 @@ class CameraMaintainer(threading.Thread):
|
||||
runtime=True,
|
||||
)
|
||||
elif update_type == CameraConfigUpdateEnum.remove.name:
|
||||
for camera in updated_cameras:
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
self.capture_processes.pop(camera, None)
|
||||
self.camera_processes.pop(camera, None)
|
||||
self.camera_stop_events.pop(camera, None)
|
||||
self.region_grids.pop(camera, None)
|
||||
self.camera_metrics.pop(camera, None)
|
||||
self.ptz_metrics.pop(camera, None)
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
|
||||
# ensure the capture processes are done
|
||||
for camera in self.capture_processes.keys():
|
||||
for camera in self.camera_processes.keys():
|
||||
self.__stop_camera_capture_process(camera)
|
||||
|
||||
# ensure the camera processors are done
|
||||
for camera in self.camera_processes.keys():
|
||||
for camera in self.capture_processes.keys():
|
||||
self.__stop_camera_process(camera)
|
||||
|
||||
self.update_subscriber.stop()
|
||||
|
||||
@ -26,8 +26,8 @@ class ConfigPublisher:
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
|
||||
|
||||
class ConfigSubscriber:
|
||||
@ -55,5 +55,5 @@ class ConfigSubscriber:
|
||||
return (None, None)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
|
||||
@ -110,9 +110,6 @@ class Dispatcher:
|
||||
payload: str,
|
||||
sub_command: str | None = None,
|
||||
) -> None:
|
||||
if camera_name not in self.config.cameras:
|
||||
return
|
||||
|
||||
try:
|
||||
if command_type == "set":
|
||||
if sub_command:
|
||||
@ -134,9 +131,6 @@ class Dispatcher:
|
||||
|
||||
def handle_request_region_grid() -> Any:
|
||||
camera = payload
|
||||
if camera not in self.config.cameras:
|
||||
return None
|
||||
|
||||
grid = get_camera_regions_grid(
|
||||
camera,
|
||||
self.config.cameras[camera].detect,
|
||||
@ -249,11 +243,7 @@ class Dispatcher:
|
||||
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
|
||||
|
||||
def handle_on_connect() -> None:
|
||||
camera_status = {
|
||||
camera: status
|
||||
for camera, status in self.camera_activity.last_camera_activity.copy().items()
|
||||
if camera in self.config.cameras
|
||||
}
|
||||
camera_status = self.camera_activity.last_camera_activity.copy()
|
||||
audio_detections = self.audio_activity.current_audio_detections.copy()
|
||||
cameras_with_status = camera_status.keys()
|
||||
|
||||
@ -356,8 +346,7 @@ class Dispatcher:
|
||||
# example /cam_name/notifications/suspend payload=duration
|
||||
camera_name = parts[-3]
|
||||
command = parts[-2]
|
||||
if camera_name in self.config.cameras:
|
||||
self._on_camera_notification_suspend(camera_name, payload)
|
||||
self._on_camera_notification_suspend(camera_name, payload)
|
||||
except IndexError:
|
||||
logger.error(
|
||||
f"Received invalid {topic.split('/')[-1]} command: {topic}"
|
||||
|
||||
@ -61,8 +61,8 @@ class InterProcessCommunicator(Communicator):
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
self.reader_thread.join()
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
|
||||
|
||||
class InterProcessRequestor:
|
||||
@ -82,5 +82,5 @@ class InterProcessRequestor:
|
||||
return ""
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
|
||||
@ -43,7 +43,7 @@ class ZmqProxy:
|
||||
|
||||
def stop(self) -> None:
|
||||
# destroying the context will tell the proxy to stop
|
||||
self.context.destroy(linger=0)
|
||||
self.context.destroy()
|
||||
self.runner.join()
|
||||
|
||||
|
||||
@ -66,8 +66,8 @@ class Publisher(Generic[T]):
|
||||
self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}")
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
|
||||
|
||||
class Subscriber(Generic[T]):
|
||||
@ -96,8 +96,8 @@ class Subscriber(Generic[T]):
|
||||
return self._return_object("", None)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
|
||||
def _return_object(self, topic: str, payload: T | None) -> T | None:
|
||||
return payload
|
||||
|
||||
@ -242,14 +242,6 @@ 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)
|
||||
|
||||
@ -24,17 +24,10 @@ class MotionConfig(FrigateBaseModel):
|
||||
lightning_threshold: float = Field(
|
||||
default=0.8,
|
||||
title="Lightning threshold",
|
||||
description="Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events.",
|
||||
description="Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0).",
|
||||
ge=0.3,
|
||||
le=1.0,
|
||||
)
|
||||
skip_motion_threshold: Optional[float] = Field(
|
||||
default=None,
|
||||
title="Skip motion threshold",
|
||||
description="If set to a value between 0.0 and 1.0, and more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto‑tracking an object. The trade‑off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Leave unset (None) to disable this feature.",
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
)
|
||||
improve_contrast: bool = Field(
|
||||
default=True,
|
||||
title="Improve contrast",
|
||||
|
||||
@ -17,7 +17,6 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
birdseye = "birdseye"
|
||||
detect = "detect"
|
||||
enabled = "enabled"
|
||||
ffmpeg = "ffmpeg"
|
||||
motion = "motion" # includes motion and motion masks
|
||||
notifications = "notifications"
|
||||
objects = "objects"
|
||||
@ -81,8 +80,8 @@ class CameraConfigUpdateSubscriber:
|
||||
self.camera_configs[camera] = updated_config
|
||||
return
|
||||
elif update_type == CameraConfigUpdateEnum.remove:
|
||||
self.config.cameras.pop(camera, None)
|
||||
self.camera_configs.pop(camera, None)
|
||||
self.config.cameras.pop(camera)
|
||||
self.camera_configs.pop(camera)
|
||||
return
|
||||
|
||||
config = self.camera_configs.get(camera)
|
||||
@ -92,9 +91,6 @@ 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:
|
||||
|
||||
@ -61,7 +61,6 @@ from .classification import (
|
||||
FaceRecognitionConfig,
|
||||
LicensePlateRecognitionConfig,
|
||||
SemanticSearchConfig,
|
||||
SemanticSearchModelEnum,
|
||||
)
|
||||
from .database import DatabaseConfig
|
||||
from .env import EnvVars
|
||||
@ -594,10 +593,8 @@ 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.enabled and isinstance(
|
||||
self.semantic_search.model, str
|
||||
):
|
||||
if self.semantic_search.model not in self.genai:
|
||||
raise ValueError(
|
||||
|
||||
@ -14,8 +14,6 @@ RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||
TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
|
||||
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
||||
CACHE_DIR = "/tmp/cache"
|
||||
REPLAY_CAMERA_PREFIX = "_replay_"
|
||||
REPLAY_DIR = os.path.join(CACHE_DIR, "replay")
|
||||
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||
PLUS_API_HOST = "https://api.frigate.video"
|
||||
|
||||
|
||||
@ -401,10 +401,35 @@ class LicensePlateProcessingMixin:
|
||||
all_confidences.append(flat_confidences)
|
||||
all_areas.append(combined_area)
|
||||
|
||||
# Step 3: Sort the combined plates
|
||||
# Step 3: Filter and 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(
|
||||
zip(all_license_plates, all_confidences, all_areas),
|
||||
filtered_data,
|
||||
key=lambda x: (x[2], len(x[0]), sum(x[1]) / len(x[1]) if x[1] else 0),
|
||||
reverse=True,
|
||||
)
|
||||
@ -1532,27 +1557,6 @@ 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(
|
||||
{
|
||||
|
||||
@ -12,7 +12,6 @@ from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import LicensePlateRecognitionConfig
|
||||
from frigate.data_processing.common.license_plate.mixin import (
|
||||
WRITE_DEBUG_IMAGES,
|
||||
LicensePlateProcessingMixin,
|
||||
@ -48,11 +47,6 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
super().__init__(config, metrics, model_runner)
|
||||
|
||||
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
||||
"""Update LPR config at runtime."""
|
||||
self.lpr_config = lpr_config
|
||||
logger.debug("LPR config updated dynamically")
|
||||
|
||||
def process_data(
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
) -> None:
|
||||
|
||||
@ -19,7 +19,6 @@ from frigate.comms.event_metadata_updater import (
|
||||
)
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import FaceRecognitionConfig
|
||||
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
||||
from frigate.data_processing.common.face.model import (
|
||||
ArcFaceRecognizer,
|
||||
@ -96,11 +95,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
self.recognizer.build()
|
||||
|
||||
def update_config(self, face_config: FaceRecognitionConfig) -> None:
|
||||
"""Update face recognition config at runtime."""
|
||||
self.face_config = face_config
|
||||
logger.debug("Face recognition config updated dynamically")
|
||||
|
||||
def __download_models(self, path: str) -> None:
|
||||
try:
|
||||
file_name = os.path.basename(path)
|
||||
|
||||
@ -8,7 +8,6 @@ import numpy as np
|
||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import LicensePlateRecognitionConfig
|
||||
from frigate.data_processing.common.license_plate.mixin import (
|
||||
LicensePlateProcessingMixin,
|
||||
)
|
||||
@ -41,11 +40,6 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
||||
self.camera_current_cars: dict[str, list[str]] = {}
|
||||
super().__init__(config, metrics)
|
||||
|
||||
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
||||
"""Update LPR config at runtime."""
|
||||
self.lpr_config = lpr_config
|
||||
logger.debug("LPR config updated dynamically")
|
||||
|
||||
def process_frame(
|
||||
self,
|
||||
obj_data: dict[str, Any],
|
||||
|
||||
@ -1,443 +0,0 @@
|
||||
"""Debug replay camera management for replaying recordings with detection overlays."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess as sp
|
||||
import threading
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdatePublisher,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import (
|
||||
CLIPS_DIR,
|
||||
RECORD_DIR,
|
||||
REPLAY_CAMERA_PREFIX,
|
||||
REPLAY_DIR,
|
||||
THUMB_DIR,
|
||||
)
|
||||
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DebugReplayManager:
|
||||
"""Manages a single debug replay session."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.replay_camera_name: str | None = None
|
||||
self.source_camera: str | None = None
|
||||
self.clip_path: str | None = None
|
||||
self.start_ts: float | None = None
|
||||
self.end_ts: float | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Whether a replay session is currently active."""
|
||||
return self.replay_camera_name is not None
|
||||
|
||||
def start(
|
||||
self,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> str:
|
||||
"""Start a debug replay session.
|
||||
|
||||
Args:
|
||||
source_camera: Name of the source camera to replay
|
||||
start_ts: Start timestamp
|
||||
end_ts: End timestamp
|
||||
frigate_config: Current Frigate configuration
|
||||
config_publisher: Publisher for camera config updates
|
||||
|
||||
Returns:
|
||||
The replay camera name
|
||||
|
||||
Raises:
|
||||
ValueError: If a session is already active or parameters are invalid
|
||||
RuntimeError: If clip generation fails
|
||||
"""
|
||||
with self._lock:
|
||||
return self._start_locked(
|
||||
source_camera, start_ts, end_ts, frigate_config, config_publisher
|
||||
)
|
||||
|
||||
def _start_locked(
|
||||
self,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> str:
|
||||
if self.active:
|
||||
raise ValueError("A replay session is already active")
|
||||
|
||||
if source_camera not in frigate_config.cameras:
|
||||
raise ValueError(f"Camera '{source_camera}' not found")
|
||||
|
||||
if end_ts <= start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
# Query recordings for the source camera in the time range
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(start_ts, end_ts)
|
||||
| Recordings.end_time.between(start_ts, end_ts)
|
||||
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
|
||||
)
|
||||
.where(Recordings.camera == source_camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
|
||||
if not recordings.count():
|
||||
raise ValueError(
|
||||
f"No recordings found for camera '{source_camera}' in the specified time range"
|
||||
)
|
||||
|
||||
# Create replay directory
|
||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
||||
|
||||
# Generate replay camera name
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
||||
|
||||
# Build concat file for ffmpeg
|
||||
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
||||
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
|
||||
# Concatenate recordings into a single clip with -c copy (fast)
|
||||
ffmpeg_cmd = [
|
||||
frigate_config.ffmpeg.ffmpeg_path,
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
clip_path,
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"Generating replay clip for %s (%.1f - %.1f)",
|
||||
source_camera,
|
||||
start_ts,
|
||||
end_ts,
|
||||
)
|
||||
|
||||
try:
|
||||
result = sp.run(
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("FFmpeg error: %s", result.stderr)
|
||||
raise RuntimeError(
|
||||
f"Failed to generate replay clip: {result.stderr[-500:]}"
|
||||
)
|
||||
except sp.TimeoutExpired:
|
||||
raise RuntimeError("Clip generation timed out")
|
||||
finally:
|
||||
# Clean up concat file
|
||||
if os.path.exists(concat_file):
|
||||
os.remove(concat_file)
|
||||
|
||||
if not os.path.exists(clip_path):
|
||||
raise RuntimeError("Clip file was not created")
|
||||
|
||||
# Build camera config dict for the replay camera
|
||||
source_config = frigate_config.cameras[source_camera]
|
||||
camera_dict = self._build_camera_config_dict(
|
||||
source_config, replay_name, clip_path
|
||||
)
|
||||
|
||||
# Build an in-memory config with the replay camera added
|
||||
config_file = find_config_file()
|
||||
yaml_parser = YAML()
|
||||
with open(config_file, "r") as f:
|
||||
config_data = yaml_parser.load(f)
|
||||
|
||||
if "cameras" not in config_data or config_data["cameras"] is None:
|
||||
config_data["cameras"] = {}
|
||||
config_data["cameras"][replay_name] = camera_dict
|
||||
|
||||
try:
|
||||
new_config = FrigateConfig.parse_object(config_data)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to validate replay camera config: {e}")
|
||||
|
||||
# Update the running config
|
||||
frigate_config.cameras[replay_name] = new_config.cameras[replay_name]
|
||||
|
||||
# Publish the add event
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name),
|
||||
new_config.cameras[replay_name],
|
||||
)
|
||||
|
||||
# Store session state
|
||||
self.replay_camera_name = replay_name
|
||||
self.source_camera = source_camera
|
||||
self.clip_path = clip_path
|
||||
self.start_ts = start_ts
|
||||
self.end_ts = end_ts
|
||||
|
||||
logger.info("Debug replay started: %s -> %s", source_camera, replay_name)
|
||||
return replay_name
|
||||
|
||||
def stop(
|
||||
self,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
"""Stop the active replay session and clean up all artifacts.
|
||||
|
||||
Args:
|
||||
frigate_config: Current Frigate configuration
|
||||
config_publisher: Publisher for camera config updates
|
||||
"""
|
||||
with self._lock:
|
||||
self._stop_locked(frigate_config, config_publisher)
|
||||
|
||||
def _stop_locked(
|
||||
self,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
if not self.active:
|
||||
logger.warning("No active replay session to stop")
|
||||
return
|
||||
|
||||
replay_name = self.replay_camera_name
|
||||
|
||||
# Publish remove event so subscribers stop and remove from their config
|
||||
if replay_name in frigate_config.cameras:
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
||||
frigate_config.cameras[replay_name],
|
||||
)
|
||||
# Do NOT pop here — let subscribers handle removal from the shared
|
||||
# config dict when they process the ZMQ message to avoid race conditions
|
||||
|
||||
# Defensive DB cleanup
|
||||
self._cleanup_db(replay_name)
|
||||
|
||||
# Remove filesystem artifacts
|
||||
self._cleanup_files(replay_name)
|
||||
|
||||
# Reset state
|
||||
self.replay_camera_name = None
|
||||
self.source_camera = None
|
||||
self.clip_path = None
|
||||
self.start_ts = None
|
||||
self.end_ts = None
|
||||
|
||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||
|
||||
def _build_camera_config_dict(
|
||||
self,
|
||||
source_config,
|
||||
replay_name: str,
|
||||
clip_path: str,
|
||||
) -> dict:
|
||||
"""Build a camera config dictionary for the replay camera.
|
||||
|
||||
Args:
|
||||
source_config: Source camera's CameraConfig
|
||||
replay_name: Name for the replay camera
|
||||
clip_path: Path to the replay clip file
|
||||
|
||||
Returns:
|
||||
Camera config as a dictionary
|
||||
"""
|
||||
# Extract detect config (exclude computed fields)
|
||||
detect_dict = source_config.detect.model_dump(
|
||||
exclude={"min_initialized", "max_disappeared", "enabled_in_config"}
|
||||
)
|
||||
|
||||
# Extract objects config, using .dict() on filters to convert
|
||||
# RuntimeFilterConfig ndarray masks back to string coordinates
|
||||
objects_dict = {
|
||||
"track": source_config.objects.track,
|
||||
"mask": {
|
||||
mask_id: (
|
||||
mask_cfg.model_dump(
|
||||
exclude={"raw_coordinates", "enabled_in_config"}
|
||||
)
|
||||
if mask_cfg is not None
|
||||
else None
|
||||
)
|
||||
for mask_id, mask_cfg in source_config.objects.mask.items()
|
||||
}
|
||||
if source_config.objects.mask
|
||||
else {},
|
||||
"filters": {
|
||||
name: filt.dict() if hasattr(filt, "dict") else filt.model_dump()
|
||||
for name, filt in source_config.objects.filters.items()
|
||||
},
|
||||
}
|
||||
|
||||
# Extract zones (exclude_defaults avoids serializing empty defaults
|
||||
# like distances=[] that fail validation on re-parse)
|
||||
zones_dict = {}
|
||||
for zone_name, zone_config in source_config.zones.items():
|
||||
zone_dump = zone_config.model_dump(
|
||||
exclude={"contour", "color"}, exclude_defaults=True
|
||||
)
|
||||
# Always include required fields
|
||||
zone_dump.setdefault("coordinates", zone_config.coordinates)
|
||||
zones_dict[zone_name] = zone_dump
|
||||
|
||||
# Extract motion config (exclude runtime fields)
|
||||
motion_dict = {}
|
||||
if source_config.motion is not None:
|
||||
motion_dict = source_config.motion.model_dump(
|
||||
exclude={
|
||||
"frame_shape",
|
||||
"raw_mask",
|
||||
"mask",
|
||||
"improved_contrast_enabled",
|
||||
"rasterized_mask",
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": clip_path,
|
||||
"roles": ["detect"],
|
||||
"input_args": "-re -stream_loop -1 -fflags +genpts",
|
||||
}
|
||||
],
|
||||
"hwaccel_args": [],
|
||||
},
|
||||
"detect": detect_dict,
|
||||
"objects": objects_dict,
|
||||
"zones": zones_dict,
|
||||
"motion": motion_dict,
|
||||
"record": {"enabled": False},
|
||||
"snapshots": {"enabled": False},
|
||||
"review": {
|
||||
"alerts": {"enabled": False},
|
||||
"detections": {"enabled": False},
|
||||
},
|
||||
"birdseye": {"enabled": False},
|
||||
"audio": {"enabled": False},
|
||||
"lpr": {"enabled": False},
|
||||
"face_recognition": {"enabled": False},
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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),
|
||||
]
|
||||
|
||||
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
|
||||
if os.path.exists(REPLAY_DIR):
|
||||
try:
|
||||
shutil.rmtree(REPLAY_DIR)
|
||||
logger.debug("Removed replay cache directory")
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove replay cache: %s", e)
|
||||
|
||||
|
||||
def cleanup_replay_cameras() -> None:
|
||||
"""Remove any stale replay camera artifacts on startup.
|
||||
|
||||
Since replay cameras are memory-only and never written to YAML, they
|
||||
won't appear in the config after a restart. This function cleans up
|
||||
filesystem and database artifacts from any replay that was running when
|
||||
the process stopped.
|
||||
|
||||
Must be called AFTER the database is bound.
|
||||
"""
|
||||
stale_cameras: set[str] = set()
|
||||
|
||||
# Scan filesystem for leftover replay artifacts to derive camera names
|
||||
for dir_path in [RECORD_DIR, CLIPS_DIR, THUMB_DIR]:
|
||||
if os.path.isdir(dir_path):
|
||||
for entry in os.listdir(dir_path):
|
||||
if entry.startswith(REPLAY_CAMERA_PREFIX):
|
||||
stale_cameras.add(entry)
|
||||
|
||||
if os.path.isdir(REPLAY_DIR):
|
||||
for entry in os.listdir(REPLAY_DIR):
|
||||
if entry.startswith(REPLAY_CAMERA_PREFIX) and entry.endswith(".mp4"):
|
||||
stale_cameras.add(entry.removesuffix(".mp4"))
|
||||
|
||||
if not stale_cameras:
|
||||
return
|
||||
|
||||
logger.info("Cleaning up stale replay camera artifacts: %s", list(stale_cameras))
|
||||
|
||||
manager = DebugReplayManager()
|
||||
for camera_name in stale_cameras:
|
||||
manager._cleanup_db(camera_name)
|
||||
manager._cleanup_files(camera_name)
|
||||
|
||||
if os.path.exists(REPLAY_DIR):
|
||||
try:
|
||||
shutil.rmtree(REPLAY_DIR)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove replay cache directory: %s", e)
|
||||
@ -80,6 +80,7 @@ class Embeddings:
|
||||
self.db = db
|
||||
self.metrics = metrics
|
||||
self.requestor = InterProcessRequestor()
|
||||
self.genai_manager = genai_manager
|
||||
|
||||
self.image_inference_speed = InferenceSpeed(self.metrics.image_embeddings_speed)
|
||||
self.image_eps = EventsPerSecond()
|
||||
@ -107,9 +108,9 @@ class Embeddings:
|
||||
)
|
||||
|
||||
model_cfg = self.config.semantic_search.model
|
||||
is_genai_model = isinstance(model_cfg, str)
|
||||
|
||||
if not isinstance(model_cfg, SemanticSearchModelEnum):
|
||||
# GenAI provider
|
||||
if is_genai_model:
|
||||
embeddings_client = (
|
||||
genai_manager.embeddings_client if genai_manager else None
|
||||
)
|
||||
@ -160,7 +161,7 @@ class Embeddings:
|
||||
|
||||
def get_model_definitions(self):
|
||||
model_cfg = self.config.semantic_search.model
|
||||
if not isinstance(model_cfg, SemanticSearchModelEnum):
|
||||
if isinstance(model_cfg, str):
|
||||
# GenAI provider: no ONNX models to download
|
||||
models = []
|
||||
elif model_cfg == SemanticSearchModelEnum.jinav2:
|
||||
@ -250,6 +251,14 @@ class Embeddings:
|
||||
|
||||
embeddings = self.vision_embedding(valid_thumbs)
|
||||
|
||||
if len(embeddings) != len(valid_ids):
|
||||
logger.warning(
|
||||
"Batch embed returned %d embeddings for %d thumbnails; skipping batch",
|
||||
len(embeddings),
|
||||
len(valid_ids),
|
||||
)
|
||||
return []
|
||||
|
||||
if upsert:
|
||||
items = []
|
||||
for i in range(len(valid_ids)):
|
||||
@ -272,9 +281,15 @@ class Embeddings:
|
||||
|
||||
def embed_description(
|
||||
self, event_id: str, description: str, upsert: bool = True
|
||||
) -> np.ndarray:
|
||||
) -> np.ndarray | None:
|
||||
start = datetime.datetime.now().timestamp()
|
||||
embedding = self.text_embedding([description])[0]
|
||||
embeddings = self.text_embedding([description])
|
||||
if not embeddings:
|
||||
logger.warning(
|
||||
"Failed to generate description embedding for event %s", event_id
|
||||
)
|
||||
return None
|
||||
embedding = embeddings[0]
|
||||
|
||||
if upsert:
|
||||
self.db.execute_sql(
|
||||
@ -297,8 +312,32 @@ class Embeddings:
|
||||
# upsert embeddings one by one to avoid token limit
|
||||
embeddings = []
|
||||
|
||||
for desc in event_descriptions.values():
|
||||
embeddings.append(self.text_embedding([desc])[0])
|
||||
for eid, desc in event_descriptions.items():
|
||||
result = self.text_embedding([desc])
|
||||
if not result:
|
||||
logger.warning(
|
||||
"Failed to generate description embedding for event %s", eid
|
||||
)
|
||||
continue
|
||||
embeddings.append(result[0])
|
||||
|
||||
if not embeddings:
|
||||
logger.warning("No description embeddings generated in batch")
|
||||
return np.array([])
|
||||
|
||||
# Build ids list for only successful embeddings - we need to track which succeeded
|
||||
ids = list(event_descriptions.keys())
|
||||
if len(embeddings) != len(ids):
|
||||
# Rebuild ids/embeddings for only successful ones (match by order)
|
||||
ids = []
|
||||
embeddings_filtered = []
|
||||
for eid, desc in event_descriptions.items():
|
||||
result = self.text_embedding([desc])
|
||||
if result:
|
||||
ids.append(eid)
|
||||
embeddings_filtered.append(result[0])
|
||||
ids = ids
|
||||
embeddings = embeddings_filtered
|
||||
|
||||
if upsert:
|
||||
ids = list(event_descriptions.keys())
|
||||
@ -338,12 +377,14 @@ class Embeddings:
|
||||
# Get total count of events to process
|
||||
total_events = Event.select().count()
|
||||
|
||||
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
|
||||
batch_size = (
|
||||
4
|
||||
if (
|
||||
isinstance(self.config.semantic_search.model, str)
|
||||
or self.config.semantic_search.model == SemanticSearchModelEnum.jinav2
|
||||
)
|
||||
else 32
|
||||
)
|
||||
current_page = 1
|
||||
|
||||
totals = {
|
||||
@ -628,6 +669,8 @@ class Embeddings:
|
||||
if trigger.type == "description":
|
||||
logger.debug(f"Generating embedding for trigger description {trigger_name}")
|
||||
embedding = self.embed_description(None, trigger.data, upsert=False)
|
||||
if embedding is None:
|
||||
return b""
|
||||
return embedding.astype(np.float32).tobytes()
|
||||
|
||||
elif trigger.type == "thumbnail":
|
||||
@ -663,6 +706,8 @@ class Embeddings:
|
||||
embedding = self.embed_thumbnail(
|
||||
str(trigger.data), thumbnail, upsert=False
|
||||
)
|
||||
if embedding is None:
|
||||
return b""
|
||||
return embedding.astype(np.float32).tobytes()
|
||||
|
||||
else:
|
||||
|
||||
@ -99,13 +99,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self.classification_config_subscriber = ConfigSubscriber(
|
||||
"config/classification/custom/"
|
||||
)
|
||||
self.bird_classification_config_subscriber = ConfigSubscriber(
|
||||
"config/classification", exact=True
|
||||
)
|
||||
self.face_recognition_config_subscriber = ConfigSubscriber(
|
||||
"config/face_recognition", exact=True
|
||||
)
|
||||
self.lpr_config_subscriber = ConfigSubscriber("config/lpr", exact=True)
|
||||
|
||||
# Configure Frigate DB
|
||||
db = SqliteVecQueueDatabase(
|
||||
@ -281,9 +274,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
while not self.stop_event.is_set():
|
||||
self.config_updater.check_for_updates()
|
||||
self._check_classification_config_updates()
|
||||
self._check_bird_classification_config_updates()
|
||||
self._check_face_recognition_config_updates()
|
||||
self._check_lpr_config_updates()
|
||||
self._process_requests()
|
||||
self._process_updates()
|
||||
self._process_recordings_updates()
|
||||
@ -295,9 +285,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
|
||||
self.config_updater.stop()
|
||||
self.classification_config_subscriber.stop()
|
||||
self.bird_classification_config_subscriber.stop()
|
||||
self.face_recognition_config_subscriber.stop()
|
||||
self.lpr_config_subscriber.stop()
|
||||
self.event_subscriber.stop()
|
||||
self.event_end_subscriber.stop()
|
||||
self.recordings_subscriber.stop()
|
||||
@ -370,62 +357,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
|
||||
)
|
||||
|
||||
def _check_bird_classification_config_updates(self) -> None:
|
||||
"""Check for bird classification config updates."""
|
||||
topic, classification_config = (
|
||||
self.bird_classification_config_subscriber.check_for_update()
|
||||
)
|
||||
|
||||
if topic is None:
|
||||
return
|
||||
|
||||
self.config.classification = classification_config
|
||||
logger.debug("Applied dynamic bird classification config update")
|
||||
|
||||
def _check_face_recognition_config_updates(self) -> None:
|
||||
"""Check for face recognition config updates."""
|
||||
topic, face_config = self.face_recognition_config_subscriber.check_for_update()
|
||||
|
||||
if topic is None:
|
||||
return
|
||||
|
||||
previous_min_area = self.config.face_recognition.min_area
|
||||
self.config.face_recognition = face_config
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if camera_config.face_recognition.min_area == previous_min_area:
|
||||
camera_config.face_recognition.min_area = face_config.min_area
|
||||
|
||||
for processor in self.realtime_processors:
|
||||
if isinstance(processor, FaceRealTimeProcessor):
|
||||
processor.update_config(face_config)
|
||||
|
||||
logger.debug("Applied dynamic face recognition config update")
|
||||
|
||||
def _check_lpr_config_updates(self) -> None:
|
||||
"""Check for LPR config updates."""
|
||||
topic, lpr_config = self.lpr_config_subscriber.check_for_update()
|
||||
|
||||
if topic is None:
|
||||
return
|
||||
|
||||
previous_min_area = self.config.lpr.min_area
|
||||
self.config.lpr = lpr_config
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if camera_config.lpr.min_area == previous_min_area:
|
||||
camera_config.lpr.min_area = lpr_config.min_area
|
||||
|
||||
for processor in self.realtime_processors:
|
||||
if isinstance(processor, LicensePlateRealTimeProcessor):
|
||||
processor.update_config(lpr_config)
|
||||
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, LicensePlatePostProcessor):
|
||||
processor.update_config(lpr_config)
|
||||
|
||||
logger.debug("Applied dynamic LPR config update")
|
||||
|
||||
def _process_requests(self) -> None:
|
||||
"""Process embeddings requests"""
|
||||
|
||||
@ -491,9 +422,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if self.config.semantic_search.enabled:
|
||||
self.embeddings.update_stats()
|
||||
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
# no need to process updated objects if no processors are active
|
||||
if len(self.realtime_processors) == 0 and len(self.post_processors) == 0:
|
||||
@ -711,10 +640,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if not camera or camera not in self.config.cameras:
|
||||
return
|
||||
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
|
||||
camera_config = self.config.cameras[camera]
|
||||
dedicated_lpr_enabled = (
|
||||
camera_config.type == CameraTypeEnum.lpr
|
||||
and "license_plate" not in camera_config.objects.track
|
||||
|
||||
@ -7,7 +7,6 @@ from typing import Dict
|
||||
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import ObjectClassificationType
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
from frigate.models import Event
|
||||
from frigate.util.builtin import to_relative_box
|
||||
@ -147,9 +146,7 @@ class EventProcessor(threading.Thread):
|
||||
|
||||
if should_update_db(self.events_in_process[event_data["id"]], event_data):
|
||||
updated_db = True
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
camera_config = self.config.cameras[camera]
|
||||
width = camera_config.detect.width
|
||||
height = camera_config.detect.height
|
||||
first_detector = list(self.config.detectors.values())[0]
|
||||
@ -286,10 +283,6 @@ class EventProcessor(threading.Thread):
|
||||
def handle_external_detection(
|
||||
self, event_type: EventStateEnum, event_data: Event
|
||||
) -> None:
|
||||
# Skip replay cameras
|
||||
if event_data.get("camera", "").startswith(REPLAY_CAMERA_PREFIX):
|
||||
return
|
||||
|
||||
if event_type == EventStateEnum.start:
|
||||
event = {
|
||||
Event.id: event_data["id"],
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"""Ollama Provider for Frigate AI."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
@ -109,22 +108,7 @@ class OllamaClient(GenAIClient):
|
||||
if msg.get("name"):
|
||||
msg_dict["name"] = msg["name"]
|
||||
if msg.get("tool_calls"):
|
||||
# Ollama requires tool call arguments as dicts, but the
|
||||
# conversation format (OpenAI-style) stores them as JSON
|
||||
# strings. Convert back to dicts for Ollama.
|
||||
ollama_tool_calls = []
|
||||
for tc in msg["tool_calls"]:
|
||||
func = tc.get("function") or {}
|
||||
args = func.get("arguments") or {}
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
ollama_tool_calls.append(
|
||||
{"function": {"name": func.get("name", ""), "arguments": args}}
|
||||
)
|
||||
msg_dict["tool_calls"] = ollama_tool_calls
|
||||
msg_dict["tool_calls"] = msg["tool_calls"]
|
||||
request_messages.append(msg_dict)
|
||||
|
||||
request_params: dict[str, Any] = {
|
||||
@ -136,27 +120,25 @@ class OllamaClient(GenAIClient):
|
||||
request_params["stream"] = True
|
||||
if tools:
|
||||
request_params["tools"] = tools
|
||||
if tool_choice:
|
||||
request_params["tool_choice"] = (
|
||||
"none"
|
||||
if tool_choice == "none"
|
||||
else "required"
|
||||
if tool_choice == "required"
|
||||
else "auto"
|
||||
)
|
||||
return request_params
|
||||
|
||||
def _message_from_response(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Parse Ollama chat response into {content, tool_calls, finish_reason}."""
|
||||
if not response or "message" not in response:
|
||||
logger.debug("Ollama response empty or missing 'message' key")
|
||||
return {
|
||||
"content": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
message = response["message"]
|
||||
logger.debug(
|
||||
"Ollama response message keys: %s, content_len=%s, thinking_len=%s, "
|
||||
"tool_calls=%s, done=%s",
|
||||
list(message.keys()) if hasattr(message, "keys") else "N/A",
|
||||
len(message.get("content", "") or "") if message.get("content") else 0,
|
||||
len(message.get("thinking", "") or "") if message.get("thinking") else 0,
|
||||
bool(message.get("tool_calls")),
|
||||
response.get("done"),
|
||||
)
|
||||
content = message.get("content", "").strip() if message.get("content") else None
|
||||
tool_calls = parse_tool_calls_from_message(message)
|
||||
finish_reason = "error"
|
||||
@ -216,13 +198,7 @@ class OllamaClient(GenAIClient):
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
):
|
||||
"""Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
When tools are provided, Ollama streaming does not include tool_calls
|
||||
in the response chunks. To work around this, we use a non-streaming
|
||||
call when tools are present to ensure tool calls are captured, then
|
||||
emit the content as a single delta followed by the final message.
|
||||
"""
|
||||
"""Stream chat with tools; yields content deltas then final message."""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
"Ollama provider has not been initialized. Check your Ollama configuration."
|
||||
@ -237,27 +213,6 @@ class OllamaClient(GenAIClient):
|
||||
)
|
||||
return
|
||||
try:
|
||||
# Ollama does not return tool_calls in streaming mode, so fall
|
||||
# back to a non-streaming call when tools are provided.
|
||||
if tools:
|
||||
logger.debug(
|
||||
"Ollama: tools provided, using non-streaming call for tool support"
|
||||
)
|
||||
request_params = self._build_request_params(
|
||||
messages, tools, tool_choice, stream=False
|
||||
)
|
||||
async_client = OllamaAsyncClient(
|
||||
host=self.genai_config.base_url,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response = await async_client.chat(**request_params)
|
||||
result = self._message_from_response(response)
|
||||
content = result.get("content")
|
||||
if content:
|
||||
yield ("content_delta", content)
|
||||
yield ("message", result)
|
||||
return
|
||||
|
||||
request_params = self._build_request_params(
|
||||
messages, tools, tool_choice, stream=True
|
||||
)
|
||||
@ -267,23 +222,27 @@ class OllamaClient(GenAIClient):
|
||||
)
|
||||
content_parts: list[str] = []
|
||||
final_message: dict[str, Any] | None = None
|
||||
stream = await async_client.chat(**request_params)
|
||||
async for chunk in stream:
|
||||
if not chunk or "message" not in chunk:
|
||||
continue
|
||||
msg = chunk.get("message", {})
|
||||
delta = msg.get("content") or ""
|
||||
if delta:
|
||||
content_parts.append(delta)
|
||||
yield ("content_delta", delta)
|
||||
if chunk.get("done"):
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
final_message = {
|
||||
"content": full_content,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
break
|
||||
try:
|
||||
stream = await async_client.chat(**request_params)
|
||||
async for chunk in stream:
|
||||
if not chunk or "message" not in chunk:
|
||||
continue
|
||||
msg = chunk.get("message", {})
|
||||
delta = msg.get("content") or ""
|
||||
if delta:
|
||||
content_parts.append(delta)
|
||||
yield ("content_delta", delta)
|
||||
if chunk.get("done"):
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
tool_calls = parse_tool_calls_from_message(msg)
|
||||
final_message = {
|
||||
"content": full_content,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": "tool_calls" if tool_calls else "stop",
|
||||
}
|
||||
break
|
||||
finally:
|
||||
await async_client.close()
|
||||
|
||||
if final_message is not None:
|
||||
yield ("message", final_message)
|
||||
|
||||
@ -23,26 +23,21 @@ def parse_tool_calls_from_message(
|
||||
if not raw or not isinstance(raw, list):
|
||||
return None
|
||||
result = []
|
||||
for idx, tool_call in enumerate(raw):
|
||||
for tool_call in raw:
|
||||
function_data = tool_call.get("function") or {}
|
||||
raw_arguments = function_data.get("arguments") or {}
|
||||
if isinstance(raw_arguments, dict):
|
||||
arguments = raw_arguments
|
||||
elif isinstance(raw_arguments, str):
|
||||
try:
|
||||
arguments = json.loads(raw_arguments)
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse tool call arguments: %s, tool: %s",
|
||||
e,
|
||||
function_data.get("name", "unknown"),
|
||||
)
|
||||
arguments = {}
|
||||
else:
|
||||
try:
|
||||
arguments_str = function_data.get("arguments") or "{}"
|
||||
arguments = json.loads(arguments_str)
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse tool call arguments: %s, tool: %s",
|
||||
e,
|
||||
function_data.get("name", "unknown"),
|
||||
)
|
||||
arguments = {}
|
||||
result.append(
|
||||
{
|
||||
"id": tool_call.get("id", "") or f"call_{idx}",
|
||||
"id": tool_call.get("id", ""),
|
||||
"name": function_data.get("name", ""),
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
@ -1,864 +0,0 @@
|
||||
"""Motion search job management with background execution and parallel verification."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.jobs.manager import (
|
||||
get_job_by_id,
|
||||
set_current_job,
|
||||
)
|
||||
from frigate.models import Recordings
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants
|
||||
HEATMAP_GRID_SIZE = 16
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotionSearchMetrics:
|
||||
"""Metrics collected during motion search execution."""
|
||||
|
||||
segments_scanned: int = 0
|
||||
segments_processed: int = 0
|
||||
metadata_inactive_segments: int = 0
|
||||
heatmap_roi_skip_segments: int = 0
|
||||
fallback_full_range_segments: int = 0
|
||||
frames_decoded: int = 0
|
||||
wall_time_seconds: float = 0.0
|
||||
segments_with_errors: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotionSearchResult:
|
||||
"""A single search result with timestamp and change info."""
|
||||
|
||||
timestamp: float
|
||||
change_percentage: float
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotionSearchJob(Job):
|
||||
"""Job state for motion search operations."""
|
||||
|
||||
job_type: str = "motion_search"
|
||||
camera: str = ""
|
||||
start_time_range: float = 0.0
|
||||
end_time_range: float = 0.0
|
||||
polygon_points: list[list[float]] = field(default_factory=list)
|
||||
threshold: int = 30
|
||||
min_area: float = 5.0
|
||||
frame_skip: int = 5
|
||||
parallel: bool = False
|
||||
max_results: int = 25
|
||||
|
||||
# Track progress
|
||||
total_frames_processed: int = 0
|
||||
|
||||
# Metrics for observability
|
||||
metrics: Optional[MotionSearchMetrics] = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for WebSocket transmission."""
|
||||
d = asdict(self)
|
||||
if self.metrics:
|
||||
d["metrics"] = self.metrics.to_dict()
|
||||
return d
|
||||
|
||||
|
||||
def create_polygon_mask(
|
||||
polygon_points: list[list[float]], frame_width: int, frame_height: int
|
||||
) -> np.ndarray:
|
||||
"""Create a binary mask from normalized polygon coordinates."""
|
||||
motion_points = np.array(
|
||||
[[int(p[0] * frame_width), int(p[1] * frame_height)] for p in polygon_points],
|
||||
dtype=np.int32,
|
||||
)
|
||||
mask = np.zeros((frame_height, frame_width), dtype=np.uint8)
|
||||
cv2.fillPoly(mask, [motion_points], 255)
|
||||
return mask
|
||||
|
||||
|
||||
def compute_roi_bbox_normalized(
|
||||
polygon_points: list[list[float]],
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Compute the bounding box of the ROI in normalized coordinates (0-1).
|
||||
|
||||
Returns (x_min, y_min, x_max, y_max) in normalized coordinates.
|
||||
"""
|
||||
if not polygon_points:
|
||||
return (0.0, 0.0, 1.0, 1.0)
|
||||
|
||||
x_coords = [p[0] for p in polygon_points]
|
||||
y_coords = [p[1] for p in polygon_points]
|
||||
return (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
|
||||
|
||||
|
||||
def heatmap_overlaps_roi(
|
||||
heatmap: dict[str, int], roi_bbox: tuple[float, float, float, float]
|
||||
) -> bool:
|
||||
"""Check if a sparse motion heatmap has any overlap with the ROI bounding box.
|
||||
|
||||
Args:
|
||||
heatmap: Sparse dict mapping cell index (str) to intensity (1-255).
|
||||
roi_bbox: (x_min, y_min, x_max, y_max) in normalized coordinates (0-1).
|
||||
|
||||
Returns:
|
||||
True if there is overlap (any active cell in the ROI region).
|
||||
"""
|
||||
if not isinstance(heatmap, dict):
|
||||
# Invalid heatmap, assume overlap to be safe
|
||||
return True
|
||||
|
||||
x_min, y_min, x_max, y_max = roi_bbox
|
||||
|
||||
# Convert normalized coordinates to grid cells (0-15)
|
||||
grid_x_min = max(0, int(x_min * HEATMAP_GRID_SIZE))
|
||||
grid_y_min = max(0, int(y_min * HEATMAP_GRID_SIZE))
|
||||
grid_x_max = min(HEATMAP_GRID_SIZE - 1, int(x_max * HEATMAP_GRID_SIZE))
|
||||
grid_y_max = min(HEATMAP_GRID_SIZE - 1, int(y_max * HEATMAP_GRID_SIZE))
|
||||
|
||||
# Check each cell in the ROI bbox
|
||||
for y in range(grid_y_min, grid_y_max + 1):
|
||||
for x in range(grid_x_min, grid_x_max + 1):
|
||||
idx = str(y * HEATMAP_GRID_SIZE + x)
|
||||
if idx in heatmap:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def segment_passes_activity_gate(recording: Recordings) -> bool:
|
||||
"""Check if a segment passes the activity gate.
|
||||
|
||||
Returns True if any of motion, objects, or regions is non-zero/non-null.
|
||||
Returns True if all are null (old segments without data).
|
||||
"""
|
||||
motion = recording.motion
|
||||
objects = recording.objects
|
||||
regions = recording.regions
|
||||
|
||||
# Old segments without metadata - pass through (conservative)
|
||||
if motion is None and objects is None and regions is None:
|
||||
return True
|
||||
|
||||
# Pass if any activity indicator is positive
|
||||
return bool(motion) or bool(objects) or bool(regions)
|
||||
|
||||
|
||||
def segment_passes_heatmap_gate(
|
||||
recording: Recordings, roi_bbox: tuple[float, float, float, float]
|
||||
) -> bool:
|
||||
"""Check if a segment passes the heatmap overlap gate.
|
||||
|
||||
Returns True if:
|
||||
- No heatmap is stored (old segments).
|
||||
- The heatmap overlaps with the ROI bbox.
|
||||
"""
|
||||
heatmap = getattr(recording, "motion_heatmap", None)
|
||||
if heatmap is None:
|
||||
# No heatmap stored, fall back to activity gate
|
||||
return True
|
||||
|
||||
return heatmap_overlaps_roi(heatmap, roi_bbox)
|
||||
|
||||
|
||||
class MotionSearchRunner(threading.Thread):
|
||||
"""Thread-based runner for motion search jobs with parallel verification."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
job: MotionSearchJob,
|
||||
config: FrigateConfig,
|
||||
cancel_event: threading.Event,
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name=f"motion_search_{job.id}")
|
||||
self.job = job
|
||||
self.config = config
|
||||
self.cancel_event = cancel_event
|
||||
self.internal_stop_event = threading.Event()
|
||||
self.requestor = InterProcessRequestor()
|
||||
self.metrics = MotionSearchMetrics()
|
||||
self.job.metrics = self.metrics
|
||||
|
||||
# Worker cap: min(4, cpu_count)
|
||||
cpu_count = os.cpu_count() or 1
|
||||
self.max_workers = min(4, cpu_count)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Execute the motion search job."""
|
||||
try:
|
||||
self.job.status = JobStatusTypesEnum.running
|
||||
self.job.start_time = datetime.now().timestamp()
|
||||
self._broadcast_status()
|
||||
|
||||
results = self._execute_search()
|
||||
|
||||
if self.cancel_event.is_set():
|
||||
self.job.status = JobStatusTypesEnum.cancelled
|
||||
else:
|
||||
self.job.status = JobStatusTypesEnum.success
|
||||
self.job.results = {
|
||||
"results": [r.to_dict() for r in results],
|
||||
"total_frames_processed": self.job.total_frames_processed,
|
||||
}
|
||||
|
||||
self.job.end_time = datetime.now().timestamp()
|
||||
self.metrics.wall_time_seconds = self.job.end_time - self.job.start_time
|
||||
self.job.metrics = self.metrics
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s completed: status=%s, results=%d, frames=%d",
|
||||
self.job.id,
|
||||
self.job.status,
|
||||
len(results),
|
||||
self.job.total_frames_processed,
|
||||
)
|
||||
self._broadcast_status()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Motion search job %s failed: %s", self.job.id, e)
|
||||
self.job.status = JobStatusTypesEnum.failed
|
||||
self.job.error_message = str(e)
|
||||
self.job.end_time = datetime.now().timestamp()
|
||||
self.metrics.wall_time_seconds = self.job.end_time - (
|
||||
self.job.start_time or 0
|
||||
)
|
||||
self.job.metrics = self.metrics
|
||||
self._broadcast_status()
|
||||
|
||||
finally:
|
||||
if self.requestor:
|
||||
self.requestor.stop()
|
||||
|
||||
def _broadcast_status(self) -> None:
|
||||
"""Broadcast job status update via IPC to WebSocket subscribers."""
|
||||
if self.job.status == JobStatusTypesEnum.running and self.job.start_time:
|
||||
self.metrics.wall_time_seconds = (
|
||||
datetime.now().timestamp() - self.job.start_time
|
||||
)
|
||||
|
||||
try:
|
||||
self.requestor.send_data(UPDATE_JOB_STATE, self.job.to_dict())
|
||||
except Exception as e:
|
||||
logger.warning("Failed to broadcast motion search status: %s", e)
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
"""Check if processing should stop due to cancellation or internal limits."""
|
||||
return self.cancel_event.is_set() or self.internal_stop_event.is_set()
|
||||
|
||||
def _execute_search(self) -> list[MotionSearchResult]:
|
||||
"""Main search execution logic."""
|
||||
camera_name = self.job.camera
|
||||
camera_config = self.config.cameras.get(camera_name)
|
||||
if not camera_config:
|
||||
raise ValueError(f"Camera {camera_name} not found")
|
||||
|
||||
frame_width = camera_config.detect.width
|
||||
frame_height = camera_config.detect.height
|
||||
|
||||
# Create polygon mask
|
||||
polygon_mask = create_polygon_mask(
|
||||
self.job.polygon_points, frame_width, frame_height
|
||||
)
|
||||
|
||||
if np.count_nonzero(polygon_mask) == 0:
|
||||
logger.warning("Polygon mask is empty for job %s", self.job.id)
|
||||
return []
|
||||
|
||||
# Compute ROI bbox in normalized coordinates for heatmap gate
|
||||
roi_bbox = compute_roi_bbox_normalized(self.job.polygon_points)
|
||||
|
||||
# Query recordings
|
||||
recordings = list(
|
||||
Recordings.select()
|
||||
.where(
|
||||
(
|
||||
Recordings.start_time.between(
|
||||
self.job.start_time_range, self.job.end_time_range
|
||||
)
|
||||
)
|
||||
| (
|
||||
Recordings.end_time.between(
|
||||
self.job.start_time_range, self.job.end_time_range
|
||||
)
|
||||
)
|
||||
| (
|
||||
(self.job.start_time_range > Recordings.start_time)
|
||||
& (self.job.end_time_range < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
|
||||
if not recordings:
|
||||
logger.debug("No recordings found for motion search job %s", self.job.id)
|
||||
return []
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: queried %d recording segments for camera %s "
|
||||
"(range %.1f - %.1f)",
|
||||
self.job.id,
|
||||
len(recordings),
|
||||
camera_name,
|
||||
self.job.start_time_range,
|
||||
self.job.end_time_range,
|
||||
)
|
||||
|
||||
self.metrics.segments_scanned = len(recordings)
|
||||
|
||||
# Apply activity and heatmap gates
|
||||
filtered_recordings = []
|
||||
for recording in recordings:
|
||||
if not segment_passes_activity_gate(recording):
|
||||
self.metrics.metadata_inactive_segments += 1
|
||||
self.metrics.segments_processed += 1
|
||||
logger.debug(
|
||||
"Motion search job %s: segment %s skipped by activity gate "
|
||||
"(motion=%s, objects=%s, regions=%s)",
|
||||
self.job.id,
|
||||
recording.id,
|
||||
recording.motion,
|
||||
recording.objects,
|
||||
recording.regions,
|
||||
)
|
||||
continue
|
||||
if not segment_passes_heatmap_gate(recording, roi_bbox):
|
||||
self.metrics.heatmap_roi_skip_segments += 1
|
||||
self.metrics.segments_processed += 1
|
||||
logger.debug(
|
||||
"Motion search job %s: segment %s skipped by heatmap gate "
|
||||
"(heatmap present=%s, roi_bbox=%s)",
|
||||
self.job.id,
|
||||
recording.id,
|
||||
recording.motion_heatmap is not None,
|
||||
roi_bbox,
|
||||
)
|
||||
continue
|
||||
filtered_recordings.append(recording)
|
||||
|
||||
self._broadcast_status()
|
||||
|
||||
# Fallback: if all segments were filtered out, scan all segments
|
||||
# This allows motion search to find things the detector missed
|
||||
if not filtered_recordings and recordings:
|
||||
logger.info(
|
||||
"All %d segments filtered by gates, falling back to full scan",
|
||||
len(recordings),
|
||||
)
|
||||
self.metrics.fallback_full_range_segments = len(recordings)
|
||||
filtered_recordings = recordings
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: %d/%d segments passed gates "
|
||||
"(activity_skipped=%d, heatmap_skipped=%d)",
|
||||
self.job.id,
|
||||
len(filtered_recordings),
|
||||
len(recordings),
|
||||
self.metrics.metadata_inactive_segments,
|
||||
self.metrics.heatmap_roi_skip_segments,
|
||||
)
|
||||
|
||||
if self.job.parallel:
|
||||
return self._search_motion_parallel(filtered_recordings, polygon_mask)
|
||||
|
||||
return self._search_motion_sequential(filtered_recordings, polygon_mask)
|
||||
|
||||
def _search_motion_parallel(
|
||||
self,
|
||||
recordings: list[Recordings],
|
||||
polygon_mask: np.ndarray,
|
||||
) -> list[MotionSearchResult]:
|
||||
"""Search for motion in parallel across segments, streaming results."""
|
||||
all_results: list[MotionSearchResult] = []
|
||||
total_frames = 0
|
||||
next_recording_idx_to_merge = 0
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: starting motion search with %d workers "
|
||||
"across %d segments",
|
||||
self.job.id,
|
||||
self.max_workers,
|
||||
len(recordings),
|
||||
)
|
||||
|
||||
# Initialize partial results on the job so they stream to the frontend
|
||||
self.job.results = {"results": [], "total_frames_processed": 0}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
futures: dict[Future, int] = {}
|
||||
completed_segments: dict[int, tuple[list[MotionSearchResult], int]] = {}
|
||||
|
||||
for idx, recording in enumerate(recordings):
|
||||
if self._should_stop():
|
||||
break
|
||||
|
||||
future = executor.submit(
|
||||
self._process_recording_for_motion,
|
||||
recording.path,
|
||||
recording.start_time,
|
||||
recording.end_time,
|
||||
self.job.start_time_range,
|
||||
self.job.end_time_range,
|
||||
polygon_mask,
|
||||
self.job.threshold,
|
||||
self.job.min_area,
|
||||
self.job.frame_skip,
|
||||
)
|
||||
futures[future] = idx
|
||||
|
||||
for future in as_completed(futures):
|
||||
if self._should_stop():
|
||||
# Cancel remaining futures
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
break
|
||||
|
||||
recording_idx = futures[future]
|
||||
recording = recordings[recording_idx]
|
||||
|
||||
try:
|
||||
results, frames = future.result()
|
||||
self.metrics.segments_processed += 1
|
||||
completed_segments[recording_idx] = (results, frames)
|
||||
|
||||
while next_recording_idx_to_merge in completed_segments:
|
||||
segment_results, segment_frames = completed_segments.pop(
|
||||
next_recording_idx_to_merge
|
||||
)
|
||||
|
||||
all_results.extend(segment_results)
|
||||
total_frames += segment_frames
|
||||
self.job.total_frames_processed = total_frames
|
||||
self.metrics.frames_decoded = total_frames
|
||||
|
||||
if segment_results:
|
||||
deduped = self._deduplicate_results(all_results)
|
||||
self.job.results = {
|
||||
"results": [
|
||||
r.to_dict() for r in deduped[: self.job.max_results]
|
||||
],
|
||||
"total_frames_processed": total_frames,
|
||||
}
|
||||
|
||||
self._broadcast_status()
|
||||
|
||||
if segment_results and len(deduped) >= self.job.max_results:
|
||||
self.internal_stop_event.set()
|
||||
for pending_future in futures:
|
||||
pending_future.cancel()
|
||||
break
|
||||
|
||||
next_recording_idx_to_merge += 1
|
||||
|
||||
if self.internal_stop_event.is_set():
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
self.metrics.segments_processed += 1
|
||||
self.metrics.segments_with_errors += 1
|
||||
self._broadcast_status()
|
||||
logger.warning(
|
||||
"Error processing segment %s: %s",
|
||||
recording.path,
|
||||
e,
|
||||
)
|
||||
|
||||
self.job.total_frames_processed = total_frames
|
||||
self.metrics.frames_decoded = total_frames
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: motion search complete, "
|
||||
"found %d raw results, decoded %d frames, %d segment errors",
|
||||
self.job.id,
|
||||
len(all_results),
|
||||
total_frames,
|
||||
self.metrics.segments_with_errors,
|
||||
)
|
||||
|
||||
# Sort and deduplicate results
|
||||
all_results.sort(key=lambda x: x.timestamp)
|
||||
return self._deduplicate_results(all_results)[: self.job.max_results]
|
||||
|
||||
def _search_motion_sequential(
|
||||
self,
|
||||
recordings: list[Recordings],
|
||||
polygon_mask: np.ndarray,
|
||||
) -> list[MotionSearchResult]:
|
||||
"""Search for motion sequentially across segments, streaming results."""
|
||||
all_results: list[MotionSearchResult] = []
|
||||
total_frames = 0
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: starting sequential motion search across %d segments",
|
||||
self.job.id,
|
||||
len(recordings),
|
||||
)
|
||||
|
||||
self.job.results = {"results": [], "total_frames_processed": 0}
|
||||
|
||||
for recording in recordings:
|
||||
if self.cancel_event.is_set():
|
||||
break
|
||||
|
||||
try:
|
||||
results, frames = self._process_recording_for_motion(
|
||||
recording.path,
|
||||
recording.start_time,
|
||||
recording.end_time,
|
||||
self.job.start_time_range,
|
||||
self.job.end_time_range,
|
||||
polygon_mask,
|
||||
self.job.threshold,
|
||||
self.job.min_area,
|
||||
self.job.frame_skip,
|
||||
)
|
||||
all_results.extend(results)
|
||||
total_frames += frames
|
||||
|
||||
self.job.total_frames_processed = total_frames
|
||||
self.metrics.frames_decoded = total_frames
|
||||
self.metrics.segments_processed += 1
|
||||
|
||||
if results:
|
||||
all_results.sort(key=lambda x: x.timestamp)
|
||||
deduped = self._deduplicate_results(all_results)[
|
||||
: self.job.max_results
|
||||
]
|
||||
self.job.results = {
|
||||
"results": [r.to_dict() for r in deduped],
|
||||
"total_frames_processed": total_frames,
|
||||
}
|
||||
|
||||
self._broadcast_status()
|
||||
|
||||
if results and len(deduped) >= self.job.max_results:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
self.metrics.segments_processed += 1
|
||||
self.metrics.segments_with_errors += 1
|
||||
self._broadcast_status()
|
||||
logger.warning("Error processing segment %s: %s", recording.path, e)
|
||||
|
||||
self.job.total_frames_processed = total_frames
|
||||
self.metrics.frames_decoded = total_frames
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: sequential motion search complete, "
|
||||
"found %d raw results, decoded %d frames, %d segment errors",
|
||||
self.job.id,
|
||||
len(all_results),
|
||||
total_frames,
|
||||
self.metrics.segments_with_errors,
|
||||
)
|
||||
|
||||
all_results.sort(key=lambda x: x.timestamp)
|
||||
return self._deduplicate_results(all_results)[: self.job.max_results]
|
||||
|
||||
def _deduplicate_results(
|
||||
self, results: list[MotionSearchResult], min_gap: float = 1.0
|
||||
) -> list[MotionSearchResult]:
|
||||
"""Deduplicate results that are too close together."""
|
||||
if not results:
|
||||
return results
|
||||
|
||||
deduplicated: list[MotionSearchResult] = []
|
||||
last_timestamp = 0.0
|
||||
|
||||
for result in results:
|
||||
if result.timestamp - last_timestamp >= min_gap:
|
||||
deduplicated.append(result)
|
||||
last_timestamp = result.timestamp
|
||||
|
||||
return deduplicated
|
||||
|
||||
def _process_recording_for_motion(
|
||||
self,
|
||||
recording_path: str,
|
||||
recording_start: float,
|
||||
recording_end: float,
|
||||
search_start: float,
|
||||
search_end: float,
|
||||
polygon_mask: np.ndarray,
|
||||
threshold: int,
|
||||
min_area: float,
|
||||
frame_skip: int,
|
||||
) -> tuple[list[MotionSearchResult], int]:
|
||||
"""Process a single recording file for motion detection.
|
||||
|
||||
This method is designed to be called from a thread pool.
|
||||
|
||||
Args:
|
||||
min_area: Minimum change area as a percentage of the ROI (0-100).
|
||||
"""
|
||||
results: list[MotionSearchResult] = []
|
||||
frames_processed = 0
|
||||
|
||||
if not os.path.exists(recording_path):
|
||||
logger.warning("Recording file not found: %s", recording_path)
|
||||
return results, frames_processed
|
||||
|
||||
cap = cv2.VideoCapture(recording_path)
|
||||
if not cap.isOpened():
|
||||
logger.error("Could not open recording: %s", recording_path)
|
||||
return results, frames_processed
|
||||
|
||||
try:
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
recording_duration = recording_end - recording_start
|
||||
|
||||
# Calculate frame range
|
||||
start_offset = max(0, search_start - recording_start)
|
||||
end_offset = min(recording_duration, search_end - recording_start)
|
||||
start_frame = int(start_offset * fps)
|
||||
end_frame = int(end_offset * fps)
|
||||
start_frame = max(0, min(start_frame, total_frames - 1))
|
||||
end_frame = max(0, min(end_frame, total_frames))
|
||||
|
||||
if start_frame >= end_frame:
|
||||
return results, frames_processed
|
||||
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
||||
|
||||
# Get ROI bounding box
|
||||
roi_bbox = cv2.boundingRect(polygon_mask)
|
||||
roi_x, roi_y, roi_w, roi_h = roi_bbox
|
||||
|
||||
prev_frame_gray = None
|
||||
frame_step = max(frame_skip, 1)
|
||||
frame_idx = start_frame
|
||||
|
||||
while frame_idx < end_frame:
|
||||
if self._should_stop():
|
||||
break
|
||||
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
frame_idx += 1
|
||||
continue
|
||||
|
||||
if (frame_idx - start_frame) % frame_step != 0:
|
||||
frame_idx += 1
|
||||
continue
|
||||
|
||||
frames_processed += 1
|
||||
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Handle frame dimension changes
|
||||
if gray.shape != polygon_mask.shape:
|
||||
resized_mask = cv2.resize(
|
||||
polygon_mask, (gray.shape[1], gray.shape[0]), cv2.INTER_NEAREST
|
||||
)
|
||||
current_bbox = cv2.boundingRect(resized_mask)
|
||||
else:
|
||||
resized_mask = polygon_mask
|
||||
current_bbox = roi_bbox
|
||||
|
||||
roi_x, roi_y, roi_w, roi_h = current_bbox
|
||||
cropped_gray = gray[roi_y : roi_y + roi_h, roi_x : roi_x + roi_w]
|
||||
cropped_mask = resized_mask[
|
||||
roi_y : roi_y + roi_h, roi_x : roi_x + roi_w
|
||||
]
|
||||
|
||||
cropped_mask_area = np.count_nonzero(cropped_mask)
|
||||
if cropped_mask_area == 0:
|
||||
frame_idx += 1
|
||||
continue
|
||||
|
||||
# Convert percentage to pixel count for this ROI
|
||||
min_area_pixels = int((min_area / 100.0) * cropped_mask_area)
|
||||
|
||||
masked_gray = cv2.bitwise_and(
|
||||
cropped_gray, cropped_gray, mask=cropped_mask
|
||||
)
|
||||
|
||||
if prev_frame_gray is not None:
|
||||
diff = cv2.absdiff(prev_frame_gray, masked_gray)
|
||||
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
|
||||
_, thresh = cv2.threshold(
|
||||
diff_blurred, threshold, 255, cv2.THRESH_BINARY
|
||||
)
|
||||
thresh_dilated = cv2.dilate(thresh, None, iterations=1)
|
||||
thresh_masked = cv2.bitwise_and(
|
||||
thresh_dilated, thresh_dilated, mask=cropped_mask
|
||||
)
|
||||
|
||||
change_pixels = cv2.countNonZero(thresh_masked)
|
||||
if change_pixels > min_area_pixels:
|
||||
contours, _ = cv2.findContours(
|
||||
thresh_masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
total_change_area = sum(
|
||||
cv2.contourArea(c)
|
||||
for c in contours
|
||||
if cv2.contourArea(c) >= min_area_pixels
|
||||
)
|
||||
if total_change_area > 0:
|
||||
frame_time_offset = (frame_idx - start_frame) / fps
|
||||
timestamp = (
|
||||
recording_start + start_offset + frame_time_offset
|
||||
)
|
||||
change_percentage = (
|
||||
total_change_area / cropped_mask_area
|
||||
) * 100
|
||||
results.append(
|
||||
MotionSearchResult(
|
||||
timestamp=timestamp,
|
||||
change_percentage=round(change_percentage, 2),
|
||||
)
|
||||
)
|
||||
|
||||
prev_frame_gray = masked_gray
|
||||
frame_idx += 1
|
||||
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
logger.debug(
|
||||
"Motion search segment complete: %s, %d frames processed, %d results found",
|
||||
recording_path,
|
||||
frames_processed,
|
||||
len(results),
|
||||
)
|
||||
return results, frames_processed
|
||||
|
||||
|
||||
# Module-level state for managing per-camera jobs
|
||||
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
|
||||
|
||||
def stop_all_motion_search_jobs() -> None:
|
||||
"""Cancel all running motion search jobs for clean shutdown."""
|
||||
with _jobs_lock:
|
||||
for job_id, (job, cancel_event) in _motion_search_jobs.items():
|
||||
if job.status in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running):
|
||||
cancel_event.set()
|
||||
logger.debug("Signalling motion search job %s to stop", job_id)
|
||||
|
||||
|
||||
def start_motion_search_job(
|
||||
config: FrigateConfig,
|
||||
camera_name: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
polygon_points: list[list[float]],
|
||||
threshold: int = 30,
|
||||
min_area: float = 5.0,
|
||||
frame_skip: int = 5,
|
||||
parallel: bool = False,
|
||||
max_results: int = 25,
|
||||
) -> str:
|
||||
"""Start a new motion search job.
|
||||
|
||||
Returns the job ID.
|
||||
"""
|
||||
job = MotionSearchJob(
|
||||
camera=camera_name,
|
||||
start_time_range=start_time,
|
||||
end_time_range=end_time,
|
||||
polygon_points=polygon_points,
|
||||
threshold=threshold,
|
||||
min_area=min_area,
|
||||
frame_skip=frame_skip,
|
||||
parallel=parallel,
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
cancel_event = threading.Event()
|
||||
|
||||
with _jobs_lock:
|
||||
_motion_search_jobs[job.id] = (job, cancel_event)
|
||||
|
||||
set_current_job(job)
|
||||
|
||||
runner = MotionSearchRunner(job, config, cancel_event)
|
||||
runner.start()
|
||||
|
||||
logger.debug(
|
||||
"Started motion search job %s for camera %s: "
|
||||
"time_range=%.1f-%.1f, threshold=%d, min_area=%.1f%%, "
|
||||
"frame_skip=%d, parallel=%s, max_results=%d, polygon_points=%d vertices",
|
||||
job.id,
|
||||
camera_name,
|
||||
start_time,
|
||||
end_time,
|
||||
threshold,
|
||||
min_area,
|
||||
frame_skip,
|
||||
parallel,
|
||||
max_results,
|
||||
len(polygon_points),
|
||||
)
|
||||
return job.id
|
||||
|
||||
|
||||
def get_motion_search_job(job_id: str) -> Optional[MotionSearchJob]:
|
||||
"""Get a motion search job by ID."""
|
||||
with _jobs_lock:
|
||||
job_entry = _motion_search_jobs.get(job_id)
|
||||
if job_entry:
|
||||
return job_entry[0]
|
||||
# Check completed jobs via manager
|
||||
return get_job_by_id("motion_search", job_id)
|
||||
|
||||
|
||||
def cancel_motion_search_job(job_id: str) -> bool:
|
||||
"""Cancel a motion search job.
|
||||
|
||||
Returns True if cancellation was initiated, False if job not found.
|
||||
"""
|
||||
with _jobs_lock:
|
||||
job_entry = _motion_search_jobs.get(job_id)
|
||||
if not job_entry:
|
||||
return False
|
||||
|
||||
job, cancel_event = job_entry
|
||||
|
||||
if job.status not in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running):
|
||||
# Already finished
|
||||
return True
|
||||
|
||||
cancel_event.set()
|
||||
job.status = JobStatusTypesEnum.cancelled
|
||||
job_payload = job.to_dict()
|
||||
logger.info("Cancelled motion search job %s", job_id)
|
||||
|
||||
requestor: Optional[InterProcessRequestor] = None
|
||||
try:
|
||||
requestor = InterProcessRequestor()
|
||||
requestor.send_data(UPDATE_JOB_STATE, job_payload)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to broadcast cancelled motion search job %s: %s", job_id, e
|
||||
)
|
||||
finally:
|
||||
if requestor:
|
||||
requestor.stop()
|
||||
|
||||
return True
|
||||
@ -78,7 +78,6 @@ class Recordings(Model):
|
||||
dBFS = IntegerField(null=True)
|
||||
segment_size = FloatField(default=0) # this should be stored as MB
|
||||
regions = IntegerField(null=True)
|
||||
motion_heatmap = JSONField(null=True) # 16x16 grid, 256 values (0-255)
|
||||
|
||||
|
||||
class ExportCase(Model):
|
||||
|
||||
@ -176,32 +176,11 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
motion_boxes = []
|
||||
pct_motion = 0
|
||||
|
||||
# skip motion entirely if the scene change percentage exceeds configured
|
||||
# threshold. this is useful to ignore lighting storms, IR mode switches,
|
||||
# etc. rather than registering them as brief motion and then recalibrating.
|
||||
# note: skipping means the frame is dropped and **no recording will be
|
||||
# created**, which could hide a legitimate object if the camera is actively
|
||||
# auto‑tracking. the alternative is to allow motion and accept a small
|
||||
# recording that can be reviewed in the timeline. disabled by default (None).
|
||||
if (
|
||||
self.config.skip_motion_threshold is not None
|
||||
and pct_motion > self.config.skip_motion_threshold
|
||||
):
|
||||
# force a recalibration so we transition to the new background
|
||||
self.calibrating = True
|
||||
return []
|
||||
|
||||
# once the motion is less than 5% and the number of contours is < 4, assume its calibrated
|
||||
if pct_motion < 0.05 and len(motion_boxes) <= 4:
|
||||
self.calibrating = False
|
||||
|
||||
# if calibrating or the motion contours are > 80% of the image area
|
||||
# (lightning, ir, ptz) recalibrate. the lightning threshold does **not**
|
||||
# stop motion detection entirely; it simply halts additional processing for
|
||||
# the current frame once the percentage crosses the threshold. this helps
|
||||
# reduce false positive object detections and CPU usage during high‑motion
|
||||
# events. recordings continue to be generated because users expect data
|
||||
# while a PTZ camera is moving.
|
||||
# if calibrating or the motion contours are > 80% of the image area (lightning, ir, ptz) recalibrate
|
||||
if self.calibrating or pct_motion > self.config.lightning_threshold:
|
||||
self.calibrating = True
|
||||
|
||||
|
||||
@ -273,13 +273,17 @@ 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.last_refresh_time = 0
|
||||
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
||||
|
||||
if config.birdseye.layout.max_cameras:
|
||||
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)
|
||||
@ -416,13 +420,12 @@ class BirdsEyeFrameManager:
|
||||
[
|
||||
cam
|
||||
for cam, cam_data in self.cameras.items()
|
||||
if cam in self.config.cameras
|
||||
and self.config.cameras[cam].birdseye.enabled
|
||||
if self.config.cameras[cam].birdseye.enabled
|
||||
and self.config.cameras[cam].enabled_in_config
|
||||
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.config.birdseye.inactivity_threshold
|
||||
< self.inactivity_threshold
|
||||
]
|
||||
)
|
||||
logger.debug(f"Active cameras: {active_cameras}")
|
||||
@ -720,11 +723,8 @@ class BirdsEyeFrameManager:
|
||||
Update birdseye for a specific camera with new frame data.
|
||||
Returns (frame_changed, layout_changed) to indicate if the frame or layout changed.
|
||||
"""
|
||||
# don't process if camera was removed or birdseye is disabled
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False, False
|
||||
|
||||
# don't process if birdseye is disabled for this camera
|
||||
camera_config = self.config.cameras[camera]
|
||||
force_update = False
|
||||
|
||||
# disabling birdseye is a little tricky
|
||||
|
||||
@ -15,7 +15,6 @@ 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
|
||||
@ -23,12 +22,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
PROCESS_PRIORITY_MED,
|
||||
REPLAY_CAMERA_PREFIX,
|
||||
)
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, PROCESS_PRIORITY_MED
|
||||
from frigate.output.birdseye import Birdseye
|
||||
from frigate.output.camera import JsmpegCamera
|
||||
from frigate.output.preview import PreviewRecorder
|
||||
@ -85,32 +79,6 @@ class OutputProcess(FrigateProcess):
|
||||
)
|
||||
self.config = config
|
||||
|
||||
def is_debug_replay_camera(self, camera: str) -> bool:
|
||||
return camera.startswith(REPLAY_CAMERA_PREFIX)
|
||||
|
||||
def add_camera(
|
||||
self,
|
||||
camera: str,
|
||||
websocket_server: WSGIServer,
|
||||
jsmpeg_cameras: dict[str, JsmpegCamera],
|
||||
preview_recorders: dict[str, PreviewRecorder],
|
||||
preview_write_times: dict[str, float],
|
||||
birdseye: Birdseye | None,
|
||||
) -> None:
|
||||
camera_config = self.config.cameras[camera]
|
||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
||||
camera_config, self.stop_event, websocket_server
|
||||
)
|
||||
preview_recorders[camera] = PreviewRecorder(camera_config)
|
||||
preview_write_times[camera] = 0
|
||||
|
||||
if (
|
||||
birdseye is not None
|
||||
and self.config.birdseye.enabled
|
||||
and camera_config.birdseye.enabled
|
||||
):
|
||||
birdseye.add_camera(camera)
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup(self.config.logger)
|
||||
|
||||
@ -139,7 +107,6 @@ class OutputProcess(FrigateProcess):
|
||||
CameraConfigUpdateEnum.record,
|
||||
],
|
||||
)
|
||||
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
||||
|
||||
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
||||
birdseye: Birdseye | None = None
|
||||
@ -151,17 +118,14 @@ class OutputProcess(FrigateProcess):
|
||||
move_preview_frames("cache")
|
||||
|
||||
for camera, cam_config in self.config.cameras.items():
|
||||
if not cam_config.enabled_in_config or self.is_debug_replay_camera(camera):
|
||||
if not cam_config.enabled_in_config:
|
||||
continue
|
||||
|
||||
self.add_camera(
|
||||
camera,
|
||||
websocket_server,
|
||||
jsmpeg_cameras,
|
||||
preview_recorders,
|
||||
preview_write_times,
|
||||
birdseye,
|
||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
||||
cam_config, self.stop_event, websocket_server
|
||||
)
|
||||
preview_recorders[camera] = PreviewRecorder(cam_config)
|
||||
preview_write_times[camera] = 0
|
||||
|
||||
if self.config.birdseye.enabled:
|
||||
birdseye = Birdseye(self.config, self.stop_event, websocket_server)
|
||||
@ -169,34 +133,24 @@ 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()
|
||||
|
||||
if CameraConfigUpdateEnum.add in updates:
|
||||
for camera in updates["add"]:
|
||||
if not self.is_debug_replay_camera(camera):
|
||||
self.add_camera(
|
||||
camera,
|
||||
websocket_server,
|
||||
jsmpeg_cameras,
|
||||
preview_recorders,
|
||||
preview_write_times,
|
||||
birdseye,
|
||||
)
|
||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
||||
self.config.cameras[camera], self.stop_event, websocket_server
|
||||
)
|
||||
preview_recorders[camera] = PreviewRecorder(
|
||||
self.config.cameras[camera]
|
||||
)
|
||||
preview_write_times[camera] = 0
|
||||
|
||||
if (
|
||||
self.config.birdseye.enabled
|
||||
and self.config.cameras[camera].birdseye.enabled
|
||||
):
|
||||
birdseye.add_camera(camera)
|
||||
|
||||
(topic, data) = detection_subscriber.check_for_update(timeout=1)
|
||||
now = datetime.datetime.now().timestamp()
|
||||
@ -220,11 +174,7 @@ class OutputProcess(FrigateProcess):
|
||||
_,
|
||||
) = data
|
||||
|
||||
if (
|
||||
camera not in self.config.cameras
|
||||
or not self.config.cameras[camera].enabled
|
||||
or self.is_debug_replay_camera(camera)
|
||||
):
|
||||
if not self.config.cameras[camera].enabled:
|
||||
continue
|
||||
|
||||
frame = frame_manager.get(
|
||||
@ -313,7 +263,6 @@ 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()
|
||||
|
||||
@ -50,13 +50,11 @@ class SegmentInfo:
|
||||
active_object_count: int,
|
||||
region_count: int,
|
||||
average_dBFS: int,
|
||||
motion_heatmap: dict[str, int] | None = None,
|
||||
) -> None:
|
||||
self.motion_count = motion_count
|
||||
self.active_object_count = active_object_count
|
||||
self.region_count = region_count
|
||||
self.average_dBFS = average_dBFS
|
||||
self.motion_heatmap = motion_heatmap
|
||||
|
||||
def should_discard_segment(self, retain_mode: RetainModeEnum) -> bool:
|
||||
keep = False
|
||||
@ -289,12 +287,11 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
|
||||
# publish most recently available recording time and None if disabled
|
||||
camera_cfg = self.config.cameras.get(camera)
|
||||
self.recordings_publisher.publish(
|
||||
(
|
||||
camera,
|
||||
recordings[0]["start_time"].timestamp()
|
||||
if camera_cfg and camera_cfg.record.enabled
|
||||
if self.config.cameras[camera].record.enabled
|
||||
else None,
|
||||
None,
|
||||
),
|
||||
@ -318,8 +315,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
) -> Optional[Recordings]:
|
||||
cache_path: str = recording["cache_path"]
|
||||
start_time: datetime.datetime = recording["start_time"]
|
||||
record_config = self.config.cameras[camera].record
|
||||
|
||||
# Just delete files if camera removed or recordings are turned off
|
||||
# Just delete files if recordings are turned off
|
||||
if (
|
||||
camera not in self.config.cameras
|
||||
or not self.config.cameras[camera].record.enabled
|
||||
@ -456,59 +454,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
if end_time < retain_cutoff:
|
||||
self.drop_segment(cache_path)
|
||||
|
||||
def _compute_motion_heatmap(
|
||||
self, camera: str, motion_boxes: list[tuple[int, int, int, int]]
|
||||
) -> dict[str, int] | None:
|
||||
"""Compute a 16x16 motion intensity heatmap from motion boxes.
|
||||
|
||||
Returns a sparse dict mapping cell index (as string) to intensity (1-255).
|
||||
Only cells with motion are included.
|
||||
|
||||
Args:
|
||||
camera: Camera name to get detect dimensions from.
|
||||
motion_boxes: List of (x1, y1, x2, y2) pixel coordinates.
|
||||
|
||||
Returns:
|
||||
Sparse dict like {"45": 3, "46": 5}, or None if no boxes.
|
||||
"""
|
||||
if not motion_boxes:
|
||||
return None
|
||||
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if not camera_config:
|
||||
return None
|
||||
|
||||
frame_width = camera_config.detect.width
|
||||
frame_height = camera_config.detect.height
|
||||
|
||||
if frame_width <= 0 or frame_height <= 0:
|
||||
return None
|
||||
|
||||
GRID_SIZE = 16
|
||||
counts: dict[int, int] = {}
|
||||
|
||||
for box in motion_boxes:
|
||||
if len(box) < 4:
|
||||
continue
|
||||
x1, y1, x2, y2 = box
|
||||
|
||||
# Convert pixel coordinates to grid cells
|
||||
grid_x1 = max(0, int((x1 / frame_width) * GRID_SIZE))
|
||||
grid_y1 = max(0, int((y1 / frame_height) * GRID_SIZE))
|
||||
grid_x2 = min(GRID_SIZE - 1, int((x2 / frame_width) * GRID_SIZE))
|
||||
grid_y2 = min(GRID_SIZE - 1, int((y2 / frame_height) * GRID_SIZE))
|
||||
|
||||
for y in range(grid_y1, grid_y2 + 1):
|
||||
for x in range(grid_x1, grid_x2 + 1):
|
||||
idx = y * GRID_SIZE + x
|
||||
counts[idx] = min(255, counts.get(idx, 0) + 1)
|
||||
|
||||
if not counts:
|
||||
return None
|
||||
|
||||
# Convert to string keys for JSON storage
|
||||
return {str(k): v for k, v in counts.items()}
|
||||
|
||||
def segment_stats(
|
||||
self, camera: str, start_time: datetime.datetime, end_time: datetime.datetime
|
||||
) -> SegmentInfo:
|
||||
@ -516,8 +461,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
active_count = 0
|
||||
region_count = 0
|
||||
motion_count = 0
|
||||
all_motion_boxes: list[tuple[int, int, int, int]] = []
|
||||
|
||||
for frame in self.object_recordings_info[camera]:
|
||||
# frame is after end time of segment
|
||||
if frame[0] > end_time.timestamp():
|
||||
@ -536,8 +479,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
motion_count += len(frame[2])
|
||||
region_count += len(frame[3])
|
||||
# Collect motion boxes for heatmap computation
|
||||
all_motion_boxes.extend(frame[2])
|
||||
|
||||
audio_values = []
|
||||
for frame in self.audio_recordings_info[camera]:
|
||||
@ -557,14 +498,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
average_dBFS = 0 if not audio_values else np.average(audio_values)
|
||||
|
||||
motion_heatmap = self._compute_motion_heatmap(camera, all_motion_boxes)
|
||||
|
||||
return SegmentInfo(
|
||||
motion_count,
|
||||
active_count,
|
||||
region_count,
|
||||
round(average_dBFS),
|
||||
motion_heatmap,
|
||||
motion_count, active_count, region_count, round(average_dBFS)
|
||||
)
|
||||
|
||||
async def move_segment(
|
||||
@ -655,7 +590,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
Recordings.regions.name: segment_info.region_count,
|
||||
Recordings.dBFS.name: segment_info.average_dBFS,
|
||||
Recordings.segment_size.name: segment_size,
|
||||
Recordings.motion_heatmap.name: segment_info.motion_heatmap,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to store recording segment {cache_path}")
|
||||
|
||||
@ -652,9 +652,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
if camera not in self.indefinite_events:
|
||||
self.indefinite_events[camera] = {}
|
||||
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
if (
|
||||
not self.config.cameras[camera].enabled
|
||||
or not self.config.cameras[camera].record.enabled
|
||||
|
||||
@ -340,9 +340,6 @@ def stats_snapshot(
|
||||
|
||||
stats["cameras"] = {}
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
if name not in config.cameras:
|
||||
continue
|
||||
|
||||
total_camera_fps += camera_stats.camera_fps.value
|
||||
total_process_fps += camera_stats.process_fps.value
|
||||
total_skipped_fps += camera_stats.skipped_fps.value
|
||||
|
||||
@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from peewee import SQL, fn
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import RECORD_DIR, REPLAY_CAMERA_PREFIX
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.models import Event, Recordings
|
||||
from frigate.util.builtin import clear_and_unlink
|
||||
|
||||
@ -32,10 +32,6 @@ class StorageMaintainer(threading.Thread):
|
||||
def calculate_camera_bandwidth(self) -> None:
|
||||
"""Calculate an average MB/hr for each camera."""
|
||||
for camera in self.config.cameras.keys():
|
||||
# Skip replay cameras
|
||||
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||
continue
|
||||
|
||||
# cameras with < 50 segments should be refreshed to keep size accurate
|
||||
# when few segments are available
|
||||
if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True):
|
||||
@ -81,10 +77,6 @@ class StorageMaintainer(threading.Thread):
|
||||
usages: dict[str, dict] = {}
|
||||
|
||||
for camera in self.config.cameras.keys():
|
||||
# Skip replay cameras
|
||||
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||
continue
|
||||
|
||||
camera_storage = (
|
||||
Recordings.select(fn.SUM(Recordings.segment_size))
|
||||
.where(Recordings.camera == camera, Recordings.segment_size != 0)
|
||||
|
||||
@ -13,7 +13,6 @@ from pydantic import Json
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import BASE_DIR, CACHE_DIR
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
@ -142,7 +141,6 @@ class BaseTestHttp(unittest.TestCase):
|
||||
stats,
|
||||
event_metadata_publisher,
|
||||
None,
|
||||
DebugReplayManager(),
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
|
||||
@ -22,32 +22,3 @@ class TestHttpApp(BaseTestHttp):
|
||||
response = client.get("/stats")
|
||||
response_json = response.json()
|
||||
assert response_json == self.test_stats
|
||||
|
||||
def test_config_set_in_memory_replaces_objects_track_list(self):
|
||||
self.minimal_config["cameras"]["front_door"]["objects"] = {
|
||||
"track": ["person", "car"],
|
||||
}
|
||||
app = super().create_app()
|
||||
app.config_publisher = Mock()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"requires_restart": 0,
|
||||
"skip_save": True,
|
||||
"update_topic": "config/cameras/front_door/objects",
|
||||
"config_data": {
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"objects": {
|
||||
"track": ["person"],
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
||||
|
||||
@ -1,261 +0,0 @@
|
||||
"""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()
|
||||
@ -151,22 +151,6 @@ class TestConfig(unittest.TestCase):
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert "dog" in frigate_config.cameras["back"].objects.track
|
||||
|
||||
def test_deep_merge_override_replaces_list_values(self):
|
||||
base = {"objects": {"track": ["person", "face"]}}
|
||||
update = {"objects": {"track": ["person"]}}
|
||||
|
||||
merged = deep_merge(base, update, override=True)
|
||||
|
||||
assert merged["objects"]["track"] == ["person"]
|
||||
|
||||
def test_deep_merge_merge_lists_still_appends(self):
|
||||
base = {"track": ["person"]}
|
||||
update = {"track": ["face"]}
|
||||
|
||||
merged = deep_merge(base, update, override=True, merge_lists=True)
|
||||
|
||||
assert merged["track"] == ["person", "face"]
|
||||
|
||||
def test_override_birdseye(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.config.camera.motion import MotionConfig
|
||||
from frigate.motion.improved_motion import ImprovedMotionDetector
|
||||
|
||||
|
||||
class TestImprovedMotionDetector(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# small frame for testing; actual frames are grayscale
|
||||
self.frame_shape = (100, 100) # height, width
|
||||
self.config = MotionConfig()
|
||||
# motion detector assumes a rasterized_mask attribute exists on config
|
||||
# when update_mask() is called; add one manually by bypassing pydantic.
|
||||
object.__setattr__(
|
||||
self.config,
|
||||
"rasterized_mask",
|
||||
np.ones((self.frame_shape[0], self.frame_shape[1]), dtype=np.uint8),
|
||||
)
|
||||
|
||||
# create minimal PTZ metrics stub to satisfy detector checks
|
||||
class _Stub:
|
||||
def __init__(self, value=False):
|
||||
self.value = value
|
||||
|
||||
def is_set(self):
|
||||
return bool(self.value)
|
||||
|
||||
class DummyPTZ:
|
||||
def __init__(self):
|
||||
self.autotracker_enabled = _Stub(False)
|
||||
self.motor_stopped = _Stub(False)
|
||||
self.stop_time = _Stub(0)
|
||||
|
||||
self.detector = ImprovedMotionDetector(
|
||||
self.frame_shape, self.config, fps=30, ptz_metrics=DummyPTZ()
|
||||
)
|
||||
|
||||
# establish a baseline frame (all zeros)
|
||||
base_frame = np.zeros(
|
||||
(self.frame_shape[0], self.frame_shape[1]), dtype=np.uint8
|
||||
)
|
||||
self.detector.detect(base_frame)
|
||||
|
||||
def _half_change_frame(self) -> np.ndarray:
|
||||
"""Produce a frame where roughly half of the pixels are different."""
|
||||
frame = np.zeros((self.frame_shape[0], self.frame_shape[1]), dtype=np.uint8)
|
||||
# flip the top half to white
|
||||
frame[: self.frame_shape[0] // 2, :] = 255
|
||||
return frame
|
||||
|
||||
def test_skip_motion_threshold_default(self):
|
||||
"""With the default (None) setting, motion should always be reported."""
|
||||
frame = self._half_change_frame()
|
||||
boxes = self.detector.detect(frame)
|
||||
self.assertTrue(
|
||||
boxes, "Expected motion boxes when skip threshold is unset (disabled)"
|
||||
)
|
||||
|
||||
def test_skip_motion_threshold_applied(self):
|
||||
"""Setting a low skip threshold should prevent any boxes from being returned."""
|
||||
# change the config and update the detector reference
|
||||
self.config.skip_motion_threshold = 0.4
|
||||
self.detector.config = self.config
|
||||
self.detector.update_mask()
|
||||
|
||||
frame = self._half_change_frame()
|
||||
boxes = self.detector.detect(frame)
|
||||
self.assertEqual(
|
||||
boxes,
|
||||
[],
|
||||
"Motion boxes should be empty when scene change exceeds skip threshold",
|
||||
)
|
||||
|
||||
def test_skip_motion_threshold_does_not_affect_calibration(self):
|
||||
"""Even when skipping, the detector should go into calibrating state."""
|
||||
self.config.skip_motion_threshold = 0.4
|
||||
self.detector.config = self.config
|
||||
self.detector.update_mask()
|
||||
|
||||
frame = self._half_change_frame()
|
||||
_ = self.detector.detect(frame)
|
||||
self.assertTrue(
|
||||
self.detector.calibrating,
|
||||
"Detector should be in calibrating state after skip event",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -86,9 +86,7 @@ class TimelineProcessor(threading.Thread):
|
||||
event_data: dict[Any, Any],
|
||||
) -> bool:
|
||||
"""Handle object detection."""
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False
|
||||
camera_config = self.config.cameras[camera]
|
||||
event_id = event_data["id"]
|
||||
|
||||
# Base timeline entry data that all entries will share
|
||||
|
||||
@ -690,13 +690,9 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
self.create_camera_state(camera)
|
||||
elif "remove" in updated_topics:
|
||||
for camera in updated_topics["remove"]:
|
||||
removed_camera_state = self.camera_states[camera]
|
||||
removed_camera_state.shutdown()
|
||||
camera_state = self.camera_states[camera]
|
||||
camera_state.shutdown()
|
||||
self.camera_states.pop(camera)
|
||||
self.camera_activity.pop(camera, None)
|
||||
self.last_motion_detected.pop(camera, None)
|
||||
|
||||
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
|
||||
|
||||
# manage camera disabled state
|
||||
for camera, config in self.config.cameras.items():
|
||||
@ -704,10 +700,6 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
continue
|
||||
|
||||
current_enabled = config.enabled
|
||||
camera_state = self.camera_states.get(camera)
|
||||
if camera_state is None:
|
||||
continue
|
||||
|
||||
camera_state = self.camera_states[camera]
|
||||
|
||||
if camera_state.prev_enabled and not current_enabled:
|
||||
@ -760,11 +752,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
continue
|
||||
|
||||
if not camera_config.enabled:
|
||||
if not self.config.cameras[camera].enabled:
|
||||
logger.debug(f"Camera {camera} disabled, skipping update")
|
||||
continue
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ from frigate.config import (
|
||||
SnapshotsConfig,
|
||||
UIConfig,
|
||||
)
|
||||
from frigate.const import CLIPS_DIR, REPLAY_CAMERA_PREFIX, THUMB_DIR
|
||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||
from frigate.detectors.detector_config import ModelConfig
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.util.builtin import sanitize_float
|
||||
@ -621,9 +621,6 @@ class TrackedObject:
|
||||
if not self.camera_config.name:
|
||||
return
|
||||
|
||||
if self.camera_config.name.startswith(REPLAY_CAMERA_PREFIX):
|
||||
return
|
||||
|
||||
directory = os.path.join(THUMB_DIR, self.camera_config.name)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
|
||||
@ -84,8 +84,7 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
|
||||
"""
|
||||
:param dct1: First dict to merge
|
||||
:param dct2: Second dict to merge
|
||||
:param override: if same key exists in both dictionaries, should override? otherwise ignore.
|
||||
:param merge_lists: if True, lists will be merged.
|
||||
:param override: if same key exists in both dictionaries, should override? otherwise ignore. (default=True)
|
||||
:return: The merge dictionary
|
||||
"""
|
||||
merged = copy.deepcopy(dct1)
|
||||
@ -97,8 +96,6 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
|
||||
elif isinstance(v1, list) and isinstance(v2, list):
|
||||
if merge_lists:
|
||||
merged[k] = v1 + v2
|
||||
elif override:
|
||||
merged[k] = copy.deepcopy(v2)
|
||||
else:
|
||||
if override:
|
||||
merged[k] = copy.deepcopy(v2)
|
||||
|
||||
@ -9,7 +9,6 @@ from typing import Any, Optional, Union
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import CONFIG_DIR, EXPORT_DIR
|
||||
from frigate.util.builtin import deep_merge
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -689,78 +688,3 @@ class StreamInfoRetriever:
|
||||
info = asyncio.run(get_video_properties(ffmpeg, path))
|
||||
self.stream_cache[path] = info
|
||||
return info
|
||||
|
||||
|
||||
def apply_section_update(camera_config, section: str, update: dict) -> Optional[str]:
|
||||
"""Merge an update dict into a camera config section and rebuild runtime variants.
|
||||
|
||||
For motion and object filter sections, the plain Pydantic models are rebuilt
|
||||
as RuntimeMotionConfig / RuntimeFilterConfig so that rasterized numpy masks
|
||||
are recomputed. This mirrors the logic in FrigateConfig.post_validation.
|
||||
|
||||
Args:
|
||||
camera_config: The CameraConfig instance to update.
|
||||
section: Config section name (e.g. "motion", "objects").
|
||||
update: Nested dict of field updates to merge.
|
||||
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
"""
|
||||
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
|
||||
|
||||
current = getattr(camera_config, section, None)
|
||||
if current is None:
|
||||
return f"Section '{section}' not found on camera '{camera_config.name}'"
|
||||
|
||||
try:
|
||||
frame_shape = camera_config.frame_shape
|
||||
|
||||
if section == "motion":
|
||||
merged = deep_merge(
|
||||
current.model_dump(exclude_unset=True, exclude={"rasterized_mask"}),
|
||||
update,
|
||||
override=True,
|
||||
)
|
||||
camera_config.motion = RuntimeMotionConfig(
|
||||
frame_shape=frame_shape, **merged
|
||||
)
|
||||
|
||||
elif section == "objects":
|
||||
merged = deep_merge(
|
||||
current.model_dump(
|
||||
exclude={"filters": {"__all__": {"rasterized_mask"}}}
|
||||
),
|
||||
update,
|
||||
override=True,
|
||||
)
|
||||
new_objects = current.__class__.model_validate(merged)
|
||||
|
||||
# Preserve private _all_objects from original config
|
||||
try:
|
||||
new_objects._all_objects = current._all_objects
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Rebuild RuntimeFilterConfig with merged global + per-object masks
|
||||
for obj_name, filt in new_objects.filters.items():
|
||||
merged_mask = dict(filt.mask)
|
||||
if new_objects.mask:
|
||||
for gid, gmask in new_objects.mask.items():
|
||||
merged_mask[f"global_{gid}"] = gmask
|
||||
|
||||
new_objects.filters[obj_name] = RuntimeFilterConfig(
|
||||
frame_shape=frame_shape,
|
||||
mask=merged_mask,
|
||||
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
|
||||
)
|
||||
camera_config.objects = new_objects
|
||||
|
||||
else:
|
||||
merged = deep_merge(current.model_dump(), update, override=True)
|
||||
setattr(camera_config, section, current.__class__.model_validate(merged))
|
||||
|
||||
except Exception:
|
||||
logger.exception("Config validation error")
|
||||
return "Validation error. Check logs for details."
|
||||
|
||||
return None
|
||||
|
||||
@ -151,9 +151,7 @@ def sync_recordings(
|
||||
|
||||
max_inserts = 1000
|
||||
for batch in chunked(recordings_to_delete, max_inserts):
|
||||
RecordingsToDelete.insert_many(
|
||||
[{"id": r["id"]} for r in batch]
|
||||
).execute()
|
||||
RecordingsToDelete.insert_many(batch).execute()
|
||||
|
||||
try:
|
||||
deleted = (
|
||||
|
||||
@ -110,7 +110,6 @@ def ensure_torch_dependencies() -> bool:
|
||||
"pip",
|
||||
"install",
|
||||
"--break-system-packages",
|
||||
"setuptools<81",
|
||||
"torch",
|
||||
"torchvision",
|
||||
],
|
||||
|
||||
@ -214,11 +214,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
None,
|
||||
{config.name: config},
|
||||
[
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.ffmpeg,
|
||||
CameraConfigUpdateEnum.record,
|
||||
],
|
||||
[CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.record],
|
||||
)
|
||||
self.requestor = InterProcessRequestor()
|
||||
self.was_enabled = self.config.enabled
|
||||
@ -258,13 +254,9 @@ 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._check_config_updates()
|
||||
self.config_subscriber.check_for_updates()
|
||||
return self.config.enabled
|
||||
|
||||
def reset_capture_thread(
|
||||
@ -325,24 +317,7 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
# 1 second watchdog loop
|
||||
while not self.stop_event.wait(1):
|
||||
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
|
||||
enabled = self._update_enabled_state()
|
||||
if enabled != self.was_enabled:
|
||||
if enabled:
|
||||
self.logger.debug(f"Enabling camera {self.config.name}")
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
"""Peewee migrations -- 035_add_motion_heatmap.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.python(func, *args, **kwargs) # Run python code
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
|
||||
"""
|
||||
|
||||
import peewee as pw
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql('ALTER TABLE "recordings" ADD COLUMN "motion_heatmap" TEXT NULL')
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
pass
|
||||
4524
web/package-lock.json
generated
4524
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"postinstall": "patch-package",
|
||||
"build": "tsc && vite build --base=/BASE_PATH/",
|
||||
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .",
|
||||
"lint:fix": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --fix .",
|
||||
@ -28,13 +27,12 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
@ -52,7 +50,8 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^12.35.0",
|
||||
"embla-carousel-react": "^8.2.0",
|
||||
"framer-motion": "^11.5.4",
|
||||
"hls.js": "^1.5.20",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
@ -62,28 +61,30 @@
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.477.0",
|
||||
"monaco-yaml": "^5.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"next-themes": "^0.3.0",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^19.2.4",
|
||||
"react": "^18.3.1",
|
||||
"react-apexcharts": "^1.4.1",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-grid-layout": "^1.5.0",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-konva": "^19.2.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-tracked": "^2.0.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use-websocket": "^4.8.1",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"recoil": "^0.7.7",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sonner": "^1.5.0",
|
||||
"sort-by": "^1.2.0",
|
||||
"strftime": "^0.10.3",
|
||||
"swr": "^2.3.2",
|
||||
@ -91,7 +92,7 @@
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.2.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vaul": "^0.9.1",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@ -100,8 +101,11 @@
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@types/lodash": "^4.17.12",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"@types/react-icons": "^3.0.0",
|
||||
"@types/react-transition-group": "^4.4.10",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
@ -112,26 +116,19 @@
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.8",
|
||||
"eslint-plugin-vitest-globals": "^1.5.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.1.1",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"msw": "^2.3.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^3.0.7"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
diff --git a/node_modules/@radix-ui/react-compose-refs/dist/index.js b/node_modules/@radix-ui/react-compose-refs/dist/index.js
|
||||
index 5ba7a95..65aa7be 100644
|
||||
--- a/node_modules/@radix-ui/react-compose-refs/dist/index.js
|
||||
+++ b/node_modules/@radix-ui/react-compose-refs/dist/index.js
|
||||
@@ -69,6 +69,31 @@ function composeRefs(...refs) {
|
||||
};
|
||||
}
|
||||
function useComposedRefs(...refs) {
|
||||
- return React.useCallback(composeRefs(...refs), refs);
|
||||
+ const refsRef = React.useRef(refs);
|
||||
+ React.useLayoutEffect(() => {
|
||||
+ refsRef.current = refs;
|
||||
+ });
|
||||
+ return React.useCallback((node) => {
|
||||
+ let hasCleanup = false;
|
||||
+ const cleanups = refsRef.current.map((ref) => {
|
||||
+ const cleanup = setRef(ref, node);
|
||||
+ if (!hasCleanup && typeof cleanup === "function") {
|
||||
+ hasCleanup = true;
|
||||
+ }
|
||||
+ return cleanup;
|
||||
+ });
|
||||
+ if (hasCleanup) {
|
||||
+ return () => {
|
||||
+ for (let i = 0; i < cleanups.length; i++) {
|
||||
+ const cleanup = cleanups[i];
|
||||
+ if (typeof cleanup === "function") {
|
||||
+ cleanup();
|
||||
+ } else {
|
||||
+ setRef(refsRef.current[i], null);
|
||||
+ }
|
||||
+ }
|
||||
+ };
|
||||
+ }
|
||||
+ }, []);
|
||||
}
|
||||
//# sourceMappingURL=index.js.map
|
||||
diff --git a/node_modules/@radix-ui/react-compose-refs/dist/index.mjs b/node_modules/@radix-ui/react-compose-refs/dist/index.mjs
|
||||
index 7dd9172..d1b53a5 100644
|
||||
--- a/node_modules/@radix-ui/react-compose-refs/dist/index.mjs
|
||||
+++ b/node_modules/@radix-ui/react-compose-refs/dist/index.mjs
|
||||
@@ -32,7 +32,32 @@ function composeRefs(...refs) {
|
||||
};
|
||||
}
|
||||
function useComposedRefs(...refs) {
|
||||
- return React.useCallback(composeRefs(...refs), refs);
|
||||
+ const refsRef = React.useRef(refs);
|
||||
+ React.useLayoutEffect(() => {
|
||||
+ refsRef.current = refs;
|
||||
+ });
|
||||
+ return React.useCallback((node) => {
|
||||
+ let hasCleanup = false;
|
||||
+ const cleanups = refsRef.current.map((ref) => {
|
||||
+ const cleanup = setRef(ref, node);
|
||||
+ if (!hasCleanup && typeof cleanup === "function") {
|
||||
+ hasCleanup = true;
|
||||
+ }
|
||||
+ return cleanup;
|
||||
+ });
|
||||
+ if (hasCleanup) {
|
||||
+ return () => {
|
||||
+ for (let i = 0; i < cleanups.length; i++) {
|
||||
+ const cleanup = cleanups[i];
|
||||
+ if (typeof cleanup === "function") {
|
||||
+ cleanup();
|
||||
+ } else {
|
||||
+ setRef(refsRef.current[i], null);
|
||||
+ }
|
||||
+ }
|
||||
+ };
|
||||
+ }
|
||||
+ }, []);
|
||||
}
|
||||
export {
|
||||
composeRefs,
|
||||
@ -1,46 +0,0 @@
|
||||
diff --git a/node_modules/@radix-ui/react-slot/dist/index.js b/node_modules/@radix-ui/react-slot/dist/index.js
|
||||
index 3691205..3b62ea8 100644
|
||||
--- a/node_modules/@radix-ui/react-slot/dist/index.js
|
||||
+++ b/node_modules/@radix-ui/react-slot/dist/index.js
|
||||
@@ -85,11 +85,12 @@ function createSlotClone(ownerName) {
|
||||
if (isLazyComponent(children) && typeof use === "function") {
|
||||
children = use(children._payload);
|
||||
}
|
||||
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
|
||||
+ const composedRef = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, childrenRef);
|
||||
if (React.isValidElement(children)) {
|
||||
- const childrenRef = getElementRef(children);
|
||||
const props2 = mergeProps(slotProps, children.props);
|
||||
if (children.type !== React.Fragment) {
|
||||
- props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;
|
||||
+ props2.ref = forwardedRef ? composedRef : childrenRef;
|
||||
}
|
||||
return React.cloneElement(children, props2);
|
||||
}
|
||||
diff --git a/node_modules/@radix-ui/react-slot/dist/index.mjs b/node_modules/@radix-ui/react-slot/dist/index.mjs
|
||||
index d7ea374..a990150 100644
|
||||
--- a/node_modules/@radix-ui/react-slot/dist/index.mjs
|
||||
+++ b/node_modules/@radix-ui/react-slot/dist/index.mjs
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/slot.tsx
|
||||
import * as React from "react";
|
||||
-import { composeRefs } from "@radix-ui/react-compose-refs";
|
||||
+import { composeRefs, useComposedRefs } from "@radix-ui/react-compose-refs";
|
||||
import { Fragment as Fragment2, jsx } from "react/jsx-runtime";
|
||||
var REACT_LAZY_TYPE = Symbol.for("react.lazy");
|
||||
var use = React[" use ".trim().toString()];
|
||||
@@ -45,11 +45,12 @@ function createSlotClone(ownerName) {
|
||||
if (isLazyComponent(children) && typeof use === "function") {
|
||||
children = use(children._payload);
|
||||
}
|
||||
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
|
||||
+ const composedRef = useComposedRefs(forwardedRef, childrenRef);
|
||||
if (React.isValidElement(children)) {
|
||||
- const childrenRef = getElementRef(children);
|
||||
const props2 = mergeProps(slotProps, children.props);
|
||||
if (children.type !== React.Fragment) {
|
||||
- props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
|
||||
+ props2.ref = forwardedRef ? composedRef : childrenRef;
|
||||
}
|
||||
return React.cloneElement(children, props2);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
diff --git a/node_modules/react-use-websocket/dist/lib/use-websocket.js b/node_modules/react-use-websocket/dist/lib/use-websocket.js
|
||||
index f01db48..b30aff2 100644
|
||||
--- a/node_modules/react-use-websocket/dist/lib/use-websocket.js
|
||||
+++ b/node_modules/react-use-websocket/dist/lib/use-websocket.js
|
||||
@@ -139,15 +139,15 @@ var useWebSocket = function (url, options, connect) {
|
||||
}
|
||||
protectedSetLastMessage = function (message) {
|
||||
if (!expectClose_1) {
|
||||
- (0, react_dom_1.flushSync)(function () { return setLastMessage(message); });
|
||||
+ setLastMessage(message);
|
||||
}
|
||||
};
|
||||
protectedSetReadyState = function (state) {
|
||||
if (!expectClose_1) {
|
||||
- (0, react_dom_1.flushSync)(function () { return setReadyState(function (prev) {
|
||||
+ setReadyState(function (prev) {
|
||||
var _a;
|
||||
return (__assign(__assign({}, prev), (convertedUrl.current && (_a = {}, _a[convertedUrl.current] = state, _a))));
|
||||
- }); });
|
||||
+ });
|
||||
}
|
||||
};
|
||||
if (createOrJoin_1) {
|
||||
@ -117,7 +117,6 @@
|
||||
"button": {
|
||||
"add": "Add",
|
||||
"apply": "Apply",
|
||||
"applying": "Applying…",
|
||||
"reset": "Reset",
|
||||
"undo": "Undo",
|
||||
"done": "Done",
|
||||
@ -253,7 +252,6 @@
|
||||
"review": "Review",
|
||||
"explore": "Explore",
|
||||
"export": "Export",
|
||||
"actions": "Actions",
|
||||
"uiPlayground": "UI Playground",
|
||||
"faceLibrary": "Face Library",
|
||||
"classification": "Classification",
|
||||
|
||||
@ -264,11 +264,7 @@
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "Lightning threshold",
|
||||
"description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events."
|
||||
},
|
||||
"skip_motion_threshold": {
|
||||
"label": "Skip motion threshold",
|
||||
"description": "If more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto‑tracking an object. The trade‑off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Range 0.0 to 1.0."
|
||||
"description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0)."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Improve contrast",
|
||||
@ -868,8 +864,7 @@
|
||||
"description": "A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used."
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Enabled",
|
||||
"description": "Enable or disable this zone. Disabled zones are ignored at runtime."
|
||||
"label": "Whether this zone is active. Disabled zones are ignored at runtime."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of zone."
|
||||
|
||||
@ -1391,11 +1391,7 @@
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "Lightning threshold",
|
||||
"description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events."
|
||||
},
|
||||
"skip_motion_threshold": {
|
||||
"label": "Skip motion threshold",
|
||||
"description": "If more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto‑tracking an object. The trade‑off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Range 0.0 to 1.0."
|
||||
"description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0)."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Improve contrast",
|
||||
|
||||
@ -61,25 +61,5 @@
|
||||
"detected": "detected",
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Needs review",
|
||||
"securityConcern": "Security concern",
|
||||
"motionSearch": {
|
||||
"menuItem": "Motion search",
|
||||
"openMenu": "Camera options"
|
||||
},
|
||||
"motionPreviews": {
|
||||
"menuItem": "View motion previews",
|
||||
"title": "Motion previews: {{camera}}",
|
||||
"mobileSettingsTitle": "Motion Preview Settings",
|
||||
"mobileSettingsDesc": "Adjust playback speed and dimming, and choose a date to review motion-only clips.",
|
||||
"dim": "Dim",
|
||||
"dimAria": "Adjust dimming intensity",
|
||||
"dimDesc": "Increase dimming to increase motion area visibility.",
|
||||
"speed": "Speed",
|
||||
"speedAria": "Select preview playback speed",
|
||||
"speedDesc": "Choose how quickly preview clips play.",
|
||||
"back": "Back",
|
||||
"empty": "No previews available",
|
||||
"noPreview": "Preview unavailable",
|
||||
"seekAria": "Seek {{camera}} player to {{time}}"
|
||||
}
|
||||
"securityConcern": "Security concern"
|
||||
}
|
||||
|
||||
@ -216,10 +216,6 @@
|
||||
},
|
||||
"hideObjectDetails": {
|
||||
"label": "Hide object path"
|
||||
},
|
||||
"debugReplay": {
|
||||
"label": "Debug replay",
|
||||
"aria": "View this tracked object in the debug replay view"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
{
|
||||
"documentTitle": "Motion Search - Frigate",
|
||||
"title": "Motion Search",
|
||||
"description": "Draw a polygon to define the region of interest, and specify a time range to search for motion changes within that region.",
|
||||
"selectCamera": "Motion Search is loading",
|
||||
"startSearch": "Start Search",
|
||||
"searchStarted": "Search started",
|
||||
"searchCancelled": "Search cancelled",
|
||||
"cancelSearch": "Cancel",
|
||||
"searching": "Search in progress.",
|
||||
"searchComplete": "Search complete",
|
||||
"noResultsYet": "Run a search to find motion changes in the selected region",
|
||||
"noChangesFound": "No pixel changes detected in the selected region",
|
||||
"changesFound_one": "Found {{count}} motion change",
|
||||
"changesFound_other": "Found {{count}} motion changes",
|
||||
"framesProcessed": "{{count}} frames processed",
|
||||
"jumpToTime": "Jump to this time",
|
||||
"results": "Results",
|
||||
"showSegmentHeatmap": "Heatmap",
|
||||
"newSearch": "New Search",
|
||||
"clearResults": "Clear Results",
|
||||
"clearROI": "Clear polygon",
|
||||
"polygonControls": {
|
||||
"points_one": "{{count}} point",
|
||||
"points_other": "{{count}} points",
|
||||
"undo": "Undo last point",
|
||||
"reset": "Reset polygon"
|
||||
},
|
||||
"motionHeatmapLabel": "Motion Heatmap",
|
||||
"dialog": {
|
||||
"title": "Motion Search",
|
||||
"cameraLabel": "Camera",
|
||||
"previewAlt": "Camera preview for {{camera}}"
|
||||
},
|
||||
"timeRange": {
|
||||
"title": "Search Range",
|
||||
"start": "Start time",
|
||||
"end": "End time"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Search Settings",
|
||||
"parallelMode": "Parallel mode",
|
||||
"parallelModeDesc": "Scan multiple recording segments at the same time (faster, but significantly more CPU intensive)",
|
||||
"threshold": "Sensitivity Threshold",
|
||||
"thresholdDesc": "Lower values detect smaller changes (1-255)",
|
||||
"minArea": "Minimum Change Area",
|
||||
"minAreaDesc": "Minimum percentage of the region of interest that must change to be considered significant",
|
||||
"frameSkip": "Frame Skip",
|
||||
"frameSkipDesc": "Process every Nth frame. Set this to your camera's frame rate to process one frame per second (e.g. 5 for a 5 FPS camera, 30 for a 30 FPS camera). Higher values will be faster, but may miss short motion events.",
|
||||
"maxResults": "Maximum Results",
|
||||
"maxResultsDesc": "Stop after this many matching timestamps"
|
||||
},
|
||||
"errors": {
|
||||
"noCamera": "Please select a camera",
|
||||
"noROI": "Please draw a region of interest",
|
||||
"noTimeRange": "Please select a time range",
|
||||
"invalidTimeRange": "End time must be after start time",
|
||||
"searchFailed": "Search failed: {{message}}",
|
||||
"polygonTooSmall": "Polygon must have at least 3 points",
|
||||
"unknown": "Unknown error"
|
||||
},
|
||||
"changePercentage": "{{percentage}}% changed",
|
||||
"metrics": {
|
||||
"title": "Search Metrics",
|
||||
"segmentsScanned": "Segments scanned",
|
||||
"segmentsProcessed": "Processed",
|
||||
"segmentsSkippedInactive": "Skipped (no activity)",
|
||||
"segmentsSkippedHeatmap": "Skipped (no ROI overlap)",
|
||||
"fallbackFullRange": "Fallback full-range scan",
|
||||
"framesDecoded": "Frames decoded",
|
||||
"wallTime": "Search time",
|
||||
"segmentErrors": "Segment errors",
|
||||
"seconds": "{{seconds}}s"
|
||||
}
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
{
|
||||
"title": "Debug Replay",
|
||||
"description": "Replay camera recordings for debugging. The object list shows a time-delayed summary of detected objects and the Messages tab shows a stream of Frigate's internal messages from the replay footage.",
|
||||
"websocket_messages": "Messages",
|
||||
"dialog": {
|
||||
"title": "Start Debug Replay",
|
||||
"description": "Create a temporary replay camera that loops historical footage for debugging object detection and tracking issues. The replay camera will have the same detection configuration as the source camera. Choose a time range to begin.",
|
||||
"camera": "Source Camera",
|
||||
"timeRange": "Time Range",
|
||||
"preset": {
|
||||
"1m": "Last 1 Minute",
|
||||
"5m": "Last 5 Minutes",
|
||||
"timeline": "From Timeline",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"startButton": "Start Replay",
|
||||
"selectFromTimeline": "Select",
|
||||
"starting": "Starting replay...",
|
||||
"startLabel": "Start",
|
||||
"endLabel": "End",
|
||||
"toast": {
|
||||
"success": "Debug replay started successfully",
|
||||
"error": "Failed to start debug replay: {{error}}",
|
||||
"alreadyActive": "A replay session is already active",
|
||||
"stopped": "Debug replay stopped",
|
||||
"stopError": "Failed to stop debug replay: {{error}}",
|
||||
"goToReplay": "Go to Replay"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"noSession": "No Active Replay Session",
|
||||
"noSessionDesc": "Start a debug replay from the History view by clicking the Debug Replay button in the toolbar.",
|
||||
"goToRecordings": "Go to History",
|
||||
"sourceCamera": "Source Camera",
|
||||
"replayCamera": "Replay Camera",
|
||||
"initializingReplay": "Initializing replay...",
|
||||
"stoppingReplay": "Stopping replay...",
|
||||
"stopReplay": "Stop Replay",
|
||||
"confirmStop": {
|
||||
"title": "Stop Debug Replay?",
|
||||
"description": "This will stop the replay session and clean up all temporary data. Are you sure?",
|
||||
"confirm": "Stop Replay",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"activity": "Activity",
|
||||
"objects": "Object List",
|
||||
"audioDetections": "Audio Detections",
|
||||
"noActivity": "No activity detected",
|
||||
"activeTracking": "Active tracking",
|
||||
"noActiveTracking": "No active tracking",
|
||||
"configuration": "Configuration",
|
||||
"configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file."
|
||||
}
|
||||
}
|
||||
@ -83,8 +83,7 @@
|
||||
"triggers": "Triggers",
|
||||
"debug": "Debug",
|
||||
"frigateplus": "Frigate+",
|
||||
"mediaSync": "Media sync",
|
||||
"regionGrid": "Region grid"
|
||||
"maintenance": "Maintenance"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
@ -1233,16 +1232,6 @@
|
||||
"previews": "Previews",
|
||||
"exports": "Exports",
|
||||
"recordings": "Recordings"
|
||||
},
|
||||
"regionGrid": {
|
||||
"title": "Region Grid",
|
||||
"desc": "The region grid is an optimization that learns where objects of different sizes typically appear in each camera's field of view. Frigate uses this data to efficiently size detection regions. The grid is automatically built over time from tracked object data.",
|
||||
"clear": "Clear region grid",
|
||||
"clearConfirmTitle": "Clear Region Grid",
|
||||
"clearConfirmDesc": "Clearing the region grid is not recommended unless you have recently changed your detector model size or have changed your camera's physical position and are having object tracking issues. The grid will be automatically rebuilt over time as objects are tracked. A Frigate restart is required for changes to take effect.",
|
||||
"clearSuccess": "Region grid cleared successfully",
|
||||
"clearError": "Failed to clear region grid",
|
||||
"restartRequired": "Restart required for region grid changes to take effect"
|
||||
}
|
||||
},
|
||||
"configForm": {
|
||||
@ -1403,7 +1392,6 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": "Settings saved successfully",
|
||||
"applied": "Settings applied successfully",
|
||||
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
|
||||
"error": "Failed to save settings",
|
||||
"validationError": "Validation failed: {{message}}",
|
||||
|
||||
@ -7,39 +7,12 @@
|
||||
"logs": {
|
||||
"frigate": "Frigate Logs - Frigate",
|
||||
"go2rtc": "Go2RTC Logs - Frigate",
|
||||
"nginx": "Nginx Logs - Frigate",
|
||||
"websocket": "Messages Logs - Frigate"
|
||||
"nginx": "Nginx Logs - Frigate"
|
||||
}
|
||||
},
|
||||
"title": "System",
|
||||
"metrics": "System metrics",
|
||||
"logs": {
|
||||
"websocket": {
|
||||
"label": "Messages",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"clear": "Clear",
|
||||
"filter": {
|
||||
"all": "All topics",
|
||||
"topics": "Topics",
|
||||
"events": "Events",
|
||||
"reviews": "Reviews",
|
||||
"classification": "Classification",
|
||||
"face_recognition": "Face Recognition",
|
||||
"lpr": "LPR",
|
||||
"camera_activity": "Camera activity",
|
||||
"system": "System",
|
||||
"camera": "Camera",
|
||||
"all_cameras": "All cameras",
|
||||
"cameras_count_one": "{{count}} Camera",
|
||||
"cameras_count_other": "{{count}} Cameras"
|
||||
},
|
||||
"empty": "No messages captured yet",
|
||||
"count": "{{count}} messages",
|
||||
"expanded": {
|
||||
"payload": "Payload"
|
||||
}
|
||||
},
|
||||
"download": {
|
||||
"label": "Download Logs"
|
||||
},
|
||||
@ -216,8 +189,7 @@
|
||||
"cameraIsOffline": "{{camera}} is offline",
|
||||
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
||||
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
|
||||
"shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB.",
|
||||
"debugReplayActive": "Debug replay session is active"
|
||||
"shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB."
|
||||
},
|
||||
"enrichments": {
|
||||
"title": "Enrichments",
|
||||
|
||||
@ -11,6 +11,7 @@ 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";
|
||||
@ -29,7 +30,6 @@ const Classification = lazy(() => import("@/pages/ClassificationModel"));
|
||||
const Chat = lazy(() => import("@/pages/Chat"));
|
||||
const Logs = lazy(() => import("@/pages/Logs"));
|
||||
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
||||
const Replay = lazy(() => import("@/pages/Replay"));
|
||||
|
||||
function App() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
@ -38,11 +38,13 @@ function App() {
|
||||
|
||||
return (
|
||||
<Providers>
|
||||
<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>
|
||||
</AuthProvider>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@ -82,13 +84,17 @@ function DefaultAppView() {
|
||||
: "bottom-8 left-[52px]",
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
>
|
||||
<Suspense>
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoute requiredRoles={mainRouteRoles} />}>
|
||||
<Route
|
||||
element={
|
||||
mainRouteRoles ? (
|
||||
<ProtectedRoute requiredRoles={mainRouteRoles} />
|
||||
) : (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Route index element={<Live />} />
|
||||
<Route path="/review" element={<Events />} />
|
||||
<Route path="/explore" element={<Explore />} />
|
||||
@ -102,8 +108,7 @@ function DefaultAppView() {
|
||||
<Route path="/faces" element={<FaceLibrary />} />
|
||||
<Route path="/classification" element={<Classification />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/playground" element={<UIPlayground />} />{" "}
|
||||
<Route path="/replay" element={<Replay />} />{" "}
|
||||
<Route path="/playground" element={<UIPlayground />} />
|
||||
</Route>
|
||||
<Route path="/unauthorized" element={<AccessDenied />} />
|
||||
<Route path="*" element={<Redirect to="/" />} />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { baseUrl } from "./baseUrl";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||
import {
|
||||
EmbeddingsReindexProgressType,
|
||||
@ -17,13 +17,6 @@ import { FrigateStats } from "@/types/stats";
|
||||
import { createContainer } from "react-tracked";
|
||||
import useDeepMemo from "@/hooks/use-deep-memo";
|
||||
|
||||
export type WsFeedMessage = {
|
||||
topic: string;
|
||||
payload: unknown;
|
||||
timestamp: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type Update = {
|
||||
topic: string;
|
||||
payload: unknown;
|
||||
@ -36,9 +29,6 @@ type WsState = {
|
||||
|
||||
type useValueReturn = [WsState, (update: Update) => void];
|
||||
|
||||
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
|
||||
let wsMessageIdCounter = 0;
|
||||
|
||||
function useValue(): useValueReturn {
|
||||
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
|
||||
|
||||
@ -53,13 +43,8 @@ function useValue(): useValueReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
let cameraActivity: { [key: string]: Partial<FrigateCameraState> };
|
||||
|
||||
try {
|
||||
cameraActivity = JSON.parse(activityValue);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const cameraActivity: { [key: string]: FrigateCameraState } =
|
||||
JSON.parse(activityValue);
|
||||
|
||||
if (Object.keys(cameraActivity).length === 0) {
|
||||
return;
|
||||
@ -68,12 +53,6 @@ function useValue(): useValueReturn {
|
||||
const cameraStates: WsState = {};
|
||||
|
||||
Object.entries(cameraActivity).forEach(([name, state]) => {
|
||||
const cameraConfig = state?.config;
|
||||
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
record,
|
||||
detect,
|
||||
@ -88,7 +67,7 @@ function useValue(): useValueReturn {
|
||||
detections,
|
||||
object_descriptions,
|
||||
review_descriptions,
|
||||
} = cameraConfig;
|
||||
} = state["config"];
|
||||
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
||||
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
|
||||
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
||||
@ -136,17 +115,6 @@ function useValue(): useValueReturn {
|
||||
...prevState,
|
||||
[data.topic]: data.payload,
|
||||
}));
|
||||
|
||||
// Notify feed subscribers
|
||||
if (wsMessageSubscribers.size > 0) {
|
||||
const feedMsg: WsFeedMessage = {
|
||||
topic: data.topic,
|
||||
payload: data.payload,
|
||||
timestamp: Date.now(),
|
||||
id: String(wsMessageIdCounter++),
|
||||
};
|
||||
wsMessageSubscribers.forEach((cb) => cb(feedMsg));
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpen: () => {
|
||||
@ -772,16 +740,3 @@ export function useJobStatus(
|
||||
|
||||
return { payload: currentJob as Job | null };
|
||||
}
|
||||
|
||||
export function useWsMessageSubscribe(callback: (msg: WsFeedMessage) => void) {
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (msg: WsFeedMessage) => callbackRef.current(msg);
|
||||
wsMessageSubscribers.add(handler);
|
||||
return () => {
|
||||
wsMessageSubscribers.delete(handler);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
export default function ProtectedRoute({
|
||||
requiredRoles,
|
||||
}: {
|
||||
requiredRoles?: string[];
|
||||
requiredRoles: string[];
|
||||
}) {
|
||||
const { auth } = useContext(AuthContext);
|
||||
|
||||
@ -36,13 +36,6 @@ 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" />
|
||||
|
||||
@ -26,8 +26,7 @@ export default function CameraImage({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const cameraConfig = config?.cameras?.[camera];
|
||||
const { name } = cameraConfig ?? { name: camera };
|
||||
const { name } = config ? config.cameras[camera] : "";
|
||||
const { payload: enabledState } = useEnabledState(camera);
|
||||
const enabled = enabledState ? enabledState === "ON" : true;
|
||||
|
||||
@ -35,15 +34,15 @@ export default function CameraImage({
|
||||
useResizeObserver(containerRef);
|
||||
|
||||
const requestHeight = useMemo(() => {
|
||||
if (!cameraConfig || containerHeight == 0) {
|
||||
if (!config || containerHeight == 0) {
|
||||
return 360;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
cameraConfig.detect.height,
|
||||
config.cameras[camera].detect.height,
|
||||
Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)),
|
||||
);
|
||||
}, [cameraConfig, containerHeight]);
|
||||
}, [config, camera, containerHeight]);
|
||||
|
||||
const [isPortraitImage, setIsPortraitImage] = useState(false);
|
||||
|
||||
|
||||
@ -25,7 +25,14 @@ const audio: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["num_threads"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"listen",
|
||||
"filters",
|
||||
"min_volume",
|
||||
"max_not_heard",
|
||||
"num_threads",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["num_threads"],
|
||||
|
||||
@ -28,7 +28,10 @@ const birdseye: SectionConfigOverrides = {
|
||||
"width",
|
||||
"height",
|
||||
"quality",
|
||||
"mode",
|
||||
"layout.scaling_factor",
|
||||
"inactivity_threshold",
|
||||
"layout.max_cameras",
|
||||
"idle_heartbeat_fps",
|
||||
],
|
||||
uiSchema: {
|
||||
|
||||
@ -3,7 +3,7 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const classification: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/custom_classification/object_classification",
|
||||
restartRequired: ["bird.enabled"],
|
||||
restartRequired: ["bird.enabled", "bird.threshold"],
|
||||
hiddenFields: ["custom"],
|
||||
advancedFields: [],
|
||||
},
|
||||
|
||||
@ -30,7 +30,16 @@ const detect: SectionConfigOverrides = {
|
||||
],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||
|
||||
@ -32,7 +32,18 @@ const faceRecognition: SectionConfigOverrides = {
|
||||
"blur_confidence_filter",
|
||||
"device",
|
||||
],
|
||||
restartRequired: ["enabled", "model_size", "device"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"model_size",
|
||||
"unknown_score",
|
||||
"detection_threshold",
|
||||
"recognition_threshold",
|
||||
"min_area",
|
||||
"min_faces",
|
||||
"save_attempts",
|
||||
"blur_confidence_filter",
|
||||
"device",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -116,7 +116,16 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: [
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
fieldOrder: [
|
||||
"hwaccel_args",
|
||||
"path",
|
||||
@ -153,7 +162,17 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
fieldGroups: {
|
||||
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
||||
},
|
||||
restartRequired: [],
|
||||
restartRequired: [
|
||||
"inputs",
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -40,7 +40,21 @@ const lpr: SectionConfigOverrides = {
|
||||
"device",
|
||||
"replace_rules",
|
||||
],
|
||||
restartRequired: ["model_size", "enhancement", "device"],
|
||||
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",
|
||||
],
|
||||
uiSchema: {
|
||||
format: {
|
||||
"ui:options": { size: "md" },
|
||||
|
||||
@ -8,7 +8,6 @@ const motion: SectionConfigOverrides = {
|
||||
"enabled",
|
||||
"threshold",
|
||||
"lightning_threshold",
|
||||
"skip_motion_threshold",
|
||||
"improve_contrast",
|
||||
"contour_area",
|
||||
"delta_alpha",
|
||||
@ -23,7 +22,6 @@ const motion: SectionConfigOverrides = {
|
||||
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
||||
advancedFields: [
|
||||
"lightning_threshold",
|
||||
"skip_motion_threshold",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
@ -31,35 +29,21 @@ const motion: SectionConfigOverrides = {
|
||||
],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["frame_height"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"threshold",
|
||||
"lightning_threshold",
|
||||
"improve_contrast",
|
||||
"contour_area",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
"mqtt_off_delay",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["frame_height"],
|
||||
},
|
||||
replay: {
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"threshold",
|
||||
"contour_area",
|
||||
"lightning_threshold",
|
||||
"improve_contrast",
|
||||
],
|
||||
fieldGroups: {
|
||||
sensitivity: ["threshold", "contour_area"],
|
||||
algorithm: ["improve_contrast"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled",
|
||||
"enabled_in_config",
|
||||
"mask",
|
||||
"raw_mask",
|
||||
"mqtt_off_delay",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
],
|
||||
advancedFields: ["lightning_threshold"],
|
||||
},
|
||||
};
|
||||
|
||||
export default motion;
|
||||
|
||||
@ -83,7 +83,7 @@ const objects: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: ["track", "alert", "detect", "filters", "genai"],
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"mask",
|
||||
@ -99,28 +99,6 @@ const objects: SectionConfigOverrides = {
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
},
|
||||
replay: {
|
||||
restartRequired: [],
|
||||
fieldOrder: ["track", "filters"],
|
||||
fieldGroups: {
|
||||
tracking: ["track"],
|
||||
filtering: ["filters"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"alert",
|
||||
"detect",
|
||||
"mask",
|
||||
"raw_mask",
|
||||
"genai",
|
||||
"genai.enabled_in_config",
|
||||
"filters.*.mask",
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
],
|
||||
advancedFields: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default objects;
|
||||
|
||||
@ -29,7 +29,16 @@ const record: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"expire_interval",
|
||||
"continuous",
|
||||
"motion",
|
||||
"alerts",
|
||||
"detections",
|
||||
"preview",
|
||||
"export",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
|
||||
@ -44,7 +44,7 @@ const review: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: ["alerts", "detections", "genai"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
|
||||
@ -27,7 +27,14 @@ const snapshots: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"bounding_box",
|
||||
"crop",
|
||||
"quality",
|
||||
"timestamp",
|
||||
"retain",
|
||||
],
|
||||
hiddenFields: ["enabled_in_config", "required_zones"],
|
||||
},
|
||||
camera: {
|
||||
|
||||
@ -3,7 +3,14 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const telemetry: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/reference",
|
||||
restartRequired: ["version_check"],
|
||||
restartRequired: [
|
||||
"network_interfaces",
|
||||
"stats.amd_gpu_stats",
|
||||
"stats.intel_gpu_stats",
|
||||
"stats.intel_gpu_device",
|
||||
"stats.network_bandwidth",
|
||||
"version_check",
|
||||
],
|
||||
fieldOrder: ["network_interfaces", "stats", "version_check"],
|
||||
advancedFields: [],
|
||||
},
|
||||
|
||||
@ -4,5 +4,4 @@ export type SectionConfigOverrides = {
|
||||
base?: SectionConfig;
|
||||
global?: Partial<SectionConfig>;
|
||||
camera?: Partial<SectionConfig>;
|
||||
replay?: Partial<SectionConfig>;
|
||||
};
|
||||
|
||||
@ -56,7 +56,6 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import {
|
||||
cameraUpdateTopicMap,
|
||||
globalCameraDefaultSections,
|
||||
buildOverrides,
|
||||
buildConfigDataForPath,
|
||||
sanitizeSectionData as sharedSanitizeSectionData,
|
||||
@ -96,9 +95,9 @@ export interface SectionConfig {
|
||||
}
|
||||
|
||||
export interface BaseSectionProps {
|
||||
/** Whether this is at global, camera, or replay level */
|
||||
level: "global" | "camera" | "replay";
|
||||
/** Camera name (required if level is "camera" or "replay") */
|
||||
/** Whether this is at global or camera level */
|
||||
level: "global" | "camera";
|
||||
/** Camera name (required if level is "camera") */
|
||||
cameraName?: string;
|
||||
/** Whether to show override indicator badge */
|
||||
showOverrideIndicator?: boolean;
|
||||
@ -118,10 +117,6 @@ export interface BaseSectionProps {
|
||||
defaultCollapsed?: boolean;
|
||||
/** Whether to show the section title (default: false for global, true for camera) */
|
||||
showTitle?: boolean;
|
||||
/** If true, apply config in-memory only without writing to YAML */
|
||||
skipSave?: boolean;
|
||||
/** If true, buttons are not sticky at the bottom */
|
||||
noStickyButtons?: boolean;
|
||||
/** Callback when section status changes */
|
||||
onStatusChange?: (status: {
|
||||
hasChanges: boolean;
|
||||
@ -161,16 +156,12 @@ export function ConfigSection({
|
||||
collapsible = false,
|
||||
defaultCollapsed = true,
|
||||
showTitle,
|
||||
skipSave = false,
|
||||
noStickyButtons = false,
|
||||
onStatusChange,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
}: ConfigSectionProps) {
|
||||
// For replay level, treat as camera-level config access
|
||||
const effectiveLevel = level === "replay" ? "camera" : level;
|
||||
const { t, i18n } = useTranslation([
|
||||
effectiveLevel === "camera" ? "config/cameras" : "config/global",
|
||||
level === "camera" ? "config/cameras" : "config/global",
|
||||
"config/cameras",
|
||||
"views/settings",
|
||||
"common",
|
||||
@ -183,10 +174,10 @@ export function ConfigSection({
|
||||
// Create a key for this section's pending data
|
||||
const pendingDataKey = useMemo(
|
||||
() =>
|
||||
effectiveLevel === "camera" && cameraName
|
||||
level === "camera" && cameraName
|
||||
? `${cameraName}::${sectionPath}`
|
||||
: sectionPath,
|
||||
[effectiveLevel, cameraName, sectionPath],
|
||||
[level, cameraName, sectionPath],
|
||||
);
|
||||
|
||||
// Use pending data from parent if available, otherwise use local state
|
||||
@ -231,23 +222,20 @@ export function ConfigSection({
|
||||
const lastPendingDataKeyRef = useRef<string | null>(null);
|
||||
|
||||
const updateTopic =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
level === "camera" && cameraName
|
||||
? cameraUpdateTopicMap[sectionPath]
|
||||
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
||||
: undefined
|
||||
: globalCameraDefaultSections.has(sectionPath) &&
|
||||
cameraUpdateTopicMap[sectionPath]
|
||||
? `config/cameras/*/${cameraUpdateTopicMap[sectionPath]}`
|
||||
: `config/${sectionPath}`;
|
||||
: `config/${sectionPath}`;
|
||||
// Default: show title for camera level (since it might be collapsible), hide for global
|
||||
const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
|
||||
const shouldShowTitle = showTitle ?? level === "camera";
|
||||
|
||||
// Fetch config
|
||||
const { data: config, mutate: refreshConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
// Get section schema using cached hook
|
||||
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
|
||||
const sectionSchema = useSectionSchema(sectionPath, level);
|
||||
|
||||
// Apply special case handling for sections with problematic schema defaults
|
||||
const modifiedSchema = useMemo(
|
||||
@ -259,7 +247,7 @@ export function ConfigSection({
|
||||
// Get override status
|
||||
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
|
||||
config,
|
||||
cameraName: effectiveLevel === "camera" ? cameraName : undefined,
|
||||
cameraName: level === "camera" ? cameraName : undefined,
|
||||
sectionPath,
|
||||
compareFields: sectionConfig.overrideFields,
|
||||
});
|
||||
@ -268,12 +256,12 @@ export function ConfigSection({
|
||||
const rawSectionValue = useMemo(() => {
|
||||
if (!config) return undefined;
|
||||
|
||||
if (effectiveLevel === "camera" && cameraName) {
|
||||
if (level === "camera" && cameraName) {
|
||||
return get(config.cameras?.[cameraName], sectionPath);
|
||||
}
|
||||
|
||||
return get(config, sectionPath);
|
||||
}, [config, cameraName, sectionPath, effectiveLevel]);
|
||||
}, [config, level, cameraName, sectionPath]);
|
||||
|
||||
const rawFormData = useMemo(() => {
|
||||
if (!config) return {};
|
||||
@ -340,10 +328,9 @@ export function ConfigSection({
|
||||
[rawFormData, sanitizeSectionData],
|
||||
);
|
||||
|
||||
// Clear pendingData whenever the section/camera key changes (e.g., switching
|
||||
// cameras) or when there is no pending data yet (initialization).
|
||||
// This prevents RJSF's initial onChange call from being treated as a user edit.
|
||||
// Only clear if pendingData is managed locally (not by parent).
|
||||
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
||||
// This prevents RJSF's initial onChange call from being treated as a user edit
|
||||
// Only clear if pendingData is managed locally (not by parent)
|
||||
useEffect(() => {
|
||||
const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey;
|
||||
|
||||
@ -352,16 +339,15 @@ export function ConfigSection({
|
||||
isInitializingRef.current = true;
|
||||
setPendingOverrides(undefined);
|
||||
setDirtyOverrides(undefined);
|
||||
|
||||
// Reset local pending data when switching sections/cameras
|
||||
if (onPendingDataChange === undefined) {
|
||||
setPendingData(null);
|
||||
}
|
||||
} else if (!pendingData) {
|
||||
isInitializingRef.current = true;
|
||||
setPendingOverrides(undefined);
|
||||
setDirtyOverrides(undefined);
|
||||
}
|
||||
|
||||
if (onPendingDataChange === undefined) {
|
||||
setPendingData(null);
|
||||
}
|
||||
}, [
|
||||
onPendingDataChange,
|
||||
pendingData,
|
||||
@ -498,7 +484,7 @@ export function ConfigSection({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const basePath =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
level === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
const rawData = sanitizeSectionData(rawFormData);
|
||||
@ -509,7 +495,7 @@ export function ConfigSection({
|
||||
);
|
||||
const sanitizedOverrides = sanitizeOverridesForSection(
|
||||
sectionPath,
|
||||
effectiveLevel,
|
||||
level,
|
||||
overrides,
|
||||
);
|
||||
|
||||
@ -522,26 +508,16 @@ export function ConfigSection({
|
||||
return;
|
||||
}
|
||||
|
||||
const needsRestart = skipSave
|
||||
? false
|
||||
: requiresRestartForOverrides(sanitizedOverrides);
|
||||
const needsRestart = requiresRestartForOverrides(sanitizedOverrides);
|
||||
|
||||
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
|
||||
await axios.put("config/set", {
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
config_data: configData,
|
||||
...(skipSave ? { skip_save: true } : {}),
|
||||
});
|
||||
|
||||
if (skipSave) {
|
||||
toast.success(
|
||||
t("toast.applied", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Settings applied successfully",
|
||||
}),
|
||||
);
|
||||
} else if (needsRestart) {
|
||||
if (needsRestart) {
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter", {
|
||||
@ -620,7 +596,7 @@ export function ConfigSection({
|
||||
}, [
|
||||
sectionPath,
|
||||
pendingData,
|
||||
effectiveLevel,
|
||||
level,
|
||||
cameraName,
|
||||
t,
|
||||
refreshConfig,
|
||||
@ -632,16 +608,15 @@ export function ConfigSection({
|
||||
updateTopic,
|
||||
setPendingData,
|
||||
requiresRestartForOverrides,
|
||||
skipSave,
|
||||
]);
|
||||
|
||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||
const handleResetToGlobal = useCallback(async () => {
|
||||
if (effectiveLevel === "camera" && !cameraName) return;
|
||||
if (level === "camera" && !cameraName) return;
|
||||
|
||||
try {
|
||||
const basePath =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
level === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
|
||||
@ -657,7 +632,7 @@ export function ConfigSection({
|
||||
t("toast.resetSuccess", {
|
||||
ns: "views/settings",
|
||||
defaultValue:
|
||||
effectiveLevel === "global"
|
||||
level === "global"
|
||||
? "Reset to defaults"
|
||||
: "Reset to global defaults",
|
||||
}),
|
||||
@ -676,7 +651,7 @@ export function ConfigSection({
|
||||
}
|
||||
}, [
|
||||
sectionPath,
|
||||
effectiveLevel,
|
||||
level,
|
||||
cameraName,
|
||||
requiresRestart,
|
||||
t,
|
||||
@ -686,8 +661,8 @@ export function ConfigSection({
|
||||
]);
|
||||
|
||||
const sectionValidation = useMemo(
|
||||
() => getSectionValidation({ sectionPath, level: effectiveLevel, t }),
|
||||
[sectionPath, effectiveLevel, t],
|
||||
() => getSectionValidation({ sectionPath, level, t }),
|
||||
[sectionPath, level, t],
|
||||
);
|
||||
|
||||
const customValidate = useMemo(() => {
|
||||
@ -758,7 +733,7 @@ export function ConfigSection({
|
||||
// nested under the section name (e.g., `audio.label`). For global-level
|
||||
// sections, keys are nested under the section name in `config/global`.
|
||||
const configNamespace =
|
||||
effectiveLevel === "camera" ? "config/cameras" : "config/global";
|
||||
level === "camera" ? "config/cameras" : "config/global";
|
||||
const title = t(`${sectionPath}.label`, {
|
||||
ns: configNamespace,
|
||||
defaultValue: defaultTitle,
|
||||
@ -794,7 +769,7 @@ export function ConfigSection({
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
level,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
@ -809,7 +784,7 @@ export function ConfigSection({
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
level === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
@ -829,12 +804,7 @@ export function ConfigSection({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-full border-t border-secondary bg-background pt-0",
|
||||
!noStickyButtons && "sticky bottom-0 z-50",
|
||||
)}
|
||||
>
|
||||
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-4 pt-2 md:flex-row",
|
||||
@ -852,17 +822,15 @@ export function ConfigSection({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||
{((effectiveLevel === "camera" && isOverridden) ||
|
||||
effectiveLevel === "global") &&
|
||||
!hasChanges &&
|
||||
!skipSave && (
|
||||
{((level === "camera" && isOverridden) || level === "global") &&
|
||||
!hasChanges && (
|
||||
<Button
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
variant="outline"
|
||||
disabled={isSaving || disabled}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
{effectiveLevel === "global"
|
||||
{level === "global"
|
||||
? t("button.resetToDefault", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Default",
|
||||
@ -894,18 +862,11 @@ export function ConfigSection({
|
||||
{isSaving ? (
|
||||
<>
|
||||
<ActivityIndicator className="h-4 w-4" />
|
||||
{skipSave
|
||||
? t("button.applying", {
|
||||
ns: "common",
|
||||
defaultValue: "Applying...",
|
||||
})
|
||||
: t("button.saving", {
|
||||
ns: "common",
|
||||
defaultValue: "Saving...",
|
||||
})}
|
||||
{t("button.saving", {
|
||||
ns: "common",
|
||||
defaultValue: "Saving...",
|
||||
})}
|
||||
</>
|
||||
) : skipSave ? (
|
||||
t("button.apply", { ns: "common", defaultValue: "Apply" })
|
||||
) : (
|
||||
t("button.save", { ns: "common", defaultValue: "Save" })
|
||||
)}
|
||||
@ -937,7 +898,7 @@ export function ConfigSection({
|
||||
setIsResetDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
{effectiveLevel === "global"
|
||||
{level === "global"
|
||||
? t("button.resetToDefault", { ns: "common" })
|
||||
: t("button.resetToGlobal", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
@ -962,7 +923,7 @@ export function ConfigSection({
|
||||
)}
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
level === "camera" &&
|
||||
isOverridden && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("button.overridden", {
|
||||
@ -1006,7 +967,7 @@ export function ConfigSection({
|
||||
<div className="flex items-center gap-3">
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
level === "camera" &&
|
||||
isOverridden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
||||
@ -114,17 +114,10 @@ interface PropertyElement {
|
||||
content: React.ReactElement;
|
||||
}
|
||||
|
||||
/** Shape of the props that RJSF injects into each property element. */
|
||||
interface RjsfElementProps {
|
||||
schema?: { type?: string | string[] };
|
||||
uiSchema?: Record<string, unknown> & {
|
||||
"ui:widget"?: string;
|
||||
"ui:options"?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function isObjectLikeElement(item: PropertyElement) {
|
||||
const fieldSchema = (item.content.props as RjsfElementProps)?.schema;
|
||||
const fieldSchema = item.content.props?.schema as
|
||||
| { type?: string | string[] }
|
||||
| undefined;
|
||||
return fieldSchema?.type === "object";
|
||||
}
|
||||
|
||||
@ -170,21 +163,16 @@ function GridLayoutObjectFieldTemplate(
|
||||
|
||||
// Override the properties rendering with grid layout
|
||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] ===
|
||||
"hidden";
|
||||
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
|
||||
|
||||
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
|
||||
|
||||
// Separate regular and advanced properties
|
||||
const advancedProps = visibleProps.filter(
|
||||
(p) =>
|
||||
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
|
||||
?.advanced === true,
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
|
||||
);
|
||||
const regularProps = visibleProps.filter(
|
||||
(p) =>
|
||||
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
|
||||
?.advanced !== true,
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
||||
);
|
||||
const hasModifiedAdvanced = advancedProps.some((prop) =>
|
||||
isPathModified([...fieldPath, prop.name]),
|
||||
|
||||
@ -448,9 +448,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const errorsProps = errors?.props as { errors?: unknown[] } | undefined;
|
||||
const hasFieldErrors = !!errors && (errorsProps?.errors?.length ?? 0) > 0;
|
||||
|
||||
const renderStandardLabel = () => {
|
||||
if (!shouldRenderStandardLabel) {
|
||||
return null;
|
||||
@ -462,7 +459,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
hasFieldErrors && "text-destructive",
|
||||
errors && errors.props?.errors?.length > 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{finalLabel}
|
||||
@ -500,7 +497,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
hasFieldErrors && "text-destructive",
|
||||
errors && errors.props?.errors?.length > 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{finalLabel}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
|
||||
// Renders simple nullable types as single inputs instead of dropdowns
|
||||
|
||||
import type { JSX } from "react";
|
||||
import {
|
||||
MultiSchemaFieldTemplateProps,
|
||||
StrictRJSFSchema,
|
||||
|
||||
@ -25,15 +25,6 @@ import {
|
||||
import get from "lodash/get";
|
||||
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||
|
||||
/** Shape of the props that RJSF injects into each property element. */
|
||||
interface RjsfElementProps {
|
||||
schema?: { type?: string | string[] };
|
||||
uiSchema?: Record<string, unknown> & {
|
||||
"ui:widget"?: string;
|
||||
"ui:options"?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
const {
|
||||
title,
|
||||
@ -191,21 +182,16 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
uiSchema?.["ui:options"]?.disableNestedCard === true;
|
||||
|
||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] ===
|
||||
"hidden";
|
||||
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
|
||||
|
||||
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
|
||||
|
||||
// Check for advanced section grouping
|
||||
const advancedProps = visibleProps.filter(
|
||||
(p) =>
|
||||
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
|
||||
?.advanced === true,
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
|
||||
);
|
||||
const regularProps = visibleProps.filter(
|
||||
(p) =>
|
||||
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
|
||||
?.advanced !== true,
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
||||
);
|
||||
const hasModifiedAdvanced = advancedProps.some((prop) =>
|
||||
checkSubtreeModified([...fieldPath, prop.name]),
|
||||
@ -347,7 +333,9 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
|
||||
const ungrouped = items.filter((item) => !grouped.has(item.name));
|
||||
const isObjectLikeField = (item: (typeof properties)[number]) => {
|
||||
const fieldSchema = (item.content.props as RjsfElementProps)?.schema;
|
||||
const fieldSchema = item.content.props.schema as
|
||||
| { type?: string | string[] }
|
||||
| undefined;
|
||||
return fieldSchema?.type === "object";
|
||||
};
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ export function AudioLabelSwitchesWidget(props: WidgetProps) {
|
||||
getDisplayLabel: getAudioLabelDisplayName,
|
||||
i18nKey: "audioLabels",
|
||||
listClassName:
|
||||
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
enableSearch: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -94,7 +94,7 @@ export function ObjectLabelSwitchesWidget(props: WidgetProps) {
|
||||
getDisplayLabel: getObjectLabelDisplayName,
|
||||
i18nKey: "objectLabels",
|
||||
listClassName:
|
||||
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -42,7 +42,7 @@ export function ZoneSwitchesWidget(props: WidgetProps) {
|
||||
getEntities: getZoneNames,
|
||||
getDisplayLabel: getZoneDisplayName,
|
||||
i18nKey: "zoneNames",
|
||||
listClassName: "relative max-h-64 overflow-y-auto scrollbar-container",
|
||||
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user