|
16 | 16 | image sequences or AviSynth scripts are supported as inputs. |
17 | 17 | """ |
18 | 18 |
|
| 19 | +import time |
19 | 20 | import typing as ty |
20 | 21 | from fractions import Fraction |
21 | 22 | from logging import getLogger |
|
31 | 32 |
|
32 | 33 | logger = getLogger("pyscenedetect") |
33 | 34 |
|
| 35 | +# MoviePy spawns ffmpeg as a subprocess and reads frame bytes over stdout. Under |
| 36 | +# load the parent can read before the child has flushed its first write, which |
| 37 | +# surfaces as OSError (see #496). A short retry clears nearly all such flakes. |
| 38 | +_FFMPEG_RETRY_COUNT = 2 |
| 39 | +_FFMPEG_RETRY_BACKOFF_SECS = 0.5 |
| 40 | + |
| 41 | + |
| 42 | +def _retry_on_oserror(op_name: str, fn: ty.Callable): |
| 43 | + """Run ``fn``, retrying up to ``_FFMPEG_RETRY_COUNT`` times on ``OSError``.""" |
| 44 | + last_exc: ty.Optional[OSError] = None |
| 45 | + for attempt in range(_FFMPEG_RETRY_COUNT + 1): |
| 46 | + try: |
| 47 | + return fn() |
| 48 | + except OSError as ex: |
| 49 | + last_exc = ex |
| 50 | + if attempt < _FFMPEG_RETRY_COUNT: |
| 51 | + logger.warning( |
| 52 | + "ffmpeg %s failed (attempt %d/%d), retrying: %s", |
| 53 | + op_name, |
| 54 | + attempt + 1, |
| 55 | + _FFMPEG_RETRY_COUNT + 1, |
| 56 | + ex, |
| 57 | + ) |
| 58 | + time.sleep(_FFMPEG_RETRY_BACKOFF_SECS) |
| 59 | + assert last_exc is not None |
| 60 | + raise last_exc |
| 61 | + |
34 | 62 |
|
35 | 63 | class VideoStreamMoviePy(VideoStream): |
36 | 64 | """MoviePy `FFMPEG_VideoReader` backend.""" |
@@ -63,7 +91,9 @@ def __init__( |
63 | 91 | # cases return IOErrors (e.g. could not read duration/video resolution). These |
64 | 92 | # should be mapped to specific errors, e.g. write a function to map MoviePy |
65 | 93 | # exceptions to a new set of equivalents. |
66 | | - self._reader = FFMPEG_VideoReader(path, print_infos=print_infos) |
| 94 | + self._reader = _retry_on_oserror( |
| 95 | + "open", lambda: FFMPEG_VideoReader(path, print_infos=print_infos) |
| 96 | + ) |
67 | 97 | # This will always be one behind self._reader.lastread when we finally call read() |
68 | 98 | # as MoviePy caches the first frame when opening the video. Thus self._last_frame |
69 | 99 | # will always be the current frame, and self._reader.lastread will be the next. |
@@ -184,7 +214,9 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): |
184 | 214 | if not isinstance(target, FrameTimecode): |
185 | 215 | target = FrameTimecode(target, self.frame_rate) |
186 | 216 | try: |
187 | | - self._last_frame = self._reader.get_frame(target.seconds) |
| 217 | + self._last_frame = _retry_on_oserror( |
| 218 | + "seek", lambda: self._reader.get_frame(target.seconds) |
| 219 | + ) |
188 | 220 | if hasattr(self._reader, "last_read") and target >= self.duration: |
189 | 221 | raise SeekError("MoviePy > 2.0 does not have proper EOF semantics (#461).") |
190 | 222 | self._frame_number = min( |
@@ -212,7 +244,9 @@ def reset(self, print_infos=False): |
212 | 244 | self._last_frame_rgb = None |
213 | 245 | self._frame_number = 0 |
214 | 246 | self._eof = False |
215 | | - self._reader = FFMPEG_VideoReader(self._path, print_infos=print_infos) |
| 247 | + self._reader = _retry_on_oserror( |
| 248 | + "reset", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos) |
| 249 | + ) |
216 | 250 |
|
217 | 251 | def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: |
218 | 252 | if not hasattr(self._reader, "lastread") or self._eof: |
|
0 commit comments