diff --git a/frigate/record/export.py b/frigate/record/export.py index 32df8be75..cf1506c37 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -420,6 +420,7 @@ class RecordingExporter(threading.Thread): return None total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0]) + last_recorded_end = windows[-1][1] def wall_to_output(t: float) -> float: t = max(float(self.start_time), min(float(self.end_time), t)) @@ -432,8 +433,18 @@ class RecordingExporter(threading.Thread): chapter_blocks: list[str] = [] for review in review_rows: + if review.start_time is None: + continue + # In-progress segments have a NULL end_time until the activity + # closes; clamp to the last recorded second so the chapter never + # extends past the actual video. + review_end = ( + float(review.end_time) + if review.end_time is not None + else last_recorded_end + ) start_out = wall_to_output(float(review.start_time)) - end_out = wall_to_output(float(review.end_time)) + end_out = wall_to_output(review_end) # Drop chapters that fall entirely in a recording gap, or are # too short to be navigable in a player. @@ -516,16 +527,14 @@ class RecordingExporter(threading.Thread): except DoesNotExist: return "" - diff = self.start_time - preview.start_time - minutes = int(diff / 60) - seconds = int(diff % 60) + diff = max(0.0, float(self.start_time) - float(preview.start_time)) ffmpeg_cmd = [ "/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support "-hide_banner", "-loglevel", "warning", "-ss", - f"00:{minutes}:{seconds}", + f"{diff:.3f}", "-i", preview.path, "-frames", diff --git a/frigate/test/test_export_progress.py b/frigate/test/test_export_progress.py index 903914815..62883c13a 100644 --- a/frigate/test/test_export_progress.py +++ b/frigate/test/test_export_progress.py @@ -499,5 +499,56 @@ class TestSchedulesCleanup(unittest.TestCase): assert job.id not in manager.jobs +class TestChapterMetadataInProgressReview(unittest.TestCase): + """Regression: in-progress review segments have end_time=NULL until the + activity closes. The chapter builder must clamp the chapter end to the + last recorded second instead of crashing on float(None).""" + + def _fake_select_returning(self, rows: list) -> MagicMock: + mock_query = MagicMock() + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.iterator.return_value = iter(rows) + return mock_query + + def test_in_progress_review_does_not_crash_and_clamps_to_last_recording( + self, + ) -> None: + exporter = _make_exporter(end_minus_start=200) + # Recordings cover [1000, 1150]; export window is [1000, 1200] so + # the last recorded second is 1150 (a 50s gap at the tail). + recordings = [ + MagicMock(start_time=1000.0, end_time=1150.0), + ] + in_progress = MagicMock( + start_time=1100.0, + end_time=None, + severity="alert", + data={"objects": ["person"]}, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + chapter_path = os.path.join(tmpdir, "chapters.txt") + exporter._chapter_metadata_path = lambda: chapter_path # type: ignore[method-assign] + + with patch( + "frigate.record.export.ReviewSegment.select", + return_value=self._fake_select_returning([in_progress]), + ): + result = exporter._build_chapter_metadata_file(recordings) + + assert result == chapter_path + with open(chapter_path) as f: + content = f.read() + + # Output time is windows[-1][1] - windows[-1][0] = 150s. + # Review starts at wall=1100, output offset = 100s -> 100000ms. + # Clamped end = last_recorded_end (1150) -> output offset = 150s -> 150000ms. + assert "[CHAPTER]" in content + assert "START=100000" in content + assert "END=150000" in content + assert "title=Alert: person" in content + + if __name__ == "__main__": unittest.main()