From 34740145cab5a572d0411e5155801814078e438a Mon Sep 17 00:00:00 2001 From: AmitMY Date: Thu, 4 Jun 2026 09:49:01 +0200 Subject: [PATCH 1/6] fix(frames): apply display-matrix rotation when decoding frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyAV decodes frames in their stored orientation and ignores the container's rotation side data (unlike the ffmpeg CLI, which autorotates). Phone-recorded vertical videos store landscape frames with a 90° display matrix, so frames and metadata came out sideways — and so did every downstream consumer (e.g. pose estimation). - rotate decoded frames to display orientation via VideoFrame.rotation (np.rot90 k=1 for rotation=90 matches ffmpeg autorotate pixel-exactly) - report display-oriented width/height on VideoMetadata and expose a new `rotation` field (default 0, backward compatible) - read_frames_from_stream decodes the first frame eagerly to learn the rotation on non-seekable input (pipes) and replays it through the generator - pin av>=14.1 (VideoFrame.rotation was added in 14.1) - add tests/assets/rotated90.mp4 (rotation=90 clip) and regression tests comparing pixels against ffmpeg autorotate output Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- simple_video_utils/frames.py | 37 +++++++++-- simple_video_utils/metadata.py | 49 ++++++++++++-- tests/assets/rotated90.mp4 | Bin 0 -> 78186 bytes tests/test_rotation.py | 114 +++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 tests/assets/rotated90.mp4 create mode 100644 tests/test_rotation.py diff --git a/pyproject.toml b/pyproject.toml index 749769e..f5392d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ license = {text = "MIT"} readme = "README.md" requires-python = ">=3.8" dependencies = [ - "av", + "av>=14.1", # VideoFrame.rotation was added in 14.1 "numpy", ] diff --git a/simple_video_utils/frames.py b/simple_video_utils/frames.py index 64763e1..2ed3e0d 100644 --- a/simple_video_utils/frames.py +++ b/simple_video_utils/frames.py @@ -6,6 +6,23 @@ from simple_video_utils.metadata import VideoMetadata, _open_container, video_metadata_from_container +def _frame_to_rgb(frame: av.VideoFrame) -> np.ndarray: + """ + Convert a frame to an RGB array in display orientation. + + PyAV decodes frames in their stored orientation and does not apply the + container's display-matrix rotation (unlike the ffmpeg CLI, which + autorotates). Phone-recorded videos commonly store landscape frames with + a 90° rotation tag, so we apply it here. + """ + array = frame.to_ndarray(format='rgb24') + rotation = frame.rotation % 360 + if rotation and rotation % 90 == 0: + # rotation=90 with k=1 (counterclockwise) matches ffmpeg autorotate pixel-exactly + array = np.rot90(array, k=rotation // 90) + return array + + def _generate_frames( container: av.container.InputContainer, skip_frames: int = 0, @@ -23,7 +40,7 @@ def _generate_frames( max_frames: Maximum number of frames to yield, or None for all remaining. Yields: - RGB numpy arrays (H, W, 3) for frames after skipping. + RGB numpy arrays (H, W, 3) in display orientation for frames after skipping. """ frames_decoded = 0 frames_yielded = 0 @@ -33,7 +50,7 @@ def _generate_frames( frames_decoded += 1 continue - yield frame.to_ndarray(format='rgb24') + yield _frame_to_rgb(frame) frames_yielded += 1 if max_frames is not None and frames_yielded >= max_frames: @@ -219,11 +236,23 @@ def read_frames_from_stream( container = av.open(stream, mode='r', buffer_size=buffer_size) for s in container.streams.video: s.thread_type = thread_type - meta = video_metadata_from_container(container) + + # The display-matrix rotation is only exposed per-frame, and the stream may + # not be seekable (e.g. a pipe) — so decode the first frame eagerly for the + # metadata and hand it back through the generator. + first_frame = next(container.decode(video=0), None) + rotation = first_frame.rotation if first_frame is not None else 0 + meta = video_metadata_from_container(container, rotation=rotation) def frame_generator() -> Generator[np.ndarray, None, None]: try: - yield from _generate_frames(container, skip_frames=skip_frames, max_frames=None) + remaining_skip = skip_frames + if first_frame is not None: + if remaining_skip == 0: + yield _frame_to_rgb(first_frame) + else: + remaining_skip -= 1 + yield from _generate_frames(container, skip_frames=remaining_skip, max_frames=None) finally: container.close() diff --git a/simple_video_utils/metadata.py b/simple_video_utils/metadata.py index 257cc7d..8561c70 100644 --- a/simple_video_utils/metadata.py +++ b/simple_video_utils/metadata.py @@ -13,6 +13,7 @@ class VideoMetadata(NamedTuple): nb_frames: Optional[int] time_base: Optional[str] duration: Optional[float] # seconds; None if the container header doesn't carry one + rotation: int = 0 # display-matrix rotation in degrees; width/height already account for it @contextmanager @@ -30,8 +31,39 @@ def _open_container(source: Union[str, io.BytesIO]): container.close() -def video_metadata_from_container(container: av.container.InputContainer) -> VideoMetadata: - """Extract metadata from an open PyAV container.""" +def _probe_rotation(container: av.container.InputContainer) -> int: + """ + Read the display-matrix rotation by decoding the first frame, then rewind. + + PyAV only exposes the rotation per-frame (``VideoFrame.rotation``), not on + the stream. Requires a seekable container; returns 0 if the video can't be + decoded. + """ + try: + frame = next(container.decode(video=0), None) + rotation = frame.rotation if frame is not None else 0 + except (av.FFmpegError, OSError): + rotation = 0 + container.seek(0) + return rotation + + +def video_metadata_from_container( + container: av.container.InputContainer, + rotation: Optional[int] = None, +) -> VideoMetadata: + """ + Extract metadata from an open PyAV container. + + Width/height are reported in display orientation (rotation applied), + matching the frames yielded by the frames module. + + Args: + container: Open PyAV container. + rotation: Display rotation in degrees if already known (e.g. from a + decoded frame). When None, it is probed by decoding the first + frame and rewinding — pass it explicitly for non-seekable input. + """ stream = container.streams.video[0] fps = float(stream.average_rate) if stream.average_rate else 0.0 nb_frames = stream.frames if stream.frames > 0 else None @@ -50,13 +82,22 @@ def video_metadata_from_container(container: av.container.InputContainer) -> Vid else: duration = None + if rotation is None: + rotation = _probe_rotation(container) + rotation %= 360 + + width, height = stream.width, stream.height + if rotation % 180 == 90: + width, height = height, width + return VideoMetadata( - width=stream.width, - height=stream.height, + width=width, + height=height, fps=fps, nb_frames=nb_frames, time_base=time_base, duration=duration, + rotation=rotation, ) diff --git a/tests/assets/rotated90.mp4 b/tests/assets/rotated90.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..2d78b80485f2a6d5d5ac2ad456e68efbee788f40 GIT binary patch literal 78186 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 ZmPY4s7IwIbX!}IkOVIaakh^4K^}%Om(7^xz literal 0 HcmV?d00001 diff --git a/tests/test_rotation.py b/tests/test_rotation.py new file mode 100644 index 0000000..b72604b --- /dev/null +++ b/tests/test_rotation.py @@ -0,0 +1,114 @@ +"""Regression tests for videos with a display-matrix rotation (e.g. phone recordings). + +PyAV decodes frames in their stored orientation and does not apply the +rotation side data. These tests ensure frames are rotated to display +orientation and metadata reports display dimensions, matching the ffmpeg +CLI's autorotate behavior. +""" + +import subprocess +from io import BytesIO +from pathlib import Path + +import numpy as np +import pytest + +from simple_video_utils.frames import read_frames_exact, read_frames_from_stream +from simple_video_utils.metadata import video_metadata, video_metadata_from_bytes + + +@pytest.fixture +def video_path(): + """Vertical phone-style video: stored as 640x360 landscape with rotation=90.""" + return str(Path(__file__).parent / "assets" / "rotated90.mp4") + + +@pytest.fixture +def video_bytes(video_path): + """Load the rotated video as bytes.""" + return Path(video_path).read_bytes() + + +def ffmpeg_autorotated_frames(src: str, num_frames: int, width: int, height: int) -> np.ndarray: + """Decode frames with the ffmpeg CLI, which applies the display rotation.""" + cmd = [ + "ffmpeg", "-v", "error", + "-i", src, + "-frames:v", str(num_frames), + "-f", "rawvideo", "-pix_fmt", "rgb24", + "pipe:1", + ] + out = subprocess.run(cmd, check=True, capture_output=True).stdout + return np.frombuffer(out, dtype=np.uint8).reshape(num_frames, height, width, 3) + + +def assert_frames_close(frame: np.ndarray, ref: np.ndarray, err_msg: str): + """Assert frames match up to YUV->RGB rounding differences between ffmpeg builds. + + A wrong rotation direction produces a mean difference of ~70, so the + tolerance still catches orientation errors. + """ + assert frame.shape == ref.shape, err_msg + diff = np.abs(frame.astype(int) - ref.astype(int)) + assert diff.max() <= 3, f"{err_msg}: max diff {diff.max()}" + assert diff.mean() < 1, f"{err_msg}: mean diff {diff.mean():.2f}" + + +class TestRotatedVideo: + """Tests using a 90°-rotated vertical video.""" + + def test_metadata_reports_display_orientation(self, video_path): + """Metadata width/height must be the display (portrait) dimensions.""" + meta = video_metadata(video_path) + + assert (meta.width, meta.height) == (360, 640) + assert meta.rotation == 90 + assert meta.nb_frames == 30 + + def test_metadata_from_bytes_reports_display_orientation(self, video_bytes): + """Bytes-based metadata must match the path-based behavior.""" + meta = video_metadata_from_bytes(video_bytes) + + assert (meta.width, meta.height) == (360, 640) + assert meta.rotation == 90 + + def test_unrotated_video_has_zero_rotation(self): + """A video without a display matrix reports rotation=0 and unchanged dims.""" + meta = video_metadata(str(Path(__file__).parent / "assets" / "example.mp4")) + + assert meta.rotation == 0 + + def test_frames_are_rotated_to_display_orientation(self, video_path): + """Decoded frames must come out portrait, not the stored landscape.""" + frames = list(read_frames_exact(video_path, 0, 4)) + + assert len(frames) == 5 + assert all(frame.shape == (640, 360, 3) for frame in frames) + + def test_frames_match_ffmpeg_autorotate(self, video_path): + """Rotating the wrong way yields the same shape — compare pixels against ffmpeg.""" + frames = list(read_frames_exact(video_path, 0, 4)) + ref = ffmpeg_autorotated_frames(video_path, 5, width=360, height=640) + + for i, frame in enumerate(frames): + assert_frames_close(frame, ref[i], f"Frame {i} differs from ffmpeg") + + def test_stream_reading_rotates(self, video_bytes, video_path): + """The stream path must rotate frames and report display-oriented metadata.""" + meta, frames = read_frames_from_stream(BytesIO(video_bytes)) + ref = ffmpeg_autorotated_frames(video_path, 3, width=360, height=640) + + assert (meta.width, meta.height, meta.rotation) == (360, 640, 90) + for i in range(3): + assert_frames_close(next(frames), ref[i], f"Frame {i} differs from ffmpeg") + + def test_stream_reading_with_skip_frames(self, video_bytes, video_path): + """skip_frames must still work with the eagerly-decoded first frame.""" + ref = ffmpeg_autorotated_frames(video_path, 3, width=360, height=640) + _, frames = read_frames_from_stream(BytesIO(video_bytes), skip_frames=2) + + assert_frames_close(next(frames), ref[2], "Skipped-to frame differs from ffmpeg") + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From a69420b1cf0c23d69b7939f8eab153eb7c41f3b7 Mon Sep 17 00:00:00 2001 From: AmitMY Date: Thu, 4 Jun 2026 09:51:51 +0200 Subject: [PATCH 2/6] fix: return contiguous arrays for rotated frames np.rot90 yields a non-contiguous view (negative strides), which consumers like MediaPipe and OpenCV reject. Co-Authored-By: Claude Opus 4.8 (1M context) --- simple_video_utils/frames.py | 6 ++++-- tests/test_rotation.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/simple_video_utils/frames.py b/simple_video_utils/frames.py index 2ed3e0d..e6fba8d 100644 --- a/simple_video_utils/frames.py +++ b/simple_video_utils/frames.py @@ -18,8 +18,10 @@ def _frame_to_rgb(frame: av.VideoFrame) -> np.ndarray: array = frame.to_ndarray(format='rgb24') rotation = frame.rotation % 360 if rotation and rotation % 90 == 0: - # rotation=90 with k=1 (counterclockwise) matches ffmpeg autorotate pixel-exactly - array = np.rot90(array, k=rotation // 90) + # rotation=90 with k=1 (counterclockwise) matches ffmpeg autorotate pixel-exactly. + # np.rot90 returns a non-contiguous view, which consumers like MediaPipe + # and OpenCV reject — copy to a contiguous array. + array = np.ascontiguousarray(np.rot90(array, k=rotation // 90)) return array diff --git a/tests/test_rotation.py b/tests/test_rotation.py index b72604b..e8e1cee 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -84,6 +84,8 @@ def test_frames_are_rotated_to_display_orientation(self, video_path): assert len(frames) == 5 assert all(frame.shape == (640, 360, 3) for frame in frames) + # np.rot90 alone yields a non-contiguous view, which MediaPipe/OpenCV reject + assert all(frame.flags["C_CONTIGUOUS"] for frame in frames) def test_frames_match_ffmpeg_autorotate(self, video_path): """Rotating the wrong way yields the same shape — compare pixels against ffmpeg.""" From 6b5045d94ef3a3940e542ae59697beafa30cafa1 Mon Sep 17 00:00:00 2001 From: AmitMY Date: Thu, 4 Jun 2026 10:01:49 +0200 Subject: [PATCH 3/6] chore: require Python >=3.9 for av>=14.1 PyAV 14.1 (which added VideoFrame.rotation) dropped Python 3.8 support; 3.8 has been EOL since October 2024. Drop it from the CI matrix, bump requires-python, and apply the pyupgrade fixes ruff now flags for the 3.9 target (typing.Tuple/Generator -> builtins/collections.abc). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yaml | 2 +- pyproject.toml | 2 +- simple_video_utils/frames.py | 11 ++++++----- tests/test_regression.py | 3 ++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fba824f..0a38a31 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v6 diff --git a/pyproject.toml b/pyproject.toml index f5392d7..3a017c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] license = {text = "MIT"} readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "av>=14.1", # VideoFrame.rotation was added in 14.1 "numpy", diff --git a/simple_video_utils/frames.py b/simple_video_utils/frames.py index e6fba8d..ea3b3a5 100644 --- a/simple_video_utils/frames.py +++ b/simple_video_utils/frames.py @@ -1,4 +1,5 @@ -from typing import BinaryIO, Generator, Optional, Tuple +from collections.abc import Generator +from typing import BinaryIO, Optional import av import numpy as np @@ -64,7 +65,7 @@ def _validate_parameters( end_frame: Optional[int], start_time: Optional[float], end_time: Optional[float], -) -> Tuple[bool, bool]: +) -> tuple[bool, bool]: """Validate that time and frame parameters aren't mixed.""" has_frame_params = start_frame is not None or end_frame is not None has_time_params = start_time is not None or end_time is not None @@ -80,7 +81,7 @@ def _convert_time_to_frames( start_time: Optional[float], end_time: Optional[float], fps: float, -) -> Tuple[int, Optional[int]]: +) -> tuple[int, Optional[int]]: """Convert time-based parameters to frame indices.""" start = int((start_time or 0.0) * fps) end = int(end_time * fps) if end_time is not None else None @@ -95,7 +96,7 @@ def _convert_time_to_frames( def _normalize_frame_range( start_frame: Optional[int], end_frame: Optional[int], -) -> Tuple[int, Optional[int]]: +) -> tuple[int, Optional[int]]: """Normalize frame parameters with defaults and validation.""" start = start_frame if start_frame is not None else 0 @@ -214,7 +215,7 @@ def read_frames_from_stream( skip_frames: int = 0, thread_type: str = "AUTO", buffer_size: int = 32768, # PyAV default buffer size, can be reduced for lower latency when realtime streaming -) -> Tuple[VideoMetadata, Generator[np.ndarray, None, None]]: +) -> tuple[VideoMetadata, Generator[np.ndarray, None, None]]: """ Read frames from a video stream (file-like object). diff --git a/tests/test_regression.py b/tests/test_regression.py index 76e151b..e6de98a 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -2,9 +2,10 @@ import json import subprocess +from collections.abc import Generator from functools import lru_cache from pathlib import Path -from typing import Generator, NamedTuple, Optional +from typing import NamedTuple, Optional import numpy as np import pytest From 4509d12fa709f811039f0021d9843f3dd46cafd8 Mon Sep 17 00:00:00 2001 From: AmitMY Date: Thu, 4 Jun 2026 10:04:53 +0200 Subject: [PATCH 4/6] fix: close container on setup failure in read_frames_from_stream The eager first-frame decode made setup-time exceptions likelier (e.g. corrupted input); previously container.close() only ran in the generator's finally, leaking the container if setup raised. Co-Authored-By: Claude Opus 4.8 (1M context) --- simple_video_utils/frames.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/simple_video_utils/frames.py b/simple_video_utils/frames.py index ea3b3a5..a766668 100644 --- a/simple_video_utils/frames.py +++ b/simple_video_utils/frames.py @@ -237,15 +237,19 @@ def read_frames_from_stream( seeking (MP4 with moov at end), the stream must be fully available. """ container = av.open(stream, mode='r', buffer_size=buffer_size) - for s in container.streams.video: - s.thread_type = thread_type - - # The display-matrix rotation is only exposed per-frame, and the stream may - # not be seekable (e.g. a pipe) — so decode the first frame eagerly for the - # metadata and hand it back through the generator. - first_frame = next(container.decode(video=0), None) - rotation = first_frame.rotation if first_frame is not None else 0 - meta = video_metadata_from_container(container, rotation=rotation) + try: + for s in container.streams.video: + s.thread_type = thread_type + + # The display-matrix rotation is only exposed per-frame, and the stream may + # not be seekable (e.g. a pipe) — so decode the first frame eagerly for the + # metadata and hand it back through the generator. + first_frame = next(container.decode(video=0), None) + rotation = first_frame.rotation if first_frame is not None else 0 + meta = video_metadata_from_container(container, rotation=rotation) + except Exception: + container.close() + raise def frame_generator() -> Generator[np.ndarray, None, None]: try: From 12b5bd3c239449f09f3849ad1a2fdc77323c6f14 Mon Sep 17 00:00:00 2001 From: AmitMY Date: Thu, 4 Jun 2026 10:06:21 +0200 Subject: [PATCH 5/6] test: cover rotation on a truly non-seekable pipe BytesIO is seekable, so the existing stream tests didn't exercise the no-rewind path that real pipe input (e.g. an HTTP upload) takes. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_rotation.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_rotation.py b/tests/test_rotation.py index e8e1cee..2dbdfa9 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -6,7 +6,9 @@ CLI's autorotate behavior. """ +import os import subprocess +import threading from io import BytesIO from pathlib import Path @@ -111,6 +113,29 @@ def test_stream_reading_with_skip_frames(self, video_bytes, video_path): assert_frames_close(next(frames), ref[2], "Skipped-to frame differs from ffmpeg") + def test_stream_reading_from_non_seekable_pipe(self, video_bytes): + """Rotation must work on a truly non-seekable stream (BytesIO can seek; a pipe cannot).""" + read_fd, write_fd = os.pipe() + read_file = os.fdopen(read_fd, "rb") + write_file = os.fdopen(write_fd, "wb") + + def writer(): + write_file.write(video_bytes) + write_file.close() + + thread = threading.Thread(target=writer) + thread.start() + try: + meta, frames = read_frames_from_stream(read_file) + frame_list = list(frames) + finally: + thread.join() + read_file.close() + + assert (meta.width, meta.height, meta.rotation) == (360, 640, 90) + assert len(frame_list) == 30 + assert all(frame.shape == (640, 360, 3) for frame in frame_list) + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 5a7a53e83c99bc838efacac2f383b3067e143dcf Mon Sep 17 00:00:00 2001 From: AmitMY Date: Thu, 4 Jun 2026 10:08:59 +0200 Subject: [PATCH 6/6] chore: align supported Python with the README badge (>=3.10) The README has advertised 3.10+ all along; 3.9 went EOL in October 2025 and limits av to <16. Drop 3.9 from CI, bump requires-python, and apply the pyupgrade/bugbear fixes ruff enables for the 3.10 target (Optional[X] -> X | None, zip strict=). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yaml | 2 +- pyproject.toml | 2 +- simple_video_utils/frames.py | 32 ++++++++++++++++---------------- simple_video_utils/metadata.py | 12 ++++++------ tests/test_frames.py | 14 +++++++------- tests/test_regression.py | 12 ++++++------ 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0a38a31..10a3b69 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v6 diff --git a/pyproject.toml b/pyproject.toml index 3a017c5..e8c3c64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] license = {text = "MIT"} readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "av>=14.1", # VideoFrame.rotation was added in 14.1 "numpy", diff --git a/simple_video_utils/frames.py b/simple_video_utils/frames.py index a766668..ce12142 100644 --- a/simple_video_utils/frames.py +++ b/simple_video_utils/frames.py @@ -1,5 +1,5 @@ from collections.abc import Generator -from typing import BinaryIO, Optional +from typing import BinaryIO import av import numpy as np @@ -29,7 +29,7 @@ def _frame_to_rgb(frame: av.VideoFrame) -> np.ndarray: def _generate_frames( container: av.container.InputContainer, skip_frames: int = 0, - max_frames: Optional[int] = None, + max_frames: int | None = None, ) -> Generator[np.ndarray, None, None]: """ Generate RGB frames from a container's current position. @@ -61,10 +61,10 @@ def _generate_frames( frames_decoded += 1 def _validate_parameters( - start_frame: Optional[int], - end_frame: Optional[int], - start_time: Optional[float], - end_time: Optional[float], + start_frame: int | None, + end_frame: int | None, + start_time: float | None, + end_time: float | None, ) -> tuple[bool, bool]: """Validate that time and frame parameters aren't mixed.""" has_frame_params = start_frame is not None or end_frame is not None @@ -78,10 +78,10 @@ def _validate_parameters( def _convert_time_to_frames( - start_time: Optional[float], - end_time: Optional[float], + start_time: float | None, + end_time: float | None, fps: float, -) -> tuple[int, Optional[int]]: +) -> tuple[int, int | None]: """Convert time-based parameters to frame indices.""" start = int((start_time or 0.0) * fps) end = int(end_time * fps) if end_time is not None else None @@ -94,9 +94,9 @@ def _convert_time_to_frames( def _normalize_frame_range( - start_frame: Optional[int], - end_frame: Optional[int], -) -> tuple[int, Optional[int]]: + start_frame: int | None, + end_frame: int | None, +) -> tuple[int, int | None]: """Normalize frame parameters with defaults and validation.""" start = start_frame if start_frame is not None else 0 @@ -142,10 +142,10 @@ def _calculate_seek_position( def read_frames_exact( src: str, - start_frame: Optional[int] = None, - end_frame: Optional[int] = None, - start_time: Optional[float] = None, - end_time: Optional[float] = None, + start_frame: int | None = None, + end_frame: int | None = None, + start_time: float | None = None, + end_time: float | None = None, thread_type: str = "AUTO", ) -> Generator[np.ndarray, None, None]: """ diff --git a/simple_video_utils/metadata.py b/simple_video_utils/metadata.py index 8561c70..5537d6c 100644 --- a/simple_video_utils/metadata.py +++ b/simple_video_utils/metadata.py @@ -1,7 +1,7 @@ import io from contextlib import contextmanager from functools import lru_cache -from typing import NamedTuple, Optional, Union +from typing import NamedTuple import av @@ -10,14 +10,14 @@ class VideoMetadata(NamedTuple): width: int height: int fps: float - nb_frames: Optional[int] - time_base: Optional[str] - duration: Optional[float] # seconds; None if the container header doesn't carry one + nb_frames: int | None + time_base: str | None + duration: float | None # seconds; None if the container header doesn't carry one rotation: int = 0 # display-matrix rotation in degrees; width/height already account for it @contextmanager -def _open_container(source: Union[str, io.BytesIO]): +def _open_container(source: str | io.BytesIO): """Context manager for safely opening and closing PyAV containers.""" container = None try: @@ -50,7 +50,7 @@ def _probe_rotation(container: av.container.InputContainer) -> int: def video_metadata_from_container( container: av.container.InputContainer, - rotation: Optional[int] = None, + rotation: int | None = None, ) -> VideoMetadata: """ Extract metadata from an open PyAV container. diff --git a/tests/test_frames.py b/tests/test_frames.py index b3d224b..dba8ec2 100644 --- a/tests/test_frames.py +++ b/tests/test_frames.py @@ -75,7 +75,7 @@ def test_sequential_vs_range_reading(self, video_path): assert len(range_frames) == len(individual_frames) == 3 - for range_frame, individual_frame in zip(range_frames, individual_frames): + for range_frame, individual_frame in zip(range_frames, individual_frames, strict=False): np.testing.assert_array_equal(range_frame, individual_frame) def test_frames_are_different(self, video_path): @@ -165,7 +165,7 @@ def test_end_frame_none_consistency(self, video_path): assert len(frames1) == len(frames2) # Frames should be identical - for f1, f2 in zip(frames1, frames2): + for f1, f2 in zip(frames1, frames2, strict=False): np.testing.assert_array_equal(f1, f2) def test_end_frame_none_vs_explicit_end(self, video_path): @@ -253,7 +253,7 @@ def test_time_vs_frame_equivalence(self, video_path): assert len(frames_by_index) == len(frames_by_time) # Frames should be identical - for i, (frame_idx, frame_time) in enumerate(zip(frames_by_index, frames_by_time)): + for i, (frame_idx, frame_time) in enumerate(zip(frames_by_index, frames_by_time, strict=False)): np.testing.assert_array_equal( frame_idx, frame_time, @@ -297,7 +297,7 @@ def test_no_parameters_reads_all(self, video_path): # Should produce same result assert len(frames_no_params) == len(frames_explicit) - for f1, f2 in zip(frames_no_params, frames_explicit): + for f1, f2 in zip(frames_no_params, frames_explicit, strict=False): np.testing.assert_array_equal(f1, f2) def test_time_vs_frame_seeking_precision_remote(self): @@ -336,7 +336,7 @@ def test_time_vs_frame_seeking_precision_remote(self): ) # Every frame should be identical - for i, (frame_time, frame_idx) in enumerate(zip(frames_by_time, frames_by_frame)): + for i, (frame_time, frame_idx) in enumerate(zip(frames_by_time, frames_by_frame, strict=False)): actual_frame_num = start_frame_idx + i np.testing.assert_array_equal( frame_time, @@ -410,7 +410,7 @@ def test_read_frames_from_stream_all_frames(self, video_bytes, video_path): assert len(stream_frames) == len(file_frames) # Frames should be identical - for i, (stream_frame, file_frame) in enumerate(zip(stream_frames, file_frames)): + for i, (stream_frame, file_frame) in enumerate(zip(stream_frames, file_frames, strict=False)): np.testing.assert_array_equal( stream_frame, file_frame, @@ -430,7 +430,7 @@ def test_read_frames_from_stream_skip_frames(self, video_bytes, video_path): assert len(stream_frames) == len(file_frames) - for i, (stream_frame, file_frame) in enumerate(zip(stream_frames, file_frames)): + for i, (stream_frame, file_frame) in enumerate(zip(stream_frames, file_frames, strict=False)): np.testing.assert_array_equal( stream_frame, file_frame, diff --git a/tests/test_regression.py b/tests/test_regression.py index e6de98a..31f222d 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -5,7 +5,7 @@ from collections.abc import Generator from functools import lru_cache from pathlib import Path -from typing import NamedTuple, Optional +from typing import NamedTuple import numpy as np import pytest @@ -18,8 +18,8 @@ class VideoMetadata(NamedTuple): width: int height: int fps: float - nb_frames: Optional[int] - time_base: Optional[str] + nb_frames: int | None + time_base: str | None @lru_cache(maxsize=8) @@ -54,7 +54,7 @@ def ffprobe(url_or_path: str) -> VideoMetadata: def ffmpeg_read_frames_exact( # noqa: C901 src: str, start_frame: int, - end_frame: Optional[int] = None, + end_frame: int | None = None, ) -> Generator[np.ndarray, None, None]: """ Return frames [start_frame, end_frame] inclusive as RGB np.ndarrays using ffmpeg. @@ -195,7 +195,7 @@ def test_frames_match_ffmpeg_from_start(self, video_path): ) # Every frame should be identical (pixel-perfect) - for i, (pyav_frame, ffmpeg_frame) in enumerate(zip(pyav_frames, ffmpeg_frames)): + for i, (pyav_frame, ffmpeg_frame) in enumerate(zip(pyav_frames, ffmpeg_frames, strict=False)): np.testing.assert_array_equal( pyav_frame, ffmpeg_frame, @@ -266,7 +266,7 @@ def test_frames_match_ffmpeg_time_based(self, video_path): ) # Every frame should be identical - for i, (pyav_frame, ffmpeg_frame) in enumerate(zip(pyav_frames, ffmpeg_frames)): + for i, (pyav_frame, ffmpeg_frame) in enumerate(zip(pyav_frames, ffmpeg_frames, strict=False)): actual_frame_num = start_frame + i np.testing.assert_array_equal( pyav_frame,