mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 02:29:19 +03:00
Compare commits
12 Commits
e0e16bb06a
...
13750c535a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13750c535a | ||
|
|
c2e667c0dd | ||
|
|
c9bd907721 | ||
|
|
34cc1208a6 | ||
|
|
2babfd2ec9 | ||
|
|
229436c94a | ||
|
|
02678f4a09 | ||
|
|
65db9b0aec | ||
|
|
2782931c72 | ||
|
|
b2118382cb | ||
|
|
9fe9345263 | ||
|
|
7f172ca2c6 |
@ -38,7 +38,6 @@ 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.
|
||||
@ -53,7 +52,6 @@ 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
|
||||
@ -81,27 +79,49 @@ 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 be re-calibrated. 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 recalibrate. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed.
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
### Skip Motion On Large Scene Changes
|
||||
|
||||
Lightning threshold does not stop motion based recordings from being saved.
|
||||
```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.
|
||||
|
||||
:::
|
||||
|
||||
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,12 +480,16 @@ 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. (default: shown below)
|
||||
# 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)
|
||||
# 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.
|
||||
|
||||
@ -589,23 +589,38 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
request.app.frigate_config = config
|
||||
request.app.genai_manager.update_config(config)
|
||||
|
||||
if request.app.stats_emitter is not None:
|
||||
request.app.stats_emitter.config = config
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
|
||||
if field == "add":
|
||||
settings = config.cameras[camera]
|
||||
elif field == "remove":
|
||||
settings = old_config.cameras[camera]
|
||||
if camera == "*":
|
||||
# Wildcard: fan out update to all cameras
|
||||
enum_value = CameraConfigUpdateEnum[field]
|
||||
for camera_name in config.cameras:
|
||||
settings = config.get_nested_object(
|
||||
f"config/cameras/{camera_name}/{field}"
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(enum_value, camera_name),
|
||||
settings,
|
||||
)
|
||||
else:
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
if field == "add":
|
||||
settings = config.cameras[camera]
|
||||
elif field == "remove":
|
||||
settings = old_config.cameras[camera]
|
||||
else:
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum[field], camera
|
||||
),
|
||||
settings,
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum[field], camera
|
||||
),
|
||||
settings,
|
||||
)
|
||||
else:
|
||||
# Generic handling for global config updates
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
|
||||
@ -32,6 +32,12 @@ 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():
|
||||
"""
|
||||
@ -284,6 +290,15 @@ 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
|
||||
@ -744,10 +759,30 @@ def profile(request: Request):
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
|
||||
return JSONResponse(
|
||||
response = 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",
|
||||
|
||||
@ -11,6 +11,7 @@ class Tags(Enum):
|
||||
classification = "Classification"
|
||||
logs = "Logs"
|
||||
media = "Media"
|
||||
motion_search = "Motion Search"
|
||||
notifications = "Notifications"
|
||||
preview = "Preview"
|
||||
recordings = "Recordings"
|
||||
|
||||
@ -22,6 +22,7 @@ from frigate.api import (
|
||||
event,
|
||||
export,
|
||||
media,
|
||||
motion_search,
|
||||
notification,
|
||||
preview,
|
||||
record,
|
||||
@ -135,6 +136,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
|
||||
|
||||
@ -24,6 +24,7 @@ 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,
|
||||
@ -1005,6 +1006,23 @@ 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)],
|
||||
@ -1263,6 +1281,13 @@ def preview_gif(
|
||||
else:
|
||||
# need to generate from existing images
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
if not os.path.isdir(preview_dir):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Preview not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
file_start = f"preview_{camera_name}"
|
||||
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
@ -1438,6 +1463,13 @@ def preview_mp4(
|
||||
else:
|
||||
# need to generate from existing images
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
if not os.path.isdir(preview_dir):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Preview not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
file_start = f"preview_{camera_name}"
|
||||
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
|
||||
292
frigate/api/motion_search.py
Normal file
292
frigate/api/motion_search.py
Normal file
@ -0,0 +1,292 @@
|
||||
"""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,6 +261,7 @@ async def recordings(
|
||||
Recordings.segment_size,
|
||||
Recordings.motion,
|
||||
Recordings.objects,
|
||||
Recordings.motion_heatmap,
|
||||
Recordings.duration,
|
||||
)
|
||||
.where(
|
||||
|
||||
@ -51,6 +51,7 @@ 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,
|
||||
@ -599,6 +600,9 @@ 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
|
||||
|
||||
@ -236,6 +236,7 @@ class AudioActivityManager:
|
||||
None,
|
||||
"audio",
|
||||
{},
|
||||
None,
|
||||
),
|
||||
EventMetadataTypeEnum.manual_event_create.value,
|
||||
)
|
||||
|
||||
@ -242,6 +242,14 @@ class CameraConfig(FrigateBaseModel):
|
||||
def create_ffmpeg_cmds(self):
|
||||
if "_ffmpeg_cmds" in self:
|
||||
return
|
||||
self._build_ffmpeg_cmds()
|
||||
|
||||
def recreate_ffmpeg_cmds(self):
|
||||
"""Force regeneration of ffmpeg commands from current config."""
|
||||
self._build_ffmpeg_cmds()
|
||||
|
||||
def _build_ffmpeg_cmds(self):
|
||||
"""Build ffmpeg commands from the current ffmpeg config."""
|
||||
ffmpeg_cmds = []
|
||||
for ffmpeg_input in self.ffmpeg.inputs:
|
||||
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
||||
|
||||
@ -24,10 +24,17 @@ 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).",
|
||||
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.",
|
||||
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,6 +17,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
birdseye = "birdseye"
|
||||
detect = "detect"
|
||||
enabled = "enabled"
|
||||
ffmpeg = "ffmpeg"
|
||||
motion = "motion" # includes motion and motion masks
|
||||
notifications = "notifications"
|
||||
objects = "objects"
|
||||
@ -91,6 +92,9 @@ class CameraConfigUpdateSubscriber:
|
||||
|
||||
if update_type == CameraConfigUpdateEnum.audio:
|
||||
config.audio = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.ffmpeg:
|
||||
config.ffmpeg = updated_config
|
||||
config.recreate_ffmpeg_cmds()
|
||||
elif update_type == CameraConfigUpdateEnum.audio_transcription:
|
||||
config.audio_transcription = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.birdseye:
|
||||
|
||||
@ -12,6 +12,7 @@ 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,
|
||||
@ -47,6 +48,11 @@ 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,6 +19,7 @@ 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,
|
||||
@ -95,6 +96,11 @@ 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,6 +8,7 @@ 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,
|
||||
)
|
||||
@ -40,6 +41,11 @@ 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],
|
||||
|
||||
@ -99,6 +99,13 @@ 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(
|
||||
@ -273,6 +280,9 @@ 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()
|
||||
@ -284,6 +294,9 @@ 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()
|
||||
@ -356,6 +369,62 @@ 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"""
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""Ollama Provider for Frigate AI."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
@ -108,7 +109,22 @@ class OllamaClient(GenAIClient):
|
||||
if msg.get("name"):
|
||||
msg_dict["name"] = msg["name"]
|
||||
if msg.get("tool_calls"):
|
||||
msg_dict["tool_calls"] = msg["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
|
||||
request_messages.append(msg_dict)
|
||||
|
||||
request_params: dict[str, Any] = {
|
||||
@ -120,25 +136,27 @@ 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"
|
||||
@ -198,7 +216,13 @@ 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."""
|
||||
"""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.
|
||||
"""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
"Ollama provider has not been initialized. Check your Ollama configuration."
|
||||
@ -213,6 +237,27 @@ 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
|
||||
)
|
||||
@ -222,27 +267,23 @@ class OllamaClient(GenAIClient):
|
||||
)
|
||||
content_parts: list[str] = []
|
||||
final_message: dict[str, Any] | None = None
|
||||
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()
|
||||
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
|
||||
|
||||
if final_message is not None:
|
||||
yield ("message", final_message)
|
||||
|
||||
@ -23,21 +23,26 @@ def parse_tool_calls_from_message(
|
||||
if not raw or not isinstance(raw, list):
|
||||
return None
|
||||
result = []
|
||||
for tool_call in raw:
|
||||
for idx, tool_call in enumerate(raw):
|
||||
function_data = tool_call.get("function") or {}
|
||||
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"),
|
||||
)
|
||||
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:
|
||||
arguments = {}
|
||||
result.append(
|
||||
{
|
||||
"id": tool_call.get("id", ""),
|
||||
"id": tool_call.get("id", "") or f"call_{idx}",
|
||||
"name": function_data.get("name", ""),
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
864
frigate/jobs/motion_search.py
Normal file
864
frigate/jobs/motion_search.py
Normal file
@ -0,0 +1,864 @@
|
||||
"""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,6 +78,7 @@ 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,11 +176,32 @@ 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
|
||||
# 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 self.calibrating or pct_motion > self.config.lightning_threshold:
|
||||
self.calibrating = True
|
||||
|
||||
|
||||
@ -273,17 +273,13 @@ class BirdsEyeFrameManager:
|
||||
stop_event: mp.Event,
|
||||
):
|
||||
self.config = config
|
||||
self.mode = config.birdseye.mode
|
||||
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
|
||||
self.frame_shape = (height, width)
|
||||
self.yuv_shape = (height * 3 // 2, width)
|
||||
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
||||
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
|
||||
self.stop_event = stop_event
|
||||
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
||||
|
||||
if config.birdseye.layout.max_cameras:
|
||||
self.last_refresh_time = 0
|
||||
self.last_refresh_time = 0
|
||||
|
||||
# initialize the frame as black and with the Frigate logo
|
||||
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
|
||||
@ -426,7 +422,7 @@ class BirdsEyeFrameManager:
|
||||
and self.config.cameras[cam].enabled
|
||||
and cam_data["last_active_frame"] > 0
|
||||
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
||||
< self.inactivity_threshold
|
||||
< self.config.birdseye.inactivity_threshold
|
||||
]
|
||||
)
|
||||
logger.debug(f"Active cameras: {active_cameras}")
|
||||
|
||||
@ -15,6 +15,7 @@ from ws4py.server.wsgirefserver import (
|
||||
)
|
||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
||||
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||
from frigate.comms.ws import WebSocket
|
||||
from frigate.config import FrigateConfig
|
||||
@ -138,6 +139,7 @@ class OutputProcess(FrigateProcess):
|
||||
CameraConfigUpdateEnum.record,
|
||||
],
|
||||
)
|
||||
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
||||
|
||||
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
||||
birdseye: Birdseye | None = None
|
||||
@ -167,6 +169,20 @@ class OutputProcess(FrigateProcess):
|
||||
websocket_thread.start()
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
update_topic, birdseye_config = (
|
||||
birdseye_config_subscriber.check_for_update()
|
||||
)
|
||||
|
||||
if update_topic is not None:
|
||||
previous_global_mode = self.config.birdseye.mode
|
||||
self.config.birdseye = birdseye_config
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if camera_config.birdseye.mode == previous_global_mode:
|
||||
camera_config.birdseye.mode = birdseye_config.mode
|
||||
|
||||
logger.debug("Applied dynamic birdseye config update")
|
||||
|
||||
# check if there is an updated config
|
||||
updates = config_subscriber.check_for_updates()
|
||||
|
||||
@ -297,6 +313,7 @@ class OutputProcess(FrigateProcess):
|
||||
birdseye.stop()
|
||||
|
||||
config_subscriber.stop()
|
||||
birdseye_config_subscriber.stop()
|
||||
websocket_server.manager.close_all()
|
||||
websocket_server.manager.stop()
|
||||
websocket_server.manager.join()
|
||||
|
||||
@ -50,11 +50,13 @@ 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
|
||||
@ -454,6 +456,59 @@ 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:
|
||||
@ -461,6 +516,8 @@ 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():
|
||||
@ -479,6 +536,8 @@ 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]:
|
||||
@ -498,8 +557,14 @@ 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_count,
|
||||
active_count,
|
||||
region_count,
|
||||
round(average_dBFS),
|
||||
motion_heatmap,
|
||||
)
|
||||
|
||||
async def move_segment(
|
||||
@ -590,6 +655,7 @@ 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}")
|
||||
|
||||
261
frigate/test/http_api/test_http_config_set.py
Normal file
261
frigate/test/http_api/test_http_config_set.py
Normal file
@ -0,0 +1,261 @@
|
||||
"""Tests for the config_set endpoint's wildcard camera propagation."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ruamel.yaml
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdatePublisher,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
"""Test that wildcard camera updates fan out to all cameras."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
},
|
||||
"back_yard": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 720,
|
||||
"width": 1280,
|
||||
"fps": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _create_app_with_publisher(self):
|
||||
"""Create app with a mocked config publisher."""
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
mock_publisher.publisher = MagicMock()
|
||||
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
mock_publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
return {"username": username, "role": role}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return list(self.minimal_config.get("cameras", {}).keys())
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
return app, mock_publisher
|
||||
|
||||
def _write_config_file(self):
|
||||
"""Write the minimal config to a temp YAML file and return the path."""
|
||||
yaml = ruamel.yaml.YAML()
|
||||
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
||||
yaml.dump(self.minimal_config, f)
|
||||
f.close()
|
||||
return f.name
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_wildcard_detect_update_fans_out_to_all_cameras(self, mock_find_config):
|
||||
"""config/cameras/*/detect fans out to all cameras."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"detect": {"fps": 15}},
|
||||
"update_topic": "config/cameras/*/detect",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
# Verify publish_update called for each camera
|
||||
self.assertEqual(mock_publisher.publish_update.call_count, 2)
|
||||
|
||||
published_cameras = set()
|
||||
for c in mock_publisher.publish_update.call_args_list:
|
||||
topic = c[0][0]
|
||||
self.assertIsInstance(topic, CameraConfigUpdateTopic)
|
||||
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
|
||||
published_cameras.add(topic.camera)
|
||||
|
||||
self.assertEqual(published_cameras, {"front_door", "back_yard"})
|
||||
|
||||
# Global publisher should NOT be called for wildcard
|
||||
mock_publisher.publisher.publish.assert_not_called()
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_wildcard_motion_update_fans_out(self, mock_find_config):
|
||||
"""config/cameras/*/motion fans out to all cameras."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"motion": {"threshold": 30}},
|
||||
"update_topic": "config/cameras/*/motion",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
published_cameras = set()
|
||||
for c in mock_publisher.publish_update.call_args_list:
|
||||
topic = c[0][0]
|
||||
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.motion)
|
||||
published_cameras.add(topic.camera)
|
||||
|
||||
self.assertEqual(published_cameras, {"front_door", "back_yard"})
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_camera_specific_topic_only_updates_one_camera(self, mock_find_config):
|
||||
"""config/cameras/front_door/detect only updates front_door."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {
|
||||
"cameras": {"front_door": {"detect": {"fps": 20}}}
|
||||
},
|
||||
"update_topic": "config/cameras/front_door/detect",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Only one camera updated
|
||||
self.assertEqual(mock_publisher.publish_update.call_count, 1)
|
||||
topic = mock_publisher.publish_update.call_args[0][0]
|
||||
self.assertEqual(topic.camera, "front_door")
|
||||
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
|
||||
|
||||
# Global publisher should NOT be called
|
||||
mock_publisher.publisher.publish.assert_not_called()
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_wildcard_sends_merged_per_camera_config(self, mock_find_config):
|
||||
"""Wildcard fan-out sends each camera's own merged config."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"detect": {"fps": 15}},
|
||||
"update_topic": "config/cameras/*/detect",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for c in mock_publisher.publish_update.call_args_list:
|
||||
camera_detect_config = c[0][1]
|
||||
self.assertIsNotNone(camera_detect_config)
|
||||
self.assertTrue(hasattr(camera_detect_config, "fps"))
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_non_camera_global_topic_uses_generic_publish(self, mock_find_config):
|
||||
"""Non-camera topics (e.g. config/live) use the generic publisher."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"live": {"height": 720}},
|
||||
"update_topic": "config/live",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Global topic publisher called
|
||||
mock_publisher.publisher.publish.assert_called_once()
|
||||
|
||||
# Camera-level publish_update NOT called
|
||||
mock_publisher.publish_update.assert_not_called()
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
91
frigate/test/test_motion_detector.py
Normal file
91
frigate/test/test_motion_detector.py
Normal file
@ -0,0 +1,91 @@
|
||||
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()
|
||||
@ -151,7 +151,9 @@ def sync_recordings(
|
||||
|
||||
max_inserts = 1000
|
||||
for batch in chunked(recordings_to_delete, max_inserts):
|
||||
RecordingsToDelete.insert_many(batch).execute()
|
||||
RecordingsToDelete.insert_many(
|
||||
[{"id": r["id"]} for r in batch]
|
||||
).execute()
|
||||
|
||||
try:
|
||||
deleted = (
|
||||
|
||||
@ -110,6 +110,7 @@ def ensure_torch_dependencies() -> bool:
|
||||
"pip",
|
||||
"install",
|
||||
"--break-system-packages",
|
||||
"setuptools<81",
|
||||
"torch",
|
||||
"torchvision",
|
||||
],
|
||||
|
||||
@ -214,7 +214,11 @@ class CameraWatchdog(threading.Thread):
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
None,
|
||||
{config.name: config},
|
||||
[CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.record],
|
||||
[
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.ffmpeg,
|
||||
CameraConfigUpdateEnum.record,
|
||||
],
|
||||
)
|
||||
self.requestor = InterProcessRequestor()
|
||||
self.was_enabled = self.config.enabled
|
||||
@ -254,9 +258,13 @@ class CameraWatchdog(threading.Thread):
|
||||
self._last_record_status = status
|
||||
self._last_status_update_time = now
|
||||
|
||||
def _check_config_updates(self) -> dict[str, list[str]]:
|
||||
"""Check for config updates and return the update dict."""
|
||||
return self.config_subscriber.check_for_updates()
|
||||
|
||||
def _update_enabled_state(self) -> bool:
|
||||
"""Fetch the latest config and update enabled state."""
|
||||
self.config_subscriber.check_for_updates()
|
||||
self._check_config_updates()
|
||||
return self.config.enabled
|
||||
|
||||
def reset_capture_thread(
|
||||
@ -317,7 +325,24 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
# 1 second watchdog loop
|
||||
while not self.stop_event.wait(1):
|
||||
enabled = self._update_enabled_state()
|
||||
updates = self._check_config_updates()
|
||||
|
||||
# Handle ffmpeg config changes by restarting all ffmpeg processes
|
||||
if "ffmpeg" in updates and self.config.enabled:
|
||||
self.logger.debug(
|
||||
"FFmpeg config updated for %s, restarting ffmpeg processes",
|
||||
self.config.name,
|
||||
)
|
||||
self.stop_all_ffmpeg()
|
||||
self.start_all_ffmpeg()
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_enable_time = datetime.now().astimezone(timezone.utc)
|
||||
last_restart_time = datetime.now().timestamp()
|
||||
continue
|
||||
|
||||
enabled = self.config.enabled
|
||||
if enabled != self.was_enabled:
|
||||
if enabled:
|
||||
self.logger.debug(f"Enabling camera {self.config.name}")
|
||||
|
||||
34
migrations/035_add_motion_heatmap.py
Normal file
34
migrations/035_add_motion_heatmap.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""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
|
||||
4594
web/package-lock.json
generated
4594
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,7 @@
|
||||
"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 .",
|
||||
@ -27,12 +28,13 @@
|
||||
"@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.3",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
@ -50,8 +52,7 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"embla-carousel-react": "^8.2.0",
|
||||
"framer-motion": "^11.5.4",
|
||||
"framer-motion": "^12.35.0",
|
||||
"hls.js": "^1.5.20",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
@ -61,30 +62,28 @@
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.477.0",
|
||||
"monaco-yaml": "^5.3.1",
|
||||
"next-themes": "^0.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^18.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-apexcharts": "^1.4.1",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-grid-layout": "^1.5.0",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-konva": "^19.2.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"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.4.4",
|
||||
"recoil": "^0.7.7",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^1.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sort-by": "^1.2.0",
|
||||
"strftime": "^0.10.3",
|
||||
"swr": "^2.3.2",
|
||||
@ -92,7 +91,7 @@
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.2.0",
|
||||
"vaul": "^0.9.1",
|
||||
"vaul": "^1.1.2",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@ -101,11 +100,8 @@
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@types/lodash": "^4.17.12",
|
||||
"@types/node": "^20.14.10",
|
||||
"@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/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
@ -116,19 +112,26 @@
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.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.8.2",
|
||||
"typescript": "^5.9.3",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
75
web/patches/@radix-ui+react-compose-refs+1.1.2.patch
Normal file
75
web/patches/@radix-ui+react-compose-refs+1.1.2.patch
Normal file
@ -0,0 +1,75 @@
|
||||
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,
|
||||
46
web/patches/@radix-ui+react-slot+1.2.4.patch
Normal file
46
web/patches/@radix-ui+react-slot+1.2.4.patch
Normal file
@ -0,0 +1,46 @@
|
||||
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);
|
||||
}
|
||||
23
web/patches/react-use-websocket+4.8.1.patch
Normal file
23
web/patches/react-use-websocket+4.8.1.patch
Normal file
@ -0,0 +1,23 @@
|
||||
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) {
|
||||
@ -264,7 +264,11 @@
|
||||
},
|
||||
"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)."
|
||||
"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."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Improve contrast",
|
||||
@ -864,7 +868,8 @@
|
||||
"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": "Whether this zone is active. Disabled zones are ignored at runtime."
|
||||
"label": "Enabled",
|
||||
"description": "Enable or disable this zone. Disabled zones are ignored at runtime."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of zone."
|
||||
|
||||
@ -1391,7 +1391,11 @@
|
||||
},
|
||||
"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)."
|
||||
"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."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Improve contrast",
|
||||
|
||||
@ -61,5 +61,25 @@
|
||||
"detected": "detected",
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Needs review",
|
||||
"securityConcern": "Security concern"
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
||||
75
web/public/locales/en/views/motionSearch.json
Normal file
75
web/public/locales/en/views/motionSearch.json
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -83,7 +83,8 @@
|
||||
"triggers": "Triggers",
|
||||
"debug": "Debug",
|
||||
"frigateplus": "Frigate+",
|
||||
"maintenance": "Maintenance"
|
||||
"mediaSync": "Media sync",
|
||||
"regionGrid": "Region grid"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
@ -1232,6 +1233,16 @@
|
||||
"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": {
|
||||
|
||||
@ -25,14 +25,7 @@ const audio: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"listen",
|
||||
"filters",
|
||||
"min_volume",
|
||||
"max_not_heard",
|
||||
"num_threads",
|
||||
],
|
||||
restartRequired: ["num_threads"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["num_threads"],
|
||||
|
||||
@ -28,10 +28,7 @@ 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", "bird.threshold"],
|
||||
restartRequired: ["bird.enabled"],
|
||||
hiddenFields: ["custom"],
|
||||
advancedFields: [],
|
||||
},
|
||||
|
||||
@ -30,16 +30,7 @@ const detect: SectionConfigOverrides = {
|
||||
],
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
],
|
||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||
|
||||
@ -32,18 +32,7 @@ const faceRecognition: SectionConfigOverrides = {
|
||||
"blur_confidence_filter",
|
||||
"device",
|
||||
],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"model_size",
|
||||
"unknown_score",
|
||||
"detection_threshold",
|
||||
"recognition_threshold",
|
||||
"min_area",
|
||||
"min_faces",
|
||||
"save_attempts",
|
||||
"blur_confidence_filter",
|
||||
"device",
|
||||
],
|
||||
restartRequired: ["enabled", "model_size", "device"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -116,16 +116,7 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"hwaccel_args",
|
||||
"path",
|
||||
@ -162,17 +153,7 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
fieldGroups: {
|
||||
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
||||
},
|
||||
restartRequired: [
|
||||
"inputs",
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
restartRequired: [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -40,21 +40,7 @@ const lpr: SectionConfigOverrides = {
|
||||
"device",
|
||||
"replace_rules",
|
||||
],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"model_size",
|
||||
"detection_threshold",
|
||||
"min_area",
|
||||
"recognition_threshold",
|
||||
"min_plate_length",
|
||||
"format",
|
||||
"match_distance",
|
||||
"known_plates",
|
||||
"enhancement",
|
||||
"debug_save_plates",
|
||||
"device",
|
||||
"replace_rules",
|
||||
],
|
||||
restartRequired: ["model_size", "enhancement", "device"],
|
||||
uiSchema: {
|
||||
format: {
|
||||
"ui:options": { size: "md" },
|
||||
|
||||
@ -8,6 +8,7 @@ const motion: SectionConfigOverrides = {
|
||||
"enabled",
|
||||
"threshold",
|
||||
"lightning_threshold",
|
||||
"skip_motion_threshold",
|
||||
"improve_contrast",
|
||||
"contour_area",
|
||||
"delta_alpha",
|
||||
@ -22,6 +23,7 @@ const motion: SectionConfigOverrides = {
|
||||
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
||||
advancedFields: [
|
||||
"lightning_threshold",
|
||||
"skip_motion_threshold",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
@ -29,17 +31,7 @@ const motion: SectionConfigOverrides = {
|
||||
],
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"threshold",
|
||||
"lightning_threshold",
|
||||
"improve_contrast",
|
||||
"contour_area",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
"mqtt_off_delay",
|
||||
],
|
||||
restartRequired: ["frame_height"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["frame_height"],
|
||||
|
||||
@ -83,7 +83,7 @@ const objects: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["track", "alert", "detect", "filters", "genai"],
|
||||
restartRequired: [],
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"mask",
|
||||
|
||||
@ -29,16 +29,7 @@ const record: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"expire_interval",
|
||||
"continuous",
|
||||
"motion",
|
||||
"alerts",
|
||||
"detections",
|
||||
"preview",
|
||||
"export",
|
||||
],
|
||||
restartRequired: [],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
|
||||
@ -44,7 +44,7 @@ const review: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["alerts", "detections", "genai"],
|
||||
restartRequired: [],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
|
||||
@ -27,14 +27,7 @@ const snapshots: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"bounding_box",
|
||||
"crop",
|
||||
"quality",
|
||||
"timestamp",
|
||||
"retain",
|
||||
],
|
||||
restartRequired: [],
|
||||
hiddenFields: ["enabled_in_config", "required_zones"],
|
||||
},
|
||||
camera: {
|
||||
|
||||
@ -3,14 +3,7 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const telemetry: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/reference",
|
||||
restartRequired: [
|
||||
"network_interfaces",
|
||||
"stats.amd_gpu_stats",
|
||||
"stats.intel_gpu_stats",
|
||||
"stats.intel_gpu_device",
|
||||
"stats.network_bandwidth",
|
||||
"version_check",
|
||||
],
|
||||
restartRequired: ["version_check"],
|
||||
fieldOrder: ["network_interfaces", "stats", "version_check"],
|
||||
advancedFields: [],
|
||||
},
|
||||
|
||||
@ -56,6 +56,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import {
|
||||
cameraUpdateTopicMap,
|
||||
globalCameraDefaultSections,
|
||||
buildOverrides,
|
||||
buildConfigDataForPath,
|
||||
sanitizeSectionData as sharedSanitizeSectionData,
|
||||
@ -234,7 +235,10 @@ export function ConfigSection({
|
||||
? cameraUpdateTopicMap[sectionPath]
|
||||
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
||||
: undefined
|
||||
: `config/${sectionPath}`;
|
||||
: globalCameraDefaultSections.has(sectionPath) &&
|
||||
cameraUpdateTopicMap[sectionPath]
|
||||
? `config/cameras/*/${cameraUpdateTopicMap[sectionPath]}`
|
||||
: `config/${sectionPath}`;
|
||||
// Default: show title for camera level (since it might be collapsible), hide for global
|
||||
const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
|
||||
|
||||
@ -827,7 +831,7 @@ export function ConfigSection({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-full border-t border-secondary bg-background pb-5 pt-0",
|
||||
"w-full border-t border-secondary bg-background pt-0",
|
||||
!noStickyButtons && "sticky bottom-0 z-50",
|
||||
)}
|
||||
>
|
||||
|
||||
@ -114,10 +114,17 @@ 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?.schema as
|
||||
| { type?: string | string[] }
|
||||
| undefined;
|
||||
const fieldSchema = (item.content.props as RjsfElementProps)?.schema;
|
||||
return fieldSchema?.type === "object";
|
||||
}
|
||||
|
||||
@ -163,16 +170,21 @@ function GridLayoutObjectFieldTemplate(
|
||||
|
||||
// Override the properties rendering with grid layout
|
||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
|
||||
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] ===
|
||||
"hidden";
|
||||
|
||||
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
|
||||
|
||||
// Separate regular and advanced properties
|
||||
const advancedProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
|
||||
(p) =>
|
||||
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
|
||||
?.advanced === true,
|
||||
);
|
||||
const regularProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
||||
(p) =>
|
||||
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
|
||||
?.advanced !== true,
|
||||
);
|
||||
const hasModifiedAdvanced = advancedProps.some((prop) =>
|
||||
isPathModified([...fieldPath, prop.name]),
|
||||
|
||||
@ -448,6 +448,9 @@ 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;
|
||||
@ -459,7 +462,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
errors && errors.props?.errors?.length > 0 && "text-destructive",
|
||||
hasFieldErrors && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{finalLabel}
|
||||
@ -497,7 +500,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
errors && errors.props?.errors?.length > 0 && "text-destructive",
|
||||
hasFieldErrors && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{finalLabel}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// 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,6 +25,15 @@ 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,
|
||||
@ -182,16 +191,21 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
uiSchema?.["ui:options"]?.disableNestedCard === true;
|
||||
|
||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
|
||||
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] ===
|
||||
"hidden";
|
||||
|
||||
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
|
||||
|
||||
// Check for advanced section grouping
|
||||
const advancedProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
|
||||
(p) =>
|
||||
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
|
||||
?.advanced === true,
|
||||
);
|
||||
const regularProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
||||
(p) =>
|
||||
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
|
||||
?.advanced !== true,
|
||||
);
|
||||
const hasModifiedAdvanced = advancedProps.some((prop) =>
|
||||
checkSubtreeModified([...fieldPath, prop.name]),
|
||||
@ -333,9 +347,7 @@ 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.schema as
|
||||
| { type?: string | string[] }
|
||||
| undefined;
|
||||
const fieldSchema = (item.content.props as RjsfElementProps)?.schema;
|
||||
return fieldSchema?.type === "object";
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { t } from "i18next";
|
||||
import type { JSX } from "react";
|
||||
import { FunctionComponent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface IProp {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LogSeverity } from "@/types/log";
|
||||
import { ReactNode, useMemo, useRef } from "react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { isIOS } from "react-device-detect";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
type ChipProps = {
|
||||
className?: string;
|
||||
@ -17,39 +17,31 @@ export default function Chip({
|
||||
in: inProp = true,
|
||||
onClick,
|
||||
}: ChipProps) {
|
||||
const nodeRef = useRef(null);
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
in={inProp}
|
||||
nodeRef={nodeRef}
|
||||
timeout={500}
|
||||
classNames={{
|
||||
enter: "opacity-0",
|
||||
enterActive: "opacity-100 transition-opacity duration-500 ease-in-out",
|
||||
exit: "opacity-100",
|
||||
exitActive: "opacity-0 transition-opacity duration-500 ease-in-out",
|
||||
}}
|
||||
unmountOnExit
|
||||
>
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className={cn(
|
||||
"flex items-center rounded-2xl px-2 py-1.5",
|
||||
className,
|
||||
!isIOS && "z-10",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<AnimatePresence>
|
||||
{inProp && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
className={cn(
|
||||
"flex items-center rounded-2xl px-2 py-1.5",
|
||||
className,
|
||||
!isIOS && "z-10",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -155,7 +155,7 @@ export default function SearchResultActions({
|
||||
</a>
|
||||
</MenuItem>
|
||||
)}
|
||||
{searchResult.has_snapshot && (
|
||||
{searchResult.has_snapshot && searchResult?.data?.type === "object" && (
|
||||
<MenuItem aria-label={t("itemMenu.downloadSnapshot.aria")}>
|
||||
<a
|
||||
className="flex items-center"
|
||||
@ -167,6 +167,7 @@ export default function SearchResultActions({
|
||||
</MenuItem>
|
||||
)}
|
||||
{searchResult.has_snapshot &&
|
||||
searchResult?.data?.type === "object" &&
|
||||
config?.cameras[searchResult.camera].snapshots.clean_copy && (
|
||||
<MenuItem aria-label={t("itemMenu.downloadCleanSnapshot.aria")}>
|
||||
<a
|
||||
|
||||
@ -55,9 +55,9 @@ export function MobilePage({
|
||||
});
|
||||
|
||||
return (
|
||||
<MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}>
|
||||
<MobilePageContext value={{ open, onOpenChange: setOpen }}>
|
||||
{children}
|
||||
</MobilePageContext.Provider>
|
||||
</MobilePageContext>
|
||||
);
|
||||
}
|
||||
|
||||
@ -102,7 +102,7 @@ export function MobilePagePortal({
|
||||
type MobilePageContentProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
scrollerRef?: React.RefObject<HTMLDivElement>;
|
||||
scrollerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export function MobilePageContent({
|
||||
|
||||
@ -10,7 +10,7 @@ import Konva from "konva";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
|
||||
type DebugDrawingLayerProps = {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
};
|
||||
|
||||
@ -81,7 +81,7 @@ export default function DetailActionsMenu({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent align="end">
|
||||
{search.has_snapshot && (
|
||||
{search.has_snapshot && search?.data?.type === "object" && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
@ -95,7 +95,8 @@ export default function DetailActionsMenu({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{search.has_snapshot &&
|
||||
config?.cameras[search.camera].snapshots.clean_copy && (
|
||||
config?.cameras[search.camera].snapshots.clean_copy &&
|
||||
search?.data?.type === "object" && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
|
||||
@ -17,7 +17,7 @@ type ObjectPathProps = {
|
||||
color?: number[];
|
||||
width?: number;
|
||||
pointRadius?: number;
|
||||
imgRef: React.RefObject<HTMLImageElement>;
|
||||
imgRef: React.RefObject<HTMLImageElement | null>;
|
||||
onPointClick?: (index: number) => void;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import type { JSX } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
type PlatformAwareDialogProps = {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
@ -57,6 +58,7 @@ type HlsVideoPlayerProps = {
|
||||
isDetailMode?: boolean;
|
||||
camera?: string;
|
||||
currentTimeOverride?: number;
|
||||
transformedOverlay?: ReactNode;
|
||||
};
|
||||
|
||||
export default function HlsVideoPlayer({
|
||||
@ -81,6 +83,7 @@ export default function HlsVideoPlayer({
|
||||
isDetailMode = false,
|
||||
camera,
|
||||
currentTimeOverride,
|
||||
transformedOverlay,
|
||||
}: HlsVideoPlayerProps) {
|
||||
const { t } = useTranslation("components/player");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -91,7 +94,7 @@ export default function HlsVideoPlayer({
|
||||
|
||||
// playback
|
||||
|
||||
const hlsRef = useRef<Hls>();
|
||||
const hlsRef = useRef<Hls>(undefined);
|
||||
const [useHlsCompat, setUseHlsCompat] = useState(false);
|
||||
const [loadedMetadata, setLoadedMetadata] = useState(false);
|
||||
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
|
||||
@ -350,157 +353,162 @@ export default function HlsVideoPlayer({
|
||||
height: isMobile ? "100%" : undefined,
|
||||
}}
|
||||
>
|
||||
{isDetailMode &&
|
||||
camera &&
|
||||
currentTime &&
|
||||
loadedMetadata &&
|
||||
videoDimensions.width > 0 &&
|
||||
videoDimensions.height > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50",
|
||||
isDesktop
|
||||
? "size-full"
|
||||
: "mx-auto flex items-center justify-center portrait:max-h-[50dvh]",
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: `${videoDimensions.width} / ${videoDimensions.height}`,
|
||||
}}
|
||||
>
|
||||
<ObjectTrackOverlay
|
||||
key={`overlay-${currentTime}`}
|
||||
camera={camera}
|
||||
showBoundingBoxes={!isPlaying}
|
||||
currentTime={currentTime}
|
||||
videoWidth={videoDimensions.width}
|
||||
videoHeight={videoDimensions.height}
|
||||
className="absolute inset-0 z-10"
|
||||
onSeekToTime={(timestamp, play) => {
|
||||
if (onSeekToTime) {
|
||||
onSeekToTime(timestamp, play);
|
||||
}
|
||||
<div className="relative size-full">
|
||||
{transformedOverlay}
|
||||
{isDetailMode &&
|
||||
camera &&
|
||||
currentTime &&
|
||||
loadedMetadata &&
|
||||
videoDimensions.width > 0 &&
|
||||
videoDimensions.height > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50",
|
||||
isDesktop
|
||||
? "size-full"
|
||||
: "mx-auto flex items-center justify-center portrait:max-h-[50dvh]",
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: `${videoDimensions.width} / ${videoDimensions.height}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={`size-full rounded-lg bg-black md:rounded-2xl ${loadedMetadata ? "" : "invisible"} cursor-pointer`}
|
||||
preload="auto"
|
||||
autoPlay
|
||||
controls={!frigateControls}
|
||||
playsInline
|
||||
muted={muted}
|
||||
onClick={
|
||||
isDesktop
|
||||
? () => {
|
||||
if (zoomScale == 1.0) onPlayPause(!isPlaying);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onVolumeChange={() => {
|
||||
setVolume(videoRef.current?.volume ?? 1.0, true);
|
||||
if (!frigateControls) {
|
||||
setMuted(videoRef.current?.muted);
|
||||
}
|
||||
}}
|
||||
onPlay={() => {
|
||||
setIsPlaying(true);
|
||||
|
||||
if (isMobile) {
|
||||
setControls(true);
|
||||
setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
|
||||
}
|
||||
}}
|
||||
onPlaying={onPlaying}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
clearTimeout(bufferTimeout);
|
||||
|
||||
if (isMobile && mobileCtrlTimeout) {
|
||||
clearTimeout(mobileCtrlTimeout);
|
||||
}
|
||||
}}
|
||||
onWaiting={() => {
|
||||
if (onError != undefined) {
|
||||
if (videoRef.current?.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBufferTimeout(
|
||||
setTimeout(() => {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
videoRef.current
|
||||
) {
|
||||
onError("stalled");
|
||||
>
|
||||
<ObjectTrackOverlay
|
||||
key={`overlay-${currentTime}`}
|
||||
camera={camera}
|
||||
showBoundingBoxes={!isPlaying}
|
||||
currentTime={currentTime}
|
||||
videoWidth={videoDimensions.width}
|
||||
videoHeight={videoDimensions.height}
|
||||
className="absolute inset-0 z-10"
|
||||
onSeekToTime={(timestamp, play) => {
|
||||
if (onSeekToTime) {
|
||||
onSeekToTime(timestamp, play);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={`size-full rounded-lg bg-black md:rounded-2xl ${loadedMetadata ? "" : "invisible"} cursor-pointer`}
|
||||
preload="auto"
|
||||
autoPlay
|
||||
controls={!frigateControls}
|
||||
playsInline
|
||||
muted={muted}
|
||||
onClick={
|
||||
isDesktop
|
||||
? () => {
|
||||
if (zoomScale == 1.0) onPlayPause(!isPlaying);
|
||||
}
|
||||
}, 3000),
|
||||
);
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
onProgress={() => {
|
||||
if (onError != undefined) {
|
||||
if (videoRef.current?.paused) {
|
||||
onVolumeChange={() => {
|
||||
setVolume(videoRef.current?.volume ?? 1.0, true);
|
||||
if (!frigateControls) {
|
||||
setMuted(videoRef.current?.muted);
|
||||
}
|
||||
}}
|
||||
onPlay={() => {
|
||||
setIsPlaying(true);
|
||||
|
||||
if (isMobile) {
|
||||
setControls(true);
|
||||
setMobileCtrlTimeout(
|
||||
setTimeout(() => setControls(false), 4000),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onPlaying={onPlaying}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
clearTimeout(bufferTimeout);
|
||||
|
||||
if (isMobile && mobileCtrlTimeout) {
|
||||
clearTimeout(mobileCtrlTimeout);
|
||||
}
|
||||
}}
|
||||
onWaiting={() => {
|
||||
if (onError != undefined) {
|
||||
if (videoRef.current?.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBufferTimeout(
|
||||
setTimeout(() => {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
videoRef.current
|
||||
) {
|
||||
onError("stalled");
|
||||
}
|
||||
}, 3000),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onProgress={() => {
|
||||
if (onError != undefined) {
|
||||
if (videoRef.current?.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bufferTimeout) {
|
||||
clearTimeout(bufferTimeout);
|
||||
setBufferTimeout(undefined);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={() => {
|
||||
if (!onTimeUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bufferTimeout) {
|
||||
clearTimeout(bufferTimeout);
|
||||
setBufferTimeout(undefined);
|
||||
const frameTime = getVideoTime();
|
||||
|
||||
if (frameTime) {
|
||||
onTimeUpdate(frameTime);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={() => {
|
||||
if (!onTimeUpdate) {
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onLoadedData={() => {
|
||||
onPlayerLoaded?.();
|
||||
handleLoadedMetadata();
|
||||
|
||||
const frameTime = getVideoTime();
|
||||
if (videoRef.current) {
|
||||
if (playbackRate) {
|
||||
videoRef.current.playbackRate = playbackRate;
|
||||
}
|
||||
|
||||
if (frameTime) {
|
||||
onTimeUpdate(frameTime);
|
||||
}
|
||||
}}
|
||||
onLoadedData={() => {
|
||||
onPlayerLoaded?.();
|
||||
handleLoadedMetadata();
|
||||
|
||||
if (videoRef.current) {
|
||||
if (playbackRate) {
|
||||
videoRef.current.playbackRate = playbackRate;
|
||||
if (volume) {
|
||||
videoRef.current.volume = volume;
|
||||
}
|
||||
}
|
||||
|
||||
if (volume) {
|
||||
videoRef.current.volume = volume;
|
||||
}}
|
||||
onEnded={() => {
|
||||
if (onClipEnded) {
|
||||
onClipEnded(getVideoTime() ?? 0);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onEnded={() => {
|
||||
if (onClipEnded) {
|
||||
onClipEnded(getVideoTime() ?? 0);
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
if (
|
||||
!hlsRef.current &&
|
||||
// @ts-expect-error code does exist
|
||||
unsupportedErrorCodes.includes(e.target.error.code) &&
|
||||
videoRef.current
|
||||
) {
|
||||
setLoadedMetadata(false);
|
||||
setUseHlsCompat(true);
|
||||
} else {
|
||||
toast.error(
|
||||
}}
|
||||
onError={(e) => {
|
||||
if (
|
||||
!hlsRef.current &&
|
||||
// @ts-expect-error code does exist
|
||||
`Failed to play recordings (error ${e.target.error.code}): ${e.target.error.message}`,
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
unsupportedErrorCodes.includes(e.target.error.code) &&
|
||||
videoRef.current
|
||||
) {
|
||||
setLoadedMetadata(false);
|
||||
setUseHlsCompat(true);
|
||||
} else {
|
||||
toast.error(
|
||||
// @ts-expect-error code does exist
|
||||
`Failed to play recordings (error ${e.target.error.code}): ${e.target.error.message}`,
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
);
|
||||
|
||||
@ -51,10 +51,10 @@ export default function WebRtcPlayer({
|
||||
|
||||
// camera states
|
||||
|
||||
const pcRef = useRef<RTCPeerConnection | undefined>();
|
||||
const pcRef = useRef<RTCPeerConnection | undefined>(undefined);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
|
||||
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
const PeerConnection = useCallback(
|
||||
async (media: string) => {
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useApiHost } from "@/api";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
@ -40,6 +47,7 @@ type DynamicVideoPlayerProps = {
|
||||
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
toggleFullscreen: () => void;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
transformedOverlay?: ReactNode;
|
||||
};
|
||||
export default function DynamicVideoPlayer({
|
||||
className,
|
||||
@ -58,6 +66,7 @@ export default function DynamicVideoPlayer({
|
||||
setFullResolution,
|
||||
toggleFullscreen,
|
||||
containerRef,
|
||||
transformedOverlay,
|
||||
}: DynamicVideoPlayerProps) {
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
const apiHost = useApiHost();
|
||||
@ -312,6 +321,7 @@ export default function DynamicVideoPlayer({
|
||||
isDetailMode={isDetailMode}
|
||||
camera={contextCamera || camera}
|
||||
currentTimeOverride={currentTime}
|
||||
transformedOverlay={transformedOverlay}
|
||||
/>
|
||||
)}
|
||||
<PreviewPlayer
|
||||
|
||||
@ -10,7 +10,7 @@ import { snapPointToLines } from "@/utils/canvasUtil";
|
||||
import { usePolygonStates } from "@/hooks/use-polygon-states";
|
||||
|
||||
type PolygonCanvasProps = {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
camera: string;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
@ -18,7 +18,7 @@ import Konva from "konva";
|
||||
import { Vector2d } from "konva/lib/types";
|
||||
|
||||
type PolygonDrawerProps = {
|
||||
stageRef: RefObject<Konva.Stage>;
|
||||
stageRef: RefObject<Konva.Stage | null>;
|
||||
points: number[][];
|
||||
distances: number[];
|
||||
isActive: boolean;
|
||||
|
||||
@ -37,8 +37,8 @@ export type EventReviewTimelineProps = {
|
||||
events: ReviewSegment[];
|
||||
visibleTimestamps?: number[];
|
||||
severityType: ReviewSeverity;
|
||||
timelineRef?: RefObject<HTMLDivElement>;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
timelineRef?: RefObject<HTMLDivElement | null>;
|
||||
contentRef: RefObject<HTMLDivElement | null>;
|
||||
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
||||
isZooming: boolean;
|
||||
zoomDirection: TimelineZoomDirection;
|
||||
|
||||
@ -28,7 +28,7 @@ type EventSegmentProps = {
|
||||
minimapStartTime?: number;
|
||||
minimapEndTime?: number;
|
||||
severityType: ReviewSeverity;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
contentRef: RefObject<HTMLDivElement | null>;
|
||||
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
|
||||
dense: boolean;
|
||||
|
||||
@ -25,6 +25,7 @@ export type MotionReviewTimelineProps = {
|
||||
timestampSpread: number;
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
scrollToTime?: number;
|
||||
showHandlebar?: boolean;
|
||||
handlebarTime?: number;
|
||||
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
@ -41,8 +42,8 @@ export type MotionReviewTimelineProps = {
|
||||
events: ReviewSegment[];
|
||||
motion_events: MotionData[];
|
||||
noRecordingRanges?: RecordingSegment[];
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
timelineRef?: RefObject<HTMLDivElement>;
|
||||
contentRef: RefObject<HTMLDivElement | null>;
|
||||
timelineRef?: RefObject<HTMLDivElement | null>;
|
||||
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
||||
dense?: boolean;
|
||||
isZooming: boolean;
|
||||
@ -58,6 +59,7 @@ export function MotionReviewTimeline({
|
||||
timestampSpread,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
scrollToTime,
|
||||
showHandlebar = false,
|
||||
handlebarTime,
|
||||
setHandlebarTime,
|
||||
@ -176,6 +178,15 @@ export function MotionReviewTimeline({
|
||||
[],
|
||||
);
|
||||
|
||||
// allow callers to request the timeline center on a specific time
|
||||
useEffect(() => {
|
||||
if (scrollToTime == undefined) return;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToSegment(alignStartDateToTimeline(scrollToTime), true, "auto");
|
||||
}, 0);
|
||||
}, [scrollToTime, scrollToSegment, alignStartDateToTimeline]);
|
||||
|
||||
// keep handlebar centered when zooming
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
|
||||
@ -20,8 +20,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
|
||||
export type ReviewTimelineProps = {
|
||||
timelineRef: RefObject<HTMLDivElement>;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
contentRef: RefObject<HTMLDivElement | null>;
|
||||
segmentDuration: number;
|
||||
timelineDuration: number;
|
||||
timelineStartAligned: number;
|
||||
@ -343,9 +343,12 @@ export function ReviewTimeline({
|
||||
|
||||
useEffect(() => {
|
||||
if (onHandlebarDraggingChange) {
|
||||
onHandlebarDraggingChange(isDraggingHandlebar);
|
||||
// Keep existing callback name but treat it as a generic dragging signal.
|
||||
// This allows consumers (e.g. export-handle timelines) to correctly
|
||||
// enable preview scrubbing while dragging export handles.
|
||||
onHandlebarDraggingChange(isDragging);
|
||||
}
|
||||
}, [isDraggingHandlebar, onHandlebarDraggingChange]);
|
||||
}, [isDragging, onHandlebarDraggingChange]);
|
||||
|
||||
const isHandlebarInNoRecordingPeriod = useMemo(() => {
|
||||
if (!getRecordingAvailability || handlebarTime === undefined) return false;
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
|
||||
export type SummaryTimelineProps = {
|
||||
reviewTimelineRef: React.RefObject<HTMLDivElement>;
|
||||
reviewTimelineRef: React.RefObject<HTMLDivElement | null>;
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
segmentDuration: number;
|
||||
|
||||
@ -10,7 +10,7 @@ import { EventSegment } from "./EventSegment";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
|
||||
type VirtualizedEventSegmentsProps = {
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement | null>;
|
||||
segments: number[];
|
||||
events: ReviewSegment[];
|
||||
segmentDuration: number;
|
||||
@ -19,7 +19,7 @@ type VirtualizedEventSegmentsProps = {
|
||||
minimapStartTime?: number;
|
||||
minimapEndTime?: number;
|
||||
severityType: ReviewSeverity;
|
||||
contentRef: React.RefObject<HTMLDivElement>;
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
dense: boolean;
|
||||
alignStartDateToTimeline: (timestamp: number) => number;
|
||||
|
||||
@ -10,7 +10,7 @@ import MotionSegment from "./MotionSegment";
|
||||
import { ReviewSegment, MotionData } from "@/types/review";
|
||||
|
||||
type VirtualizedMotionSegmentsProps = {
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement | null>;
|
||||
segments: number[];
|
||||
events: ReviewSegment[];
|
||||
motion_events: MotionData[];
|
||||
@ -19,7 +19,7 @@ type VirtualizedMotionSegmentsProps = {
|
||||
showMinimap: boolean;
|
||||
minimapStartTime?: number;
|
||||
minimapEndTime?: number;
|
||||
contentRef: React.RefObject<HTMLDivElement>;
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
dense: boolean;
|
||||
motionOnly: boolean;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { JSX } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "./button";
|
||||
import { Calendar } from "./calendar";
|
||||
@ -124,8 +125,8 @@ export function DateRangePicker({
|
||||
);
|
||||
|
||||
// Refs to store the values of range and rangeCompare when the date picker is opened
|
||||
const openedRangeRef = useRef<DateRange | undefined>();
|
||||
const openedRangeCompareRef = useRef<DateRange | undefined>();
|
||||
const openedRangeRef = useRef<DateRange | undefined>(undefined);
|
||||
const openedRangeCompareRef = useRef<DateRange | undefined>(undefined);
|
||||
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(
|
||||
undefined,
|
||||
|
||||
@ -1,265 +0,0 @@
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
Carousel.displayName = "Carousel";
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = "CarouselContent";
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = "CarouselItem";
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
aria-label={t("trackingDetails.carousel.previous")}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">{t("trackingDetails.carousel.previous")}</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = "CarouselPrevious";
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
aria-label={t("trackingDetails.carousel.next")}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">{t("trackingDetails.carousel.next")}</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
@ -33,9 +33,9 @@ const FormField = <
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<FormFieldContext value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
</FormFieldContext>
|
||||
);
|
||||
};
|
||||
|
||||
@ -77,9 +77,9 @@ const FormItem = React.forwardRef<
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<FormItemContext value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-1", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
</FormItemContext>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { ForwardedRef, forwardRef } from "react";
|
||||
import { IconType } from "react-icons";
|
||||
|
||||
interface IconWrapperProps {
|
||||
interface IconWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon: IconType;
|
||||
className?: string;
|
||||
[key: string]: any;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const IconWrapper = forwardRef(
|
||||
|
||||
26
web/src/components/ui/progress.tsx
Normal file
26
web/src/components/ui/progress.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@ -141,7 +141,7 @@ const SidebarProvider = React.forwardRef<
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<SidebarContext value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
@ -161,7 +161,7 @@ const SidebarProvider = React.forwardRef<
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
</SidebarContext>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -22,9 +22,9 @@ const ToggleGroup = React.forwardRef<
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
<ToggleGroupContext value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupContext>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
|
||||
@ -102,9 +102,5 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
axios.get("/logout", { withCredentials: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ auth, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
return <AuthContext value={{ auth, login, logout }}>{children}</AuthContext>;
|
||||
}
|
||||
|
||||
@ -125,11 +125,7 @@ export function DetailStreamProvider({
|
||||
isDetailMode,
|
||||
};
|
||||
|
||||
return (
|
||||
<DetailStreamContext.Provider value={value}>
|
||||
{children}
|
||||
</DetailStreamContext.Provider>
|
||||
);
|
||||
return <DetailStreamContext value={value}>{children}</DetailStreamContext>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
|
||||
@ -77,9 +77,9 @@ export function LanguageProvider({
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageProviderContext.Provider {...props} value={value}>
|
||||
<LanguageProviderContext {...props} value={value}>
|
||||
{children}
|
||||
</LanguageProviderContext.Provider>
|
||||
</LanguageProviderContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ThemeProvider } from "@/context/theme-provider";
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { ApiProvider } from "@/api";
|
||||
import { IconContext } from "react-icons";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@ -15,25 +14,23 @@ type TProvidersProps = {
|
||||
|
||||
function providers({ children }: TProvidersProps) {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AuthProvider>
|
||||
<ApiProvider>
|
||||
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
|
||||
<LanguageProvider>
|
||||
<TooltipProvider>
|
||||
<IconContext.Provider value={{ size: "20" }}>
|
||||
<StatusBarMessagesProvider>
|
||||
<StreamingSettingsProvider>
|
||||
{children}
|
||||
</StreamingSettingsProvider>
|
||||
</StatusBarMessagesProvider>
|
||||
</IconContext.Provider>
|
||||
</TooltipProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</ApiProvider>
|
||||
</AuthProvider>
|
||||
</RecoilRoot>
|
||||
<AuthProvider>
|
||||
<ApiProvider>
|
||||
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
|
||||
<LanguageProvider>
|
||||
<TooltipProvider>
|
||||
<IconContext.Provider value={{ size: "20" }}>
|
||||
<StatusBarMessagesProvider>
|
||||
<StreamingSettingsProvider>
|
||||
{children}
|
||||
</StreamingSettingsProvider>
|
||||
</StatusBarMessagesProvider>
|
||||
</IconContext.Provider>
|
||||
</TooltipProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</ApiProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -107,10 +107,10 @@ export function StatusBarMessagesProvider({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StatusBarMessagesContext.Provider
|
||||
<StatusBarMessagesContext
|
||||
value={{ messages, addMessage, removeMessage, clearMessages }}
|
||||
>
|
||||
{children}
|
||||
</StatusBarMessagesContext.Provider>
|
||||
</StatusBarMessagesContext>
|
||||
);
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ export function StreamingSettingsProvider({
|
||||
}, [allGroupsStreamingSettings, setPersistedGroupStreamingSettings]);
|
||||
|
||||
return (
|
||||
<StreamingSettingsContext.Provider
|
||||
<StreamingSettingsContext
|
||||
value={{
|
||||
allGroupsStreamingSettings,
|
||||
setAllGroupsStreamingSettings,
|
||||
@ -52,7 +52,7 @@ export function StreamingSettingsProvider({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StreamingSettingsContext.Provider>
|
||||
</StreamingSettingsContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -124,9 +124,9 @@ export function ThemeProvider({
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
<ThemeProviderContext {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
</ThemeProviderContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -31,25 +31,24 @@ export function useResizeObserver(...refs: RefType[]) {
|
||||
[],
|
||||
);
|
||||
|
||||
// Resolve refs to actual DOM elements for use as stable effect dependencies.
|
||||
// Rest params create a new array each call, but the underlying elements are
|
||||
// stable DOM nodes, so spreading them into the dep array avoids re-running
|
||||
// the effect on every render.
|
||||
const elements = refs.map((ref) =>
|
||||
ref instanceof Window ? document.body : ref.current,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refs.forEach((ref) => {
|
||||
if (ref instanceof Window) {
|
||||
resizeObserver.observe(document.body);
|
||||
} else if (ref.current) {
|
||||
resizeObserver.observe(ref.current);
|
||||
}
|
||||
elements.forEach((el) => {
|
||||
if (el) resizeObserver.observe(el);
|
||||
});
|
||||
|
||||
return () => {
|
||||
refs.forEach((ref) => {
|
||||
if (ref instanceof Window) {
|
||||
resizeObserver.unobserve(document.body);
|
||||
} else if (ref.current) {
|
||||
resizeObserver.unobserve(ref.current);
|
||||
}
|
||||
});
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [refs, resizeObserver]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...elements, resizeObserver]);
|
||||
|
||||
if (dimensions.length == refs.length) {
|
||||
return dimensions;
|
||||
|
||||
@ -8,14 +8,19 @@ import {
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { MotionData, ReviewSegment } from "@/types/review";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTimelineUtils } from "./use-timeline-utils";
|
||||
import { AudioDetection, ObjectType } from "@/types/ws";
|
||||
import { useTimelineUtils } from "./use-timeline-utils";
|
||||
import useDeepMemo from "./use-deep-memo";
|
||||
import { isEqual } from "lodash";
|
||||
import { useAutoFrigateStats } from "./use-stats";
|
||||
import useSWR from "swr";
|
||||
import { getAttributeLabels } from "@/utils/iconUtil";
|
||||
|
||||
export type MotionOnlyRange = {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
};
|
||||
|
||||
type useCameraActivityReturn = {
|
||||
enabled?: boolean;
|
||||
activeTracking: boolean;
|
||||
@ -204,9 +209,9 @@ export function useCameraMotionNextTimestamp(
|
||||
return [];
|
||||
}
|
||||
|
||||
const ranges = [];
|
||||
let currentSegmentStart = null;
|
||||
let currentSegmentEnd = null;
|
||||
const ranges: [number, number][] = [];
|
||||
let currentSegmentStart: number | null = null;
|
||||
let currentSegmentEnd: number | null = null;
|
||||
|
||||
// align motion start to timeline start
|
||||
const offset =
|
||||
@ -215,13 +220,19 @@ export function useCameraMotionNextTimestamp(
|
||||
segmentDuration;
|
||||
|
||||
const startIndex = Math.abs(Math.floor(offset / 15));
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
for (
|
||||
let i = startIndex;
|
||||
i < motionData.length;
|
||||
i = i + segmentDuration / 15
|
||||
) {
|
||||
const motionStart = motionData[i].start_time;
|
||||
const motionStart = motionData[i]?.start_time;
|
||||
|
||||
if (motionStart == undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const motionEnd = motionStart + segmentDuration;
|
||||
|
||||
const segmentMotion = motionData
|
||||
@ -230,10 +241,10 @@ export function useCameraMotionNextTimestamp(
|
||||
const overlappingReviewItems = reviewItems.some(
|
||||
(item) =>
|
||||
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
||||
((item.end_time ?? Date.now() / 1000) > motionStart &&
|
||||
(item.end_time ?? Date.now() / 1000) <= motionEnd) ||
|
||||
((item.end_time ?? now) > motionStart &&
|
||||
(item.end_time ?? now) <= motionEnd) ||
|
||||
(item.start_time <= motionStart &&
|
||||
(item.end_time ?? Date.now() / 1000) >= motionEnd),
|
||||
(item.end_time ?? now) >= motionEnd),
|
||||
);
|
||||
|
||||
if (!segmentMotion || overlappingReviewItems) {
|
||||
@ -241,16 +252,14 @@ export function useCameraMotionNextTimestamp(
|
||||
currentSegmentStart = motionStart;
|
||||
}
|
||||
currentSegmentEnd = motionEnd;
|
||||
} else {
|
||||
if (currentSegmentStart !== null) {
|
||||
ranges.push([currentSegmentStart, currentSegmentEnd]);
|
||||
currentSegmentStart = null;
|
||||
currentSegmentEnd = null;
|
||||
}
|
||||
} else if (currentSegmentStart !== null && currentSegmentEnd !== null) {
|
||||
ranges.push([currentSegmentStart, currentSegmentEnd]);
|
||||
currentSegmentStart = null;
|
||||
currentSegmentEnd = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegmentStart !== null) {
|
||||
if (currentSegmentStart !== null && currentSegmentEnd !== null) {
|
||||
ranges.push([currentSegmentStart, currentSegmentEnd]);
|
||||
}
|
||||
|
||||
@ -304,3 +313,93 @@ export function useCameraMotionNextTimestamp(
|
||||
|
||||
return nextTimestamp;
|
||||
}
|
||||
|
||||
export function useCameraMotionOnlyRanges(
|
||||
segmentDuration: number,
|
||||
reviewItems: ReviewSegment[],
|
||||
motionData: MotionData[],
|
||||
) {
|
||||
const motionOnlyRanges = useMemo(() => {
|
||||
if (!motionData?.length || !reviewItems) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fallbackBucketDuration = Math.max(1, segmentDuration / 2);
|
||||
const normalizedMotionData = Array.from(
|
||||
motionData
|
||||
.reduce((accumulator, item) => {
|
||||
const currentMotion = accumulator.get(item.start_time) ?? 0;
|
||||
accumulator.set(
|
||||
item.start_time,
|
||||
Math.max(currentMotion, item.motion ?? 0),
|
||||
);
|
||||
return accumulator;
|
||||
}, new Map<number, number>())
|
||||
.entries(),
|
||||
)
|
||||
.map(([start_time, motion]) => ({ start_time, motion }))
|
||||
.sort((left, right) => left.start_time - right.start_time);
|
||||
|
||||
const bucketRanges: MotionOnlyRange[] = [];
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
for (let i = 0; i < normalizedMotionData.length; i++) {
|
||||
const motionStart = normalizedMotionData[i].start_time;
|
||||
const motionEnd = motionStart + fallbackBucketDuration;
|
||||
|
||||
const overlappingReviewItems = reviewItems.some(
|
||||
(item) =>
|
||||
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
||||
((item.end_time ?? now) > motionStart &&
|
||||
(item.end_time ?? now) <= motionEnd) ||
|
||||
(item.start_time <= motionStart &&
|
||||
(item.end_time ?? now) >= motionEnd),
|
||||
);
|
||||
|
||||
const isMotionOnlySegment =
|
||||
(normalizedMotionData[i].motion ?? 0) > 0 && !overlappingReviewItems;
|
||||
|
||||
if (!isMotionOnlySegment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bucketRanges.push({
|
||||
start_time: motionStart,
|
||||
end_time: motionEnd,
|
||||
});
|
||||
}
|
||||
|
||||
if (!bucketRanges.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mergedRanges = bucketRanges.reduce<MotionOnlyRange[]>(
|
||||
(ranges, range) => {
|
||||
if (!ranges.length) {
|
||||
return [range];
|
||||
}
|
||||
|
||||
const previousRange = ranges[ranges.length - 1];
|
||||
const isContiguous =
|
||||
range.start_time <= previousRange.end_time + 0.001 &&
|
||||
range.start_time >= previousRange.end_time - 0.001;
|
||||
|
||||
if (isContiguous) {
|
||||
previousRange.end_time = Math.max(
|
||||
previousRange.end_time,
|
||||
range.end_time,
|
||||
);
|
||||
return ranges;
|
||||
}
|
||||
|
||||
ranges.push(range);
|
||||
return ranges;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return mergedRanges;
|
||||
}, [motionData, reviewItems, segmentDuration]);
|
||||
|
||||
return motionOnlyRanges;
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { LiveStreamMetadata } from "@/types/live";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
const DEFER_DELAY_MS = 2000;
|
||||
const EMPTY_METADATA: { [key: string]: LiveStreamMetadata } = {};
|
||||
|
||||
/**
|
||||
* Hook that fetches go2rtc stream metadata with deferred loading.
|
||||
@ -77,7 +78,7 @@ export default function useDeferredStreamMetadata(streamNames: string[]) {
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const { data: metadata = {} } = useSWR<{
|
||||
const { data: metadata = EMPTY_METADATA } = useSWR<{
|
||||
[key: string]: LiveStreamMetadata;
|
||||
}>(swrKey, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
|
||||
@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
|
||||
import useUserInteraction from "./use-user-interaction";
|
||||
|
||||
type DraggableElementProps = {
|
||||
contentRef: React.RefObject<HTMLElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
segmentsRef: React.RefObject<HTMLDivElement>;
|
||||
draggableElementRef: React.RefObject<HTMLDivElement>;
|
||||
contentRef: React.RefObject<HTMLElement | null>;
|
||||
timelineRef: React.RefObject<HTMLDivElement | null>;
|
||||
segmentsRef: React.RefObject<HTMLDivElement | null>;
|
||||
draggableElementRef: React.RefObject<HTMLDivElement | null>;
|
||||
segmentDuration: number;
|
||||
showDraggableElement: boolean;
|
||||
draggableElementTime?: number;
|
||||
|
||||
@ -78,7 +78,7 @@ function removeEventListeners(
|
||||
}
|
||||
|
||||
export function useFullscreen<T extends HTMLElement = HTMLElement>(
|
||||
elementRef: RefObject<T>,
|
||||
elementRef: RefObject<T | null>,
|
||||
) {
|
||||
const [fullscreen, setFullscreen] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const useImageLoaded = (): [
|
||||
React.RefObject<HTMLImageElement>,
|
||||
React.RefObject<HTMLImageElement | null>,
|
||||
boolean,
|
||||
() => void,
|
||||
] => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user