diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 9e2ab094..5abe78c6 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -238,6 +238,8 @@ def timecode(self) -> Timecode: # *NOTE*: Although OpenCV has `CAP_PROP_PTS`, it doesn't seem to be reliable. For now, we # use `CAP_PROP_POS_MSEC` instead, converting to microseconds for sufficient precision to # avoid frame-boundary rounding errors at common framerates like 24000/1001. + if self._eof_timecode is not None: + return self._eof_timecode ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) time_base = Fraction(1, 1000000) return Timecode(pts=round(ms * 1000), time_base=time_base) @@ -272,6 +274,7 @@ def seek(self, target: TimecodeLike): raise ValueError("Target seek position cannot be negative!") target_secs = (self.base_timecode + target).seconds self._has_grabbed = False + self._eof_timecode = None if target_secs > 0: # Seek one frame before target so the next read() returns the frame at target. one_frame_ms = 1000.0 / float(self._frame_rate) @@ -310,6 +313,9 @@ def reset(self): def read(self, decode: bool = True) -> np.ndarray | bool: if not self._cap.isOpened(): return False + # At the end of some VFR streams OpenCV reports an earlier CAP_PROP_POS_MSEC once grab() + # fails, so keep `position` on the last frame read like the other backends do (#561). + last_timecode = self.timecode if self._has_grabbed else None has_grabbed = self._cap.grab() # If we failed to grab the frame, retry a few times if required. if not has_grabbed: @@ -327,8 +333,10 @@ def read(self, decode: bool = True) -> np.ndarray | bool: logger.warning("Failed to decode some frames, results may be inaccurate.") # We didn't manage to grab a frame even after retrying, so just return. if not has_grabbed: + self._eof_timecode = last_timecode return False self._has_grabbed = True + self._eof_timecode = None # Need to make sure we actually grabbed a frame before calling retrieve. if decode and self._has_grabbed: _, frame = self._cap.retrieve() @@ -391,6 +399,7 @@ def _open_capture(self, frame_rate: FrameRate | None = None): self._cap: cv2.VideoCapture = cap self._frame_rate: Fraction = framerate_to_fraction(frame_rate) self._has_grabbed = False + self._eof_timecode: Timecode | None = None cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 1.0) # https://github.com/opencv/opencv/issues/26795 diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 0a6989e1..ce8589c1 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -95,6 +95,16 @@ def test_vfr_position_monotonic_opencv(test_vfr_video: str, auto_close): assert frame_count > 0 +def test_vfr_position_after_eof_opencv(test_vfr_drop3_video: str, auto_close): + """Position should stay on the last frame once read() hits the end of the video (#561).""" + video = auto_close(open_video(test_vfr_drop3_video, backend="opencv")) + last_position = None + while video.read() is not False: + last_position = video.position + assert last_position is not None + assert video.position == last_position + + @pytest.mark.parametrize("backend", ["pyav", "opencv"]) def test_vfr_scene_detection(test_vfr_video: str, backend: str, auto_close): """Scene detection on VFR video should produce timestamps matching known ground truth. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index a1c6fbbe..bce462df 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -6,6 +6,7 @@ ### Unreleased - [bugfix] OTIO exports use the full source duration when provided, including from the CLI, instead of declaring a fixed 1,980-frame duration. The available range remains unspecified when the duration is unknown. Scene source ranges are unchanged. [#580](https://github.com/Breakthrough/PySceneDetect/pull/580) + - [bugfix] The OpenCV backend keeps `position` on the last frame after reaching the end of a VFR video, so the last scene no longer ends early (or before it starts) [#561](https://github.com/Breakthrough/PySceneDetect/issues/561) ### PySceneDetect 0.7.2 (October 11, 2026)