Skip to content

Commit fefd967

Browse files
authored
Add close() and context manager support to VideoStream (#587)
This adds a public `close()` method and `with` support to `VideoStream`, so callers can release file handles and decoders right away instead of waiting for garbage collection (fixes #568). The base method does nothing, so existing subclasses keep working, while the OpenCV, PyAV, MoviePy and concat backends release their resources and raise `ValueError` if used after closing. Properties like `path`, `frame_rate`, `duration` and `frame_size` still work after closing, because each backend records them in `close()` just before it releases the handle. `VideoStreamConcat` now also closes each input once it is done with it, including when construction fails, which used to leave every input open.
2 parents 650eace + 7fdc989 commit fefd967

12 files changed

Lines changed: 476 additions & 97 deletions

File tree

‎scenedetect/backends/__init__.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@
5050
In both examples above, the resulting ``video`` can be used with
5151
:meth:`SceneManager.detect_scenes() <scenedetect.scene_manager.SceneManager.detect_scenes>`.
5252
53+
Backends which open a video themselves can be closed with
54+
:meth:`close() <scenedetect.video_stream.VideoStream.close>` to release the file handles and
55+
decoders they hold, or used as a context manager to close them automatically:
56+
57+
.. code:: python
58+
59+
with open_video("video.mp4") as video:
60+
scene_manager.detect_scenes(video=video)
61+
5362
Multiple videos can be opened as one continuous stream by passing a list of paths to
5463
:func:`open_video`, which returns a
5564
:class:`VideoStreamConcat <scenedetect.backends.concat.VideoStreamConcat>`:

‎scenedetect/backends/concat.py‎

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -136,26 +136,37 @@ def __init__(
136136

137137
# Probe all inputs up front for validation and metadata, then only keep one source
138138
# open at a time for decoding. The handle probed for the first source is kept as the
139-
# initial decode source to avoid re-opening it.
139+
# initial decode source to avoid re-opening it, and the others are closed once probed.
140140
self._sources: list[_SourceMetadata] = []
141141
first_cap: VideoStream | None = None
142-
for index in range(len(self._paths)):
143-
cap = self._open_source(index)
144-
duration = cap.duration
145-
declared_seconds = _exact_seconds(duration) if duration is not None else Fraction(0)
146-
self._sources.append(
147-
_SourceMetadata(
148-
path=self._paths[index],
149-
frame_size=cap.frame_size,
150-
frame_rate=cap.frame_rate,
151-
duration=declared_seconds,
152-
frames=duration.frame_num if duration is not None else 0,
153-
aspect_ratio=cap.aspect_ratio,
142+
cap: VideoStream | None = None
143+
try:
144+
for index in range(len(self._paths)):
145+
cap = self._open_source(index)
146+
duration = cap.duration
147+
declared_seconds = _exact_seconds(duration) if duration is not None else Fraction(0)
148+
self._sources.append(
149+
_SourceMetadata(
150+
path=self._paths[index],
151+
frame_size=cap.frame_size,
152+
frame_rate=cap.frame_rate,
153+
duration=declared_seconds,
154+
frames=duration.frame_num if duration is not None else 0,
155+
aspect_ratio=cap.aspect_ratio,
156+
)
154157
)
155-
)
156-
if index == 0:
157-
first_cap = cap
158-
self._validate_sources()
158+
if index == 0:
159+
first_cap = cap
160+
else:
161+
cap.close()
162+
self._validate_sources()
163+
except BaseException:
164+
# Don't leave any probed source open if probing or validation fails. Closing is
165+
# idempotent, so it doesn't matter if `cap` was already closed or is `first_cap`.
166+
for opened in (cap, first_cap):
167+
if opened is not None:
168+
opened.close()
169+
raise
159170

160171
# Global start time of each source in exact rational seconds. Has one extra entry at
161172
# the end holding the total (declared) duration. Values after the current source are
@@ -203,6 +214,12 @@ def _open_source(self, index: int) -> VideoStream:
203214
str(self._paths[index]), self._frame_rate_override, **self._backend_kwargs
204215
)
205216

217+
def _switch_source(self, index: int):
218+
"""Make the source at `index` the one being decoded, and close the outgoing source."""
219+
cap = self._open_source(index)
220+
outgoing, self._cap = self._cap, cap
221+
outgoing.close()
222+
206223
def _child_position_seconds(self) -> Fraction:
207224
"""Position of the current source as exact rational seconds (local timeline)."""
208225
return _exact_seconds(self._cap.position)
@@ -226,6 +243,7 @@ def _finish_current_source(self):
226243

227244
def read(self, decode: bool = True) -> np.ndarray | bool:
228245
"""Read/decode the next frame. Returns False when all inputs have been processed."""
246+
self._check_open()
229247
while True:
230248
result = self._cap.read(decode=decode)
231249
if result is not False:
@@ -236,11 +254,12 @@ def read(self, decode: bool = True) -> np.ndarray | bool:
236254
self._finish_current_source()
237255
self._index += 1
238256
logger.debug("Processing complete, opening next video: %s", self._paths[self._index])
239-
self._cap = self._open_source(self._index)
257+
self._switch_source(self._index)
240258

241259
def seek(self, target: TimecodeLike):
242260
"""Seek to `target` on the global timeline. Supports seeking across sources in
243261
either direction."""
262+
self._check_open()
244263
if not isinstance(target, FrameTimecode):
245264
target = FrameTimecode(target, self.frame_rate)
246265
if target < 0:
@@ -253,16 +272,26 @@ def seek(self, target: TimecodeLike):
253272
self._decode_failures_prior += self._cap.decode_failures
254273
self._frames_prior = sum(source.frames for source in self._sources[:index])
255274
self._index = index
256-
self._cap = self._open_source(index)
275+
self._switch_source(index)
257276
local_seconds = target_seconds - self._offsets[index]
258277
self._cap.seek(float(local_seconds))
259278

260279
def reset(self):
261280
"""Close and re-open the stream (equivalent to seeking back to the beginning)."""
281+
self._check_open()
262282
self._index = 0
263283
self._frames_prior = 0
264284
self._decode_failures_prior = 0
265-
self._cap = self._open_source(0)
285+
self._switch_source(0)
286+
287+
def close(self) -> None:
288+
"""Close the stream, closing the input video currently open for decoding (only one is
289+
kept open at a time). Has no effect if the stream is already closed. See
290+
:meth:`VideoStream.close() <scenedetect.video_stream.VideoStream.close>` for details."""
291+
if self._closed:
292+
return
293+
self._closed = True
294+
self._cap.close()
266295

267296
#
268297
# VideoStream Properties

‎scenedetect/backends/moviepy.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,10 @@ def seek(self, target: TimecodeLike):
239239
If int, interpreted as frame number.
240240
Raises:
241241
SeekError: An error occurs while seeking, or seeking is not supported.
242-
ValueError: `target` is not a valid value (i.e. it is negative).
242+
ValueError: `target` is not a valid value (i.e. it is negative), or the stream is
243+
closed.
243244
"""
245+
self._check_open()
244246
success = False
245247
if not isinstance(target, FrameTimecode):
246248
target = FrameTimecode(target, self.frame_rate)
@@ -273,6 +275,7 @@ def seek(self, target: TimecodeLike):
273275

274276
def reset(self, print_infos=False):
275277
"""Close and re-open the VideoStream (should be equivalent to calling `seek(0)`)."""
278+
self._check_open()
276279
self._last_frame = False
277280
self._last_frame_rgb = None
278281
self._frame_number = 0
@@ -281,7 +284,17 @@ def reset(self, print_infos=False):
281284
"reset", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos)
282285
)
283286

287+
def close(self) -> None:
288+
"""Close the video, terminating the ffmpeg process used to decode it. Has no effect if
289+
the stream is already closed. See
290+
:meth:`VideoStream.close() <scenedetect.video_stream.VideoStream.close>` for details."""
291+
if self._closed:
292+
return
293+
self._closed = True
294+
self._reader.close()
295+
284296
def read(self, decode: bool = True) -> np.ndarray | bool:
297+
self._check_open()
285298
if not hasattr(self._reader, "lastread") or self._eof:
286299
return False
287300
has_last_read = hasattr(self._reader, "last_read")

‎scenedetect/backends/opencv.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ def capture(self) -> cv2.VideoCapture:
171171
172172
Prefer to use this property only to take ownership of the underlying cv2.VideoCapture object
173173
backing this object. Seeking or using the read/grab methods through this property are
174-
unsupported and will leave this object in an inconsistent state.
174+
unsupported and will leave this object in an inconsistent state. Closing this object (e.g.
175+
by leaving a `with` block) releases the capture.
175176
"""
176177
return self._cap
177178

@@ -215,6 +216,8 @@ def is_seekable(self) -> bool:
215216
@property
216217
def frame_size(self) -> tuple[int, int]:
217218
"""Size of each video frame in pixels as a tuple of (width, height)."""
219+
if self._closed:
220+
return self._closed_frame_size
218221
return (
219222
math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
220223
math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
@@ -225,11 +228,15 @@ def duration(self) -> FrameTimecode | None:
225228
"""Duration of the stream as a FrameTimecode, or None if non terminating."""
226229
if self._is_device:
227230
return None
231+
if self._closed:
232+
return self.base_timecode + math.trunc(self._closed_frame_count)
228233
return self.base_timecode + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
229234

230235
@property
231236
def aspect_ratio(self) -> float:
232237
"""Display/pixel aspect ratio as a float (1.0 represents square pixels)."""
238+
if self._closed:
239+
return self._closed_aspect_ratio
233240
return _get_aspect_ratio(self._cap)
234241

235242
@property
@@ -266,6 +273,7 @@ def frame_number(self) -> int:
266273
return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES))
267274

268275
def seek(self, target: TimecodeLike):
276+
self._check_open()
269277
if self._is_device:
270278
raise SeekError("Cannot seek if input is a device!")
271279
if not isinstance(target, FrameTimecode):
@@ -307,10 +315,25 @@ def seek(self, target: TimecodeLike):
307315

308316
def reset(self):
309317
"""Close and re-open the VideoStream (should be equivalent to calling `seek(0)`)."""
318+
self._check_open()
310319
self._cap.release()
311320
self._open_capture(float(self._frame_rate))
312321

322+
def close(self) -> None:
323+
"""Close the video, releasing the underlying `cv2.VideoCapture`. Has no effect if the
324+
stream is already closed. See
325+
:meth:`VideoStream.close() <scenedetect.video_stream.VideoStream.close>` for details."""
326+
if self._closed:
327+
return
328+
# A released capture no longer reports the video's properties, so record them first.
329+
self._closed_frame_size = self.frame_size
330+
self._closed_frame_count = self._cap.get(cv2.CAP_PROP_FRAME_COUNT)
331+
self._closed_aspect_ratio = self.aspect_ratio
332+
self._closed = True
333+
self._cap.release()
334+
313335
def read(self, decode: bool = True) -> np.ndarray | bool:
336+
self._check_open()
314337
if not self._cap.isOpened():
315338
return False
316339
# At the end of some VFR streams OpenCV reports an earlier CAP_PROP_POS_MSEC once grab()
@@ -406,6 +429,9 @@ def _open_capture(self, frame_rate: FrameRate | None = None):
406429
class VideoCaptureAdapter(VideoStream):
407430
"""Adapter for existing VideoCapture objects. Unlike VideoStreamCv2, this class supports
408431
VideoCaptures which may not support seeking.
432+
433+
The VideoCapture remains owned by the caller: closing the adapter (e.g. by leaving a `with`
434+
block) does not release it.
409435
"""
410436

411437
def __init__(

‎scenedetect/backends/pyav.py‎

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#
1212
""":class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object."""
1313

14+
import contextlib
1415
import os
1516
import typing as ty
1617
import warnings
@@ -116,7 +117,7 @@ def __init__(
116117
self._reopened = True
117118
self._decode_failures = 0
118119
self._warning_displayed = False
119-
# True if `_io` was opened by this object (from a path) and must be closed in `__del__`.
120+
# True if `_io` was opened by this object (from a path) and must be closed in `close()`.
120121
# Set before anything below can raise so a partially constructed object is safe to finalize.
121122
self._owns_io = False
122123

@@ -135,7 +136,7 @@ def __init__(
135136
try:
136137
if isinstance(path_or_io, (str, os.PathLike)):
137138
self._path: str = os.fspath(path_or_io)
138-
# File handle is tied to the lifetime of this object and released in `__del__`.
139+
# File handle is owned by this object and released in `close()` (or `__del__`).
139140
self._io = open(self._path, "rb") # noqa: SIM115
140141
self._owns_io = True
141142
if not self._name:
@@ -176,23 +177,40 @@ def __init__(
176177
def __del__(self):
177178
# Finalizers must never raise - an exception here becomes an unraisable error. During
178179
# interpreter shutdown the underlying handles are reclaimed by the OS anyway.
179-
try:
180-
# Close the decode generator first to break its cycle with the container.
181-
decoder = getattr(self, "_decoder", None)
182-
if decoder is not None:
183-
decoder.close()
184-
# `_container` is unset if `__init__` raised before `av.open()` succeeded.
185-
container = getattr(self, "_container", None)
186-
if container is not None:
187-
container.close()
188-
# Closing the container does not close the file object backing it. Only close `_io`
189-
# if we opened it ourselves; file-like objects passed by the caller remain theirs.
190-
if getattr(self, "_owns_io", False):
191-
io = getattr(self, "_io", None)
192-
if io is not None:
193-
io.close()
194-
except Exception:
195-
pass
180+
with contextlib.suppress(Exception):
181+
self.close()
182+
183+
def close(self) -> None:
184+
"""Close the video, releasing the decoder, the container, and the file handle opened if
185+
`path_or_io` is a path. File-like objects passed by the caller are left open. Has no
186+
effect if the stream is already closed. See
187+
:meth:`VideoStream.close() <scenedetect.video_stream.VideoStream.close>` for details."""
188+
if self._closed:
189+
return
190+
# Record the properties read from the container while it is open. They can't be read if
191+
# `__init__` raised (`__del__` also calls this method) or the video can't be decoded.
192+
with contextlib.suppress(Exception):
193+
self._closed_frame_size = self.frame_size
194+
self._closed_aspect_ratio = self.aspect_ratio
195+
# Close the decode generator first to break its cycle with the container.
196+
decoder = getattr(self, "_decoder", None)
197+
if decoder is not None:
198+
decoder.close()
199+
self._decoder = None
200+
# `_container` is unset if `__init__` raised before `av.open()` succeeded.
201+
container = getattr(self, "_container", None)
202+
if container is not None:
203+
container.close()
204+
# Closing the container does not close the file object backing it. Only close `_io`
205+
# if we opened it ourselves; file-like objects passed by the caller remain theirs.
206+
if getattr(self, "_owns_io", False):
207+
io = getattr(self, "_io", None)
208+
if io is not None:
209+
io.close()
210+
# Only mark the stream as closed once everything is released, so a failed `close()` can be
211+
# retried. The last decoded frame is dropped, since `position` is undefined once closed.
212+
self._closed = True
213+
self._frame = None
196214

197215
#
198216
# VideoStream Methods/Properties
@@ -219,6 +237,8 @@ def is_seekable(self) -> bool:
219237
@property
220238
def frame_size(self) -> tuple[int, int]:
221239
"""Size of each video frame in pixels as a tuple of (width, height)."""
240+
if self._closed:
241+
return self._closed_frame_size
222242
return (self._codec_context.width, self._codec_context.height)
223243

224244
@property
@@ -276,6 +296,8 @@ def time_base(self) -> Fraction | None:
276296
@property
277297
def aspect_ratio(self) -> float:
278298
"""Pixel aspect ratio as a float (1.0 represents square pixels)."""
299+
if self._closed:
300+
return self._closed_aspect_ratio
279301
# Unset is None on PyAV < 19 and a falsy AVRational(0, 1) on PyAV 19+.
280302
dar = getattr(self._codec_context, "display_aspect_ratio", None)
281303
if not dar or dar.denominator <= 0:
@@ -301,8 +323,10 @@ def seek(self, target: TimecodeLike) -> None:
301323
If float, interpreted as time in seconds.
302324
If int, interpreted as frame number.
303325
Raises:
304-
ValueError: `target` is not a valid value (i.e. it is negative).
326+
ValueError: `target` is not a valid value (i.e. it is negative), or the stream is
327+
closed.
305328
"""
329+
self._check_open()
306330
if not isinstance(target, FrameTimecode):
307331
target = FrameTimecode(target, self.frame_rate)
308332
if target < 0:
@@ -326,6 +350,7 @@ def seek(self, target: TimecodeLike) -> None:
326350

327351
def reset(self):
328352
"""Close and re-open the VideoStream (should be equivalent to calling `seek(0)`)."""
353+
self._check_open()
329354
self._container.close()
330355
self._frame = None
331356
self._decoder = None
@@ -335,6 +360,7 @@ def reset(self):
335360
raise VideoOpenFailure() from ex
336361

337362
def read(self, decode: bool = True) -> np.ndarray | bool:
363+
self._check_open()
338364
consecutive_failures = 0
339365
while True:
340366
# Reuse a persistent decoder generator so the codec's internal frame buffer (used for

0 commit comments

Comments
 (0)