drop motion search stall detection and range cap

This commit is contained in:
Josh Hawkins
2026-10-18 13:14:54 -05:00
parent 9d17e55052
commit babe15b9e8
8 changed files with 33 additions and 249 deletions
-12
View File
@@ -23,9 +23,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.motion_search]) router = APIRouter(tags=[Tags.motion_search])
# Covers the UI's day windows (25 h on a DST fall-back day) with room to spare.
MAX_SEARCH_RANGE_SECONDS = 48 * 3600
class MotionSearchRequest(BaseModel): class MotionSearchRequest(BaseModel):
"""Request body for motion search.""" """Request body for motion search."""
@@ -158,15 +155,6 @@ async def start_motion_search(
status_code=400, status_code=400,
) )
if body.end_time - body.start_time > MAX_SEARCH_RANGE_SECONDS:
return JSONResponse(
content={
"success": False,
"message": f"Time range must be {MAX_SEARCH_RANGE_SECONDS // 3600} hours or less",
},
status_code=400,
)
try: try:
job_id = start_motion_search_job( job_id = start_motion_search_job(
config=config, config=config,
+29 -55
View File
@@ -20,7 +20,6 @@ from frigate.jobs.job import Job
from frigate.jobs.motion_search_batch import ( from frigate.jobs.motion_search_batch import (
build_segment_time_map, build_segment_time_map,
coalesce_runs, coalesce_runs,
keyframes_cover_stream,
stream_time_to_absolute, stream_time_to_absolute,
) )
from frigate.jobs.motion_search_decode import ( from frigate.jobs.motion_search_decode import (
@@ -54,9 +53,6 @@ FALLBACK_SAMPLE_FPS = 2.0
MAX_ACTIVE_JOBS = 3 MAX_ACTIVE_JOBS = 3
# Finished jobs stay pollable for this long before eviction (seconds). # Finished jobs stay pollable for this long before eviction (seconds).
FINISHED_JOB_TTL = 600.0 FINISHED_JOB_TTL = 600.0
# Missing stream time beyond this marks a run as truncated, e.g. a segment the
# HLS demuxer skipped after a read timeout.
MAX_STREAM_HOLE_SECONDS = 5.0
# A cancelled job stops holding a slot after this long, even if its thread is wedged. # A cancelled job stops holding a slot after this long, even if its thread is wedged.
CANCEL_GRACE_SECONDS = 60.0 CANCEL_GRACE_SECONDS = 60.0
@@ -654,44 +650,37 @@ class MotionSearchRunner(threading.Thread):
kf_pts = probe_vod_keyframe_pts( kf_pts = probe_vod_keyframe_pts(
self.ffprobe_path, vod_url, self._should_stop self.ffprobe_path, vod_url, self._should_stop
) )
# Probe and decode fetch the stream separately, so equal counts only frames = list(
# pair frames with timestamps when the probe saw the whole stream. iter_vod_frames(
if keyframes_cover_stream( self.ffmpeg_path,
kf_pts, _stream_duration(time_map), MAX_STREAM_HOLE_SECONDS vod_url,
): self.scaled[0],
frames = list( self.scaled[1],
iter_vod_frames( self.channels,
self.ffmpeg_path, self.decode_args,
vod_url, self.crop,
self.scaled[0], self.scaled,
self.scaled[1], True,
self.channels, self._should_stop,
self.decode_args, skip_nonkey=True,
self.crop, fps_rate=None,
self.scaled,
True,
self._should_stop,
skip_nonkey=True,
fps_rate=None,
)
) )
if len(frames) == len(kf_pts): )
abs_times = [stream_time_to_absolute(time_map, p) for p in kf_pts] if kf_pts and len(frames) == len(kf_pts):
indexed = list(enumerate(frames)) abs_times = [stream_time_to_absolute(time_map, p) for p in kf_pts]
indexed = list(enumerate(frames))
def _ts_kf(i: int) -> float: def _ts_kf(i: int) -> float:
return abs_times[i] return abs_times[i]
results = self._detect_with_progress(indexed, _ts_kf) results = self._detect_with_progress(indexed, _ts_kf)
return results, len(frames) return results, len(frames)
logger.debug( logger.debug(
"Keyframe count mismatch (%d decoded vs %d probed), using cadence", "Keyframe count mismatch (%d decoded vs %d probed), using cadence",
len(frames), len(frames),
len(kf_pts), len(kf_pts),
) )
else:
logger.debug("Keyframe probe has a gap, using cadence")
return self._process_run_cadence(vod_url, time_map) return self._process_run_cadence(vod_url, time_map)
@@ -715,15 +704,6 @@ class MotionSearchRunner(threading.Thread):
fps_rate=self.fps_rate, fps_rate=self.fps_rate,
) )
) )
expected = _stream_duration(time_map) * self.fps_rate
if (
not self._should_stop()
and expected - len(frames) > MAX_STREAM_HOLE_SECONDS * self.fps_rate
):
raise RuntimeError(
f"VOD decode returned {len(frames)} of ~{expected:.0f} frames"
)
indexed = list(enumerate(frames)) indexed = list(enumerate(frames))
def _ts_fps(i: int) -> float: def _ts_fps(i: int) -> float:
@@ -886,11 +866,6 @@ def _evict_finished_jobs(now: float) -> None:
del _motion_search_jobs[job_id] del _motion_search_jobs[job_id]
def _stream_duration(time_map: list[tuple[float, float, float]]) -> float:
offset, _, duration = time_map[-1]
return offset + duration
def _is_active(job: MotionSearchJob, now: float) -> bool: def _is_active(job: MotionSearchJob, now: float) -> bool:
"""True while the job's thread may still be running and holding a slot.""" """True while the job's thread may still be running and holding a slot."""
if job.end_time is not None: if job.end_time is not None:
@@ -943,10 +918,9 @@ def start_motion_search_job(
now = datetime.now().timestamp() now = datetime.now().timestamp()
_evict_finished_jobs(now) _evict_finished_jobs(now)
# A cancelled job keeps its thread until the runner sets end_time, so # A cancelled job keeps its thread until the runner sets end_time, so it
# count threads rather than statuses. # still counts toward the global cap. Its user may start a new search.
active = [j for j, _ in _motion_search_jobs.values() if _is_active(j, now)] active = [j for j, _ in _motion_search_jobs.values() if _is_active(j, now)]
# The UI forgets a cancelled job at once, so it only counts globally.
if username is not None and any( if username is not None and any(
j.username == username and j.status != JobStatusTypesEnum.cancelled j.username == username and j.status != JobStatusTypesEnum.cancelled
for j in active for j in active
-11
View File
@@ -73,14 +73,3 @@ def stream_time_to_absolute(
idx = 0 idx = 0
stream_offset, abs_start, _duration = time_map[idx] stream_offset, abs_start, _duration = time_map[idx]
return abs_start + (stream_time - stream_offset) return abs_start + (stream_time - stream_offset)
def keyframes_cover_stream(pts: list[float], duration: float, max_hole: float) -> bool:
"""True if sorted keyframe ``pts`` span 0..``duration`` with no gap over ``max_hole``.
A segment skipped or cut short by a read timeout leaves such a gap.
"""
if not pts:
return False
edges = [0.0, *pts, duration]
return max(b - a for a, b in zip(edges, edges[1:])) <= max_hole
+2 -17
View File
@@ -107,16 +107,7 @@ def keyframe_sampling_eligible(
return max(gaps) <= max_gap return max(gaps) <= max_gap
# rw_timeout (microseconds) ends a stalled VOD read instead of blocking forever. VOD_PROTOCOL_ARGS = ["-protocol_whitelist", "pipe,file,http,tcp"]
# The HLS demuxer logs this at warning level when a segment read fails or times out.
HLS_SEGMENT_SKIPPED = b"failed too many times, skipping"
VOD_PROTOCOL_ARGS = [
"-protocol_whitelist",
"pipe,file,http,tcp",
"-rw_timeout",
"30000000",
]
def build_vod_decode_command( def build_vod_decode_command(
@@ -153,7 +144,7 @@ def build_vod_decode_command(
filters.append(f"scale={sw}:{sh}") filters.append(f"scale={sw}:{sh}")
pix_fmt = "gray" if gray else "bgr24" pix_fmt = "gray" if gray else "bgr24"
cmd = [ffmpeg_path, "-hide_banner", "-loglevel", "warning"] cmd = [ffmpeg_path, "-hide_banner", "-loglevel", "error"]
if skip_nonkey: if skip_nonkey:
cmd += ["-skip_frame", "nokey"] cmd += ["-skip_frame", "nokey"]
cmd += [*decode_args, *VOD_PROTOCOL_ARGS, "-i", vod_url, "-an"] cmd += [*decode_args, *VOD_PROTOCOL_ARGS, "-i", vod_url, "-an"]
@@ -215,13 +206,8 @@ def _run_vod_decode(
yield frame yield frame
finally: finally:
terminate_ffmpeg_stream(proc) terminate_ffmpeg_stream(proc)
stderr_file.seek(0)
segment_skipped = HLS_SEGMENT_SKIPPED in stderr_file.read()
stderr_file.close() stderr_file.close()
if segment_skipped and not should_stop():
raise RuntimeError("VOD decode skipped a segment that failed to load")
if count == 0 and software_retry and not should_stop(): if count == 0 and software_retry and not should_stop():
logger.warning("Hardware VOD decode produced no frames, retrying in software") logger.warning("Hardware VOD decode produced no frames, retrying in software")
yield from _run_vod_decode( yield from _run_vod_decode(
@@ -355,7 +341,6 @@ def probe_video_dimensions(
ffprobe_path, ffprobe_path,
"-v", "-v",
"error", "error",
*VOD_PROTOCOL_ARGS,
"-select_streams", "-select_streams",
"v:0", "v:0",
"-show_entries", "-show_entries",
@@ -97,14 +97,6 @@ class TestHttpMotionSearch(BaseTestHttp):
self.assertEqual(self.start(client, f"user{i}").status_code, 200) self.assertEqual(self.start(client, f"user{i}").status_code, 200)
self.assertEqual(self.start(client, "late").status_code, 429) self.assertEqual(self.start(client, "late").status_code, 429)
def test_over_long_range_rejected(self):
body = {**BODY, "end_time": BODY["start_time"] + 48 * 3600 + 1}
with AuthTestClient(self.app) as client:
resp = self.start(client, "alice", body)
self.assertEqual(resp.status_code, 400)
self.assertEqual(motion_search._motion_search_jobs, {})
def test_finished_jobs_evicted_after_ttl(self): def test_finished_jobs_evicted_after_ttl(self):
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
old_id = self.start(client, "alice").json()["job_id"] old_id = self.start(client, "alice").json()["job_id"]
-15
View File
@@ -6,7 +6,6 @@ from dataclasses import dataclass
from frigate.jobs.motion_search_batch import ( from frigate.jobs.motion_search_batch import (
build_segment_time_map, build_segment_time_map,
coalesce_runs, coalesce_runs,
keyframes_cover_stream,
stream_time_to_absolute, stream_time_to_absolute,
) )
@@ -57,17 +56,3 @@ class TestTimestampMapping(unittest.TestCase):
run = [_Seg("a", 1000.0, 1010.0)] run = [_Seg("a", 1000.0, 1010.0)]
time_map = build_segment_time_map(run) time_map = build_segment_time_map(run)
self.assertAlmostEqual(stream_time_to_absolute(time_map, 9.9), 1009.9) self.assertAlmostEqual(stream_time_to_absolute(time_map, 9.9), 1009.9)
class TestKeyframesCoverStream(unittest.TestCase):
def test_dense_keyframes_cover(self):
self.assertTrue(keyframes_cover_stream([0.5, 2.5, 4.5, 6.5, 8.5], 10.0, 5.0))
def test_gap_from_skipped_segment(self):
self.assertFalse(keyframes_cover_stream([0.5, 2.5, 12.5, 14.5], 20.0, 5.0))
def test_truncated_tail(self):
self.assertFalse(keyframes_cover_stream([0.5, 2.5, 4.5], 20.0, 5.0))
def test_empty(self):
self.assertFalse(keyframes_cover_stream([], 10.0, 5.0))
+2 -46
View File
@@ -1,6 +1,5 @@
"""Tests for the motion search hardware-accelerated decode helpers.""" """Tests for the motion search hardware-accelerated decode helpers."""
import io
import subprocess as sp import subprocess as sp
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
@@ -9,7 +8,6 @@ from unittest import mock
from frigate.jobs.motion_search_decode import ( from frigate.jobs.motion_search_decode import (
KEYFRAME_MAX_GAP_SECONDS, KEYFRAME_MAX_GAP_SECONDS,
build_vod_decode_command, build_vod_decode_command,
iter_vod_frames,
keyframe_sampling_eligible, keyframe_sampling_eligible,
probe_video_dimensions, probe_video_dimensions,
probe_vod_keyframe_pts, probe_vod_keyframe_pts,
@@ -104,7 +102,6 @@ class TestVodDecodeCommand(unittest.TestCase):
joined = " ".join(cmd) joined = " ".join(cmd)
self.assertIn("-skip_frame nokey", joined) self.assertIn("-skip_frame nokey", joined)
self.assertIn("-protocol_whitelist pipe,file,http,tcp", joined) self.assertIn("-protocol_whitelist pipe,file,http,tcp", joined)
self.assertLess(cmd.index("-rw_timeout"), cmd.index("-i"))
self.assertIn(f"-i {self.URL}", joined) self.assertIn(f"-i {self.URL}", joined)
self.assertIn("crop=100:80:10:20", joined) self.assertIn("crop=100:80:10:20", joined)
self.assertIn("scale=50:40", joined) self.assertIn("scale=50:40", joined)
@@ -143,44 +140,6 @@ class TestVodDecodeCommand(unittest.TestCase):
self.assertIn("format=nv12", joined) self.assertIn("format=nv12", joined)
class TestVodDecodeSkippedSegment(unittest.TestCase):
def _decode(self, stderr_output: bytes) -> list:
def popen(cmd, stdout, stderr):
stderr.write(stderr_output)
return mock.Mock(stdout=io.BytesIO(b"\0" * 8))
with (
mock.patch("frigate.jobs.motion_search_decode.sp.Popen", side_effect=popen),
mock.patch("frigate.jobs.motion_search_decode.terminate_ffmpeg_stream"),
):
return list(
iter_vod_frames(
"ffmpeg",
"http://x/index.m3u8",
2,
2,
1,
[],
None,
None,
True,
lambda: False,
skip_nonkey=False,
fps_rate=2.0,
)
)
def test_clean_decode_returns_frames(self):
self.assertEqual(len(self._decode(b"")), 2)
def test_skipped_segment_raises(self):
stderr = (
b"[hls @ 0x1] Segment 1 of playlist 0 failed too many times, skipping\n"
)
with self.assertRaises(RuntimeError):
self._decode(stderr)
class TestProbeVodKeyframePts(unittest.TestCase): class TestProbeVodKeyframePts(unittest.TestCase):
def _proc(self, stdout="", returncode=0): def _proc(self, stdout="", returncode=0):
proc = mock.Mock(returncode=returncode) proc = mock.Mock(returncode=returncode)
@@ -197,13 +156,11 @@ class TestProbeVodKeyframePts(unittest.TestCase):
with mock.patch( with mock.patch(
"frigate.jobs.motion_search_decode.sp.Popen", "frigate.jobs.motion_search_decode.sp.Popen",
return_value=self._proc(sample), return_value=self._proc(sample),
) as popen: ):
pts = probe_vod_keyframe_pts( pts = probe_vod_keyframe_pts(
"ffprobe", "http://x/index.m3u8", lambda: False "ffprobe", "http://x/index.m3u8", lambda: False
) )
self.assertEqual(pts, [0.0, 2.0]) self.assertEqual(pts, [0.0, 2.0])
cmd = popen.call_args.args[0]
self.assertLess(cmd.index("-rw_timeout"), cmd.index("-i"))
def test_returns_empty_on_failure(self): def test_returns_empty_on_failure(self):
with mock.patch( with mock.patch(
@@ -237,10 +194,9 @@ class TestProbeVideoDimensions(unittest.TestCase):
completed = mock.Mock(stdout=sample, returncode=0) completed = mock.Mock(stdout=sample, returncode=0)
with mock.patch( with mock.patch(
"frigate.jobs.motion_search_decode.sp.run", return_value=completed "frigate.jobs.motion_search_decode.sp.run", return_value=completed
) as run: ):
dims = probe_video_dimensions("ffprobe", "/tmp/a.mp4") dims = probe_video_dimensions("ffprobe", "/tmp/a.mp4")
assert dims is not None assert dims is not None
self.assertIn("-rw_timeout", run.call_args.args[0])
width, height, fps = dims width, height, fps = dims
self.assertEqual((width, height), (1920, 1080)) self.assertEqual((width, height), (1920, 1080))
self.assertAlmostEqual(fps, 29.97, places=2) self.assertAlmostEqual(fps, 29.97, places=2)
-85
View File
@@ -1,85 +0,0 @@
"""Tests for motion search run decoding and truncation checks."""
import threading
import unittest
from types import SimpleNamespace
from unittest import mock
import numpy as np
from frigate.jobs.motion_search import MotionSearchJob, MotionSearchRunner
# Two contiguous 10 s segments, so the run's stream is 20 s long.
RUN = [
SimpleNamespace(start_time=1000.0, end_time=1010.0),
SimpleNamespace(start_time=1010.0, end_time=1020.0),
]
FRAME = np.zeros((2, 2), dtype=np.uint8)
class TestProcessRun(unittest.TestCase):
def setUp(self):
requestor = mock.patch("frigate.jobs.motion_search.InterProcessRequestor")
requestor.start()
self.addCleanup(requestor.stop)
self.runner = MotionSearchRunner(
MotionSearchJob(camera="cam"), mock.Mock(), threading.Event()
)
self.runner.fps_rate = 2.0
self.runner._detect_with_progress = mock.Mock(return_value=[])
def _process(self, kf_pts, keyframe_frames, cadence_frames):
def frames(*args, skip_nonkey, fps_rate):
count = keyframe_frames if skip_nonkey else cadence_frames
return iter([FRAME] * count)
with (
mock.patch(
"frigate.jobs.motion_search.probe_vod_keyframe_pts",
return_value=kf_pts,
),
mock.patch(
"frigate.jobs.motion_search.iter_vod_frames", side_effect=frames
) as decode,
):
result = self.runner._process_run(RUN)
return result, [c.kwargs["skip_nonkey"] for c in decode.call_args_list]
def test_complete_keyframe_probe_pairs_timestamps(self):
pts = [0.5, 2.5, 4.5, 6.5, 8.5, 10.5, 12.5, 14.5, 16.5, 18.5]
(_, frames), modes = self._process(pts, len(pts), 0)
self.assertEqual(frames, len(pts))
self.assertEqual(modes, [True])
ts_fn = self.runner._detect_with_progress.call_args.args[1]
self.assertAlmostEqual(ts_fn(5), 1010.5)
def test_keyframe_probe_gap_falls_back_to_cadence(self):
# Probe lost the second segment; an equal decode count must not be trusted.
pts = [0.5, 2.5, 4.5, 6.5, 8.5]
(_, frames), modes = self._process(pts, len(pts), 40)
self.assertEqual(modes, [False])
self.assertEqual(frames, 40)
def test_cadence_shortfall_raises(self):
self.runner.use_keyframe = False
with self.assertRaises(RuntimeError):
self._process([], 0, 20)
def test_zero_frames_raises(self):
self.runner.use_keyframe = False
with self.assertRaises(RuntimeError):
self._process([], 0, 0)
def test_small_cadence_drift_is_tolerated(self):
self.runner.use_keyframe = False
(_, frames), _ = self._process([], 0, 38)
self.assertEqual(frames, 38)
def test_shortfall_after_stop_does_not_raise(self):
self.runner.use_keyframe = False
self.runner.cancel_event.set()
(_, frames), _ = self._process([], 0, 3)
self.assertEqual(frames, 3)