mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 19:06:52 +03:00
keep staged export chapters aligned across stream hand-offs
Each staged run of a mixed-stream export is rendered from its own pinned vod playlist, and that playlist's first clip snaps back to the preceding keyframe, so every staged file runs up to a GOP longer than its slice of the merged timeline. Chapters were placed on the merged timeline, so they drifted further from the video at every hand-off. Chapter windows for staged exports are now planned the same way each run's playlist is, carrying that keyframe lead-in into the offsets.
This commit is contained in:
+70
-13
@@ -43,6 +43,7 @@ from frigate.util.recording_coverage import (
|
||||
build_spans,
|
||||
known_video_codecs,
|
||||
null_audio_glitches,
|
||||
realized_timeline,
|
||||
resolve_coverage,
|
||||
stream_media_summary,
|
||||
)
|
||||
@@ -85,10 +86,20 @@ class StreamRun:
|
||||
|
||||
@dataclass
|
||||
class _ChapterWindow:
|
||||
"""A merged-timeline slice, shaped like the recording rows chapters read."""
|
||||
"""A merged-timeline slice, shaped like the recording rows chapters read.
|
||||
|
||||
lead_in is the output time the slice's vod clip plays before start_time,
|
||||
from snapping its first frame back to a keyframe.
|
||||
"""
|
||||
|
||||
start_time: float
|
||||
end_time: float
|
||||
lead_in: float = 0.0
|
||||
|
||||
|
||||
def _lead_in(recording: Any) -> float:
|
||||
"""Output seconds a chapter source plays before its first wall second."""
|
||||
return recording.lead_in if isinstance(recording, _ChapterWindow) else 0.0
|
||||
|
||||
|
||||
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
||||
@@ -437,17 +448,57 @@ class RecordingExporter(threading.Thread):
|
||||
# hand-off to stage around
|
||||
return True
|
||||
|
||||
spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._stream_runs(spans)
|
||||
_spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._planned_stream_runs()
|
||||
|
||||
# a range one stream covers end to end has nothing to hand off,
|
||||
# so it stays on the existing path however long it is
|
||||
if len(runs) < 2:
|
||||
return True
|
||||
|
||||
runs = [piece for run in runs for piece in self._split_long_run(run)]
|
||||
return self._stage_stream_runs(runs, codecs, keep_audio)
|
||||
|
||||
def _planned_stream_runs(self) -> list[StreamRun]:
|
||||
"""The runs a mixed range is staged as, one pinned vod playlist each."""
|
||||
runs = self._stream_runs(self._merged_spans())
|
||||
|
||||
if len(runs) < 2:
|
||||
return runs
|
||||
|
||||
return [piece for run in runs for piece in self._split_long_run(run)]
|
||||
|
||||
def _staged_chapter_windows(self) -> list[_ChapterWindow]:
|
||||
"""Chapter windows for the staged files as they were rendered.
|
||||
|
||||
Each staged run comes from its own pinned vod playlist, whose first
|
||||
clip snaps back to the preceding keyframe, so a staged file runs up
|
||||
to a GOP longer than its slice of the merged timeline. Planning each
|
||||
run the way its playlist does carries that lead-in into the chapter
|
||||
offsets instead of letting it accumulate at every hand-off.
|
||||
"""
|
||||
windows: list[_ChapterWindow] = []
|
||||
|
||||
for run in self._planned_stream_runs():
|
||||
intervals = null_audio_glitches(
|
||||
resolve_coverage(self.camera, run.start_time, run.end_time)
|
||||
)
|
||||
|
||||
for clip in realized_timeline(intervals, run.stream_type):
|
||||
# a skipped clip is absent from the playlist and the file
|
||||
if clip["duration"] <= 0:
|
||||
continue
|
||||
|
||||
span = clip["end_time"] - clip["start_time"]
|
||||
windows.append(
|
||||
_ChapterWindow(
|
||||
clip["start_time"],
|
||||
clip["end_time"],
|
||||
max(0.0, clip["duration"] / 1000 - span),
|
||||
)
|
||||
)
|
||||
|
||||
return windows
|
||||
|
||||
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
||||
"""Collapse the merged spans into contiguous runs of one stream type.
|
||||
|
||||
@@ -844,6 +895,8 @@ class RecordingExporter(threading.Thread):
|
||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||
if clipped_end <= clipped_start:
|
||||
continue
|
||||
# a staged window's keyframe lead-in plays before it
|
||||
output_offset += _lead_in(rec)
|
||||
windows.append((clipped_start, clipped_end, output_offset))
|
||||
output_offset += clipped_end - clipped_start
|
||||
|
||||
@@ -991,9 +1044,13 @@ class RecordingExporter(threading.Thread):
|
||||
if duration_ms <= 0:
|
||||
continue
|
||||
|
||||
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
# a staged window's keyframe lead-in opens its chapter, with
|
||||
# frames captured that long before the window
|
||||
lead_in = _lead_in(rec)
|
||||
duration_ms += int(round(lead_in * 1000))
|
||||
title = datetime.datetime.fromtimestamp(
|
||||
clipped_start - lead_in, tz=tz
|
||||
).isoformat(timespec="seconds")
|
||||
chapter_blocks.append(
|
||||
"[CHAPTER]\n"
|
||||
"TIMEBASE=1/1000\n"
|
||||
@@ -1132,12 +1189,12 @@ class RecordingExporter(threading.Thread):
|
||||
if self.staged_runs:
|
||||
# each run was already rendered to a temp file with a common
|
||||
# track timescale, so the concat demuxer has nothing left to
|
||||
# reconcile and every chapter offset lines up with the merged
|
||||
# timeline the staged files reproduce
|
||||
recordings = [
|
||||
_ChapterWindow(span_start, span_end)
|
||||
for _row, span_start, span_end, _is_main in self._merged_spans()
|
||||
]
|
||||
# reconcile
|
||||
recordings = (
|
||||
self._staged_chapter_windows()
|
||||
if self.chapters not in (None, ChaptersEnum.none)
|
||||
else []
|
||||
)
|
||||
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
||||
ffmpeg_input = (
|
||||
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
||||
|
||||
@@ -558,6 +558,40 @@ class TestExportTimelineAlignment(unittest.TestCase):
|
||||
|
||||
self.assertEqual(streams, ["sub"])
|
||||
|
||||
def test_staged_chapters_carry_keyframe_lead_in(self) -> None:
|
||||
exporter = _make_exporter(
|
||||
[
|
||||
_span("/m1.mp4", 1_000, 1_020, True),
|
||||
_span("/s1.mp4", 1_020, 1_040, False),
|
||||
],
|
||||
{"h264"},
|
||||
)
|
||||
exporter.config.ui.timezone = None
|
||||
|
||||
# each run's vod clip snaps 1.5s back to a keyframe
|
||||
def timeline(_intervals: list, stream: str) -> list[dict]:
|
||||
start, end = (1_000, 1_020) if stream == "main" else (1_020, 1_040)
|
||||
return [
|
||||
{
|
||||
"start_time": start,
|
||||
"end_time": end,
|
||||
"duration": (end - start + 1.5) * 1000,
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch("frigate.record.export.resolve_coverage", return_value=[]),
|
||||
patch("frigate.record.export.realized_timeline", side_effect=timeline),
|
||||
):
|
||||
windows = exporter._staged_chapter_windows()
|
||||
|
||||
path = exporter._build_recording_segment_chapter_metadata_file(windows)
|
||||
self.addCleanup(os.remove, path)
|
||||
content = Path(path).read_text()
|
||||
|
||||
self.assertIn("START=0\nEND=21500", content)
|
||||
self.assertIn("START=21500\nEND=43000", content)
|
||||
|
||||
|
||||
class TestStagedFileCleanup(unittest.TestCase):
|
||||
"""A staged path must be tracked before ffmpeg can write to it."""
|
||||
|
||||
Reference in New Issue
Block a user