Skip to content

Commit a432a76

Browse files
committed
[backends] Fix MoviePy 2.0 EOF behavior
Skip corrupt video test on MoviePy awaiting Zulko/moviepy#2253
1 parent f1396a9 commit a432a76

File tree

3 files changed

+47
-20
lines changed

3 files changed

+47
-20
lines changed

scenedetect/_cli/controller.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
import os
1717
import time
1818
import typing as ty
19+
import warnings
1920

2021
from scenedetect._cli.context import CliContext
22+
from scenedetect.backends import VideoStreamCv2, VideoStreamMoviePy
2123
from scenedetect.frame_timecode import FrameTimecode
2224
from scenedetect.platform import get_and_create_path
2325
from scenedetect.scene_manager import CutList, SceneList, get_scenes_from_cuts
@@ -39,6 +41,11 @@ def run_scenedetect(context: CliContext):
3941
logger.debug("No input specified.")
4042
return
4143

44+
# Suppress warnings when reading past EOF in MoviePy.
45+
is_debug = context.config.get_value("global", "verbosity") != "debug"
46+
if isinstance(context.video_stream, VideoStreamMoviePy) and is_debug:
47+
warnings.filterwarnings("ignore", module="moviepy")
48+
4249
if context.load_scenes_input:
4350
# Skip detection if load-scenes was used.
4451
logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input)
@@ -49,7 +56,10 @@ def run_scenedetect(context: CliContext):
4956
logger.info("Loaded %d scenes.", len(scenes))
5057
else:
5158
# Perform scene detection on input.
52-
scenes, cuts = _detect(context)
59+
result = _detect(context)
60+
if result is None:
61+
return
62+
scenes, cuts = result
5363
scenes = _postprocess_scene_list(context, scenes)
5464
# Handle -s/--stats option.
5565
_save_stats(context)
@@ -110,7 +120,7 @@ def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]:
110120

111121
# Handle case where video failure is most likely due to multiple audio tracks (#179).
112122
# TODO(#380): Ensure this does not erroneusly fire.
113-
if num_frames <= 0 and context.video_stream.BACKEND_NAME == "opencv":
123+
if num_frames <= 0 and isinstance(context.video_stream, VideoStreamCv2):
114124
logger.critical(
115125
"Failed to read any frames from video file. This could be caused by the video"
116126
" having multiple audio tracks. If so, try installing the PyAV backend:\n"

scenedetect/backends/moviepy.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -177,29 +177,32 @@ def seek(self, target: Union[FrameTimecode, float, int]):
177177
if not isinstance(target, FrameTimecode):
178178
target = FrameTimecode(target, self.frame_rate)
179179
try:
180-
self._reader.get_frame(target.get_seconds())
180+
self._last_frame = self._reader.get_frame(target.get_seconds())
181+
if hasattr(self._reader, "last_read") and target >= self.duration:
182+
raise SeekError("MoviePy > 2.0 does not have proper EOF semantics.")
183+
self._frame_number = min(
184+
target.frame_num,
185+
FrameTimecode(self._reader.infos["duration"], self.frame_rate).frame_num - 1,
186+
)
181187
except OSError as ex:
182-
# Leave the object in a valid state.
183-
self.reset()
184188
# TODO(#380): Other backends do not currently throw an exception if attempting to seek
185189
# past EOF. We need to ensure consistency for seeking past end of video with respect to
186190
# errors and behaviour, and should probably gracefully stop at the last frame instead
187191
# of throwing an exception.
188192
if target >= self.duration:
189193
raise SeekError("Target frame is beyond end of video!") from ex
190194
raise
191-
self._last_frame = self._reader.lastread
192-
self._frame_number = min(
193-
target.frame_num,
194-
FrameTimecode(self._reader.infos["duration"], self.frame_rate).frame_num - 1,
195-
)
195+
finally:
196+
# Leave the object in a valid state.
197+
self.reset()
196198

197-
def reset(self):
199+
def reset(self, print_infos=False):
198200
"""Close and re-open the VideoStream (should be equivalent to calling `seek(0)`)."""
199-
self._reader.initialize()
200-
self._last_frame = self._reader.read_frame()
201+
self._last_frame = False
202+
self._last_frame_rgb = None
201203
self._frame_number = 0
202204
self._eof = False
205+
self._reader = FFMPEG_VideoReader(self._path, print_infos=print_infos)
203206

204207
def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]:
205208
"""Read and decode the next frame as a np.ndarray. Returns False when video ends.
@@ -213,21 +216,26 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b
213216
If decode = False, a bool indicating if advancing to the the next frame succeeded.
214217
"""
215218
if not advance:
219+
last_frame_valid = self._last_frame is not None and self._last_frame is not False
220+
if not last_frame_valid:
221+
return False
216222
if self._last_frame_rgb is None:
217223
self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB)
218224
return self._last_frame_rgb
219-
if not hasattr(self._reader, "lastread"):
225+
if not hasattr(self._reader, "lastread") or self._eof:
220226
return False
221-
# TODO: In Moviepy2.0 this is broken - lastread is updated in-place in some cases.
222-
self._last_frame = self._reader.lastread
227+
has_last_read = hasattr(self._reader, "last_read")
228+
self._last_frame = self._reader.last_read if has_last_read else self._reader.lastread
229+
# Read the *next* frame for the following call to read, and to check for EOF.
223230
frame = self._reader.read_frame()
224231
if frame is self._last_frame:
225232
if self._eof:
226233
return False
227234
self._eof = True
228235
self._frame_number += 1
229236
if decode:
230-
if self._last_frame is not None:
237+
last_frame_valid = self._last_frame is not None and self._last_frame is not False
238+
if last_frame_valid:
231239
self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB)
232-
return self._last_frame_rgb if not self._eof else False
240+
return self._last_frame_rgb
233241
return not self._eof

