diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 2e3b4195..ab0440ff 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -15,6 +15,7 @@ import os import typing as ty import warnings +from enum import Enum, auto from fractions import Fraction from logging import getLogger @@ -30,11 +31,43 @@ framerate_to_fraction, ) from scenedetect.platform import StrPath, get_file_name -from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream +from scenedetect.video_stream import ( + FrameRateUnavailable, + SeekError, + VideoOpenFailure, + VideoStream, +) logger = getLogger("pyscenedetect") VALID_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] +# FFmpeg options used when opening the container. PyAV sets AVFMT_FLAG_GENPTS on every input +# (documented in `av.open()` as of PyAV 19.1). On containers that only store decode timestamps +# (e.g. AVI) the generated PTS comes out permuted once B-frames are reordered, and even FFmpeg's +# `best_effort_timestamp` is wrong for the first frames before it falls back to DTS. Disabling +# the flag is required on every PyAV version; see https://scenedetect.com/issues/590 and +# https://github.com/PyAV-Org/PyAV/pull/2478. +_CONTAINER_OPTIONS = {"fflags": "-genpts"} + +# `Frame.best_effort_timestamp` was added in PyAV 19.1, which requires Python 3.12+. Older PyAV +# versions remain supported on Python 3.10/3.11, so its absence must be handled. +_HAS_BEST_EFFORT_TIMESTAMP = hasattr(av.VideoFrame, "best_effort_timestamp") + + +class _TimeSource(Enum): + """The origin of the stream's frame position properties. + + This should be calculated once per stream from the first decoded frame. This source must then + be used for all subsequent frames.""" + + PTS = auto() + """Frames carry presentation timestamps.""" + DTS = auto() + """Frames only carry decode timestamps (e.g. AVI, some MPEG-TS).""" + FRAMES = auto() + """Frames carry no timestamps at all (e.g. raw Annex-B H.264); count decoded frames.""" + + MAX_CONSECUTIVE_DECODE_FAILURES = 8 """Number of consecutive frame decode failures after which `VideoStreamAv.read()` gives up. Isolated corrupt frames are skipped; this bound ensures a truncated file still terminates.""" @@ -46,6 +79,20 @@ def _to_fraction(value) -> Fraction: return Fraction(value.numerator, value.denominator) +def _frame_timestamp(frame) -> int | None: + """Timestamp of `frame` in units of `frame.time_base`, or None if it has none. + + Uses FFmpeg's estimate (`best_effort_timestamp`, PyAV >= 19.1) when available: the PTS, unless + PTS values have been missing or out of order more often than DTS values, in which case the + DTS. Otherwise the PTS, falling back to the DTS. With GENPTS disabled (`_CONTAINER_OPTIONS`) + both give the same result for inputs that only store DTS; the estimate only differs for + inputs whose PTS is present but partly broken, where it may switch to DTS mid-stream like + ffmpeg/ffplay do.""" + if _HAS_BEST_EFFORT_TIMESTAMP: + return frame.best_effort_timestamp + return frame.pts if frame.pts is not None else frame.dts + + class VideoStreamAv(VideoStream): """PyAV `av.InputContainer` backend.""" @@ -117,6 +164,23 @@ def __init__( self._reopened = True self._decode_failures = 0 self._warning_displayed = False + # Presentation time of `_frame` (see `_update_timecode`), None until a frame is read. + self._timecode: Timecode | None = None + # Decided from the first decoded frame, None until then. Kept across `reset()` since it is + # a property of the input, not of the container instance. + self._time_source: _TimeSource | None = None + # In DTS mode, the DTS of the first frame decoded from the start of the stream. Frames are + # emitted after the decoder's reordering delay, so this includes both the stream start + # time and that delay. Stored with the time base it was observed in. + self._dts_offset: int = 0 + self._dts_offset_time_base: Fraction | None = None + # Set once a timestamp going backwards has been reported for this stream. + self._warned_non_monotonic = False + # Number of frames decoded since the last seek/reset (position source in frame-count mode). + self._frames_decoded = 0 + # Last observed gap between consecutive frames in ticks of `_timecode.time_base`, used to + # extrapolate a position for frames that carry no timestamp. + self._last_delta = 0 # 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 @@ -144,7 +208,7 @@ def __init__( else: self._io = path_or_io - self._container: av.container.InputContainer = av.open(self._io) # type: ignore[attr-defined] + self._container: av.container.InputContainer = self._open_container() # type: ignore[attr-defined] if threading_mode is not None: self._video_stream.thread_type = threading_mode self._reopened = False @@ -257,18 +321,17 @@ def position(self) -> FrameTimecode: This can be interpreted as presentation time stamp, thus frame 1 corresponds to the presentation time 0. Returns 0 even if `frame_number` is 1.""" - if self._frame is None or self._frame.pts is None or not self._frame.time_base: + if self._timecode is None: return self.base_timecode - timecode = Timecode(pts=self._normalized_pts(), time_base=self._frame_time_base) - return FrameTimecode(timecode=timecode, fps=self.frame_rate) + return FrameTimecode(timecode=self._timecode, fps=self.frame_rate) @property def position_ms(self) -> float: """Current position within stream as a float of the presentation time in milliseconds. The first frame has a PTS of 0.""" - if self._frame is None or self._frame.pts is None or not self._frame.time_base: + if self._timecode is None: return 0.0 - return float(self._normalized_pts() * self._frame_time_base) * 1000.0 + return float(self._timecode.seconds) * 1000.0 @property def frame_number(self) -> int: @@ -276,10 +339,9 @@ def frame_number(self) -> int: Will return 0 until the first frame is `read`. For VFR video this is an approximation derived from PTS * framerate; use `position` for accurate PTS-based timing.""" - if self._frame is None or self._frame.pts is None or not self._frame.time_base: + if self._timecode is None: return 0 - seconds = float(self._normalized_pts() * self._frame_time_base) - return round(seconds * float(self.frame_rate)) + 1 + return round(float(self._timecode.seconds) * float(self.frame_rate)) + 1 @property def rate(self) -> Fraction | None: @@ -316,7 +378,8 @@ def seek(self, target: TimecodeLike) -> None: seek(0) followed by read(). If we want to seek to the 5th frame, we call seek(4) followed by read(), at which point frame_number will be 5. - May not be supported on all input codecs (see `is_seekable`). + May not be supported on all input codecs (see `is_seekable`). Elementary streams usually + cannot be seeked, and may raise `SeekError`. Arguments: target: Target position in video stream to seek to. @@ -339,9 +402,24 @@ def seek(self, target: TimecodeLike) -> None: target_pts = (self._video_stream.start_time or 0) + int( (self.base_timecode + target).seconds / _to_fraction(self._video_stream.time_base) ) + # If no frame has been decoded yet, the container is still at the start of the stream. + # Decode one frame from there first so the time source (and in DTS mode the reordering + # delay, which is only observable on the first frame) is known before moving away. + if self._time_source is None and not beginning: + self.read(decode=False) self._frame = None self._decoder = None - self._container.seek(target_pts, stream=self._video_stream) + self._timecode = None + # Without timestamps, frame numbering resumes from the seek target. + self._frames_decoded = max(target.frame_num, 0) + # TODO(https://scenedetect.com/issues/590): For some containers (e.g. MPEG-TS), we can end + # up *after* the `target_pts`. For example, seeking to frame 100 may have us end up on 151. + # This means the loop below never gets to walk forward to the exact frame. We need to seek + # to an earlier keyframe and then readc forward to fix this. + try: + self._container.seek(target_pts, stream=self._video_stream) + except av.error.FFmpegError as ex: # type: ignore[attr-defined] + raise SeekError(f"Unable to seek in stream: {ex}") from ex if not beginning: self.read(decode=False) while self.position < target: @@ -354,8 +432,11 @@ def reset(self): self._container.close() self._frame = None self._decoder = None + self._timecode = None + self._frames_decoded = 0 + self._warned_non_monotonic = False try: - self._container = av.open(self._path if self._path else self._io) + self._container = self._open_container(rewind=True) except Exception as ex: raise VideoOpenFailure() from ex @@ -363,16 +444,16 @@ 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 - # B-frame reordering) is never flushed prematurely. Creating a new generator each call - # caused the last buffered frame to be lost at EOF. + # *NOTE*: We *must* reuse the same decoder generator for the lifetime of the container. + # Creating a new generator at each call would cause the last buffered frame to be lost + # when we hit EOF due to the codec's internal buffer having been flushed. if self._decoder is None: self._decoder = self._container.decode(video=0) try: last_frame = self._frame assert self._decoder is not None self._frame = next(self._decoder) - # NOTE: EOFError subclasses FFmpegError, so this clause must come first. + # *NOTE*: EOFError subclasses FFmpegError, so this clause must come first. except av.error.EOFError: # type: ignore[attr-defined] self._frame = last_frame if self._handle_eof(): @@ -381,8 +462,8 @@ def read(self, decode: bool = True) -> np.ndarray | bool: except StopIteration: return False except av.error.FFmpegError as ex: # type: ignore[attr-defined] - # `next()` raised before assignment, so `self._frame` is still the last good - # frame and position/frame_number are unaffected by the skipped packet. + # `next()` was raised before assignment, so `self._frame` is still the last valid + # frame. Positions and frame numbers should be unaffected by any skipped packets. self._decode_failures += 1 consecutive_failures += 1 # The decoder generator is closed once an exception propagates through it; @@ -401,6 +482,8 @@ def read(self, decode: bool = True) -> np.ndarray | bool: logger.warning("Failed to decode some frames, results may be inaccurate.") continue assert self._frame is not None + self._frames_decoded += 1 + self._update_timecode() return self._frame.to_ndarray(format="bgr24") if decode else True # @@ -423,17 +506,97 @@ def _frame_time_base(self) -> Fraction: assert self._frame is not None and self._frame.time_base return _to_fraction(self._frame.time_base) - def _normalized_pts(self) -> int: - """PTS of the current frame relative to the start of the stream. Some files have a - nonzero stream start_time (e.g. from edit lists); other backends report the first - frame's presentation time as 0, so we must do the same.""" - assert self._frame is not None and self._frame.pts is not None - start_time = self._video_stream.start_time or 0 - if start_time and self._video_stream.time_base != self._frame.time_base: - start_time = int( - start_time * _to_fraction(self._video_stream.time_base) / self._frame_time_base + def _open_container(self, rewind: bool = False) -> av.container.InputContainer: # type: ignore[attr-defined] + """Open `_io` as a PyAV container with the options this backend relies on. Every open + must go through here so a re-open (`reset`, `_handle_eof`) keeps the same behaviour. + + Arguments: + rewind: Seek `_io` back to the start before opening, for re-opens of a stream that + has already been read from. + """ + if rewind and self._io.seekable(): + self._io.seek(0) + return av.open(self._io, container_options=_CONTAINER_OPTIONS) # type: ignore[attr-defined] + + def _update_timecode(self): + """Set `_timecode` to the presentation time of `_frame`, relative to the first frame of + the stream. The VideoStream contract is to report the first frame as 0. + + The first decoded frame decides where timestamps come from for the whole stream: PTS when + present, otherwise DTS, otherwise the number of decoded frames. The source determines the + offset that is subtracted; the timestamp itself comes from `_frame_timestamp`. If a frame + lacks a timestamp, it's extrapolated from the previous frame. + """ + frame = self._frame + assert frame is not None + timestamp = _frame_timestamp(frame) + if self._time_source is None: + if frame.pts is not None: + self._time_source = _TimeSource.PTS + elif timestamp is not None: + # Frames are emitted after the decoder's reordering delay, so the first frame's + # DTS includes both the stream start time and that delay. + self._time_source = _TimeSource.DTS + self._dts_offset, self._dts_offset_time_base = timestamp, self._frame_time_base + logger.debug( + "Stream has no PTS, using DTS with offset %d (time base %s).", + self._dts_offset, + self._dts_offset_time_base, + ) + else: + self._time_source = _TimeSource.FRAMES + logger.debug("Stream has no timestamps, positions are derived from frame count.") + + if self._time_source == _TimeSource.FRAMES: + self._timecode = Timecode( + pts=max(self._frames_decoded - 1, 0), time_base=1 / self.frame_rate + ) + return + if self._time_source == _TimeSource.PTS: + offset, offset_time_base = ( + self._video_stream.start_time or 0, + _to_fraction(self._video_stream.time_base), ) - return self._frame.pts - start_time + else: + offset, offset_time_base = self._dts_offset, self._dts_offset_time_base + if timestamp is None or not frame.time_base: + # E.g. frames flushed from the decoder at end of stream carry no DTS. Extrapolate from + # the previous frame rather than falling back to 0 or stalling, so positions stay + # monotonic. + self._timecode = self._extrapolated_timecode() + return + time_base = self._frame_time_base + if offset and offset_time_base is not None and offset_time_base != time_base: + offset = int(offset * offset_time_base / time_base) + timecode = Timecode(pts=timestamp - offset, time_base=time_base) + previous = self._timecode + if previous is not None and previous.time_base == timecode.time_base: + if timecode.pts > previous.pts: + self._last_delta = timecode.pts - previous.pts + elif timecode.pts < previous.pts and not self._warned_non_monotonic: + # With GENPTS disabled this is no longer expected for DTS-only inputs, so it + # points at a genuinely broken file (or a new PyAV/FFmpeg behaviour). + self._warned_non_monotonic = True + logger.warning( + "Timestamps went backwards at frame %d (%.3fs -> %.3fs, PyAV %s); results " + "may be inaccurate. See https://scenedetect.com/issues/590", + self._frames_decoded, + float(previous.seconds), + float(timecode.seconds), + av.__version__, + ) + self._timecode = timecode + + def _extrapolated_timecode(self) -> Timecode | None: + """Position one frame after the current one, using the last observed gap between frames + (or the nominal frame duration if none has been observed yet).""" + previous = self._timecode + if previous is None: + return None + delta = self._last_delta + if delta <= 0: + delta = max(round(1 / (self.frame_rate * previous.time_base)), 1) + return Timecode(pts=previous.pts + delta, time_base=previous.time_base) def _get_duration(self) -> int: """Get video duration as number of frames based on the video and set framerate.""" @@ -441,6 +604,10 @@ def _get_duration(self) -> int: # handle time calculations internally and which time base to use. assert self.frame_rate is not None, "Frame rate must be set before calling _get_duration!" # See if we can obtain the number of frames directly from the stream itself. + # TODO(https://scenedetect.com/issues/590): AVI files remuxed by ffmpeg can declare a tick + # rate (e.g. 600/1) as the stream rate, in which case `frames` counts ticks, not frames (a + # 480 frame clip reports 12012). OpenCV reports the same value, so this matches ffmpeg, but + # `duration * frame_rate` would be correct here. Pre-dates the GENPTS workaround. if self._video_stream.frames > 0: return self._video_stream.frames # Calculate based on the reported container duration. @@ -478,8 +645,7 @@ def _handle_eof(self): last_pos_secs = self.position.seconds orig_pos = self._io.tell() try: - self._io.seek(0) - container = av.open(self._io) + container = self._open_container(rewind=True) except: self._io.seek(orig_pos) raise diff --git a/tests/conftest.py b/tests/conftest.py index dee805a4..b14102e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -130,6 +130,20 @@ def test_vfr_drop3_video() -> str: return check_exists("tests/resources/goldeneye-vfr-drop3.mp4") +@pytest.fixture +def test_no_pts_avi() -> str: + """First 480 frames of goldeneye.mp4 re-encoded with B-frames (libx264, -bf 3 -g 48) and + remuxed into an AVI, so its packets only carry decode timestamps (no PTS). See issue #590.""" + return check_exists("tests/resources/issue-590-bframes.avi") + + +@pytest.fixture +def test_raw_h264() -> str: + """Same bitstream as `test_no_pts_avi` as a raw Annex-B H.264 elementary stream, which carries + no timestamps at all. See issue #590.""" + return check_exists("tests/resources/issue-590-bframes.h264") + + @pytest.fixture def corrupt_video_file() -> str: """Video containing a corrupted frame causing a decode failure.""" diff --git a/tests/test_backend_pyav.py b/tests/test_backend_pyav.py index b9d910e3..d181c41a 100644 --- a/tests/test_backend_pyav.py +++ b/tests/test_backend_pyav.py @@ -24,7 +24,16 @@ import av import pytest -from scenedetect.backends.pyav import MAX_CONSECUTIVE_DECODE_FAILURES, VideoStreamAv, _to_fraction +import scenedetect.backends.pyav as pyav_backend +from scenedetect import SceneManager +from scenedetect.backends.pyav import ( + MAX_CONSECUTIVE_DECODE_FAILURES, + VideoStreamAv, + _frame_timestamp, + _TimeSource, + _to_fraction, +) +from scenedetect.detectors import ContentDetector def test_video_stream_pyav_bytesio(test_video_file: str): @@ -250,3 +259,150 @@ def test_duration_fallback_unset_time_base( monkeypatch.setattr(VideoStreamAv, "_video_stream", property(lambda self: fake_stream)) assert stream._get_duration() == 0 assert any("time_base" in record.message for record in caplog.records) + + +# +# Streams without presentation timestamps +# +# PyAV enables AVFMT_FLAG_GENPTS on every input, which for containers that only carry decode +# timestamps (AVI) invents out-of-order PTS once B-frames are reordered. Raw elementary streams +# have no timestamps at all. Both fixtures hold the first 480 frames of `goldeneye.mp4` re-encoded +# with B-frames, so the cuts in `goldeneye.mp4` over that range are the oracle they must match. +# + +_BFRAMES_NUM_FRAMES = 480 + + +def _detect_cuts(path: str, num_frames: int | None = None) -> list: + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + with VideoStreamAv(path) as video: + end_time = None if num_frames is None else video.base_timecode + num_frames + scene_manager.detect_scenes(video, end_time=end_time) + return [scene[0] for scene in scene_manager.get_scene_list()[1:]] + + +def test_no_pts_avi_positions_monotonic(test_no_pts_avi: str, auto_close): + """With GENPTS disabled the AVI falls back to DTS; positions must start at 0 and increase by + one frame per read instead of following the decoder's reordering.""" + stream = auto_close(VideoStreamAv(test_no_pts_avi)) + assert stream.read() is not False + assert stream._time_source == _TimeSource.DTS + assert stream.position == stream.base_timecode + assert stream.position_ms == pytest.approx(0.0) + assert stream.frame_number == 1 + previous = stream.position + for i in range(2, 100): + assert stream.read() is not False + assert stream.position > previous + assert stream.frame_number == i + previous = stream.position + + +def test_no_pts_avi_matches_mp4(test_no_pts_avi: str, test_movie_clip: str): + """Cuts detected on the AVI must land on the same frames as on the mp4 it was encoded from. + Before the fix the AVI cuts were one frame late and frame numbers were permuted.""" + expected = _detect_cuts(test_movie_clip, _BFRAMES_NUM_FRAMES) + actual = _detect_cuts(test_no_pts_avi) + assert len(expected) > 0 + assert [cut.frame_num for cut in actual] == [cut.frame_num for cut in expected] + for cut, oracle in zip(actual, expected, strict=True): + # The AVI time base (1/600) cannot represent 24000/1001 exactly, so allow a few ms. + assert cut.seconds == pytest.approx(oracle.seconds, abs=0.005) + + +def test_no_pts_avi_seek(test_no_pts_avi: str, auto_close): + """Seeking must give the same positions whether or not a frame was read first, since the DTS + offset has to be probed separately when `seek()` is the first call.""" + read_first = auto_close(VideoStreamAv(test_no_pts_avi)) + assert read_first.read() is not False + read_first.seek(50) + seek_first = auto_close(VideoStreamAv(test_no_pts_avi)) + seek_first.seek(50) + for stream in (read_first, seek_first): + assert stream.frame_number == 50 + frame_ms = 1000.0 / float(stream.frame_rate) + assert stream.position_ms == pytest.approx(49 * frame_ms, abs=frame_ms / 2) + assert stream.read() is not False + assert stream.frame_number == 51 + assert read_first.position == seek_first.position + + +def test_no_pts_avi_reopen(test_no_pts_avi: str, auto_close): + """Re-opening the container (`reset()`, and the AUTO threading EOF re-open) must keep GENPTS + disabled, otherwise positions go out of order again partway through the video.""" + stream = auto_close(VideoStreamAv(test_no_pts_avi, threading_mode="AUTO")) + for _ in range(10): + assert stream.read() is not False + stream.reset() + assert stream.frame_number == 0 + assert stream.read() is not False + assert stream.position == stream.base_timecode + previous, frames = stream.position, 1 + while stream.read() is not False: + frames += 1 + assert stream.position > previous + previous = stream.position + # Not compared to `stream.duration`: this AVI declares a 600 Hz stream rate, so ffmpeg (and + # OpenCV) report its frame count in ticks. See the TODO in `VideoStreamAv._get_duration`. + assert frames == _BFRAMES_NUM_FRAMES + + +def test_raw_stream_without_timestamps_counts_frames( + test_raw_h264: str, test_movie_clip: str, auto_close +): + """A raw elementary stream has neither PTS nor DTS. Positions are then derived from the number + of decoded frames (like the OpenCV backend) instead of every frame reporting time 0.""" + stream = auto_close(VideoStreamAv(test_raw_h264)) + for i in range(1, 11): + assert stream.read() is not False + assert stream.frame_number == i + assert stream.position == stream.base_timecode + (i - 1) + assert stream._time_source == _TimeSource.FRAMES + expected = _detect_cuts(test_movie_clip, _BFRAMES_NUM_FRAMES) + actual = _detect_cuts(test_raw_h264) + assert [cut.frame_num for cut in actual] == [cut.frame_num for cut in expected] + + +# +# Timestamp source and monotonicity +# + + +@pytest.mark.parametrize( + "has_best_effort, frame, expected", + [ + (True, SimpleNamespace(pts=None, dts=7, best_effort_timestamp=9), 9), + (True, SimpleNamespace(pts=3, dts=1, best_effort_timestamp=None), None), + (False, SimpleNamespace(pts=None, dts=7), 7), + (False, SimpleNamespace(pts=3, dts=1), 3), + (False, SimpleNamespace(pts=None, dts=None), None), + ], + ids=["best-effort", "best-effort-unset", "dts-fallback", "pts", "none"], +) +def test_frame_timestamp_source(monkeypatch: pytest.MonkeyPatch, has_best_effort, frame, expected): + """`best_effort_timestamp` is used when PyAV exposes it (19.1+), otherwise the PTS with a DTS + fallback. The module flag decides, not the frame, so an unset estimate (None) is honoured.""" + monkeypatch.setattr(pyav_backend, "_HAS_BEST_EFFORT_TIMESTAMP", has_best_effort) + assert _frame_timestamp(frame) == expected + + +def test_non_monotonic_timestamps_warn_once( + test_video_file: str, monkeypatch: pytest.MonkeyPatch, caplog, auto_close +): + """A timestamp going backwards is reported once per stream, and only when it actually goes + backwards: repeated timestamps (as in VFR video) stay silent.""" + stream = auto_close(VideoStreamAv(test_video_file)) + monkeypatch.setattr(pyav_backend, "_HAS_BEST_EFFORT_TIMESTAMP", False) + fake_stream = _AttrOverridingProxy(stream._video_stream, start_time=0) + monkeypatch.setattr(VideoStreamAv, "_video_stream", property(lambda self: fake_stream)) + time_base = Fraction(1, 1000) + for pts in (0, 40, 40, 0, 80, 20): + stream._frame = SimpleNamespace(pts=pts, dts=None, time_base=time_base) + stream._frames_decoded += 1 + stream._update_timecode() + warnings = [record for record in caplog.records if "backwards" in record.message] + assert len(warnings) == 1 + assert "frame 4" in warnings[0].message + # Positions keep following the input; the warning does not alter them. + assert stream.position_ms == pytest.approx(20.0) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index ec012f9e..5cecda8c 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -823,4 +823,5 @@ Development - [bugfix] The OpenCV backend keeps `position` on the last frame after reaching the end of a VFR video, so the last scene no longer ends early (or before it starts) [#561](https://github.com/Breakthrough/PySceneDetect/issues/561) - [bugfix] `save-html --no-images` now writes to the global `-o`/`--output` directory instead of the working directory [#583](https://github.com/Breakthrough/PySceneDetect/pull/583) - [feature] `VideoStream` has a new `close()` method to release the file handles and decoders a video holds, and can be used as a context manager (`with open_video(path) as video:`) to close it automatically. Calling `close()` more than once has no effect. Once an OpenCV, PyAV, MoviePy or concatenated stream is closed, `read()`, `seek()` and `reset()` raise `ValueError`, while properties describing the video (`path`, `name`, `frame_rate`, `duration`, `frame_size`, `aspect_ratio`) remain available. The default implementation does nothing, so existing `VideoStream` subclasses are unaffected, and `VideoCaptureAdapter` leaves the caller's `VideoCapture` open [#568](https://github.com/Breakthrough/PySceneDetect/issues/568) - - [bugfix] `VideoStreamConcat` no longer keeps its input videos open when they can't be concatenated (e.g. mismatched resolutions), and now closes each input explicitly once it is no longer needed instead of relying on garbage collection [#568](https://github.com/Breakthrough/PySceneDetect/issues/568) \ No newline at end of file + - [bugfix] `VideoStreamConcat` no longer keeps its input videos open when they can't be concatenated (e.g. mismatched resolutions), and now closes each input explicitly once it is no longer needed instead of relying on garbage collection [#568](https://github.com/Breakthrough/PySceneDetect/issues/568) + - [bugfix] Fix `pyav` backend timing for videos that lack presentation timestamps (e.g. AVI files, elementary H.264 streams). Frame positions could be out of order or stuck at zero, and shift any cuts by one or more frames. Videos are now opened with FFmpeg's `genpts` flag disabled and decode timestamps are used as a fallback, which matches both the `opencv` and `moviepy` backends. PyAV's `best_effort_timestamp` is used when available (PyAV 19.1+), and a warning is logged once if timestamps still go backwards ([#590](https://github.com/Breakthrough/PySceneDetect/issues/590), [PyAV#933](https://github.com/PyAV-Org/PyAV/discussions/933), [PyAV#2478](https://github.com/PyAV-Org/PyAV/pull/2478))