Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions scenedetect/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@
In both examples above, the resulting ``video`` can be used with
:meth:`SceneManager.detect_scenes() <scenedetect.scene_manager.SceneManager.detect_scenes>`.

Backends which open a video themselves can be closed with
:meth:`close() <scenedetect.video_stream.VideoStream.close>` to release the file handles and
decoders they hold, or used as a context manager to close them automatically:

.. code:: python

with open_video("video.mp4") as video:
scene_manager.detect_scenes(video=video)

Multiple videos can be opened as one continuous stream by passing a list of paths to
:func:`open_video`, which returns a
:class:`VideoStreamConcat <scenedetect.backends.concat.VideoStreamConcat>`:
Expand Down
69 changes: 49 additions & 20 deletions scenedetect/backends/concat.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,26 +136,37 @@ def __init__(

# Probe all inputs up front for validation and metadata, then only keep one source
# open at a time for decoding. The handle probed for the first source is kept as the
# initial decode source to avoid re-opening it.
# initial decode source to avoid re-opening it, and the others are closed once probed.
self._sources: list[_SourceMetadata] = []
first_cap: VideoStream | None = None
for index in range(len(self._paths)):
cap = self._open_source(index)
duration = cap.duration
declared_seconds = _exact_seconds(duration) if duration is not None else Fraction(0)
self._sources.append(
_SourceMetadata(
path=self._paths[index],
frame_size=cap.frame_size,
frame_rate=cap.frame_rate,
duration=declared_seconds,
frames=duration.frame_num if duration is not None else 0,
aspect_ratio=cap.aspect_ratio,
cap: VideoStream | None = None
try:
for index in range(len(self._paths)):
cap = self._open_source(index)
duration = cap.duration
declared_seconds = _exact_seconds(duration) if duration is not None else Fraction(0)
self._sources.append(
_SourceMetadata(
path=self._paths[index],
frame_size=cap.frame_size,
frame_rate=cap.frame_rate,
duration=declared_seconds,
frames=duration.frame_num if duration is not None else 0,
aspect_ratio=cap.aspect_ratio,
)
)
)
if index == 0:
first_cap = cap
self._validate_sources()
if index == 0:
first_cap = cap
else:
cap.close()
self._validate_sources()
except BaseException:
# Don't leave any probed source open if probing or validation fails. Closing is
# idempotent, so it doesn't matter if `cap` was already closed or is `first_cap`.
for opened in (cap, first_cap):
if opened is not None:
opened.close()
raise

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

def _switch_source(self, index: int):
"""Make the source at `index` the one being decoded, and close the outgoing source."""
cap = self._open_source(index)
outgoing, self._cap = self._cap, cap
outgoing.close()

def _child_position_seconds(self) -> Fraction:
"""Position of the current source as exact rational seconds (local timeline)."""
return _exact_seconds(self._cap.position)
Expand All @@ -226,6 +243,7 @@ def _finish_current_source(self):

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

def seek(self, target: TimecodeLike):
"""Seek to `target` on the global timeline. Supports seeking across sources in
either direction."""
self._check_open()
if not isinstance(target, FrameTimecode):
target = FrameTimecode(target, self.frame_rate)
if target < 0:
Expand All @@ -253,16 +272,26 @@ def seek(self, target: TimecodeLike):
self._decode_failures_prior += self._cap.decode_failures
self._frames_prior = sum(source.frames for source in self._sources[:index])
self._index = index
self._cap = self._open_source(index)
self._switch_source(index)
local_seconds = target_seconds - self._offsets[index]
self._cap.seek(float(local_seconds))

def reset(self):
"""Close and re-open the stream (equivalent to seeking back to the beginning)."""
self._check_open()
self._index = 0
self._frames_prior = 0
self._decode_failures_prior = 0
self._cap = self._open_source(0)
self._switch_source(0)

def close(self) -> None:
"""Close the stream, closing the input video currently open for decoding (only one is
kept open at a time). Has no effect if the stream is already closed. See
:meth:`VideoStream.close() <scenedetect.video_stream.VideoStream.close>` for details."""
if self._closed:
return
self._closed = True
self._cap.close()

#
# VideoStream Properties
Expand Down
15 changes: 14 additions & 1 deletion scenedetect/backends/moviepy.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,10 @@ def seek(self, target: TimecodeLike):
If int, interpreted as frame number.
Raises:
SeekError: An error occurs while seeking, or seeking is not supported.
ValueError: `target` is not a valid value (i.e. it is negative).
ValueError: `target` is not a valid value (i.e. it is negative), or the stream is
closed.
"""
self._check_open()
success = False
if not isinstance(target, FrameTimecode):
target = FrameTimecode(target, self.frame_rate)
Expand Down Expand Up @@ -273,6 +275,7 @@ def seek(self, target: TimecodeLike):

def reset(self, print_infos=False):
"""Close and re-open the VideoStream (should be equivalent to calling `seek(0)`)."""
self._check_open()
self._last_frame = False
self._last_frame_rgb = None
self._frame_number = 0
Expand All @@ -281,7 +284,17 @@ def reset(self, print_infos=False):
"reset", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos)
)

def close(self) -> None:
"""Close the video, terminating the ffmpeg process used to decode it. Has no effect if
the stream is already closed. See
:meth:`VideoStream.close() <scenedetect.video_stream.VideoStream.close>` for details."""
if self._closed:
return
self._closed = True
self._reader.close()

def read(self, decode: bool = True) -> np.ndarray | bool:
self._check_open()
if not hasattr(self._reader, "lastread") or self._eof:
return False
has_last_read = hasattr(self._reader, "last_read")
Expand Down
28 changes: 27 additions & 1 deletion scenedetect/backends/opencv.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ def capture(self) -> cv2.VideoCapture:

Prefer to use this property only to take ownership of the underlying cv2.VideoCapture object
backing this object. Seeking or using the read/grab methods through this property are
unsupported and will leave this object in an inconsistent state.
unsupported and will leave this object in an inconsistent state. Closing this object (e.g.
by leaving a `with` block) releases the capture.
"""
return self._cap

Expand Down Expand Up @@ -215,6 +216,8 @@ def is_seekable(self) -> bool:
@property
def frame_size(self) -> tuple[int, int]:
"""Size of each video frame in pixels as a tuple of (width, height)."""
if self._closed:
return self._closed_frame_size
return (
math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
Expand All @@ -225,11 +228,15 @@ def duration(self) -> FrameTimecode | None:
"""Duration of the stream as a FrameTimecode, or None if non terminating."""
if self._is_device:
return None
if self._closed:
return self.base_timecode + math.trunc(self._closed_frame_count)
return self.base_timecode + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))

@property
def aspect_ratio(self) -> float:
"""Display/pixel aspect ratio as a float (1.0 represents square pixels)."""
if self._closed:
return self._closed_aspect_ratio
return _get_aspect_ratio(self._cap)

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

def seek(self, target: TimecodeLike):
self._check_open()
if self._is_device:
raise SeekError("Cannot seek if input is a device!")
if not isinstance(target, FrameTimecode):
Expand Down Expand Up @@ -307,10 +315,25 @@ def seek(self, target: TimecodeLike):

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

def close(self) -> None:
"""Close the video, releasing the underlying `cv2.VideoCapture`. Has no effect if the
stream is already closed. See
:meth:`VideoStream.close() <scenedetect.video_stream.VideoStream.close>` for details."""
if self._closed:
return
# A released capture no longer reports the video's properties, so record them first.
self._closed_frame_size = self.frame_size
self._closed_frame_count = self._cap.get(cv2.CAP_PROP_FRAME_COUNT)
self._closed_aspect_ratio = self.aspect_ratio
self._closed = True
self._cap.release()

def read(self, decode: bool = True) -> np.ndarray | bool:
self._check_open()
if not self._cap.isOpened():
return False
# At the end of some VFR streams OpenCV reports an earlier CAP_PROP_POS_MSEC once grab()
Expand Down Expand Up @@ -406,6 +429,9 @@ def _open_capture(self, frame_rate: FrameRate | None = None):
class VideoCaptureAdapter(VideoStream):
"""Adapter for existing VideoCapture objects. Unlike VideoStreamCv2, this class supports
VideoCaptures which may not support seeking.

The VideoCapture remains owned by the caller: closing the adapter (e.g. by leaving a `with`
block) does not release it.
"""

def __init__(
Expand Down
66 changes: 46 additions & 20 deletions scenedetect/backends/pyav.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#
""":class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object."""

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

Expand All @@ -135,7 +136,7 @@ def __init__(
try:
if isinstance(path_or_io, (str, os.PathLike)):
self._path: str = os.fspath(path_or_io)
# File handle is tied to the lifetime of this object and released in `__del__`.
# File handle is owned by this object and released in `close()` (or `__del__`).
self._io = open(self._path, "rb") # noqa: SIM115
self._owns_io = True
if not self._name:
Expand Down Expand Up @@ -176,23 +177,40 @@ def __init__(
def __del__(self):
# Finalizers must never raise - an exception here becomes an unraisable error. During
# interpreter shutdown the underlying handles are reclaimed by the OS anyway.
try:
# Close the decode generator first to break its cycle with the container.
decoder = getattr(self, "_decoder", None)
if decoder is not None:
decoder.close()
# `_container` is unset if `__init__` raised before `av.open()` succeeded.
container = getattr(self, "_container", None)
if container is not None:
container.close()
# Closing the container does not close the file object backing it. Only close `_io`
# if we opened it ourselves; file-like objects passed by the caller remain theirs.
if getattr(self, "_owns_io", False):
io = getattr(self, "_io", None)
if io is not None:
io.close()
except Exception:
pass
with contextlib.suppress(Exception):
self.close()

def close(self) -> None:
"""Close the video, releasing the decoder, the container, and the file handle opened if
`path_or_io` is a path. File-like objects passed by the caller are left open. Has no
effect if the stream is already closed. See
:meth:`VideoStream.close() <scenedetect.video_stream.VideoStream.close>` for details."""
if self._closed:
return
# Record the properties read from the container while it is open. They can't be read if
# `__init__` raised (`__del__` also calls this method) or the video can't be decoded.
with contextlib.suppress(Exception):
self._closed_frame_size = self.frame_size
self._closed_aspect_ratio = self.aspect_ratio
# Close the decode generator first to break its cycle with the container.
decoder = getattr(self, "_decoder", None)
if decoder is not None:
decoder.close()
self._decoder = None
# `_container` is unset if `__init__` raised before `av.open()` succeeded.
container = getattr(self, "_container", None)
if container is not None:
container.close()
# Closing the container does not close the file object backing it. Only close `_io`
# if we opened it ourselves; file-like objects passed by the caller remain theirs.
if getattr(self, "_owns_io", False):
io = getattr(self, "_io", None)
if io is not None:
io.close()
# Only mark the stream as closed once everything is released, so a failed `close()` can be
# retried. The last decoded frame is dropped, since `position` is undefined once closed.
self._closed = True
self._frame = None

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

@property
Expand Down Expand Up @@ -276,6 +296,8 @@ def time_base(self) -> Fraction | None:
@property
def aspect_ratio(self) -> float:
"""Pixel aspect ratio as a float (1.0 represents square pixels)."""
if self._closed:
return self._closed_aspect_ratio
# Unset is None on PyAV < 19 and a falsy AVRational(0, 1) on PyAV 19+.
dar = getattr(self._codec_context, "display_aspect_ratio", None)
if not dar or dar.denominator <= 0:
Expand All @@ -301,8 +323,10 @@ def seek(self, target: TimecodeLike) -> None:
If float, interpreted as time in seconds.
If int, interpreted as frame number.
Raises:
ValueError: `target` is not a valid value (i.e. it is negative).
ValueError: `target` is not a valid value (i.e. it is negative), or the stream is
closed.
"""
self._check_open()
if not isinstance(target, FrameTimecode):
target = FrameTimecode(target, self.frame_rate)
if target < 0:
Expand All @@ -326,6 +350,7 @@ def seek(self, target: TimecodeLike) -> None:

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

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