Skip to content

Commit 95d20dd

Browse files
authored
[backends] Fix behavior of MoviePy backend (#462)
* [backends] Fix behavior of MoviePy backend There are still some fixes required in MoviePy itself to make corrupt video tests pass. * [backends] Fix MoviePy 2.0 EOF behavior Skip corrupt video test on MoviePy awaiting Zulko/moviepy#2253 * [build] Enable Moviepy tests for builds and document workarounds for #461
1 parent 2f88336 commit 95d20dd

File tree

4 files changed

+64
-20
lines changed

4 files changed

+64
-20
lines changed

.github/workflows/build.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ jobs:
6262
pip install av opencv-python-headless --only-binary :all:
6363
pip install -r requirements_headless.txt
6464
65+
- name: Install MoviePy
66+
# TODO: We can only run MoviePy tests on systems that have ffmpeg.
67+
if: ${{ runner.arch == 'X64' }}
68+
run: |
69+
pip install moviepy
70+
6571
- name: Checkout test resources
6672
run: |
6773
git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources

scenedetect/_cli/controller.py

Lines changed: 13 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,12 @@ def run_scenedetect(context: CliContext):
3941
logger.debug("No input specified.")
4042
return
4143

44+
# Suppress warnings when reading past EOF in MoviePy (#461).
45+
if VideoStreamMoviePy and isinstance(context.video_stream, VideoStreamMoviePy):
46+
is_debug = context.config.get_value("global", "verbosity") != "debug"
47+
if not is_debug:
48+
warnings.filterwarnings("ignore", module="moviepy")
49+
4250
if context.load_scenes_input:
4351
# Skip detection if load-scenes was used.
4452
logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input)
@@ -49,7 +57,10 @@ def run_scenedetect(context: CliContext):
4957
logger.info("Loaded %d scenes.", len(scenes))
5058
else:
5159
# Perform scene detection on input.
52-
scenes, cuts = _detect(context)
60+
result = _detect(context)
61+
if result is None:
62+
return
63+
scenes, cuts = result
5364
scenes = _postprocess_scene_list(context, scenes)
5465
# Handle -s/--stats option.
5566
_save_stats(context)
@@ -110,7 +121,7 @@ def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]:
110121

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

scenedetect/backends/moviepy.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -174,29 +174,38 @@ def seek(self, target: Union[FrameTimecode, float, int]):
174174
SeekError: An error occurs while seeking, or seeking is not supported.
175175
ValueError: `target` is not a valid value (i.e. it is negative).
176176
"""
177+
success = False
177178
if not isinstance(target, FrameTimecode):
178179
target = FrameTimecode(target, self.frame_rate)
179180
try:
180-
self._reader.get_frame(target.get_seconds())
181+
self._last_frame = self._reader.get_frame(target.get_seconds())
182+
if hasattr(self._reader, "last_read") and target >= self.duration:
183+
raise SeekError("MoviePy > 2.0 does not have proper EOF semantics (#461).")
184+
self._frame_number = min(
185+
target.frame_num,
186+
FrameTimecode(self._reader.infos["duration"], self.frame_rate).frame_num - 1,
187+
)
188+
success = True
181189
except OSError as ex:
182-
# Leave the object in a valid state.
183-
self.reset()
184190
# TODO(#380): Other backends do not currently throw an exception if attempting to seek
185191
# past EOF. We need to ensure consistency for seeking past end of video with respect to
186192
# errors and behaviour, and should probably gracefully stop at the last frame instead
187193
# of throwing an exception.
188194
if target >= self.duration:
189195
raise SeekError("Target frame is beyond end of video!") from ex
190196
raise
191-
self._last_frame = self._reader.lastread
192-
self._frame_number = target.frame_num
197+
finally:
198+
# Leave the object in a valid state on any errors.
199+
if not success:
200+
self.reset()
193201

194-
def reset(self):
202+
def reset(self, print_infos=False):
195203
"""Close and re-open the VideoStream (should be equivalent to calling `seek(0)`)."""
196-
self._reader.initialize()
197-
self._last_frame = self._reader.read_frame()
204+
self._last_frame = False
205+
self._last_frame_rgb = None
198206
self._frame_number = 0
199207
self._eof = False
208+
self._reader = FFMPEG_VideoReader(self._path, print_infos=print_infos)
200209

201210
def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]:
202211
"""Read and decode the next frame as a np.ndarray. Returns False when video ends.
@@ -210,21 +219,27 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b
210219
If decode = False, a bool indicating if advancing to the the next frame succeeded.
211220
"""
212221
if not advance:
222+
last_frame_valid = self._last_frame is not None and self._last_frame is not False
223+
if not last_frame_valid:
224+
return False
213225
if self._last_frame_rgb is None:
214226
self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB)
215227
return self._last_frame_rgb
216-
if not hasattr(self._reader, "lastread"):
228+
if not hasattr(self._reader, "lastread") or self._eof:
217229
return False
218-
self._last_frame = self._reader.lastread
219-
self._reader.read_frame()
220-
if self._last_frame is self._reader.lastread:
221-
# Didn't decode a new frame, must have hit EOF.
230+
has_last_read = hasattr(self._reader, "last_read")
231+
# In MoviePy 2.0 there is a separate property we need to read named differently (#461).
232+
self._last_frame = self._reader.last_read if has_last_read else self._reader.lastread
233+
# Read the *next* frame for the following call to read, and to check for EOF.
234+
frame = self._reader.read_frame()
235+
if frame is self._last_frame:
222236
if self._eof:
223237
return False
224238
self._eof = True
225239
self._frame_number += 1
226240
if decode:
227-
if self._last_frame is not None:
241+
last_frame_valid = self._last_frame is not None and self._last_frame is not False
242+
if last_frame_valid:
228243
self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB)
229-
return self._last_frame_rgb
230-
return True
244+
return self._last_frame_rgb
245+
return not self._eof

tests/test_video_stream.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@
4242
MOVIEPY_WARNING_FILTER = "ignore:.*Using the last valid frame instead.:UserWarning"
4343

4444

45+
def get_moviepy_major_version() -> int:
46+
import moviepy
47+
48+
return int(moviepy.__version__.split(".")[0])
49+
50+
4551
def calculate_frame_delta(frame_a, frame_b, roi=None) -> float:
4652
if roi:
4753
raise RuntimeError("TODO")
@@ -354,10 +360,16 @@ def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str):
354360
"""Test that backend handles video with corrupt frame gracefully with defaults."""
355361
if vs_type == VideoManager:
356362
pytest.skip(reason="VideoManager does not support handling corrupt videos.")
363+
if vs_type == VideoStreamMoviePy and get_moviepy_major_version() >= 2:
364+
# Due to changes in MoviePy 2.0 (#461), loading this file causes an exception to be thrown.
365+
# See https://github.com/Zulko/moviepy/pull/2253 for a PR that attempts to more gracefully
366+
# handle this case, however even once that is fixed, we will be unable to run this test
367+
# on certain versions of MoviePy.
368+
pytest.skip(reason="https://github.com/Zulko/moviepy/pull/2253")
357369

358370
stream = vs_type(corrupt_video_file)
359371

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.
372+
# OpenCV usually fails to read the video at frame 45, but the remaining frames all seem to
373+
# decode just fine. Make sure all backends can get to 60 without reporting a failure.
362374
for frame in range(60):
363375
assert stream.read() is not False, "Failed on frame %d!" % frame

0 commit comments

Comments
 (0)