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 +2 -0
- ezmm/common/__init__.py +21 -0
- ezmm/common/items/__init__.py +15 -0
- ezmm/common/items/audio.py +6 -0
- ezmm/common/items/image.py +127 -0
- ezmm/common/items/item.py +171 -0
- ezmm/common/items/video.py +253 -0
- ezmm/common/multimodal_sequence.py +122 -0
- ezmm/common/registry.py +161 -0
- ezmm/config.py +4 -0
- ezmm/request.py +99 -0
- ezmm/ui/__init__.py +0 -0
- ezmm/ui/common.py +4 -0
- ezmm/ui/main.py +71 -0
- ezmm/util.py +100 -0
- ezmm-0.3.5.dist-info/METADATA +71 -0
- ezmm-0.3.5.dist-info/RECORD +20 -0
- ezmm-0.3.5.dist-info/WHEEL +5 -0
- ezmm-0.3.5.dist-info/licenses/LICENSE +201 -0
- ezmm-0.3.5.dist-info/top_level.txt +1 -0
ezmm/__init__.py
ADDED
ezmm/common/__init__.py
ADDED
|
@@ -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,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)
|