mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-11 01:02:48 +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,
|
build_spans,
|
||||||
known_video_codecs,
|
known_video_codecs,
|
||||||
null_audio_glitches,
|
null_audio_glitches,
|
||||||
|
realized_timeline,
|
||||||
resolve_coverage,
|
resolve_coverage,
|
||||||
stream_media_summary,
|
stream_media_summary,
|
||||||
)
|
)
|
||||||
@@ -85,10 +86,20 @@ class StreamRun:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _ChapterWindow:
|
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
|
start_time: float
|
||||||
end_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).
|
# 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
|
# hand-off to stage around
|
||||||
return True
|
return True
|
||||||
|
|
||||||
spans, codecs, keep_audio = self._resolve_coverage()
|
_spans, codecs, keep_audio = self._resolve_coverage()
|
||||||
runs = self._stream_runs(spans)
|
runs = self._planned_stream_runs()
|
||||||
|
|
||||||
# a range one stream covers end to end has nothing to hand off,
|
# a range one stream covers end to end has nothing to hand off,
|
||||||
# so it stays on the existing path however long it is
|
# so it stays on the existing path however long it is
|
||||||
if len(runs) < 2:
|
if len(runs) < 2:
|
||||||
return True
|
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)
|
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]:
|
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
||||||
"""Collapse the merged spans into contiguous runs of one stream type.
|
"""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))
|
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||||
if clipped_end <= clipped_start:
|
if clipped_end <= clipped_start:
|
||||||
continue
|
continue
|
||||||
|
# a staged window's keyframe lead-in plays before it
|
||||||
|
output_offset += _lead_in(rec)
|
||||||
windows.append((clipped_start, clipped_end, output_offset))
|
windows.append((clipped_start, clipped_end, output_offset))
|
||||||
output_offset += clipped_end - clipped_start
|
output_offset += clipped_end - clipped_start
|
||||||
|
|
||||||
@@ -991,9 +1044,13 @@ class RecordingExporter(threading.Thread):
|
|||||||
if duration_ms <= 0:
|
if duration_ms <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
# a staged window's keyframe lead-in opens its chapter, with
|
||||||
timespec="seconds"
|
# 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_blocks.append(
|
||||||
"[CHAPTER]\n"
|
"[CHAPTER]\n"
|
||||||
"TIMEBASE=1/1000\n"
|
"TIMEBASE=1/1000\n"
|
||||||
@@ -1132,12 +1189,12 @@ class RecordingExporter(threading.Thread):
|
|||||||
if self.staged_runs:
|
if self.staged_runs:
|
||||||
# each run was already rendered to a temp file with a common
|
# each run was already rendered to a temp file with a common
|
||||||
# track timescale, so the concat demuxer has nothing left to
|
# track timescale, so the concat demuxer has nothing left to
|
||||||
# reconcile and every chapter offset lines up with the merged
|
# reconcile
|
||||||
# timeline the staged files reproduce
|
recordings = (
|
||||||
recordings = [
|
self._staged_chapter_windows()
|
||||||
_ChapterWindow(span_start, span_end)
|
if self.chapters not in (None, ChaptersEnum.none)
|
||||||
for _row, span_start, span_end, _is_main in self._merged_spans()
|
else []
|
||||||
]
|
)
|
||||||
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
||||||
ffmpeg_input = (
|
ffmpeg_input = (
|
||||||
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
||||||
|
|||||||
@@ -558,6 +558,40 @@ class TestExportTimelineAlignment(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(streams, ["sub"])
|
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):
|
class TestStagedFileCleanup(unittest.TestCase):
|
||||||
"""A staged path must be tracked before ffmpeg can write to it."""
|
"""A staged path must be tracked before ffmpeg can write to it."""
|
||||||
|
|||||||
Reference in New Issue
Block a user