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.
- simple_video_utils-0.0.1/LICENSE +21 -0
- simple_video_utils-0.0.1/PKG-INFO +93 -0
- simple_video_utils-0.0.1/README.md +76 -0
- simple_video_utils-0.0.1/pyproject.toml +52 -0
- simple_video_utils-0.0.1/setup.cfg +4 -0
- simple_video_utils-0.0.1/simple_video_utils/__init__.py +1 -0
- simple_video_utils-0.0.1/simple_video_utils/frames.py +95 -0
- simple_video_utils-0.0.1/simple_video_utils/metadata.py +60 -0
- simple_video_utils-0.0.1/simple_video_utils.egg-info/PKG-INFO +93 -0
- simple_video_utils-0.0.1/simple_video_utils.egg-info/SOURCES.txt +13 -0
- simple_video_utils-0.0.1/simple_video_utils.egg-info/dependency_links.txt +1 -0
- simple_video_utils-0.0.1/simple_video_utils.egg-info/requires.txt +7 -0
- simple_video_utils-0.0.1/simple_video_utils.egg-info/top_level.txt +1 -0
- simple_video_utils-0.0.1/tests/test_frames.py +325 -0
- simple_video_utils-0.0.1/tests/test_metadata.py +81 -0
|
@@ -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
|
+

|
|
23
|
+
[](./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
|
+

|
|
6
|
+
[](./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 @@
|
|
|
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
|
+

|
|
23
|
+
[](./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 @@
|
|
|
1
|
+
|
|
@@ -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__])
|