mediaref 0.1.0__py3-none-any.whl

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.
mediaref/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """MediaRef - Lightweight media reference management for images and videos."""
2
+
3
+ from .core import MediaRef
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["MediaRef"]
7
+
8
+ # Optional loader module (requires extra dependencies)
9
+ try:
10
+ from .loader import cleanup_cache, load_batch # noqa: F401
11
+ from .video_decoder import BaseVideoDecoder, FrameBatch, PyAVVideoDecoder, TorchCodecVideoDecoder # noqa: F401
12
+ from .video_decoder.types import BatchDecodingStrategy, VideoStreamMetadata # noqa: F401
13
+
14
+ __all__.extend(
15
+ [
16
+ "load_batch",
17
+ "cleanup_cache",
18
+ "BatchDecodingStrategy",
19
+ "VideoStreamMetadata",
20
+ "BaseVideoDecoder",
21
+ "FrameBatch",
22
+ "PyAVVideoDecoder",
23
+ "TorchCodecVideoDecoder",
24
+ ]
25
+ )
26
+ except ImportError:
27
+ pass
mediaref/_internal.py ADDED
@@ -0,0 +1,254 @@
1
+ """Internal loading and encoding utilities."""
2
+
3
+ import base64
4
+ import gc
5
+ import os
6
+ from fractions import Fraction
7
+ from pathlib import Path
8
+ from typing import Literal, Optional, Union
9
+ from urllib.parse import urlparse
10
+
11
+ import av
12
+ import cv2
13
+ import numpy as np
14
+ import PIL.Image
15
+ import PIL.ImageOps
16
+ import requests
17
+
18
+ from . import cached_av
19
+
20
+ # Constants
21
+ REQUEST_TIMEOUT = 60 # HTTP request timeout in seconds
22
+ NANOSECOND = 1_000_000_000 # 1 second in nanoseconds
23
+
24
+ # Garbage collection for PyAV reference cycles
25
+ _CALLED_TIMES = 0
26
+ GC_COLLECTION_INTERVAL = 10
27
+
28
+
29
+ # ============================================================================
30
+ # Image Loading
31
+ # ============================================================================
32
+
33
+
34
+ def load_image_as_bgra(path_or_uri: str) -> np.ndarray:
35
+ """Load image from any source and return as BGRA numpy array.
36
+
37
+ Args:
38
+ path_or_uri: File path, URL, or data URI
39
+
40
+ Returns:
41
+ BGRA numpy array
42
+
43
+ Raises:
44
+ ValueError: If loading fails
45
+ FileNotFoundError: If local file doesn't exist
46
+ """
47
+ try:
48
+ if path_or_uri.startswith("data:"):
49
+ return _load_from_data_uri(path_or_uri)
50
+ else:
51
+ # Load as PIL image and convert to BGRA
52
+ pil_image = _load_pil_image(path_or_uri)
53
+ return _pil_to_bgra_array(pil_image)
54
+ except FileNotFoundError:
55
+ raise
56
+ except Exception as e:
57
+ raise ValueError(f"Failed to load image from {path_or_uri}: {e}") from e
58
+
59
+
60
+ def _load_pil_image(
61
+ image: Union[str, PIL.Image.Image],
62
+ ) -> PIL.Image.Image:
63
+ """Load image to PIL Image.
64
+
65
+ Adapted from: https://github.com/huggingface/diffusers/blob/main/src/diffusers/utils/loading_utils.py
66
+ """
67
+ if isinstance(image, str):
68
+ if image.startswith("http://") or image.startswith("https://"):
69
+ image = PIL.Image.open(requests.get(image, stream=True, timeout=REQUEST_TIMEOUT).raw)
70
+ elif os.path.isfile(image):
71
+ image = PIL.Image.open(image)
72
+ else:
73
+ raise ValueError(
74
+ f"Incorrect path or URL. URLs must start with `http://` or `https://`, "
75
+ f"and {image} is not a valid path."
76
+ )
77
+ elif isinstance(image, PIL.Image.Image):
78
+ pass
79
+ else:
80
+ raise ValueError(
81
+ "Incorrect format used for the image. Should be a URL linking to an image, a local path, or a PIL image."
82
+ )
83
+
84
+ # Handle EXIF orientation
85
+ image = PIL.ImageOps.exif_transpose(image)
86
+
87
+ # Convert to RGB
88
+ image = image.convert("RGB")
89
+
90
+ return image
91
+
92
+
93
+ def _pil_to_bgra_array(pil_image: PIL.Image.Image) -> np.ndarray:
94
+ """Convert PIL image to BGRA numpy array."""
95
+ # Ensure image is in RGB mode
96
+ if pil_image.mode != "RGB":
97
+ pil_image = pil_image.convert("RGB")
98
+
99
+ # Convert to numpy array and then to BGRA
100
+ rgb_array = np.array(pil_image)
101
+ return cv2.cvtColor(rgb_array, cv2.COLOR_RGB2BGRA)
102
+
103
+
104
+ # ============================================================================
105
+ # Video Loading
106
+ # ============================================================================
107
+
108
+
109
+ def load_video_frame_as_bgra(
110
+ path_or_url: str,
111
+ pts_ns: int,
112
+ *,
113
+ keep_av_open: bool = False,
114
+ ) -> np.ndarray:
115
+ """Load video frame and return as BGRA numpy array.
116
+
117
+ Args:
118
+ path_or_url: File path or URL to video
119
+ pts_ns: Presentation timestamp in nanoseconds
120
+ keep_av_open: Keep AV container open in cache
121
+
122
+ Returns:
123
+ BGRA numpy array
124
+
125
+ Raises:
126
+ ValueError: If loading fails
127
+ FileNotFoundError: If local file doesn't exist
128
+ """
129
+ global _CALLED_TIMES
130
+ _CALLED_TIMES += 1
131
+ if _CALLED_TIMES % GC_COLLECTION_INTERVAL == 0:
132
+ gc.collect()
133
+
134
+ try:
135
+ # Validate local file exists
136
+ if not path_or_url.startswith(("http://", "https://")):
137
+ if not Path(path_or_url).exists():
138
+ raise FileNotFoundError(f"Video file not found: {path_or_url}")
139
+
140
+ # Convert nanoseconds to fraction
141
+ pts_fraction = Fraction(pts_ns, NANOSECOND)
142
+
143
+ # Open video and read frame
144
+ container = cached_av.open(path_or_url, "r", keep_av_open=keep_av_open)
145
+ try:
146
+ frame = _read_frame_at_pts(container, pts_fraction)
147
+ rgb_array = frame.to_ndarray(format="rgb24")
148
+ return cv2.cvtColor(rgb_array, cv2.COLOR_RGB2BGRA)
149
+ finally:
150
+ if not keep_av_open:
151
+ container.close()
152
+ except FileNotFoundError:
153
+ raise
154
+ except Exception as e:
155
+ pts_seconds = pts_ns / NANOSECOND
156
+ raise ValueError(f"Failed to load frame at {pts_seconds:.3f}s from {path_or_url}: {e}") from e
157
+
158
+
159
+ def _read_frame_at_pts(
160
+ container: av.container.InputContainer,
161
+ pts: Fraction,
162
+ ) -> av.VideoFrame:
163
+ """Read single frame at or after given timestamp."""
164
+ if not container.streams.video:
165
+ raise ValueError("No video streams found")
166
+
167
+ stream = container.streams.video[0]
168
+
169
+ # Seek to the timestamp
170
+ container.seek(int(pts / stream.time_base), stream=stream)
171
+
172
+ # Decode frames until we find the right one
173
+ for frame in container.decode(stream):
174
+ if frame.time >= float(pts):
175
+ return frame
176
+
177
+ raise ValueError(f"Frame not found at {float(pts):.2f}s")
178
+
179
+
180
+ # ============================================================================
181
+ # Data URI Handling
182
+ # ============================================================================
183
+
184
+
185
+ def _load_from_data_uri(data_uri: str) -> np.ndarray:
186
+ """Load image from data URI."""
187
+ parsed = urlparse(data_uri)
188
+ if parsed.scheme != "data":
189
+ raise ValueError(f"Invalid data URI scheme: {parsed.scheme}")
190
+
191
+ try:
192
+ # Extract base64 data from data URI
193
+ data_part = parsed.path.split(",", 1)[1]
194
+ return _decode_from_base64(data_part)
195
+ except (IndexError, ValueError) as e:
196
+ raise ValueError(f"Invalid data URI format: {e}") from e
197
+
198
+
199
+ def _decode_from_base64(data: str) -> np.ndarray:
200
+ """Decode base64 string to BGRA numpy array."""
201
+ try:
202
+ image_bytes = base64.b64decode(data)
203
+ nparr = np.frombuffer(image_bytes, np.uint8)
204
+ bgr_array = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
205
+
206
+ if bgr_array is None:
207
+ raise ValueError("Failed to decode base64 image data")
208
+
209
+ return cv2.cvtColor(bgr_array, cv2.COLOR_BGR2BGRA)
210
+ except Exception as e:
211
+ raise ValueError(f"Failed to decode base64 data: {e}") from e
212
+
213
+
214
+ # ============================================================================
215
+ # Encoding
216
+ # ============================================================================
217
+
218
+
219
+ def encode_array_to_base64(
220
+ array: np.ndarray,
221
+ format: Literal["png", "jpeg", "bmp"],
222
+ quality: Optional[int] = None,
223
+ ) -> str:
224
+ """Encode BGRA numpy array to base64 string.
225
+
226
+ Args:
227
+ array: BGRA numpy array
228
+ format: Output format ('png', 'jpeg', or 'bmp')
229
+ quality: JPEG quality (1-100), ignored for PNG and BMP
230
+
231
+ Returns:
232
+ Base64 encoded string
233
+ """
234
+ # Convert BGRA to BGR for cv2 encoding
235
+ bgr_array = cv2.cvtColor(array, cv2.COLOR_BGRA2BGR)
236
+
237
+ # Encode based on format
238
+ if format == "png":
239
+ success, encoded = cv2.imencode(".png", bgr_array)
240
+ elif format == "jpeg":
241
+ if quality is None:
242
+ quality = 85
243
+ if not (1 <= quality <= 100):
244
+ raise ValueError("JPEG quality must be between 1 and 100")
245
+ success, encoded = cv2.imencode(".jpg", bgr_array, [cv2.IMWRITE_JPEG_QUALITY, quality])
246
+ elif format == "bmp":
247
+ success, encoded = cv2.imencode(".bmp", bgr_array)
248
+ else:
249
+ raise ValueError(f"Unsupported format: {format}")
250
+
251
+ if not success:
252
+ raise ValueError(f"Failed to encode image as {format}")
253
+
254
+ return base64.b64encode(encoded.tobytes()).decode("utf-8")
mediaref/_typing.py ADDED
@@ -0,0 +1,5 @@
1
+ import os
2
+ from typing import Union
3
+
4
+ # NOTE: pathlib.Path is an instance of os.PathLike, so we don't need to include it separately
5
+ PathLike = Union[str, bytes, os.PathLike]
@@ -0,0 +1,72 @@
1
+ import os
2
+ from typing import Literal, overload
3
+
4
+ import av
5
+ import av.container
6
+
7
+ from .._typing import PathLike
8
+ from ..resource_cache import ResourceCache
9
+ from .input_container_mixin import InputContainerMixin
10
+
11
+ DEFAULT_CACHE_SIZE = int(os.environ.get("AV_CACHE_SIZE", 10))
12
+
13
+ # Global container cache for efficient video file access
14
+ _container_cache: ResourceCache["MockedInputContainer"] = ResourceCache(max_size=DEFAULT_CACHE_SIZE)
15
+
16
+
17
+ @overload
18
+ def open(
19
+ file: PathLike, mode: Literal["r"], *, keep_av_open: bool = False, **kwargs
20
+ ) -> av.container.InputContainer: ...
21
+
22
+
23
+ @overload
24
+ def open(file: PathLike, mode: Literal["w"], **kwargs) -> av.container.OutputContainer: ...
25
+
26
+
27
+ def open(file: PathLike, mode: Literal["r", "w"], *, keep_av_open: bool = False, **kwargs):
28
+ """
29
+ Open video container with optional caching for read operations.
30
+
31
+ Args:
32
+ file: Video file path or URL
33
+ mode: Access mode ('r' for read, 'w' for write)
34
+ keep_av_open: Enable caching for read containers
35
+ **kwargs: Additional arguments passed to av.open
36
+ """
37
+ if mode == "r":
38
+ if not keep_av_open:
39
+ # Direct access without caching
40
+ return av.open(file, "r", **kwargs)
41
+
42
+ # Use cached container when keep_av_open=True
43
+ cache_key = str(file)
44
+ if cache_key not in _container_cache:
45
+ return MockedInputContainer(file, **kwargs)
46
+ return _container_cache[cache_key].obj
47
+ else:
48
+ return av.open(file, mode, **kwargs)
49
+
50
+
51
+ def cleanup_cache():
52
+ """Clear all cached video containers from memory."""
53
+ _container_cache.clear()
54
+
55
+
56
+ class MockedInputContainer(InputContainerMixin):
57
+ """Cached wrapper for PyAV InputContainer with reference counting."""
58
+
59
+ def __init__(self, file: PathLike, **kwargs):
60
+ self._cache_key = str(file)
61
+ self._container: av.container.InputContainer = av.open(file, "r", **kwargs)
62
+ _container_cache.add_entry(self._cache_key, self)
63
+
64
+ def __enter__(self) -> "MockedInputContainer":
65
+ return self
66
+
67
+ def close(self):
68
+ """Release container reference and cleanup when no longer needed."""
69
+ _container_cache.release_entry(self._cache_key)
70
+
71
+
72
+ __all__ = ["open", "cleanup_cache"]
@@ -0,0 +1,109 @@
1
+ from fractions import Fraction
2
+ from types import TracebackType
3
+ from typing import Any, Optional, Type, Union
4
+
5
+ import av.container
6
+ from av.container.streams import StreamContainer
7
+ from av.format import ContainerFormat
8
+
9
+ Real = Union[int, float, Fraction]
10
+
11
+
12
+ class ContainerMixin:
13
+ """Mixin that provides Container interface by delegating to _container.
14
+
15
+ Methods and properties copied from PyAV container core interface:
16
+ https://github.com/PyAV-Org/PyAV/blob/main/av/container/core.pyi#L70-L98
17
+ """
18
+
19
+ _container: av.container.Container
20
+
21
+ # Properties from Container (copied from PyAV container core interface)
22
+ @property
23
+ def writeable(self) -> bool:
24
+ return self._container.writeable
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ return self._container.name
29
+
30
+ @property
31
+ def metadata_encoding(self) -> str:
32
+ return self._container.metadata_encoding
33
+
34
+ @property
35
+ def metadata_errors(self) -> str:
36
+ return self._container.metadata_errors
37
+
38
+ @property
39
+ def file(self) -> Any:
40
+ return self._container.file
41
+
42
+ @property
43
+ def buffer_size(self) -> int:
44
+ return self._container.buffer_size
45
+
46
+ @property
47
+ def input_was_opened(self) -> bool:
48
+ return self._container.input_was_opened
49
+
50
+ @property
51
+ def io_open(self) -> Any:
52
+ return self._container.io_open
53
+
54
+ @property
55
+ def open_files(self) -> Any:
56
+ return self._container.open_files
57
+
58
+ @property
59
+ def format(self) -> ContainerFormat:
60
+ return self._container.format
61
+
62
+ @property
63
+ def options(self) -> dict[str, str]:
64
+ return self._container.options
65
+
66
+ @property
67
+ def container_options(self) -> dict[str, str]:
68
+ return self._container.container_options
69
+
70
+ @property
71
+ def stream_options(self) -> list[dict[str, str]]:
72
+ return self._container.stream_options
73
+
74
+ @property
75
+ def streams(self) -> StreamContainer:
76
+ return self._container.streams
77
+
78
+ @property
79
+ def metadata(self) -> dict[str, str]:
80
+ return self._container.metadata
81
+
82
+ @property
83
+ def open_timeout(self) -> Optional[Real]:
84
+ return self._container.open_timeout
85
+
86
+ @property
87
+ def read_timeout(self) -> Optional[Real]:
88
+ return self._container.read_timeout
89
+
90
+ @property
91
+ def flags(self) -> int:
92
+ return self._container.flags
93
+
94
+ def __enter__(self) -> "ContainerMixin":
95
+ return self
96
+
97
+ def __exit__(
98
+ self,
99
+ exc_type: Optional[Type[BaseException]],
100
+ exc_val: Optional[BaseException],
101
+ exc_tb: Optional[TracebackType],
102
+ ) -> bool:
103
+ return self._container.__exit__(exc_type, exc_val, exc_tb)
104
+
105
+ def set_timeout(self, timeout: Optional[Real]) -> None:
106
+ return self._container.set_timeout(timeout)
107
+
108
+ def start_timeout(self) -> None:
109
+ return self._container.start_timeout()
@@ -0,0 +1,92 @@
1
+ from typing import Any, Iterator, Optional, Union, overload
2
+
3
+ import av.container
4
+ from av.audio.frame import AudioFrame
5
+ from av.audio.stream import AudioStream
6
+ from av.packet import Packet
7
+ from av.stream import Stream
8
+ from av.subtitles.stream import SubtitleStream
9
+ from av.subtitles.subtitle import SubtitleSet
10
+ from av.video.frame import VideoFrame
11
+ from av.video.stream import VideoStream
12
+
13
+ from .container_mixin import ContainerMixin
14
+
15
+
16
+ class InputContainerMixin(ContainerMixin):
17
+ """Mixin that provides InputContainer interface by delegating to _container.
18
+
19
+ Methods and properties copied from PyAV input container interface:
20
+ https://github.com/PyAV-Org/PyAV/blob/main/av/container/input.pyi#L14-L49
21
+ """
22
+
23
+ _container: av.container.InputContainer # type: ignore[override]
24
+
25
+ # InputContainer-specific properties (copied from PyAV input container interface)
26
+ @property
27
+ def start_time(self) -> int:
28
+ return self._container.start_time
29
+
30
+ @property
31
+ def duration(self) -> Optional[int]:
32
+ return self._container.duration
33
+
34
+ @property
35
+ def bit_rate(self) -> int:
36
+ return self._container.bit_rate
37
+
38
+ @property
39
+ def size(self) -> int:
40
+ return self._container.size
41
+
42
+ def __enter__(self) -> "InputContainerMixin":
43
+ return self
44
+
45
+ def close(self) -> None:
46
+ return self._container.close()
47
+
48
+ def demux(self, *args: Any, **kwargs: Any) -> Iterator[Packet]:
49
+ return self._container.demux(*args, **kwargs)
50
+
51
+ @overload
52
+ def decode(self, video: int) -> Iterator[VideoFrame]: ...
53
+
54
+ @overload
55
+ def decode(self, audio: int) -> Iterator[AudioFrame]: ...
56
+
57
+ @overload
58
+ def decode(self, subtitles: int) -> Iterator[SubtitleSet]: ...
59
+
60
+ @overload
61
+ def decode(self, *args: VideoStream) -> Iterator[VideoFrame]: ...
62
+
63
+ @overload
64
+ def decode(self, *args: AudioStream) -> Iterator[AudioFrame]: ...
65
+
66
+ @overload
67
+ def decode(self, *args: SubtitleStream) -> Iterator[SubtitleSet]: ...
68
+
69
+ def decode(self, *args: Any, **kwargs: Any) -> Iterator[Union[VideoFrame, AudioFrame, SubtitleSet]]:
70
+ return self._container.decode(*args, **kwargs)
71
+
72
+ def seek(
73
+ self,
74
+ offset: int,
75
+ *,
76
+ backward: bool = True,
77
+ any_frame: bool = False,
78
+ stream: Optional[Union[Stream, VideoStream, AudioStream]] = None,
79
+ unsupported_frame_offset: bool = False,
80
+ unsupported_byte_offset: bool = False,
81
+ ) -> None:
82
+ return self._container.seek(
83
+ offset,
84
+ backward=backward,
85
+ any_frame=any_frame,
86
+ stream=stream,
87
+ unsupported_frame_offset=unsupported_frame_offset,
88
+ unsupported_byte_offset=unsupported_byte_offset,
89
+ )
90
+
91
+ def flush_buffers(self) -> None:
92
+ return self._container.flush_buffers()