media-toolkit 0.0.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.
- media_toolkit/__init__.py +1 -0
- media_toolkit/core/__init__.py +4 -0
- media_toolkit/core/audio_file.py +38 -0
- media_toolkit/core/image_file.py +89 -0
- media_toolkit/core/media_file.py +259 -0
- media_toolkit/core/video/__init__.py +0 -0
- media_toolkit/core/video/video_file.py +283 -0
- media_toolkit/core/video/video_utils.py +108 -0
- media_toolkit/dependency_requirements.py +53 -0
- media_toolkit/file_conversion.py +53 -0
- media_toolkit-0.0.0.dist-info/LICENSE +674 -0
- media_toolkit-0.0.0.dist-info/METADATA +201 -0
- media_toolkit-0.0.0.dist-info/RECORD +15 -0
- media_toolkit-0.0.0.dist-info/WHEEL +5 -0
- media_toolkit-0.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from media_toolkit.core import MediaFile, ImageFile, VideoFile, AudioFile
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from media_toolkit.dependency_requirements import requires
|
|
2
|
+
from media_toolkit.core.media_file import MediaFile
|
|
3
|
+
import io
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import soundfile
|
|
7
|
+
import numpy as np
|
|
8
|
+
except ImportError:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AudioFile(MediaFile):
|
|
13
|
+
"""
|
|
14
|
+
Has file conversions that make it easy to work with image files across the web.
|
|
15
|
+
Internally it uses numpy and librosa.
|
|
16
|
+
"""
|
|
17
|
+
@requires('soundfile')
|
|
18
|
+
def to_soundfile(self):
|
|
19
|
+
return soundfile.read(self._content_buffer)
|
|
20
|
+
|
|
21
|
+
@requires('soundfile')
|
|
22
|
+
def to_np_array(self, sr: int = None, return_sample_rate: bool = False):
|
|
23
|
+
self._content_buffer.seek(0)
|
|
24
|
+
audio, sr = soundfile.read(self._content_buffer, samplerate=sr) # sr=None returns the native sample rate
|
|
25
|
+
if return_sample_rate:
|
|
26
|
+
return audio, sr
|
|
27
|
+
return audio
|
|
28
|
+
|
|
29
|
+
@requires('soundfile')
|
|
30
|
+
def from_np_array(self, np_array, sr: int = None, file_type: str = "wav"):
|
|
31
|
+
sr = 22050 if sr is None else sr
|
|
32
|
+
# write to virtual file with librosa
|
|
33
|
+
virtual_file = io.BytesIO()
|
|
34
|
+
virtual_file.name = f"audio_file.{file_type}"
|
|
35
|
+
soundfile.write(virtual_file, np_array, samplerate=sr, format=file_type)
|
|
36
|
+
|
|
37
|
+
super().from_file(virtual_file)
|
|
38
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from media_toolkit.dependency_requirements import requires_numpy, requires_cv2, requires
|
|
2
|
+
from media_toolkit.core.media_file import MediaFile
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
except ImportError:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ImageFile(MediaFile):
|
|
12
|
+
"""
|
|
13
|
+
Has file conversions that make it easy to work with image files across the web.
|
|
14
|
+
Internally it uses cv2 file format.
|
|
15
|
+
"""
|
|
16
|
+
@requires('cv2', 'numpy')
|
|
17
|
+
def from_np_array(self, np_array, img_type: str = None):
|
|
18
|
+
if isinstance(np_array, list):
|
|
19
|
+
np_array = np.array(np_array)
|
|
20
|
+
|
|
21
|
+
if img_type is None:
|
|
22
|
+
if "image/" not in self.content_type:
|
|
23
|
+
img_type, self._channels = self.detect_image_type_and_channels(np_array)
|
|
24
|
+
else:
|
|
25
|
+
img_type = self.content_type.split("/")[1]
|
|
26
|
+
self.content_type = f"image/{img_type}"
|
|
27
|
+
|
|
28
|
+
is_success, buffer = cv2.imencode(f".{img_type}", np_array)
|
|
29
|
+
if is_success:
|
|
30
|
+
# avoid to check again for image type by calling super().from_bytes instead of self.from_bytes
|
|
31
|
+
return super().from_bytes(buffer)
|
|
32
|
+
else:
|
|
33
|
+
raise ValueError(f"Could not convert np_array to {img_type} image")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@requires('numpy', 'cv2')
|
|
37
|
+
def to_np_array(self):
|
|
38
|
+
bytes = self.to_bytes()
|
|
39
|
+
return cv2.imdecode(np.frombuffer(bytes, np.uint8), -1)
|
|
40
|
+
|
|
41
|
+
@requires_numpy()
|
|
42
|
+
def to_cv2_img(self):
|
|
43
|
+
return self.to_np_array()
|
|
44
|
+
|
|
45
|
+
@requires_cv2()
|
|
46
|
+
def save(self, path: str):
|
|
47
|
+
cv2.imwrite(path, self.to_np_array())
|
|
48
|
+
|
|
49
|
+
def _file_info(self):
|
|
50
|
+
super()._file_info()
|
|
51
|
+
np_array = self.to_np_array()
|
|
52
|
+
img_type, self._channels = self.detect_image_type_and_channels(np_array)
|
|
53
|
+
if img_type is not None:
|
|
54
|
+
self.content_type = f"image/{img_type}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def detect_image_type_and_channels(image) -> (str, int):
|
|
59
|
+
"""Detect the image type and number of _channels from a numpy array."""
|
|
60
|
+
if isinstance(image, list):
|
|
61
|
+
image = np.array(image)
|
|
62
|
+
|
|
63
|
+
# Check the number of _channels
|
|
64
|
+
if len(image.shape) == 2:
|
|
65
|
+
channels = 1 # Grayscale
|
|
66
|
+
elif len(image.shape) == 3:
|
|
67
|
+
channels = image.shape[2]
|
|
68
|
+
else:
|
|
69
|
+
#raise ValueError("Unsupported image shape: {}".format(image.shape))
|
|
70
|
+
return None, None
|
|
71
|
+
|
|
72
|
+
# Detect image type by checking for specific markers
|
|
73
|
+
image_type = None
|
|
74
|
+
|
|
75
|
+
# Convert to bytes and inspect file signatures for format detection
|
|
76
|
+
success, encoded_image = cv2.imencode('.png', image)
|
|
77
|
+
if success:
|
|
78
|
+
encoded_bytes = encoded_image.tobytes()
|
|
79
|
+
if encoded_bytes.startswith(b'\x89PNG\r\n\x1a\n'):
|
|
80
|
+
image_type = 'png'
|
|
81
|
+
elif encoded_bytes[0:2] == b'\xff\xd8':
|
|
82
|
+
image_type = 'jpeg'
|
|
83
|
+
elif encoded_bytes.startswith(b'BM'):
|
|
84
|
+
image_type = 'bmp'
|
|
85
|
+
elif encoded_bytes.startswith(b'GIF'):
|
|
86
|
+
image_type = 'gif'
|
|
87
|
+
|
|
88
|
+
return image_type, channels
|
|
89
|
+
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import mimetypes
|
|
4
|
+
from typing import Union, BinaryIO
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from media_toolkit.dependency_requirements import requires_numpy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import numpy as np
|
|
12
|
+
except ImportError:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MediaFile:
|
|
17
|
+
"""
|
|
18
|
+
Has file conversions that make it easy to work standardized with files across the web and in the sdk.
|
|
19
|
+
Works natively with bytesio, base64 and binary data.
|
|
20
|
+
"""
|
|
21
|
+
def __init__(self, file_name: str = "file", content_type: str = "application/octet-stream"):
|
|
22
|
+
"""
|
|
23
|
+
:param file_name: The name of the file. Note it is overwritten if you use from_file/from_starlette.
|
|
24
|
+
:param content_type: The content type of the file. Note it is overwritten if you use from_file/from_starlette.
|
|
25
|
+
"""
|
|
26
|
+
self.content_type = content_type
|
|
27
|
+
self.file_name = file_name # the name of the file also when specified in bytesio
|
|
28
|
+
self._content_buffer = io.BytesIO()
|
|
29
|
+
|
|
30
|
+
def from_any(self, data):
|
|
31
|
+
"""
|
|
32
|
+
Load a file from any supported data type. The file is loaded into the memory as bytes.
|
|
33
|
+
"""
|
|
34
|
+
# it is already converted
|
|
35
|
+
if isinstance(data, MediaFile):
|
|
36
|
+
return data
|
|
37
|
+
|
|
38
|
+
# conversion factory
|
|
39
|
+
if type(data) in [io.BufferedReader, io.BytesIO]:
|
|
40
|
+
self.from_bytesio_or_handle(data)
|
|
41
|
+
elif isinstance(data, str):
|
|
42
|
+
if self._is_valid_file_path(data):
|
|
43
|
+
self.from_file(data)
|
|
44
|
+
else:
|
|
45
|
+
self.from_base64(data)
|
|
46
|
+
elif isinstance(data, bytes):
|
|
47
|
+
self.from_bytes(data)
|
|
48
|
+
elif type(data).__name__ == 'ndarray':
|
|
49
|
+
self.from_np_array(data)
|
|
50
|
+
elif data.__module__ == 'starlette.datastructures' and type(data).__name__ == 'UploadFile':
|
|
51
|
+
self.from_starlette_upload_file(data)
|
|
52
|
+
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def from_bytesio_or_handle(self, buffer: Union[io.BytesIO, BinaryIO, io.BufferedReader], copy: bool = True):
|
|
56
|
+
"""
|
|
57
|
+
Set the content of the file from a BytesIO or a file handle.
|
|
58
|
+
:params buffer: The buffer to read from.
|
|
59
|
+
:params copy: If true, the buffer is completely read to bytes and the bytes copied to this file.
|
|
60
|
+
"""
|
|
61
|
+
self._reset_buffer()
|
|
62
|
+
if type(buffer) in [io.BytesIO, io.BufferedReader]:
|
|
63
|
+
buffer.seek(0)
|
|
64
|
+
if not copy:
|
|
65
|
+
self._content_buffer = buffer
|
|
66
|
+
self._file_info()
|
|
67
|
+
else:
|
|
68
|
+
self.from_bytes(buffer.read())
|
|
69
|
+
buffer.seek(0)
|
|
70
|
+
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def from_bytesio(self, buffer: Union[io.BytesIO, BinaryIO], copy: bool = True):
|
|
74
|
+
return self.from_bytesio_or_handle(buffer=buffer, copy=copy)
|
|
75
|
+
|
|
76
|
+
def from_file(self, path_or_handle: Union[str, io.BytesIO, io.BufferedReader]):
|
|
77
|
+
"""
|
|
78
|
+
Load a file from a file path, file handle or base64 and convert it to BytesIO.
|
|
79
|
+
"""
|
|
80
|
+
if type(path_or_handle) in [io.BufferedReader, io.BytesIO]:
|
|
81
|
+
self.from_bytesio_or_handle(path_or_handle)
|
|
82
|
+
elif isinstance(path_or_handle, str):
|
|
83
|
+
# read file from path
|
|
84
|
+
self.file_name = os.path.basename(path_or_handle)
|
|
85
|
+
self.content_type = mimetypes.guess_type(self.file_name)[0] or "application/octet-stream"
|
|
86
|
+
with open(path_or_handle, 'rb') as file:
|
|
87
|
+
self.from_bytesio_or_handle(file)
|
|
88
|
+
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def from_bytes(self, data: bytes):
|
|
92
|
+
self._reset_buffer()
|
|
93
|
+
self._content_buffer.write(data)
|
|
94
|
+
self._content_buffer.seek(0)
|
|
95
|
+
self._file_info()
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def from_starlette_upload_file(self, starlette_upload_file):
|
|
99
|
+
"""
|
|
100
|
+
Load a file from a starlette upload file.
|
|
101
|
+
:param starlette_upload_file:
|
|
102
|
+
:return:
|
|
103
|
+
"""
|
|
104
|
+
content = starlette_upload_file.file.read()
|
|
105
|
+
|
|
106
|
+
self.file_name = starlette_upload_file.filename
|
|
107
|
+
self.content_type = starlette_upload_file.content_type
|
|
108
|
+
self.from_bytes(content)
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def from_base64(self, base64_str: str):
|
|
112
|
+
decoded = self._decode_base_64_if_is(base64_str)
|
|
113
|
+
if decoded is not None:
|
|
114
|
+
return self.from_bytes(base64.b64decode(base64_str))
|
|
115
|
+
else:
|
|
116
|
+
err_str = base64_str if len(base64_str) <= 50 else base64_str[:50] + "..."
|
|
117
|
+
raise ValueError(f"Decoding from base64 like string {err_str} was not possible. Check your data.")
|
|
118
|
+
|
|
119
|
+
@requires_numpy()
|
|
120
|
+
def from_np_array(self, np_array: np.array):
|
|
121
|
+
"""
|
|
122
|
+
Convert a numpy array to a file which is saved as bytes b"\x93NUMPY" into the buffer.
|
|
123
|
+
"""
|
|
124
|
+
self._reset_buffer()
|
|
125
|
+
np.save(self._content_buffer, np_array)
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def from_dict(self, file_result_json: dict):
|
|
129
|
+
"""
|
|
130
|
+
Load a file from a dictionary.
|
|
131
|
+
:param d: The dictionary to load from formatted as FileResult.to_json().
|
|
132
|
+
"""
|
|
133
|
+
self.file_name = file_result_json["file_name"]
|
|
134
|
+
self.content_type = file_result_json["content_type"]
|
|
135
|
+
# ToDo: the from_base64 might overwrite name and content type (ImageFile). Check if this always is intended.
|
|
136
|
+
self.from_base64(file_result_json["content"])
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
@requires_numpy()
|
|
140
|
+
def to_np_array(self, shape=None, dtype=np.uint8):
|
|
141
|
+
"""
|
|
142
|
+
If file was created with from_np_array it will return the numpy array.
|
|
143
|
+
Else it will try to convert the file to a numpy array (note this is converted bytes representation of the file).
|
|
144
|
+
:param shape: The shape of the numpy array. If None it will be returned flat.
|
|
145
|
+
:param dtype: The dtype of the numpy array. If None it will be uint8.
|
|
146
|
+
"""
|
|
147
|
+
bytes = self.to_bytes()
|
|
148
|
+
# check if was saved with np.save so bytes contains NUMPY
|
|
149
|
+
if bytes.startswith(b"\x93NUMPY"):
|
|
150
|
+
self._content_buffer.seek(0)
|
|
151
|
+
return np.load(self._content_buffer, allow_pickle=False)
|
|
152
|
+
|
|
153
|
+
shape = shape or (1, len(bytes))
|
|
154
|
+
dtype = dtype or np.uint8
|
|
155
|
+
|
|
156
|
+
arr_flat = np.frombuffer(bytes, dtype=dtype)
|
|
157
|
+
return arr_flat.reshape(shape)
|
|
158
|
+
|
|
159
|
+
def to_bytes(self) -> bytes:
|
|
160
|
+
return self.read()
|
|
161
|
+
|
|
162
|
+
def read(self) -> bytes:
|
|
163
|
+
self._content_buffer.seek(0)
|
|
164
|
+
res = self._content_buffer.read()
|
|
165
|
+
self._content_buffer.seek(0)
|
|
166
|
+
return res
|
|
167
|
+
|
|
168
|
+
def to_bytes_io(self) -> io.BytesIO:
|
|
169
|
+
return self._content_buffer
|
|
170
|
+
|
|
171
|
+
def to_base64(self):
|
|
172
|
+
return base64.b64encode(self.to_bytes()).decode()
|
|
173
|
+
|
|
174
|
+
def to_httpx_send_able_tuple(self):
|
|
175
|
+
return self.file_name, self.read(), self.content_type
|
|
176
|
+
|
|
177
|
+
def _reset_buffer(self):
|
|
178
|
+
self._content_buffer.seek(0)
|
|
179
|
+
self._content_buffer.truncate(0)
|
|
180
|
+
|
|
181
|
+
def save(self, path: str):
|
|
182
|
+
"""
|
|
183
|
+
Methods saves the file to disk.
|
|
184
|
+
If path is a folder it will save it in folder/self.filename.
|
|
185
|
+
If path is a file it will save it there.
|
|
186
|
+
:param path:
|
|
187
|
+
:return:
|
|
188
|
+
"""
|
|
189
|
+
# create folder if not exists
|
|
190
|
+
if os.path.dirname(path) != "" and not os.path.exists(os.path.dirname(path)):
|
|
191
|
+
os.makedirs(os.path.dirname(path))
|
|
192
|
+
# check if path contains a file name
|
|
193
|
+
if os.path.basename(path) == "":
|
|
194
|
+
path = os.path.join(path, self.file_name)
|
|
195
|
+
|
|
196
|
+
with open(path, 'wb') as file:
|
|
197
|
+
file.write(self.read())
|
|
198
|
+
|
|
199
|
+
def _file_info(self):
|
|
200
|
+
"""
|
|
201
|
+
After writing the file to the buffer, this method is called to determine additional file informations.
|
|
202
|
+
For videos this might be length, frame rate...
|
|
203
|
+
If you subclass don't forget to call super()._file_info() to set the file name and content type.
|
|
204
|
+
"""
|
|
205
|
+
# set file name and type
|
|
206
|
+
if hasattr(self._content_buffer, "name"):
|
|
207
|
+
self.file_name = os.path.basename(self._content_buffer.name)
|
|
208
|
+
|
|
209
|
+
if self.file_name != "file":
|
|
210
|
+
self.content_type = mimetypes.guess_type(self.file_name)[0] or "application/octet-stream"
|
|
211
|
+
else:
|
|
212
|
+
self.content_type = "application/octet-stream"
|
|
213
|
+
|
|
214
|
+
def __bytes__(self):
|
|
215
|
+
return self.to_bytes()
|
|
216
|
+
|
|
217
|
+
def __array__(self):
|
|
218
|
+
return self.to_np_array()
|
|
219
|
+
|
|
220
|
+
def to_json(self):
|
|
221
|
+
"""
|
|
222
|
+
Returns the file as a json serializable dictionary.
|
|
223
|
+
:return: { "file_name": str, "content_type": str, "content": str }
|
|
224
|
+
"""
|
|
225
|
+
return {
|
|
226
|
+
"file_name": self.file_name,
|
|
227
|
+
"content_type": self.content_type,
|
|
228
|
+
"content": self.to_base64()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _decode_base_64_if_is(data: Union[bytes, str]):
|
|
233
|
+
"""
|
|
234
|
+
Checks if a string is base64. If it is, it returns the base64 string as bytes; else returns None.
|
|
235
|
+
"""
|
|
236
|
+
if isinstance(data, str):
|
|
237
|
+
data = data.encode()
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
# Decode the data
|
|
241
|
+
decoded = base64.b64decode(data, validate=True)
|
|
242
|
+
# Re-encode the decoded data
|
|
243
|
+
back_encoded = base64.b64encode(decoded)
|
|
244
|
+
# Compare with the original encoded data
|
|
245
|
+
if back_encoded == data:
|
|
246
|
+
return decoded
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _is_valid_file_path(path: str):
|
|
254
|
+
try:
|
|
255
|
+
is_file = os.path.isfile(path)
|
|
256
|
+
return is_file
|
|
257
|
+
except:
|
|
258
|
+
return False
|
|
259
|
+
|
|
File without changes
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
from typing import List, Union
|
|
7
|
+
|
|
8
|
+
from media_toolkit.core.video.video_utils import (add_audio_to_video_file, audio_array_to_audio_file,
|
|
9
|
+
video_from_image_generator, get_sample_rate_from_audio_file)
|
|
10
|
+
from media_toolkit.dependency_requirements import requires
|
|
11
|
+
from media_toolkit.core.media_file import MediaFile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import cv2
|
|
16
|
+
import numpy as np
|
|
17
|
+
except ImportError:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from vidgear.gears import VideoGear, WriteGear
|
|
22
|
+
except:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from pydub import AudioSegment
|
|
27
|
+
from pydub.utils import mediainfo
|
|
28
|
+
except ImportError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VideoFile(MediaFile):
|
|
33
|
+
"""
|
|
34
|
+
A class to represent a video file.
|
|
35
|
+
"""
|
|
36
|
+
def __init__(self):
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.content_type = "video"
|
|
39
|
+
self.frame_count = None # an estimated value based on cv2.VideoCapture.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
40
|
+
self.frame_rate = None
|
|
41
|
+
self.shape = None
|
|
42
|
+
self.audio_sample_rate = None
|
|
43
|
+
|
|
44
|
+
def from_files(self, image_files: Union[List[str], list], frame_rate: int = 30, audio_file=None):
|
|
45
|
+
"""
|
|
46
|
+
Creates a video based of a list of image files and an audio_file file.
|
|
47
|
+
:param image_files: A list of image files to convert to a video. Either paths or numpy arrays.
|
|
48
|
+
:param frame_rate: The frame rate of the video.
|
|
49
|
+
:param audio_file: The audio_file file to add to the video, as path, bytes or AudioSegment.
|
|
50
|
+
"""
|
|
51
|
+
# Check if there are images in the list
|
|
52
|
+
if not image_files:
|
|
53
|
+
raise ValueError("The list of image files is empty.")
|
|
54
|
+
|
|
55
|
+
# Create a temporary file to store the video
|
|
56
|
+
temp_vid_file_path = video_from_image_generator(image_files, frame_rate=frame_rate, save_path=None)
|
|
57
|
+
# Merge video and audio_file using pydub
|
|
58
|
+
if audio_file is not None:
|
|
59
|
+
combined = add_audio_to_video_file(video_file=temp_vid_file_path, audio_file=audio_file)
|
|
60
|
+
self.from_file(combined)
|
|
61
|
+
os.remove(combined)
|
|
62
|
+
os.remove(temp_vid_file_path)
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
# Init self from the temp file
|
|
66
|
+
self.from_file(temp_vid_file_path)
|
|
67
|
+
# remove tempfile
|
|
68
|
+
os.remove(temp_vid_file_path)
|
|
69
|
+
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def from_image_files(self, image_files: List[str], frame_rate: int = 30):
|
|
73
|
+
"""
|
|
74
|
+
Converts a list of image files into a video file.
|
|
75
|
+
"""
|
|
76
|
+
return self.from_files(image_files, frame_rate, audio_file=None)
|
|
77
|
+
|
|
78
|
+
def from_dir(self, dir_path: str, audio: Union[str, list] = None, frame_rate: int = 30):
|
|
79
|
+
"""
|
|
80
|
+
Converts all images in a directory into a video file.
|
|
81
|
+
"""
|
|
82
|
+
image_types = ["*.png", "*.jpg", "*.jpeg"]
|
|
83
|
+
image_files = []
|
|
84
|
+
for image_type in image_types:
|
|
85
|
+
image_files.extend(glob.glob(os.path.join(dir_path, image_type)))
|
|
86
|
+
# sort by date to make sure the order is correct
|
|
87
|
+
image_files.sort(key=lambda x: os.path.getmtime(x))
|
|
88
|
+
|
|
89
|
+
# if audio_file is none, take the first audio_file file in the directory
|
|
90
|
+
if audio is None:
|
|
91
|
+
audio_types = ["*.wav", "*.mp3"]
|
|
92
|
+
for audio_type in audio_types:
|
|
93
|
+
audio = glob.glob(os.path.join(dir_path, audio_type))
|
|
94
|
+
if len(audio) > 0:
|
|
95
|
+
audio = audio[0]
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
return self.from_files(image_files=image_files, frame_rate=frame_rate, audio_file=audio)
|
|
99
|
+
|
|
100
|
+
def add_audio(self, audio_file: Union[str, list], sample_rate: int = 44100):
|
|
101
|
+
if self.audio_sample_rate is None:
|
|
102
|
+
if self.frame_rate is None:
|
|
103
|
+
raise Exception("The frame rate of the video file is not set. Read a video file first.")
|
|
104
|
+
|
|
105
|
+
if os.path.isfile(audio_file):
|
|
106
|
+
self.audio_sample_rate = get_sample_rate_from_audio_file(audio_file)
|
|
107
|
+
else:
|
|
108
|
+
|
|
109
|
+
self.audio_sample_rate = int(mediainfo(self._to_temp_file())['sample_rate'])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if isinstance(audio_file, list) or isinstance(audio_file, np.ndarray):
|
|
113
|
+
audio_file = audio_array_to_audio_file(audio_file, sample_rate=self.audio_sample_rate)
|
|
114
|
+
|
|
115
|
+
tmp = self._to_temp_file()
|
|
116
|
+
combined = add_audio_to_video_file(tmp, audio_file)
|
|
117
|
+
self.from_file(combined)
|
|
118
|
+
os.remove(tmp)
|
|
119
|
+
os.remove(combined)
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def _to_temp_file(self):
|
|
123
|
+
# get suffix
|
|
124
|
+
if self.content_type is None:
|
|
125
|
+
raise ValueError("The content type of the video file is not set.")
|
|
126
|
+
if "/" not in self.content_type:
|
|
127
|
+
raise ValueError("The content type of the video file is not valid. Read a video file first.")
|
|
128
|
+
suffix = self.content_type.split("/")[1]
|
|
129
|
+
if suffix == 'octet-stream':
|
|
130
|
+
raise ValueError("The content type of the video file is not valid. Read a video file first.")
|
|
131
|
+
|
|
132
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{suffix}") as temp_video_file:
|
|
133
|
+
temp_video_file.write(self._content_buffer.getvalue())
|
|
134
|
+
temp_video_file_path = temp_video_file.name
|
|
135
|
+
|
|
136
|
+
return temp_video_file_path
|
|
137
|
+
|
|
138
|
+
@requires('vidgear', 'numpy', 'pydub')
|
|
139
|
+
def from_video_stream(self, video_audio_stream, frame_rate: int = 30):
|
|
140
|
+
"""
|
|
141
|
+
Given a generator that yields video frames and audio_file data as numpy arrays, this creates a video.
|
|
142
|
+
The generator is expected to be in the form of: VideoFile().to_video_stream()
|
|
143
|
+
"""
|
|
144
|
+
# Reset and pre-settings
|
|
145
|
+
self._reset_buffer()
|
|
146
|
+
|
|
147
|
+
# new generator, to extract audio_file
|
|
148
|
+
audio_frames = []
|
|
149
|
+
def _frame_gen():
|
|
150
|
+
for frame in video_audio_stream:
|
|
151
|
+
# check if is video and audio_file stream or only video stream
|
|
152
|
+
if len(frame) == 2:
|
|
153
|
+
frame, audio_data = frame
|
|
154
|
+
audio_frames.append(audio_data)
|
|
155
|
+
yield frame
|
|
156
|
+
|
|
157
|
+
# Create video
|
|
158
|
+
temp_video_file_path = video_from_image_generator(_frame_gen(), frame_rate=frame_rate, save_path=None)
|
|
159
|
+
|
|
160
|
+
# Add audio_file
|
|
161
|
+
if len(audio_frames) > 0:
|
|
162
|
+
temp_audio_file = audio_array_to_audio_file(audio_frames, sample_rate=self.audio_sample_rate)
|
|
163
|
+
combined = add_audio_to_video_file(temp_video_file_path, temp_audio_file)
|
|
164
|
+
self.from_file(combined)
|
|
165
|
+
# cleanup
|
|
166
|
+
os.remove(temp_audio_file)
|
|
167
|
+
os.remove(temp_video_file_path)
|
|
168
|
+
os.remove(combined)
|
|
169
|
+
|
|
170
|
+
@requires('cv2', 'pydub')
|
|
171
|
+
def _file_info(self):
|
|
172
|
+
super()._file_info()
|
|
173
|
+
|
|
174
|
+
#if file_path is not None:
|
|
175
|
+
# temp = file_path
|
|
176
|
+
#else:
|
|
177
|
+
self._content_buffer.seek(0)
|
|
178
|
+
temp = self._to_temp_file()
|
|
179
|
+
|
|
180
|
+
cap = cv2.VideoCapture(temp)
|
|
181
|
+
self.frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # is an estimated value.
|
|
182
|
+
# determine content codec
|
|
183
|
+
# https://stackoverflow.com/questions/61659346/how-to-get-4-character-codec-code-for-videocapture-object-in-opencv
|
|
184
|
+
# h = int(cap.get(cv2.CAP_PROP_FOURCC))
|
|
185
|
+
# b = h.to_bytes(4, byteorder=sys.byteorder)
|
|
186
|
+
# codec = b.decode() # results in the codec
|
|
187
|
+
self.content_type = f"video/mp4"
|
|
188
|
+
self.frame_rate = cap.get(cv2.CAP_PROP_FPS)
|
|
189
|
+
self.shape = (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
|
|
190
|
+
cap.release()
|
|
191
|
+
# get audio sample rate
|
|
192
|
+
info = mediainfo(temp)
|
|
193
|
+
if 'sample_rate' in info:
|
|
194
|
+
try:
|
|
195
|
+
self.audio_sample_rate = int(info['sample_rate'])
|
|
196
|
+
except ValueError:
|
|
197
|
+
self.audio_sample_rate = 44100
|
|
198
|
+
os.remove(temp)
|
|
199
|
+
|
|
200
|
+
@requires('vidgear')
|
|
201
|
+
def to_image_stream(self):
|
|
202
|
+
return self.to_video_stream(include_audio=False)
|
|
203
|
+
|
|
204
|
+
@requires('pydub', 'vidgear')
|
|
205
|
+
def to_video_stream(self, include_audio=True):
|
|
206
|
+
"""
|
|
207
|
+
Yields video frames and audio_file data as numpy arrays.
|
|
208
|
+
:param include_audio: if the audio_file is included in the video stream. If not it will only yield the video frames.
|
|
209
|
+
:return:
|
|
210
|
+
"""
|
|
211
|
+
self._content_buffer.seek(0)
|
|
212
|
+
# because CamGear does not support reading from a BytesIO buffer, we need to save the buffer to a temporary file
|
|
213
|
+
temp_video_file_path = self._to_temp_file()
|
|
214
|
+
stream = VideoGear(source=temp_video_file_path).start()
|
|
215
|
+
|
|
216
|
+
if include_audio:
|
|
217
|
+
# Extract audio_file using pydub
|
|
218
|
+
audio = AudioSegment.from_file(temp_video_file_path)
|
|
219
|
+
# Calculate the audio_file segment duration per frame
|
|
220
|
+
audio_per_frame_duration = 1000 / stream.framerate # duration of each video frame in ms
|
|
221
|
+
# Initialize frame counter for audio_file
|
|
222
|
+
frame_count = 0
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
while True:
|
|
226
|
+
# Read frame
|
|
227
|
+
frame = stream.read()
|
|
228
|
+
if frame is None:
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
if not include_audio:
|
|
232
|
+
yield frame
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Calculate the start and end times for the corresponding audio_file segment
|
|
236
|
+
start_time = frame_count * audio_per_frame_duration
|
|
237
|
+
end_time = start_time + audio_per_frame_duration
|
|
238
|
+
frame_audio = audio[start_time:end_time]
|
|
239
|
+
|
|
240
|
+
# Convert audio_file segment to raw data
|
|
241
|
+
audio_data = np.array(frame_audio.get_array_of_samples())
|
|
242
|
+
|
|
243
|
+
# Yield the frame and the corresponding audio_file data
|
|
244
|
+
yield frame, audio_data
|
|
245
|
+
|
|
246
|
+
# Increment frame counter
|
|
247
|
+
frame_count += 1
|
|
248
|
+
finally:
|
|
249
|
+
# Safely close the video stream
|
|
250
|
+
stream.stop()
|
|
251
|
+
# Remove the temporary video file
|
|
252
|
+
os.remove(temp_video_file_path)
|
|
253
|
+
# accurate value instead of using cv2.CAP_PROP_FRAME_COUNT
|
|
254
|
+
self.frame_count = frame_count
|
|
255
|
+
|
|
256
|
+
@requires('pydub')
|
|
257
|
+
def extract_audio(self, path: str = None, export_type: str = 'mp4') -> Union[bytes, None]:
|
|
258
|
+
temp_video_file_path = self._to_temp_file()
|
|
259
|
+
audio = AudioSegment.from_file(temp_video_file_path)
|
|
260
|
+
|
|
261
|
+
if path is not None and len(path) > 0:
|
|
262
|
+
dirname = os.path.dirname(path)
|
|
263
|
+
if len(dirname) > 0 and not os.path.isdir(dirname):
|
|
264
|
+
os.makedirs(dirname)
|
|
265
|
+
audio.export(path, format=export_type)
|
|
266
|
+
os.remove(temp_video_file_path)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
# return as bytes
|
|
270
|
+
file = BytesIO()
|
|
271
|
+
file = audio.export(file, format=export_type)
|
|
272
|
+
file.seek(0)
|
|
273
|
+
data = file.read()
|
|
274
|
+
file.close()
|
|
275
|
+
# remove tempfile
|
|
276
|
+
os.remove(temp_video_file_path)
|
|
277
|
+
return data
|
|
278
|
+
|
|
279
|
+
def __iter__(self):
|
|
280
|
+
return self.to_video_stream()
|
|
281
|
+
|
|
282
|
+
def __len__(self):
|
|
283
|
+
return self.frame_count
|