Skip to content

Commit 1be3b55

Browse files
committed
Narrow to the CLI, restoring the existing seek() behaviour
Issue #380 reports inconsistent CLI behaviour when the start time exceeds the video length. The previous revision went further and made seek() raise SeekError on every backend when the target was at or past duration. That broke real callers: save_images seeks near the end of variable-frame-rate video, where the backends report different nominal frame rates, so a rebased scene position now raised instead of clamping. It also contradicted the note in moviepy.py saying backends "should probably gracefully stop at the last frame instead of throwing", which that revision deleted along with the test in test_video_stream.py documenting the existing contract. Reverts the seek() changes in all three backends and restores both the note and the tests. What remains is the CLI-level rejection the issue asks for, plus a guard for OpenCV 5 returning NaN from CAP_PROP_POS_FRAMES after a seek past EOF, which crashed round() and hung the decode thread.
1 parent 056a80a commit 1be3b55

6 files changed

Lines changed: 21 additions & 16 deletions

File tree

‎scenedetect/backends/moviepy.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,6 @@ def seek(self, target: TimecodeLike):
246246
target = FrameTimecode(target, self.frame_rate)
247247
duration = self.duration
248248
assert duration is not None
249-
if duration > 0 and target >= duration:
250-
raise SeekError("Target frame is beyond end of video!")
251249
try:
252250
self._last_frame = _retry_on_oserror(
253251
"seek", lambda: self._reader.get_frame(target.seconds)
@@ -260,6 +258,11 @@ def seek(self, target: TimecodeLike):
260258
)
261259
success = True
262260
except OSError as ex:
261+
# TODO(https://scenedetect.com/issues/380): Other backends do not currently throw an
262+
# exception if attempting to seek past EOF.
263+
#
264+
# We need to ensure consistency for seeking past end of video with respect to errors and
265+
# behaviour, and should probably gracefully stop at the last frame instead of throwing.
263266
if target >= duration:
264267
raise SeekError("Target frame is beyond end of video!") from ex
265268
raise

‎scenedetect/backends/opencv.py‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,6 @@ def seek(self, target: TimecodeLike):
250250
target = FrameTimecode(target, self.frame_rate)
251251
if target < 0:
252252
raise ValueError("Target seek position cannot be negative!")
253-
duration = self.duration
254-
if duration is not None and duration > 0 and target >= duration:
255-
raise SeekError("Target frame is beyond end of video!")
256253
target_secs = (self.base_timecode + target).seconds
257254
self._has_grabbed = False
258255
if target_secs > 0:

‎scenedetect/backends/pyav.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
framerate_to_fraction,
3030
)
3131
from scenedetect.platform import StrPath, get_file_name
32-
from scenedetect.video_stream import FrameRateUnavailable, SeekError, VideoOpenFailure, VideoStream
32+
from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream
3333

3434
logger = getLogger("pyscenedetect")
3535
VALID_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"]
@@ -286,15 +286,12 @@ def seek(self, target: TimecodeLike) -> None:
286286
If float, interpreted as time in seconds.
287287
If int, interpreted as frame number.
288288
Raises:
289-
SeekError: An error occurs while seeking, or `target` is beyond the end of the video.
290289
ValueError: `target` is not a valid value (i.e. it is negative).
291290
"""
292291
if not isinstance(target, FrameTimecode):
293292
target = FrameTimecode(target, self.frame_rate)
294293
if target < 0:
295294
raise ValueError("Target cannot be negative!")
296-
if self.duration > 0 and target >= self.duration:
297-
raise SeekError("Target frame is beyond end of video!")
298295
beginning = target == 0
299296

300297
target = self.base_timecode + target

‎tests/test_backend_opencv.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222

2323
from scenedetect import ContentDetector, SceneManager
2424
from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2
25-
from scenedetect.video_stream import SeekError
2625

2726
GROUND_TRUTH_CAPTURE_ADAPTER_TEST = [1, 90, 210]
2827
GROUND_TRUTH_CAPTURE_ADAPTER_CALLBACK_TEST = [180, 394]
@@ -36,9 +35,7 @@ def test_open_image_sequence(test_image_sequence: str):
3635
assert sequence.duration is not None
3736
assert sequence.duration.frame_num == 30
3837
assert sequence.read() is not False
39-
with pytest.raises(SeekError):
40-
sequence.seek(100)
41-
sequence.seek(sequence.duration.frame_num - 1)
38+
sequence.seek(100)
4239
assert sequence.position == 29
4340

4441

‎tests/test_video_stream.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,21 @@ def test_seek_past_eof(
318318
):
319319
"""Validate calling `seek()` to offset past end of video."""
320320
stream = auto_close(vs_type(test_video.path))
321-
# All backends raise SeekError when the target is at or past duration (#380).
322-
with pytest.raises(SeekError):
321+
# Seek to a large seek offset past the end of the video. Some backends only support 32-bit
322+
# frame numbers so that's our max offset. Certain backends disallow seek offsets past EOF,
323+
# in which case they should raise a SeekError (and the test is considered a pass).
324+
try:
323325
stream.seek(2**32)
326+
except SeekError:
327+
return
328+
# For those backends that do allow seek offsets past EOF, they should act as though we
329+
# seeked to the end of the video (i.e. shouldn't be able to decode any more frames).
330+
assert stream.read() is False
331+
# TODO: On some videos, the PyAV backend seems to drop a frame. See where this occurs.
332+
if vs_type == VideoStreamAv:
333+
assert stream.frame_number in (test_video.total_frames, test_video.total_frames - 1)
334+
else:
335+
assert stream.frame_number == test_video.total_frames
324336

325337
def test_seek_invalid(
326338
self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close

‎website/pages/changelog.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ PySceneDetect 0.7.1 adds support for concatenating multiple videos, along with s
1414

1515
#### API Changes
1616

17-
- [bugfix] `VideoStream.seek()` now raises `SeekError` on every backend when the target is at or past the video duration [#380](https://github.com/Breakthrough/PySceneDetect/issues/380)
1817
- [feature] `scenedetect.detect()` now accepts a `backend` keyword argument (`"opencv"`, `"pyav"`, or `"moviepy"`) similar to `open_video`. Defaults to `"opencv"`, matching prior behavior.
1918
- [feature] Add `expand_scenes_to_bounds()` helper in `scenedetect.scene_manager` to extend a scene list so the first scene starts at a given lower bound and the last scene ends at a given upper bound
2019
- [feature] `VideoStream` now provides a public read-only `decode_failures` property reporting the number of frames that failed to decode and were skipped (defaults to 0; populated by the OpenCV and PyAV backends)

0 commit comments

Comments
 (0)