diff --git a/frigate/api/media.py b/frigate/api/media.py index 971bfef83..0ea4c487e 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]) @@ -900,6 +902,33 @@ async def vod_ts( if recording.end_time > end_ts: duration -= int((recording.end_time - end_ts) * 1000) + # 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) logger.debug( 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