1111#
1212""":class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object."""
1313
14+ import contextlib
1415import os
1516import typing as ty
1617import 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