From a432a76959a21e15a8bca738e46460c04ad906b1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 22 Nov 2024 22:31:55 -0500 Subject: [PATCH] [backends] Fix MoviePy 2.0 EOF behavior Skip corrupt video test on MoviePy awaiting Zulko/moviepy#2253 --- scenedetect/_cli/controller.py | 14 ++++++++++-- scenedetect/backends/moviepy.py | 40 ++++++++++++++++++++------------- tests/test_video_stream.py | 13 +++++++++-- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 28d52b9d..480c9d66 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -16,8 +16,10 @@ import os import time import typing as ty +import warnings from scenedetect._cli.context import CliContext +from scenedetect.backends import VideoStreamCv2, VideoStreamMoviePy from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import CutList, SceneList, get_scenes_from_cuts @@ -39,6 +41,11 @@ def run_scenedetect(context: CliContext): logger.debug("No input specified.") return + # Suppress warnings when reading past EOF in MoviePy. + is_debug = context.config.get_value("global", "verbosity") != "debug" + if isinstance(context.video_stream, VideoStreamMoviePy) and is_debug: + warnings.filterwarnings("ignore", module="moviepy") + if context.load_scenes_input: # Skip detection if load-scenes was used. logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input) @@ -49,7 +56,10 @@ def run_scenedetect(context: CliContext): logger.info("Loaded %d scenes.", len(scenes)) else: # Perform scene detection on input. - scenes, cuts = _detect(context) + result = _detect(context) + if result is None: + return + scenes, cuts = result scenes = _postprocess_scene_list(context, scenes) # Handle -s/--stats option. _save_stats(context) @@ -110,7 +120,7 @@ def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: # Handle case where video failure is most likely due to multiple audio tracks (#179). # TODO(#380): Ensure this does not erroneusly fire. - if num_frames <= 0 and context.video_stream.BACKEND_NAME == "opencv": + if num_frames <= 0 and isinstance(context.video_stream, VideoStreamCv2): logger.critical( "Failed to read any frames from video file. This could be caused by the video" " having multiple audio tracks. If so, try installing the PyAV backend:\n" diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 99698fbe..3f6f9870 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -177,10 +177,14 @@ def seek(self, target: Union[FrameTimecode, float, int]): if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) try: - self._reader.get_frame(target.get_seconds()) + self._last_frame = self._reader.get_frame(target.get_seconds()) + if hasattr(self._reader, "last_read") and target >= self.duration: + raise SeekError("MoviePy > 2.0 does not have proper EOF semantics.") + self._frame_number = min( + target.frame_num, + FrameTimecode(self._reader.infos["duration"], self.frame_rate).frame_num - 1, + ) except OSError as ex: - # Leave the object in a valid state. - self.reset() # TODO(#380): Other backends do not currently throw an exception if attempting to seek # past EOF. We need to ensure consistency for seeking past end of video with respect to # errors and behaviour, and should probably gracefully stop at the last frame instead @@ -188,18 +192,17 @@ def seek(self, target: Union[FrameTimecode, float, int]): if target >= self.duration: raise SeekError("Target frame is beyond end of video!") from ex raise - self._last_frame = self._reader.lastread - self._frame_number = min( - target.frame_num, - FrameTimecode(self._reader.infos["duration"], self.frame_rate).frame_num - 1, - ) + finally: + # Leave the object in a valid state. + self.reset() - def reset(self): + def reset(self, print_infos=False): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" - self._reader.initialize() - self._last_frame = self._reader.read_frame() + self._last_frame = False + self._last_frame_rgb = None self._frame_number = 0 self._eof = False + self._reader = FFMPEG_VideoReader(self._path, print_infos=print_infos) def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. @@ -213,13 +216,17 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b If decode = False, a bool indicating if advancing to the the next frame succeeded. """ if not advance: + last_frame_valid = self._last_frame is not None and self._last_frame is not False + if not last_frame_valid: + return False if self._last_frame_rgb is None: self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) return self._last_frame_rgb - if not hasattr(self._reader, "lastread"): + if not hasattr(self._reader, "lastread") or self._eof: return False - # TODO: In Moviepy2.0 this is broken - lastread is updated in-place in some cases. - self._last_frame = self._reader.lastread + has_last_read = hasattr(self._reader, "last_read") + self._last_frame = self._reader.last_read if has_last_read else self._reader.lastread + # Read the *next* frame for the following call to read, and to check for EOF. frame = self._reader.read_frame() if frame is self._last_frame: if self._eof: @@ -227,7 +234,8 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b self._eof = True self._frame_number += 1 if decode: - if self._last_frame is not None: + last_frame_valid = self._last_frame is not None and self._last_frame is not False + if last_frame_valid: self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) - return self._last_frame_rgb if not self._eof else False + return self._last_frame_rgb return not self._eof diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 1d2074b0..161ed6cd 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -20,6 +20,7 @@ from dataclasses import dataclass from typing import List, Type +import moviepy import numpy import pytest @@ -41,6 +42,8 @@ # The warning occurs when reading the last frame, which VideoStreamMoviePy handles gracefully. MOVIEPY_WARNING_FILTER = "ignore:.*Using the last valid frame instead.:UserWarning" +MOVIEPY_MAJOR_VERSION = int(moviepy.__version__.split(".")[0]) + def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: if roi: @@ -354,10 +357,16 @@ def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoManager: pytest.skip(reason="VideoManager does not support handling corrupt videos.") + if vs_type == VideoStreamMoviePy and MOVIEPY_MAJOR_VERSION >= 2: + # Due to changes in MoviePy 2.0, loading this file causes an exception to be thrown. + # See https://github.com/Zulko/moviepy/pull/2253 for a PR that attempts to more gracefully + # handle this case, however even once that is fixed, we will be unable to run this test + # on certain versions of MoviePy. + pytest.skip(reason="https://github.com/Zulko/moviepy/pull/2253") stream = vs_type(corrupt_video_file) - # OpenCV usually fails to read the video at frame 45, so we make sure all backends can - # get to 60 without reporting a failure. + # OpenCV usually fails to read the video at frame 45, but the remaining frames all seem to + # decode just fine. Make sure all backends can get to 60 without reporting a failure. for frame in range(60): assert stream.read() is not False, "Failed on frame %d!" % frame