From ffe40932a05fb7a2e4b0dbeba27666670e23fe2b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:31:13 -0600 Subject: [PATCH] snap to keyframe instead of arbitrarily subtracting time --- frigate/api/media.py | 43 ++++++++++++++++++++---------- frigate/util/media.py | 61 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 frigate/util/media.py diff --git a/frigate/api/media.py b/frigate/api/media.py index a86230b53..a6289a2e0 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -50,10 +50,12 @@ from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment from frigate.track.object_processing import TrackedObjectProcessor from frigate.util.file import get_event_thumbnail_bytes from frigate.util.image import get_image_from_recording +from frigate.util.media import get_keyframe_before from frigate.util.time import get_dst_transitions logger = logging.getLogger(__name__) + router = APIRouter(tags=[Tags.media]) @@ -870,7 +872,6 @@ async def vod_ts( clips = [] durations = [] min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame - min_hls_segment_ms = 2000 # Minimum 2s for HLS playback compatibility max_duration_ms = MAX_SEGMENT_DURATION * 1000 recording: Recordings @@ -901,20 +902,34 @@ async def vod_ts( if recording.end_time > end_ts: duration -= int((recording.end_time - end_ts) * 1000) - # if segment is too short for reliable HLS playback and was trimmed - # by clipFrom, pull clipFrom back to ensure a minimum playable length - if duration < min_hls_segment_ms and "clipFrom" in clip: - shortage = min_hls_segment_ms - duration - new_inpoint = max(0, clip["clipFrom"] - shortage) - gained = clip["clipFrom"] - new_inpoint - clip["clipFrom"] = new_inpoint - duration += gained - logger.debug( - "VOD: extended clip %s by pulling clipFrom back to %sms, duration now %sms", - recording.path, - new_inpoint, - duration, + # nginx-vod-module pushes clipFrom forward to the next keyframe, + # which can leave too few frames and produce an empty/unplayable + # segment. Snap clipFrom back to the preceding keyframe so the + # segment always starts with a decodable frame. + if "clipFrom" in clip: + keyframe_ms = get_keyframe_before( + recording.path, clip["clipFrom"] ) + if keyframe_ms is not None: + gained = clip["clipFrom"] - keyframe_ms + clip["clipFrom"] = keyframe_ms + duration += gained + logger.debug( + "VOD: snapped clipFrom to keyframe at %sms for %s, duration now %sms", + keyframe_ms, + recording.path, + duration, + ) + else: + # could not read keyframes, remove clipFrom to use full recording + logger.debug( + "VOD: no keyframe info for %s, removing clipFrom to use full recording", + recording.path, + ) + del clip["clipFrom"] + duration = int(recording.duration * 1000) + if recording.end_time > end_ts: + duration -= int((recording.end_time - end_ts) * 1000) if duration < min_duration_ms: # skip if the clip has no valid duration (too short to contain frames) diff --git a/frigate/util/media.py b/frigate/util/media.py new file mode 100644 index 000000000..406d51cf3 --- /dev/null +++ b/frigate/util/media.py @@ -0,0 +1,61 @@ +"""Utilities for media file inspection.""" + +import subprocess as sp + +from frigate.const import DEFAULT_FFMPEG_VERSION + +FFPROBE_PATH = ( + f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe" + if DEFAULT_FFMPEG_VERSION + else "ffprobe" +) + + +def get_keyframe_before(path: str, offset_ms: int) -> int | None: + """Get the timestamp (ms) of the last keyframe at or before offset_ms. + + Uses ffprobe packet index to read keyframe positions from the mp4 file. + Returns None if ffprobe fails or no keyframe is found before the offset. + """ + try: + result = sp.run( + [ + FFPROBE_PATH, + "-select_streams", + "v:0", + "-show_entries", + "packet=pts_time,flags", + "-of", + "csv=p=0", + "-loglevel", + "error", + path, + ], + capture_output=True, + timeout=5, + ) + except (sp.TimeoutExpired, FileNotFoundError): + return None + + if result.returncode != 0: + return None + + offset_s = offset_ms / 1000.0 + best_ms = None + for line in result.stdout.decode().strip().splitlines(): + parts = line.strip().split(",") + if len(parts) != 2: + continue + ts_str, flags = parts + if "K" not in flags: + continue + try: + ts = float(ts_str) + except ValueError: + continue + if ts <= offset_s: + best_ms = int(ts * 1000) + else: + break + + return best_ms