mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-10 21:01:10 +03:00
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* update homekit docs * update dictionary * preserve function names in production builds adds only 162kb gzipped/450k unzipped to the bundle * margin tweak * fix maximum update depth exceeded when dragging the timeline handlebar Dragging the handlebar, especially quickly or with fast direction changes, could exceed React's nested update limit and unmount the whole app, leaving a blank screen. Motion search was worst affected. The drag loop committed a new time into React state on every animation frame. Edge auto-scrolling mutates scrollTop each iteration, so the value always differed and React's same-value bail-out never engaged, letting the update chain run to the limit of 50. Pace those commits to one per 100ms and flush the pending value on release, so the drop position is still exact. The handlebar position and label are written to the DOM directly and remain at frame rate. useUserInteraction dispatched state on every scroll and touchmove event; only commit on the leading edge. Motion search also passed fresh array literals for the timeline's events, motion events and unavailable ranges, giving the segment memo and the drag effect new dependencies on every render. Both views also passed an inline arrow for onHandlebarDraggingChange, which is an effect dependency that calls setState. * Verify motion search jobs belong to the requested camera * Apply persisted profile and runtime overrides before workers start Worker processes are handed a copy of the config when they start and only learn about later changes from the config_updater broadcast, which is plain ZMQ PUB/SUB with no queue, ack, or retained value, so a message published before a subscriber has connected is dropped and never re-sent. The persisted profile and the runtime camera toggles were restored only by that broadcast, at the very end of startup, so a worker that lost the race kept its yaml values for the rest of the session: audio detection kept running on a camera whose audio had been toggled off, even though /api/config, the UI, and the runtime state file all showed it disabled. Split both restores into a config half and a publish half. ProfileManager.restore_persisted_profile_to_config() and Dispatcher.reapply_runtime_state_to_config() now run right after init_profile_manager(), before the first worker starts, so every worker is handed a config that already carries both layers. ProfileManager.restore_persisted_profile() and Dispatcher.restore_runtime_state() still run at the end of startup: the recording, review, and embeddings processes start before the dispatcher exists, so the broadcast remains their only channel, and MQTT needs the retained switch states. Both config passes have to stay after init_profile_manager(), which snapshots the config as the no-profile base that deactivation resets to. * End timeline drags on touchcancel
291 lines
8.9 KiB
Python
291 lines
8.9 KiB
Python
"""Motion search API for detecting changes within a region of interest."""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
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",
|
|
)
|
|
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: list[MotionSearchResult] | None = None
|
|
total_frames_processed: int | None = None
|
|
error_message: str | None = None
|
|
metrics: MotionSearchMetricsResponse | None = None
|
|
scanning_timestamp: float | None = None
|
|
progress: float | None = 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,
|
|
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 or job.camera != camera_name:
|
|
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()
|
|
|
|
response_content["scanning_timestamp"] = job.scanning_timestamp
|
|
response_content["progress"] = job.progress
|
|
|
|
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 or job.camera != camera_name:
|
|
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,
|
|
)
|