simple-video-utils 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 sign
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple-video-utils
3
+ Version: 0.0.1
4
+ Summary: Shared utilities for processing videos for sign language.
5
+ Author-email: Amit Moryossef <amit@sign.mt>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: av
11
+ Requires-Dist: numpy
12
+ Provides-Extra: dev
13
+ Requires-Dist: ruff; extra == "dev"
14
+ Requires-Dist: pytest; extra == "dev"
15
+ Requires-Dist: pytest-xdist; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ # Simple Video Utils
19
+
20
+ Lightweight utilities for extracting frames and metadata from videos. Built for sign language processing workflows.
21
+
22
+ ![Python](https://img.shields.io/badge/python-3.10+-blue)
23
+ [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
24
+
25
+ ## Goal
26
+
27
+ Provide simple, efficient tools for video processing in sign language research and applications.
28
+ Uses PyAV for fast frame extraction with support for multiple formats (MP4, WebM) and remote URLs.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install simple-video-utils
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ### Extract Video Metadata
39
+
40
+ ```python
41
+ from simple_video_utils.metadata import video_metadata
42
+
43
+ meta = video_metadata("video.mp4")
44
+ print(f"{meta.width}x{meta.height} @ {meta.fps} fps")
45
+ # Output: VideoMetadata(width=1920, height=1080, fps=30.0, nb_frames=450, time_base='1/15360')
46
+ ```
47
+
48
+ ### Read Frames from File
49
+
50
+ ```python
51
+ from simple_video_utils.frames import read_frames_exact
52
+
53
+ # Read specific frame range (inclusive)
54
+ frames = list(read_frames_exact("video.mp4", start_frame=0, end_frame=10))
55
+ # Returns 11 frames as numpy arrays (H, W, 3) in RGB format
56
+
57
+ # Read from frame to end of video
58
+ frames = list(read_frames_exact("video.mp4", start_frame=5, end_frame=None))
59
+ ```
60
+
61
+ ### Read Frames from Stream
62
+
63
+ ```python
64
+ from simple_video_utils.frames import read_frames_from_stream
65
+
66
+ # Useful for uploaded files or in-memory video data
67
+ with open("video.mp4", "rb") as f:
68
+ meta, frames_gen = read_frames_from_stream(f)
69
+ for frame in frames_gen:
70
+ # Process each frame (numpy array)
71
+ pass
72
+ ```
73
+
74
+ ### Remote Videos
75
+
76
+ ```python
77
+ from simple_video_utils.metadata import video_metadata
78
+ from simple_video_utils.frames import read_frames_exact
79
+
80
+ # Works with remote URLs
81
+ url = "https://example.com/video.mp4"
82
+ meta = video_metadata(url)
83
+ frames = list(read_frames_exact(url, 0, 5))
84
+ ```
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ pip install -e ".[dev]"
90
+ pytest tests/
91
+ ruff check .
92
+ ```
93
+
@@ -0,0 +1,76 @@
1
+ # Simple Video Utils
2
+
3
+ Lightweight utilities for extracting frames and metadata from videos. Built for sign language processing workflows.
4
+
5
+ ![Python](https://img.shields.io/badge/python-3.10+-blue)
6
+ [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
7
+
8
+ ## Goal
9
+
10
+ Provide simple, efficient tools for video processing in sign language research and applications.
11
+ Uses PyAV for fast frame extraction with support for multiple formats (MP4, WebM) and remote URLs.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install simple-video-utils
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Extract Video Metadata
22
+
23
+ ```python
24
+ from simple_video_utils.metadata import video_metadata
25
+
26
+ meta = video_metadata("video.mp4")
27
+ print(f"{meta.width}x{meta.height} @ {meta.fps} fps")
28
+ # Output: VideoMetadata(width=1920, height=1080, fps=30.0, nb_frames=450, time_base='1/15360')
29
+ ```
30
+
31
+ ### Read Frames from File
32
+
33
+ ```python
34
+ from simple_video_utils.frames import read_frames_exact
35
+
36
+ # Read specific frame range (inclusive)
37
+ frames = list(read_frames_exact("video.mp4", start_frame=0, end_frame=10))
38
+ # Returns 11 frames as numpy arrays (H, W, 3) in RGB format
39
+
40
+ # Read from frame to end of video
41
+ frames = list(read_frames_exact("video.mp4", start_frame=5, end_frame=None))
42
+ ```
43
+
44
+ ### Read Frames from Stream
45
+
46
+ ```python
47
+ from simple_video_utils.frames import read_frames_from_stream
48
+
49
+ # Useful for uploaded files or in-memory video data
50
+ with open("video.mp4", "rb") as f:
51
+ meta, frames_gen = read_frames_from_stream(f)
52
+ for frame in frames_gen:
53
+ # Process each frame (numpy array)
54
+ pass
55
+ ```
56
+
57
+ ### Remote Videos
58
+
59
+ ```python
60
+ from simple_video_utils.metadata import video_metadata
61
+ from simple_video_utils.frames import read_frames_exact
62
+
63
+ # Works with remote URLs
64
+ url = "https://example.com/video.mp4"
65
+ meta = video_metadata(url)
66
+ frames = list(read_frames_exact(url, 0, 5))
67
+ ```
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ pip install -e ".[dev]"
73
+ pytest tests/
74
+ ruff check .
75
+ ```
76
+
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "simple-video-utils"
3
+ description = "Shared utilities for processing videos for sign language."
4
+ version = "v0.0.1"
5
+ authors = [
6
+ { name = "Amit Moryossef", email = "amit@sign.mt" },
7
+ ]
8
+ license = {text = "MIT"}
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "av",
13
+ "numpy",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "ruff",
19
+ "pytest",
20
+ "pytest-xdist", # For parallel test execution
21
+ ]
22
+
23
+ [tool.setuptools]
24
+ packages = [
25
+ "simple_video_utils",
26
+ ]
27
+
28
+ [tool.ruff]
29
+ line-length = 120
30
+
31
+ [tool.ruff.lint]
32
+ select = [
33
+ "E", # pycodestyle errors
34
+ "W", # pycodestyle warnings
35
+ "F", # pyflakes
36
+ "C90", # mccabe complexity
37
+ "I", # isort
38
+ "N", # pep8-naming
39
+ "UP", # pyupgrade
40
+ "B", # flake8-bugbear
41
+ "PT", # flake8-pytest-style
42
+ "W605", # invalid escape sequence
43
+ "BLE", # flake8-blind-except
44
+ "TRY", # tryceratops
45
+ ]
46
+
47
+ [tool.pytest.ini_options]
48
+ addopts = "-v"
49
+ testpaths = [
50
+ "simple_video_utils",
51
+ "tests",
52
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Simple video utilities for frame extraction and metadata."""
@@ -0,0 +1,95 @@
1
+ import io
2
+ from collections.abc import Generator
3
+ from typing import BinaryIO
4
+
5
+ import av
6
+ import numpy as np
7
+
8
+ from simple_video_utils.metadata import VideoMetadata, _open_container, video_metadata_from_bytes
9
+
10
+
11
+ def _generate_frames(
12
+ container: av.container.InputContainer,
13
+ start_frame: int = 0,
14
+ end_frame: int | None = None,
15
+ ) -> Generator[np.ndarray, None, None]:
16
+ """
17
+ Generate RGB frames from a container.
18
+
19
+ Args:
20
+ container: Open PyAV container.
21
+ start_frame: First frame index to yield (0-based).
22
+ end_frame: Last frame index to yield (inclusive), or None for all.
23
+
24
+ Yields:
25
+ RGB numpy arrays (H, W, 3).
26
+ """
27
+ frame_index = 0
28
+ for frame in container.decode(video=0):
29
+ if frame_index < start_frame:
30
+ frame_index += 1
31
+ continue
32
+
33
+ if end_frame is not None and frame_index > end_frame:
34
+ break
35
+
36
+ yield frame.to_ndarray(format='rgb24')
37
+ frame_index += 1
38
+
39
+ def read_frames_exact(
40
+ src: str,
41
+ start_frame: int,
42
+ end_frame: int | None = None,
43
+ ) -> Generator[np.ndarray, None, None]:
44
+ """
45
+ Return frames [start_frame, end_frame] inclusive as RGB np.ndarrays.
46
+ If end_frame is None, reads from start_frame to the end of the video.
47
+ Uses PyAV for efficient frame extraction.
48
+ """
49
+ if end_frame is not None:
50
+ assert end_frame >= start_frame >= 0, "invalid frame range"
51
+ else:
52
+ assert start_frame >= 0, "start_frame must be non-negative"
53
+
54
+ with _open_container(src) as container:
55
+ stream = container.streams.video[0]
56
+
57
+ # Seek to approximate start position if not starting from beginning
58
+ if start_frame > 0:
59
+ fps = float(stream.average_rate) if stream.average_rate else 30.0
60
+ seek_time_sec = max(0, (start_frame - 30) / fps)
61
+ # Convert seconds to stream time_base units
62
+ seek_timestamp = int(seek_time_sec / float(stream.time_base))
63
+ container.seek(seek_timestamp, stream=stream)
64
+
65
+ yield from _generate_frames(container, start_frame, end_frame)
66
+
67
+
68
+ def read_frames_from_stream(
69
+ stream: BinaryIO,
70
+ skip_frames: int = 0,
71
+ ) -> tuple[VideoMetadata, Generator[np.ndarray, None, None]]:
72
+ """
73
+ Read frames from a video stream (file-like object).
74
+
75
+ Args:
76
+ stream: A file-like object containing video data (e.g., uploaded file).
77
+ skip_frames: Number of initial frames to skip (for resume support).
78
+
79
+ Returns:
80
+ A tuple of (VideoMetadata, frame_generator).
81
+ The generator yields np.ndarray frames in RGB format (H, W, 3).
82
+
83
+ Note:
84
+ PyAV handles format detection and seeking automatically.
85
+ Works with MP4, WebM, and other formats.
86
+ """
87
+ video_data = stream.read()
88
+ meta = video_metadata_from_bytes(video_data)
89
+
90
+ def frame_generator() -> Generator[np.ndarray, None, None]:
91
+ """Generator that yields frames from the video data."""
92
+ with _open_container(io.BytesIO(video_data)) as container:
93
+ yield from _generate_frames(container, start_frame=skip_frames)
94
+
95
+ return meta, frame_generator()
@@ -0,0 +1,60 @@
1
+ import io
2
+ from contextlib import contextmanager
3
+ from functools import lru_cache
4
+ from typing import NamedTuple
5
+
6
+ import av
7
+
8
+
9
+ class VideoMetadata(NamedTuple):
10
+ width: int
11
+ height: int
12
+ fps: float
13
+ nb_frames: int | None
14
+ time_base: str | None
15
+
16
+
17
+ @contextmanager
18
+ def _open_container(source: str | io.BytesIO):
19
+ """Context manager for safely opening and closing PyAV containers."""
20
+ container = None
21
+ try:
22
+ container = av.open(source)
23
+ yield container
24
+ except Exception as e:
25
+ msg = "Failed to open video"
26
+ raise RuntimeError(msg) from e
27
+ finally:
28
+ if container:
29
+ container.close()
30
+
31
+
32
+ def _get_metadata_from_container(container: av.container.InputContainer) -> VideoMetadata:
33
+ """Extract metadata from an open PyAV container."""
34
+ stream = container.streams.video[0]
35
+ fps = float(stream.average_rate) if stream.average_rate else 0.0
36
+ nb_frames = stream.frames if stream.frames > 0 else None
37
+ time_base = str(stream.time_base) if stream.time_base else None
38
+
39
+ return VideoMetadata(
40
+ width=stream.width,
41
+ height=stream.height,
42
+ fps=fps,
43
+ nb_frames=nb_frames,
44
+ time_base=time_base,
45
+ )
46
+
47
+
48
+
49
+
50
+ def video_metadata_from_bytes(data: bytes) -> VideoMetadata:
51
+ """Return key video stream metadata from video bytes."""
52
+ with _open_container(io.BytesIO(data)) as container:
53
+ return _get_metadata_from_container(container)
54
+
55
+
56
+ @lru_cache(maxsize=8)
57
+ def video_metadata(url_or_path: str) -> VideoMetadata:
58
+ """Return key video stream metadata."""
59
+ with _open_container(url_or_path) as container:
60
+ return _get_metadata_from_container(container)
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple-video-utils
3
+ Version: 0.0.1
4
+ Summary: Shared utilities for processing videos for sign language.
5
+ Author-email: Amit Moryossef <amit@sign.mt>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: av
11
+ Requires-Dist: numpy
12
+ Provides-Extra: dev
13
+ Requires-Dist: ruff; extra == "dev"
14
+ Requires-Dist: pytest; extra == "dev"
15
+ Requires-Dist: pytest-xdist; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ # Simple Video Utils
19
+
20
+ Lightweight utilities for extracting frames and metadata from videos. Built for sign language processing workflows.
21
+
22
+ ![Python](https://img.shields.io/badge/python-3.10+-blue)
23
+ [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
24
+
25
+ ## Goal
26
+
27
+ Provide simple, efficient tools for video processing in sign language research and applications.
28
+ Uses PyAV for fast frame extraction with support for multiple formats (MP4, WebM) and remote URLs.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install simple-video-utils
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ### Extract Video Metadata
39
+
40
+ ```python
41
+ from simple_video_utils.metadata import video_metadata
42
+
43
+ meta = video_metadata("video.mp4")
44
+ print(f"{meta.width}x{meta.height} @ {meta.fps} fps")
45
+ # Output: VideoMetadata(width=1920, height=1080, fps=30.0, nb_frames=450, time_base='1/15360')
46
+ ```
47
+
48
+ ### Read Frames from File
49
+
50
+ ```python
51
+ from simple_video_utils.frames import read_frames_exact
52
+
53
+ # Read specific frame range (inclusive)
54
+ frames = list(read_frames_exact("video.mp4", start_frame=0, end_frame=10))
55
+ # Returns 11 frames as numpy arrays (H, W, 3) in RGB format
56
+
57
+ # Read from frame to end of video
58
+ frames = list(read_frames_exact("video.mp4", start_frame=5, end_frame=None))
59
+ ```
60
+
61
+ ### Read Frames from Stream
62
+
63
+ ```python
64
+ from simple_video_utils.frames import read_frames_from_stream
65
+
66
+ # Useful for uploaded files or in-memory video data
67
+ with open("video.mp4", "rb") as f:
68
+ meta, frames_gen = read_frames_from_stream(f)
69
+ for frame in frames_gen:
70
+ # Process each frame (numpy array)
71
+ pass
72
+ ```
73
+
74
+ ### Remote Videos
75
+
76
+ ```python
77
+ from simple_video_utils.metadata import video_metadata
78
+ from simple_video_utils.frames import read_frames_exact
79
+
80
+ # Works with remote URLs
81
+ url = "https://example.com/video.mp4"
82
+ meta = video_metadata(url)
83
+ frames = list(read_frames_exact(url, 0, 5))
84
+ ```
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ pip install -e ".[dev]"
90
+ pytest tests/
91
+ ruff check .
92
+ ```
93
+
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ simple_video_utils/__init__.py
5
+ simple_video_utils/frames.py
6
+ simple_video_utils/metadata.py
7
+ simple_video_utils.egg-info/PKG-INFO
8
+ simple_video_utils.egg-info/SOURCES.txt
9
+ simple_video_utils.egg-info/dependency_links.txt
10
+ simple_video_utils.egg-info/requires.txt
11
+ simple_video_utils.egg-info/top_level.txt
12
+ tests/test_frames.py
13
+ tests/test_metadata.py
@@ -0,0 +1,7 @@
1
+ av
2
+ numpy
3
+
4
+ [dev]
5
+ ruff
6
+ pytest
7
+ pytest-xdist
@@ -0,0 +1 @@
1
+ simple_video_utils
@@ -0,0 +1,325 @@
1
+ from io import BytesIO
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ import pytest
6
+
7
+ from simple_video_utils.frames import read_frames_exact, read_frames_from_stream
8
+ from simple_video_utils.metadata import video_metadata
9
+
10
+
11
+ class TestReadFramesExact:
12
+ """Tests for the read_frames_exact function using example.mp4."""
13
+
14
+ @pytest.fixture
15
+ def video_path(self):
16
+ """Path to the example video file."""
17
+ return str(Path(__file__).parent / "assets" / "example.mp4")
18
+
19
+ def test_invalid_frame_range_negative_start(self):
20
+ """Test that negative start frame raises AssertionError."""
21
+ with pytest.raises(AssertionError, match="invalid frame range"):
22
+ list(read_frames_exact("example.mp4", -1, 5))
23
+
24
+ def test_invalid_frame_range_end_before_start(self):
25
+ """Test that end_frame < start_frame raises AssertionError."""
26
+ with pytest.raises(AssertionError, match="invalid frame range"):
27
+ list(read_frames_exact("example.mp4", 10, 5))
28
+
29
+ def test_read_single_frame(self, video_path):
30
+ """Test reading a single frame from example.mp4."""
31
+ frames = list(read_frames_exact(video_path, 0, 0))
32
+
33
+ assert len(frames) == 1
34
+ frame = frames[0]
35
+
36
+ # Check frame properties
37
+ assert isinstance(frame, np.ndarray)
38
+ assert frame.dtype == np.uint8
39
+ assert len(frame.shape) == 3
40
+ assert frame.shape[2] == 3 # RGB channels
41
+
42
+ # Check that frame contains actual image data (not all zeros)
43
+ assert np.sum(frame) > 0
44
+
45
+ def test_read_multiple_frames(self, video_path):
46
+ """Test reading multiple consecutive frames."""
47
+ frames = list(read_frames_exact(video_path, 0, 2))
48
+
49
+ assert len(frames) == 3 # frames 0, 1, 2 (inclusive)
50
+
51
+ for frame in frames:
52
+ assert isinstance(frame, np.ndarray)
53
+ assert frame.dtype == np.uint8
54
+ assert len(frame.shape) == 3
55
+ assert frame.shape[2] == 3
56
+
57
+ def test_frame_range_consistency(self, video_path):
58
+ """Test that reading the same frame multiple times gives consistent results."""
59
+ frame1 = list(read_frames_exact(video_path, 5, 5))[0]
60
+ frame2 = list(read_frames_exact(video_path, 5, 5))[0]
61
+
62
+ np.testing.assert_array_equal(frame1, frame2)
63
+
64
+ def test_sequential_vs_range_reading(self, video_path):
65
+ """Test that reading frames individually vs as range gives same results."""
66
+ # Read frames 1, 2, 3 as a range
67
+ range_frames = list(read_frames_exact(video_path, 1, 3))
68
+
69
+ # Read each frame individually
70
+ individual_frames = [
71
+ list(read_frames_exact(video_path, 1, 1))[0],
72
+ list(read_frames_exact(video_path, 2, 2))[0],
73
+ list(read_frames_exact(video_path, 3, 3))[0],
74
+ ]
75
+
76
+ assert len(range_frames) == len(individual_frames) == 3
77
+
78
+ for range_frame, individual_frame in zip(range_frames, individual_frames, strict=False):
79
+ np.testing.assert_array_equal(range_frame, individual_frame)
80
+
81
+ def test_frames_are_different(self, video_path):
82
+ """Test that consecutive frames are actually different (video has motion)."""
83
+ frames = list(read_frames_exact(video_path, 0, 10))
84
+
85
+ if len(frames) >= 2:
86
+ # Check that not all frames are identical
87
+ differences = []
88
+ for i in range(len(frames) - 1):
89
+ diff = np.sum(np.abs(frames[i].astype(np.int16) - frames[i + 1].astype(np.int16)))
90
+ differences.append(diff)
91
+
92
+ # At least some frames should be different
93
+ assert max(differences) > 0, "All consecutive frames are identical"
94
+
95
+ def test_large_frame_range(self, video_path):
96
+ """Test reading a larger range of frames."""
97
+ # Get video metadata first to know how many frames we have
98
+ meta = video_metadata(video_path)
99
+ max_frames = meta.nb_frames or 30 # Default to 30 if unknown
100
+
101
+ if max_frames and max_frames > 10:
102
+ end_frame = min(max_frames - 1, 20) # Read up to frame 20 or video end
103
+ frames = list(read_frames_exact(video_path, 0, end_frame))
104
+
105
+ expected_count = end_frame + 1
106
+ assert len(frames) == expected_count
107
+
108
+ # All frames should have same dimensions
109
+ shapes = [frame.shape for frame in frames]
110
+ assert all(shape == shapes[0] for shape in shapes)
111
+
112
+ def test_end_frame_none_from_start(self, video_path):
113
+ """Test reading from start to end of video with end_frame=None."""
114
+ # Read entire video from start
115
+ frames_all = list(read_frames_exact(video_path, 0, None))
116
+
117
+ # Read first few frames with explicit end_frame
118
+ frames_partial = list(read_frames_exact(video_path, 0, 5))
119
+
120
+ # All frames should be valid
121
+ assert len(frames_all) > 0
122
+ assert len(frames_all) >= len(frames_partial)
123
+
124
+ # First frames should match
125
+ for i in range(min(len(frames_all), len(frames_partial))):
126
+ np.testing.assert_array_equal(frames_all[i], frames_partial[i])
127
+
128
+ def test_end_frame_none_from_middle(self, video_path):
129
+ """Test reading from middle to end of video with end_frame=None."""
130
+ start_frame = 5
131
+
132
+ # Read from middle to end with end_frame=None
133
+ frames_to_end = list(read_frames_exact(video_path, start_frame, None))
134
+
135
+ # Should get some frames
136
+ assert len(frames_to_end) > 0
137
+
138
+ # Each frame should be valid
139
+ for frame in frames_to_end:
140
+ assert isinstance(frame, np.ndarray)
141
+ assert frame.dtype == np.uint8
142
+ assert len(frame.shape) == 3
143
+ assert frame.shape[2] == 3
144
+
145
+ def test_start_frame_zero_no_seeking(self, video_path):
146
+ """Test that start_frame=0 optimization works correctly."""
147
+ # These should produce identical results
148
+ frames_with_end = list(read_frames_exact(video_path, 0, 5))
149
+ frames_without_end = list(read_frames_exact(video_path, 0, None))[:6] # Take first 6 frames
150
+
151
+ # Compare first 6 frames
152
+ assert len(frames_with_end) == 6 # frames 0-5 inclusive
153
+ assert len(frames_without_end) >= 6
154
+
155
+ for i in range(6):
156
+ np.testing.assert_array_equal(frames_with_end[i], frames_without_end[i])
157
+
158
+ def test_end_frame_none_consistency(self, video_path):
159
+ """Test that end_frame=None gives consistent results."""
160
+ # Read twice with end_frame=None
161
+ frames1 = list(read_frames_exact(video_path, 0, None))
162
+ frames2 = list(read_frames_exact(video_path, 0, None))
163
+
164
+ # Should get same number of frames
165
+ assert len(frames1) == len(frames2)
166
+
167
+ # Frames should be identical
168
+ for f1, f2 in zip(frames1, frames2, strict=False):
169
+ np.testing.assert_array_equal(f1, f2)
170
+
171
+ def test_end_frame_none_vs_explicit_end(self, video_path):
172
+ """Test end_frame=None vs explicit end_frame for entire video."""
173
+ # Get video metadata to find total frames
174
+ meta = video_metadata(video_path)
175
+ total_frames = meta.nb_frames
176
+
177
+ if total_frames and total_frames > 10:
178
+ # Read with end_frame=None
179
+ frames_none = list(read_frames_exact(video_path, 0, None))
180
+
181
+ # Read with explicit end_frame (assuming we know total frames)
182
+ frames_explicit = list(read_frames_exact(video_path, 0, total_frames - 1))
183
+
184
+ # Should get same number of frames (or close due to container metadata)
185
+ # Allow small difference due to potential metadata inaccuracy
186
+ assert abs(len(frames_none) - len(frames_explicit)) <= 1
187
+
188
+ # First several frames should match
189
+ min_len = min(len(frames_none), len(frames_explicit))
190
+ for i in range(min(min_len, 10)): # Compare first 10 frames
191
+ np.testing.assert_array_equal(frames_none[i], frames_explicit[i])
192
+
193
+ def test_bad_color_space_video(self):
194
+ """Test reading frames from a video with unusual color space metadata."""
195
+ strange_video = str(Path(__file__).parent / "assets" / "bad_colorspace.mp4")
196
+
197
+ # Test reading frames (ffmpeg 8.0+ handles this video correctly)
198
+ frames = list(read_frames_exact(strange_video, 0))
199
+ assert len(frames) == 182
200
+
201
+ def test_webm_file(self):
202
+ """Test reading frames from a WebM file."""
203
+ webm_video = str(Path(__file__).parent / "assets" / "example.webm")
204
+
205
+ # Test reading frames
206
+ frames = list(read_frames_exact(webm_video, 0))
207
+ assert len(frames) == 67
208
+
209
+ def test_remote_video_url(self):
210
+ """Test reading frames from a remote video URL."""
211
+ remote_url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4"
212
+
213
+ # Test reading first frame
214
+ frames = list(read_frames_exact(remote_url, 0, 0))
215
+ assert len(frames) == 1
216
+
217
+ frame = frames[0]
218
+ assert isinstance(frame, np.ndarray)
219
+ assert frame.dtype == np.uint8
220
+ assert len(frame.shape) == 3
221
+ assert frame.shape[2] == 3
222
+ assert np.sum(frame) > 0
223
+
224
+ # Test reading multiple frames
225
+ frames_multi = list(read_frames_exact(remote_url, 0, 2))
226
+ assert len(frames_multi) == 3
227
+
228
+
229
+ class TestReadFramesFromStream:
230
+ """Tests for streaming video input via read_frames_from_stream."""
231
+
232
+ @pytest.fixture
233
+ def video_path(self):
234
+ """Path to the example video file."""
235
+ return str(Path(__file__).parent / "assets" / "example.mp4")
236
+
237
+ @pytest.fixture
238
+ def video_bytes(self, video_path):
239
+ """Load example video as bytes."""
240
+ return Path(video_path).read_bytes()
241
+
242
+ def test_read_frames_from_stream_basic(self, video_bytes):
243
+ """Test reading frames from a BytesIO stream."""
244
+ stream = BytesIO(video_bytes)
245
+ meta, frames_gen = read_frames_from_stream(stream)
246
+
247
+ # Check metadata
248
+ assert meta.width > 0
249
+ assert meta.height > 0
250
+ assert meta.fps > 0
251
+
252
+ # Read first frame
253
+ frame = next(frames_gen)
254
+ assert isinstance(frame, np.ndarray)
255
+ assert frame.dtype == np.uint8
256
+ assert frame.shape == (meta.height, meta.width, 3)
257
+ assert np.sum(frame) > 0
258
+
259
+ def test_read_frames_from_stream_all_frames(self, video_bytes, video_path):
260
+ """Test that stream reading produces same frames as file reading."""
261
+ stream = BytesIO(video_bytes)
262
+ meta, frames_gen = read_frames_from_stream(stream)
263
+
264
+ stream_frames = list(frames_gen)
265
+ file_frames = list(read_frames_exact(video_path, 0, None))
266
+
267
+ # Same number of frames
268
+ assert len(stream_frames) == len(file_frames)
269
+
270
+ # Frames should be identical
271
+ for i, (stream_frame, file_frame) in enumerate(zip(stream_frames, file_frames, strict=False)):
272
+ np.testing.assert_array_equal(
273
+ stream_frame,
274
+ file_frame,
275
+ err_msg=f"Frame {i} differs between stream and file reading",
276
+ )
277
+
278
+ def test_read_frames_from_stream_skip_frames(self, video_bytes, video_path):
279
+ """Test skipping initial frames from stream."""
280
+ skip = 5
281
+
282
+ stream = BytesIO(video_bytes)
283
+ _, frames_gen = read_frames_from_stream(stream, skip_frames=skip)
284
+ stream_frames = list(frames_gen)
285
+
286
+ # Compare with file-based reading starting at frame 5
287
+ file_frames = list(read_frames_exact(video_path, skip, None))
288
+
289
+ assert len(stream_frames) == len(file_frames)
290
+
291
+ for i, (stream_frame, file_frame) in enumerate(zip(stream_frames, file_frames, strict=False)):
292
+ np.testing.assert_array_equal(
293
+ stream_frame,
294
+ file_frame,
295
+ err_msg=f"Frame {i} (skipped {skip}) differs",
296
+ )
297
+
298
+ def test_read_frames_from_stream_metadata_matches(self, video_bytes, video_path):
299
+ """Test that returned metadata matches expected values."""
300
+ stream = BytesIO(video_bytes)
301
+ meta_stream, _ = read_frames_from_stream(stream)
302
+ meta_file = video_metadata(video_path)
303
+
304
+ assert meta_stream.width == meta_file.width
305
+ assert meta_stream.height == meta_file.height
306
+ assert meta_stream.fps == meta_file.fps
307
+
308
+ def test_read_frames_from_stream_webm(self):
309
+ """Test reading frames from a WebM stream."""
310
+ video_path = Path(__file__).parent / "assets" / "example.webm"
311
+ video_bytes = video_path.read_bytes()
312
+
313
+ stream = BytesIO(video_bytes)
314
+ meta, frames_gen = read_frames_from_stream(stream)
315
+
316
+ assert meta.width > 0
317
+ assert meta.height > 0
318
+ assert meta.fps > 0
319
+
320
+ frames = list(frames_gen)
321
+ assert len(frames) == 67 # Same as test_webm_file
322
+
323
+
324
+ if __name__ == "__main__":
325
+ pytest.main([__file__])
@@ -0,0 +1,81 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from simple_video_utils.metadata import video_metadata, video_metadata_from_bytes
6
+
7
+
8
+ class TestVideoMetadata:
9
+ """Tests for video metadata extraction functions."""
10
+
11
+ @pytest.fixture
12
+ def video_path(self):
13
+ """Path to the example video file."""
14
+ return str(Path(__file__).parent / "assets" / "example.mp4")
15
+
16
+ @pytest.fixture
17
+ def video_bytes(self, video_path):
18
+ """Load example video as bytes."""
19
+ return Path(video_path).read_bytes()
20
+
21
+ def test_video_metadata(self, video_path):
22
+ """Test that we can read video metadata."""
23
+ meta = video_metadata(video_path)
24
+
25
+ assert meta.width > 0
26
+ assert meta.height > 0
27
+ assert meta.fps > 0
28
+ assert isinstance(meta.width, int)
29
+ assert isinstance(meta.height, int)
30
+ assert isinstance(meta.fps, float)
31
+
32
+ def test_video_metadata_from_bytes(self, video_bytes):
33
+ """Test metadata extraction from video bytes."""
34
+ meta = video_metadata_from_bytes(video_bytes)
35
+
36
+ assert meta.width > 0
37
+ assert meta.height > 0
38
+ assert meta.fps > 0
39
+ assert isinstance(meta.width, int)
40
+ assert isinstance(meta.height, int)
41
+ assert isinstance(meta.fps, float)
42
+
43
+ def test_video_metadata_from_bytes_matches_file(self, video_bytes, video_path):
44
+ """Test that bytes-based metadata matches file-based metadata."""
45
+ meta_bytes = video_metadata_from_bytes(video_bytes)
46
+ meta_file = video_metadata(video_path)
47
+
48
+ assert meta_bytes.width == meta_file.width
49
+ assert meta_bytes.height == meta_file.height
50
+ assert meta_bytes.fps == meta_file.fps
51
+
52
+ def test_bad_color_space_video(self):
53
+ """Test metadata extraction from a video with unusual color space."""
54
+ strange_video = str(Path(__file__).parent / "assets" / "bad_colorspace.mp4")
55
+
56
+ meta = video_metadata(strange_video)
57
+ assert meta.width > 0
58
+ assert meta.height > 0
59
+ assert meta.fps > 0
60
+
61
+ def test_webm_file(self):
62
+ """Test metadata extraction from WebM file."""
63
+ webm_video = str(Path(__file__).parent / "assets" / "example.webm")
64
+
65
+ meta = video_metadata(webm_video)
66
+ assert meta.width > 0
67
+ assert meta.height > 0
68
+ assert meta.fps > 0
69
+
70
+ def test_remote_video_url(self):
71
+ """Test metadata extraction from a remote video URL."""
72
+ remote_url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4"
73
+
74
+ meta = video_metadata(remote_url)
75
+ assert meta.width > 0
76
+ assert meta.height > 0
77
+ assert meta.fps > 0
78
+
79
+
80
+ if __name__ == "__main__":
81
+ pytest.main([__file__])