ezMM 0.3.5__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.
ezmm/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from ezmm.common import (MultimodalSequence, Item, Image, Video, download_image, download_item,
2
+ download_video) # TODO: Add more
@@ -0,0 +1,21 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ from ezmm.common.items import *
5
+ from ezmm.common.multimodal_sequence import MultimodalSequence
6
+ from ezmm.common.registry import ItemRegistry
7
+ import logging
8
+
9
+ APP_NAME = "ezMM"
10
+ PROJECT_ROOT = Path(__file__).parent.parent.parent
11
+
12
+ # Set up logger
13
+ logger = logging.getLogger(APP_NAME)
14
+ logger.setLevel(logging.DEBUG)
15
+
16
+ # Only add handler if none exists (avoid duplicate logs on rerun)
17
+ if not logger.hasHandlers():
18
+ handler = logging.StreamHandler(sys.stdout)
19
+ formatter = logging.Formatter('[%(levelname)s]: %(message)s')
20
+ handler.setFormatter(formatter)
21
+ logger.addHandler(handler)
@@ -0,0 +1,15 @@
1
+ from ezmm.common.items.item import Item, REF, download_item
2
+ from ezmm.common.items.image import Image, download_image
3
+ from ezmm.common.items.video import Video, download_video
4
+ from ezmm.common.items.audio import Audio
5
+
6
+ ITEM_CLASSES = [Image, Video, Audio]
7
+ KIND2ITEM = {item.kind: item for item in ITEM_CLASSES}
8
+ KINDS = [item.kind for item in ITEM_CLASSES]
9
+
10
+ # Regex patterns
11
+ KINDS_ALTERNATIVES = "|".join(KINDS)
12
+ ITEM_REF_REGEX = "(" + REF.format(kind=f"(?:{KINDS_ALTERNATIVES})", id="[0-9]+") + ")" # Captures full ref
13
+ ITEM_ID_REGEX = REF.format(kind=f"(?:{KINDS_ALTERNATIVES})", id="([0-9]+)") # Captures only ID
14
+ ITEM_KIND_REGEX = REF.format(kind=f"({KINDS_ALTERNATIVES})", id="[0-9]+") # Captures only kind
15
+ ITEM_KIND_ID_REGEX = REF.format(kind=f"({KINDS_ALTERNATIVES})", id="([0-9]+)") # Captures kind and ID
@@ -0,0 +1,6 @@
1
+ from ezmm.common.items import Item
2
+
3
+
4
+ class Audio(Item):
5
+ kind = "audio"
6
+ # TODO: Implement
@@ -0,0 +1,127 @@
1
+ import logging
2
+ from io import BytesIO
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import aiohttp
7
+ import pillow_avif # Keep this import for AVIF support
8
+ from PIL.Image import Image as PillowImage, open as pillow_open, new as pillow_new, Resampling
9
+
10
+ from ezmm.common.items.item import Item
11
+ from ezmm.request import request_static
12
+ from ezmm.util import to_base64
13
+
14
+ logger = logging.getLogger("ezMM")
15
+ logger.debug(f"`pillow_avif` v{pillow_avif.__version__} loaded for AVIF image support.")
16
+
17
+
18
+ class Image(Item):
19
+ kind = "image"
20
+ _image: Optional[PillowImage] = None
21
+
22
+ def __init__(self, file_path: str | Path | None = None,
23
+ pillow_image: PillowImage | None = None,
24
+ binary_data: bytes | None = None,
25
+ source_url: str | None = None,
26
+ reference: str | None = None):
27
+ assert file_path or pillow_image or binary_data or reference
28
+
29
+ if hasattr(self, "id"):
30
+ # The image is already initialized (existing instance returned via __new__())
31
+ return
32
+
33
+ if binary_data is not None:
34
+ pillow_image = pillow_open(BytesIO(binary_data))
35
+
36
+ if pillow_image is not None:
37
+ pillow_image = _ensure_rgb_mode(pillow_image)
38
+ # Save the image in a temporary folder
39
+ file_path = self._temp_file_path(suffix=".jpg")
40
+ file_path.parent.mkdir(parents=True, exist_ok=True)
41
+ pillow_image.save(file_path)
42
+ self._image = pillow_image
43
+
44
+ # free memory and file handle
45
+ self.close()
46
+
47
+ super().__init__(file_path,
48
+ source_url=source_url,
49
+ reference=reference)
50
+
51
+ @property
52
+ def image(self) -> PillowImage:
53
+ """Lazy-loads the PIL image of this Image item."""
54
+ if not self._image:
55
+ with pillow_open(self.file_path) as img:
56
+ self._image = _ensure_rgb_mode(img).copy()
57
+ return self._image
58
+
59
+ def get_base64_encoded(self) -> str:
60
+ return to_base64(self.image)
61
+
62
+ @property
63
+ def width(self) -> int:
64
+ return self.image.width
65
+
66
+ @property
67
+ def height(self) -> int:
68
+ return self.image.height
69
+
70
+ def _same(self, other):
71
+ return (
72
+ self.image.mode == other.image.mode and
73
+ self.image.size == other.image.size and
74
+ self.image.tobytes() == other.image.tobytes()
75
+ )
76
+
77
+ def as_html(self, path_relative_to: str | Path = None) -> str:
78
+ if path_relative_to is None:
79
+ path_relative_to = Path.cwd()
80
+ path = self.file_path.relative_to(path_relative_to).as_posix()
81
+ img = f'<img src="/{path}" alt="{self.reference}">'
82
+ if self.source_url:
83
+ return f'<a href="{self.source_url}">{img}</a>'
84
+ else:
85
+ return img
86
+
87
+ def close(self):
88
+ if self._image:
89
+ self._image.close()
90
+ self._image = None
91
+
92
+
93
+ def _ensure_rgb_mode(pillow_image: PillowImage) -> PillowImage:
94
+ """Turns any kind of image (incl. PNGs) into RGB mode to make it JPEG-saveable."""
95
+ if pillow_image.mode in ["RGBA", "P"]:
96
+ pillow_image = pillow_image.convert('RGBA')
97
+ converted = pillow_new("RGB", pillow_image.size, (255, 255, 255))
98
+ converted.paste(pillow_image, mask=pillow_image.split()[3]) # 3 is the alpha channel
99
+ return converted
100
+ if pillow_image.mode != "RGB":
101
+ return pillow_image.convert('RGB')
102
+ else:
103
+ return pillow_image
104
+
105
+
106
+ async def download_image(
107
+ image_url: str,
108
+ session: aiohttp.ClientSession,
109
+ ignore_small_images: bool = True,
110
+ max_size: tuple[int, int] = (2048, 2048)
111
+ ) -> Optional[Image]:
112
+ """Download an image from a URL and return it as an Image object."""
113
+ # TODO: Handle very large images like: https://eoimages.gsfc.nasa.gov/images/imagerecords/144000/144225/campfire_oli_2018312_lrg.jpg
114
+ content = await request_static(image_url, session, get_text=False)
115
+ # TODO: Request page dynamically (better move all request-related functions to ScrapeMM)
116
+ if content:
117
+ pillow_img = pillow_open(BytesIO(content))
118
+
119
+ if pillow_img:
120
+ if pillow_img.width > max_size[0] or pillow_img.height > max_size[1]:
121
+ pillow_img.thumbnail(max_size, Resampling.LANCZOS) # Preserves aspect ratio
122
+
123
+ if not ignore_small_images or (pillow_img.width > 256 and pillow_img.height > 256):
124
+ # TODO: Check for duplicates, i.e., reuse an existing image if it already exists in the registry
125
+ image = Image(pillow_image=pillow_img, source_url=image_url)
126
+ image.relocate(move_not_copy=True) # Ensure the image is in the temp dir + follows simple naming
127
+ return image
@@ -0,0 +1,171 @@
1
+ import asyncio
2
+ import re
3
+ from abc import ABC
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from shutil import copyfile, move
7
+ from typing import Sequence, Optional
8
+
9
+ import aiohttp
10
+
11
+ from ezmm.config import temp_dir
12
+ from ezmm.request import is_maybe_image_url, is_maybe_video_url
13
+ from ezmm.util import is_item_ref, normalize_path
14
+
15
+ REF = "<{kind}:{id}>" # General reference template, defining the reference syntax
16
+
17
+
18
+ class Item(ABC):
19
+ """An element of MultimodalSequences. The data of each item is saved in an individual file."""
20
+ kind: str # Specifies the type of the item (image, video, ...)
21
+ id: int # Unique identifier of this item within its kind
22
+ file_path: Path # The (absolute) path to the file where the data of this item is stored
23
+ source_url: str # The (web or file) URL pointing at the Item data's origin
24
+
25
+ def __new__(cls, file_path: Path | str = None, source_url: str = None, reference: str = None, **kwargs):
26
+ """Checks if there already exists an instance of the item with the given reference.
27
+ If yes, returns the existing reference. Otherwise, instantiates a new one."""
28
+
29
+ if file_path or reference:
30
+ # Look up an existing instance instead of creating a new one
31
+ from ezmm.common.registry import item_registry
32
+ item = item_registry.get_cached(reference=reference, kind=cls.kind, file_path=file_path)
33
+ if item:
34
+ item.source_url = source_url or item.source_url
35
+ return item
36
+ elif reference:
37
+ raise ValueError(f"No item with reference '{reference}'.")
38
+
39
+ return super().__new__(cls)
40
+
41
+ def __init__(self, file_path: Path | str, source_url: str = None, reference: str = None):
42
+ if hasattr(self, "id"):
43
+ # The item is already initialized (existing instance returned via __new__())
44
+ return
45
+ self.file_path = normalize_path(file_path)
46
+ self.source_url = source_url or self.file_path.absolute().as_uri()
47
+ from ezmm.common.registry import item_registry
48
+ item_registry.add_and_assign_id(self) # Ensure the item is registered and get an ID assigned
49
+
50
+ @property
51
+ def reference(self) -> str:
52
+ return REF.format(kind=self.kind, id=self.id)
53
+
54
+ def _same(self, other) -> bool:
55
+ """Compares the content data with the other item for equality."""
56
+ raise NotImplementedError
57
+
58
+ @staticmethod
59
+ def from_reference(reference: str) -> Optional["Item"]:
60
+ from ezmm.common.registry import item_registry
61
+ return item_registry.get(reference)
62
+
63
+ def close(self):
64
+ """Closes any resources held by this item."""
65
+ pass
66
+
67
+ def as_html(self, path_relative_to: str | Path = None) -> str:
68
+ """Returns the item as HTML code."""
69
+ return f"<p>Item {self.reference} does not support HTML yet.</p>"
70
+
71
+ def relocate(self, move_not_copy=False):
72
+ """Copies the item's file to the temp_dir if not
73
+ located there already. Moves it instead if move=True."""
74
+ new_path = self._default_file_path(suffix=self.file_path.suffix)
75
+ if self.file_path != new_path:
76
+ # Ensure the target directory exists
77
+ new_path.parent.mkdir(parents=True, exist_ok=True)
78
+
79
+ # Move/copy file to target directory
80
+ self.close()
81
+ move(self.file_path, new_path) if move_not_copy else copyfile(self.file_path, new_path)
82
+
83
+ # Update the file path to the new location
84
+ self.file_path = new_path
85
+ from ezmm.common.registry import item_registry
86
+ item_registry.update_file_path(self)
87
+
88
+ def get_size(self) -> int:
89
+ """Returns the size of the item in bytes."""
90
+ return self.file_path.stat().st_size
91
+
92
+ def _temp_file_path(self, suffix: str = "") -> Path:
93
+ """Used when the item's ID is not set yet."""
94
+ filename = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f") + suffix
95
+ return normalize_path(temp_dir / "items" / filename)
96
+
97
+ def _default_file_path(self, suffix: str = "") -> Path:
98
+ """Only usable after item initialization."""
99
+ default_filename = str(self.id) + suffix
100
+ return normalize_path(temp_dir / self.kind / default_filename)
101
+
102
+ def __eq__(self, other):
103
+ return (self is other or
104
+ isinstance(other, Item) and (
105
+ self.kind == other.kind and self.id == other.id or # Should never trigger
106
+ self._same(other)
107
+ ))
108
+
109
+ def __hash__(self):
110
+ # TODO: Make hash content-dependent => identify known items by hash
111
+ return hash((self.kind, self.id))
112
+
113
+
114
+ def resolve_references_from_sequence(seq: Sequence[str | Item]) -> list[str | Item]:
115
+ """Identifies all item references within the sequence and replaces them with
116
+ an instance of the referenced item. Returns the (interleaved) list of
117
+ strings and items."""
118
+ processed = []
119
+ for item in seq:
120
+ if isinstance(item, str):
121
+ if item.strip(" "): # Drop excess whitespaces
122
+ resolved = resolve_references_from_string(item)
123
+ processed.extend(resolved)
124
+ elif item: # Drop Nones
125
+ processed.append(item)
126
+ return processed
127
+
128
+
129
+ def resolve_references_from_string(string: str) -> list[str | Item]:
130
+ """Identifies all item references within the string and replaces them with
131
+ an instance of the referenced item. Returns the (interleaved) list of
132
+ strings and items."""
133
+ from ezmm.common.registry import item_registry
134
+ from ezmm.common.items import ITEM_REF_REGEX
135
+ ref_regex = rf"\s?{ITEM_REF_REGEX}\s?" # Extend to optional whitespaces before and after the ref
136
+ split = re.split(ref_regex, string)
137
+ # Replace each reference with its actual item object
138
+ for i in range(len(split)):
139
+ substr = split[i]
140
+ if is_item_ref(substr):
141
+ item = item_registry.get(substr)
142
+ if item is not None:
143
+ split[i] = item
144
+ return split
145
+
146
+
147
+ async def download_item(url: str,
148
+ session: Optional[aiohttp.ClientSession] = None,
149
+ ignore_small_images: bool = True) -> Optional[Item]:
150
+ """Downloads the item from the given URL and returns an instance of the
151
+ corresponding item class. Reuses a session if provided."""
152
+
153
+ own_session = session is None
154
+ if own_session:
155
+ session = aiohttp.ClientSession()
156
+
157
+ try:
158
+ if await is_maybe_image_url(url, session):
159
+ from ezmm.common.items.image import download_image
160
+ return await download_image(url, ignore_small_images=ignore_small_images, session=session)
161
+ if await is_maybe_video_url(url, session):
162
+ from ezmm.common.items.video import download_video
163
+ return await download_video(url, session)
164
+ # TODO: Handle audios
165
+ finally:
166
+ if own_session:
167
+ await session.close()
168
+
169
+ if __name__ == "__main__":
170
+ result = asyncio.run(download_item("https://media.cnn.com/api/v1/images/stellar/prod/02-overview-of-kursk-training-area-15april2025-wv2.jpg?q=w_1110,c_fill"))
171
+ print(result)
@@ -0,0 +1,253 @@
1
+ import base64
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from urllib.parse import urljoin
6
+
7
+ import aiohttp
8
+ import cv2
9
+ import m3u8
10
+ import numpy as np
11
+
12
+ from ezmm.common.items.item import Item
13
+ from ezmm.request import fetch_headers, HEADERS
14
+ from ezmm.util import ts_to_mp4, to_base64
15
+
16
+ logger = logging.getLogger("ezMM")
17
+
18
+
19
+ class Video(Item):
20
+ kind = "video"
21
+ _video: Optional[cv2.VideoCapture] = None
22
+
23
+ def __init__(self, file_path: str | Path = None,
24
+ binary_data: bytes = None,
25
+ source_url: str = None,
26
+ reference: str = None):
27
+ assert file_path or binary_data or reference
28
+
29
+ if hasattr(self, "id"):
30
+ return
31
+
32
+ if binary_data:
33
+ # Save binary data to temporary file
34
+ file_path = self._temp_file_path(suffix=".mp4")
35
+ file_path.parent.mkdir(parents=True, exist_ok=True)
36
+ with open(file_path, 'wb') as f:
37
+ f.write(binary_data)
38
+
39
+ super().__init__(file_path,
40
+ source_url=source_url,
41
+ reference=reference)
42
+
43
+ @property
44
+ def video(self) -> cv2.VideoCapture:
45
+ """Lazy-loads the video capture of this Video item."""
46
+ if not self._video:
47
+ self._open_video()
48
+ return self._video
49
+
50
+ def _open_video(self):
51
+ """Opens the video file for reading."""
52
+ self._video = cv2.VideoCapture(str(self.file_path))
53
+
54
+ @property
55
+ def width(self) -> int:
56
+ return int(self.video.get(cv2.CAP_PROP_FRAME_WIDTH))
57
+
58
+ @property
59
+ def height(self) -> int:
60
+ return int(self.video.get(cv2.CAP_PROP_FRAME_HEIGHT))
61
+
62
+ @property
63
+ def frame_count(self) -> int:
64
+ return int(self.video.get(cv2.CAP_PROP_FRAME_COUNT))
65
+
66
+ @property
67
+ def fps(self) -> float:
68
+ return self.video.get(cv2.CAP_PROP_FPS)
69
+
70
+ @property
71
+ def duration(self) -> float:
72
+ """Returns the duration of the video in seconds."""
73
+ return self.frame_count / self.fps
74
+
75
+ @property
76
+ def bytes(self) -> bytes:
77
+ """Returns the video as bytes."""
78
+ return self.file_path.read_bytes()
79
+
80
+ def _get_frames(self) -> list[bytes]:
81
+ """Returns the list of all frames from the video."""
82
+ frames = []
83
+ if not self.video.isOpened():
84
+ self._open_video()
85
+ while self.video.isOpened():
86
+ success, frame = self.video.read()
87
+ if not success:
88
+ logger.warning(f"Failed to read frame from video {self.file_path}")
89
+ break
90
+ _, frame = cv2.imencode(".jpeg", frame)
91
+ frames.append(frame)
92
+ self.video.release()
93
+ return frames
94
+
95
+ def sample_frames(self, n_frames: int = 5) -> list[bytes]:
96
+ """Returns the specified number of frames sampled evenly spaced from the video.
97
+ Always includes the first frame. Includes the last frame if n_frames > 1."""
98
+ assert n_frames > 0, "Number of frames must be greater than 0."
99
+ frames = self._get_frames()
100
+ n_frames = min(n_frames, len(frames))
101
+ frame_ids = np.linspace(0, len(frames) - 1, n_frames, dtype=int)
102
+ sampled = [frames[i] for i in frame_ids]
103
+ return sampled
104
+
105
+ def get_base64_encoded(self, n_frames: int = 5) -> list[str]:
106
+ """Returns base64-encoded frames, evenly sampled from the video."""
107
+ frames = self.sample_frames(n_frames)
108
+ frames_encoded = [base64.b64encode(frame).decode("utf-8") for frame in frames]
109
+ return frames_encoded
110
+
111
+ def _same(self, other):
112
+ return (
113
+ self.width == other.width and
114
+ self.height == other.height and
115
+ self.frame_count == other.frame_count and
116
+ self.file_path.read_bytes() == other.file_path.read_bytes()
117
+ )
118
+
119
+ def as_html(self, path_relative_to: str | Path = None) -> str:
120
+ if path_relative_to is None:
121
+ path_relative_to = Path.cwd()
122
+ path = self.file_path.relative_to(path_relative_to).as_posix()
123
+ return f'<video controls src="/{path}"></video>'
124
+
125
+ def close(self):
126
+ if self._video:
127
+ self._video.release()
128
+ self._video = None
129
+
130
+
131
+ async def download_video(
132
+ video_url: str,
133
+ session: aiohttp.ClientSession
134
+ ) -> Optional[Video]:
135
+ """Downloads the linked video (stream) and returns it as a Video object."""
136
+
137
+ try:
138
+ headers = await fetch_headers(video_url, session, timeout=3)
139
+ content_type = headers.get('Content-Type') or headers.get('content-type')
140
+ if content_type.startswith("video/"):
141
+ return await download_video_file(video_url, session)
142
+ elif content_type == "application/vnd.apple.mpegurl":
143
+ return await download_hls_video(video_url, session)
144
+ else:
145
+ logger.warning(f"Cannot download video! Unable to handle content type: {content_type}.")
146
+
147
+ except Exception as e:
148
+ logger.debug(f"Error downloading video from {video_url}"
149
+ f"\n{type(e).__name__}: {e}")
150
+
151
+
152
+ async def download_video_file(
153
+ video_url: str,
154
+ session: aiohttp.ClientSession
155
+ ) -> Optional[Video]:
156
+ """Download a single video file from a URL and return it as a Video object."""
157
+ try:
158
+ async with session.get(video_url, headers=HEADERS) as response:
159
+ if response.status == 200:
160
+ content = await response.read()
161
+ video = Video(binary_data=content, source_url=video_url)
162
+ video.relocate(move_not_copy=True)
163
+ return video
164
+ except Exception as e:
165
+ logger.debug(f"Error downloading video file from {video_url}"
166
+ f"\n{type(e).__name__}: {e}")
167
+
168
+
169
+ async def download_hls_video(
170
+ playlist_url: str,
171
+ session: aiohttp.ClientSession
172
+ ) -> Optional[Video]:
173
+ """Download an HTTP Live Streaming (HLS) video from a playlist URL and return it as a Video object."""
174
+ try:
175
+ # Download the m3u8 playlist file
176
+ async with session.get(playlist_url, headers=HEADERS) as response:
177
+ if response.status != 200:
178
+ logger.debug(f"Failed to download playlist: {response.status}")
179
+ return None
180
+ playlist_content = await response.text()
181
+
182
+ playlist = m3u8.loads(playlist_content)
183
+ base_url = playlist_url.rsplit('/', 1)[0] + '/'
184
+
185
+ # Check if this is a master playlist (contains variant playlists)
186
+ if playlist.is_variant:
187
+ # Choose the highest quality variant
188
+ best_playlist = playlist.playlists[-1] # Usually the last one is of highest quality
189
+
190
+ # Manually construct the absolute URL for the variant playlist
191
+ variant_url = urljoin(base_url, best_playlist.uri)
192
+
193
+ # Download the variant playlist
194
+ async with session.get(variant_url, headers=HEADERS) as var_response:
195
+ if var_response.status != 200:
196
+ logger.error(f"Failed to download variant playlist: {var_response.status}")
197
+ return None
198
+ variant_content = await var_response.text()
199
+
200
+ # Parse the variant playlist
201
+ variant_playlist = m3u8.loads(variant_content)
202
+ playlist = variant_playlist # Use this for segment downloads
203
+
204
+ # Update base_url for segment downloads
205
+ base_url = variant_url.rsplit('/', 1)[0] + '/'
206
+
207
+ # Download all segments
208
+ video_segments = []
209
+
210
+ for i, segment in enumerate(playlist.segments):
211
+ # Construct full URL for the segment
212
+ if segment.uri.startswith('http'):
213
+ segment_url = segment.uri
214
+ else:
215
+ segment_url = urljoin(base_url, segment.uri)
216
+
217
+ # Download the segment with SSL disabled
218
+ try:
219
+ async with session.get(segment_url, headers=HEADERS) as seg_response:
220
+ if seg_response.status == 200:
221
+ segment_data = await seg_response.read()
222
+ video_segments.append(segment_data)
223
+ except Exception as e:
224
+ logger.debug(f"Failed to download segment {i} from {segment_url}: {e}")
225
+
226
+ # Combine all segments
227
+ if video_segments:
228
+ ts_bytes = b''.join(video_segments)
229
+ mp4_bytes = ts_to_mp4(ts_bytes)
230
+
231
+ # Create Video object with MP4 content
232
+ video = Video(binary_data=mp4_bytes, source_url=playlist_url)
233
+ video.relocate(move_not_copy=True)
234
+ return video
235
+
236
+ except Exception as e:
237
+ logger.debug(f"Error downloading HLS video from {playlist_url}"
238
+ f"\n{type(e).__name__}: {e}")
239
+
240
+ return None
241
+
242
+
243
+ async def download_vid(url):
244
+ async with aiohttp.ClientSession() as session:
245
+ return await download_video(url, session)
246
+
247
+
248
+ if __name__ == "__main__":
249
+ import asyncio
250
+
251
+ video = asyncio.run(
252
+ download_vid("https://upload.wikimedia.org/wikipedia/commons/transcoded/a/a7/How_to_make_video.webm/How_to_make_video.webm.1080p.vp9.webm"))
253
+ print(video)