tests/test_video_stream.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from dataclasses import dataclass
2121
from typing import List, Type
2222

23+
import moviepy
2324
import numpy
2425
import pytest
2526

@@ -41,6 +42,8 @@
4142
# The warning occurs when reading the last frame, which VideoStreamMoviePy handles gracefully.
4243
MOVIEPY_WARNING_FILTER = "ignore:.*Using the last valid frame instead.:UserWarning"
4344

45+
MOVIEPY_MAJOR_VERSION = int(moviepy.__version__.split(".")[0])
46+
4447

4548
def calculate_frame_delta(frame_a, frame_b, roi=None) -> float:
4649
if roi:
@@ -354,10 +357,16 @@ def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str):
354357
"""Test that backend handles video with corrupt frame gracefully with defaults."""
355358
if vs_type == VideoManager:
356359
pytest.skip(reason="VideoManager does not support handling corrupt videos.")
360+
if vs_type == VideoStreamMoviePy and MOVIEPY_MAJOR_VERSION >= 2:
361+
# Due to changes in MoviePy 2.0, loading this file causes an exception to be thrown.
362+
# See https://github.com/Zulko/moviepy/pull/2253 for a PR that attempts to more gracefully
363+
# handle this case, however even once that is fixed, we will be unable to run this test
364+
# on certain versions of MoviePy.
365+
pytest.skip(reason="https://github.com/Zulko/moviepy/pull/2253")
357366

358367
stream = vs_type(corrupt_video_file)
359368

360-
# OpenCV usually fails to read the video at frame 45, so we make sure all backends can
361-
# get to 60 without reporting a failure.
369+
# OpenCV usually fails to read the video at frame 45, but the remaining frames all seem to
370+
# decode just fine. Make sure all backends can get to 60 without reporting a failure.
362371
for frame in range(60):
363372
assert stream.read() is not False, "Failed on frame %d!" % frame

0 commit comments

Comments
 (0)