Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions scenedetect/backends/opencv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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


Expand Down
10 changes: 10 additions & 0 deletions tests/test_vfr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions website/pages/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading