videopython 0.4.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of videopython might be problematic. Click here for more details.
- {videopython-0.4.0 → videopython-0.4.1}/PKG-INFO +15 -2
- {videopython-0.4.0 → videopython-0.4.1}/pyproject.toml +34 -9
- videopython-0.4.1/src/videopython/base/combine.py +45 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/video.py +93 -48
- {videopython-0.4.0 → videopython-0.4.1}/.gitignore +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/LICENSE +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/README.md +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/__init__.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/understanding/transcribe.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/__init__.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/compose.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/effects.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/exceptions.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/transcription.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/transforms.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/transitions.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/py.typed +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/utils/__init__.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/utils/common.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/utils/image.py +0 -0
- {videopython-0.4.0 → videopython-0.4.1}/src/videopython/utils/text.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: videopython
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Minimal video generation and processing library.
|
|
5
5
|
Project-URL: Homepage, https://github.com/bartwojtowicz/videopython/
|
|
6
6
|
Project-URL: Repository, https://github.com/bartwojtowicz/videopython/
|
|
@@ -18,9 +18,22 @@ Requires-Python: <3.13,>=3.10
|
|
|
18
18
|
Requires-Dist: numpy>=1.25.2
|
|
19
19
|
Requires-Dist: opencv-python>=4.9.0.80
|
|
20
20
|
Requires-Dist: pillow>=10.3.0
|
|
21
|
-
Requires-Dist: pydub>=0.25.1
|
|
22
21
|
Requires-Dist: soundpython>=0.1.11
|
|
23
22
|
Requires-Dist: tqdm>=4.66.3
|
|
23
|
+
Provides-Extra: ai
|
|
24
|
+
Requires-Dist: accelerate>=0.29.2; extra == 'ai'
|
|
25
|
+
Requires-Dist: diffusers>=0.26.3; extra == 'ai'
|
|
26
|
+
Requires-Dist: numba>=0.61.0; extra == 'ai'
|
|
27
|
+
Requires-Dist: openai-whisper>=20240930; extra == 'ai'
|
|
28
|
+
Requires-Dist: torch>=2.1.0; extra == 'ai'
|
|
29
|
+
Requires-Dist: transformers>=4.38.1; extra == 'ai'
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: mypy>=1.8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov>=6.1.1; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.1.14; extra == 'dev'
|
|
35
|
+
Requires-Dist: types-pillow>=10.2.0.20240213; extra == 'dev'
|
|
36
|
+
Requires-Dist: types-tqdm>=4.66.0.20240106; extra == 'dev'
|
|
24
37
|
Description-Content-Type: text/markdown
|
|
25
38
|
|
|
26
39
|
# About
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "videopython"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.1"
|
|
4
4
|
description = "Minimal video generation and processing library."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
|
|
7
7
|
{ name = "Bartosz Rudnikowicz", email = "bartoszrudnikowicz840@gmail.com" },
|
|
8
|
-
{ name = "Piotr Pukisz", email = "piotr.pukisz@gmail.com" }
|
|
8
|
+
{ name = "Piotr Pukisz", email = "piotr.pukisz@gmail.com" },
|
|
9
9
|
]
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.10, <3.13"
|
|
13
|
-
keywords = [
|
|
13
|
+
keywords = [
|
|
14
|
+
"python",
|
|
15
|
+
"videopython",
|
|
16
|
+
"video",
|
|
17
|
+
"movie",
|
|
18
|
+
"opencv",
|
|
19
|
+
"generation",
|
|
20
|
+
"editing",
|
|
21
|
+
]
|
|
14
22
|
classifiers = [
|
|
15
23
|
"License :: OSI Approved :: Apache Software License",
|
|
16
24
|
"Programming Language :: Python :: 3",
|
|
@@ -23,10 +31,8 @@ dependencies = [
|
|
|
23
31
|
"numpy>=1.25.2",
|
|
24
32
|
"opencv-python>=4.9.0.80",
|
|
25
33
|
"pillow>=10.3.0",
|
|
26
|
-
"pydub>=0.25.1",
|
|
27
|
-
"soundpython>=0.1.11",
|
|
28
34
|
"tqdm>=4.66.3",
|
|
29
|
-
|
|
35
|
+
"soundpython>=0.1.11",
|
|
30
36
|
]
|
|
31
37
|
|
|
32
38
|
[dependency-groups]
|
|
@@ -47,6 +53,25 @@ ai = [
|
|
|
47
53
|
"numba>=0.61.0",
|
|
48
54
|
]
|
|
49
55
|
|
|
56
|
+
# We have to keep it to make PIP use those dependency groups, not only UV
|
|
57
|
+
[project.optional-dependencies]
|
|
58
|
+
dev = [
|
|
59
|
+
"ruff>=0.1.14",
|
|
60
|
+
"mypy>=1.8.0",
|
|
61
|
+
"pytest>=7.4.0",
|
|
62
|
+
"types-Pillow>=10.2.0.20240213",
|
|
63
|
+
"types-tqdm>=4.66.0.20240106",
|
|
64
|
+
"pytest-cov>=6.1.1",
|
|
65
|
+
]
|
|
66
|
+
ai = [
|
|
67
|
+
"accelerate>=0.29.2",
|
|
68
|
+
"diffusers>=0.26.3",
|
|
69
|
+
"torch>=2.1.0",
|
|
70
|
+
"transformers>=4.38.1",
|
|
71
|
+
"openai-whisper>=20240930",
|
|
72
|
+
"numba>=0.61.0",
|
|
73
|
+
]
|
|
74
|
+
|
|
50
75
|
[project.urls]
|
|
51
76
|
Homepage = "https://github.com/bartwojtowicz/videopython/"
|
|
52
77
|
Repository = "https://github.com/bartwojtowicz/videopython/"
|
|
@@ -77,9 +102,9 @@ target-version = "py310"
|
|
|
77
102
|
|
|
78
103
|
[tool.ruff.lint]
|
|
79
104
|
select = [
|
|
80
|
-
"E",
|
|
81
|
-
"F",
|
|
82
|
-
"I",
|
|
105
|
+
"E", # pycodestyle errors
|
|
106
|
+
"F", # pyflakes
|
|
107
|
+
"I", # isort
|
|
83
108
|
]
|
|
84
109
|
isort.known-first-party = ["videopython"]
|
|
85
110
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from videopython.base.transforms import ResampleFPS, Resize
|
|
6
|
+
from videopython.base.video import Video
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StackVideos:
|
|
10
|
+
def __init__(self, mode: Literal["horizontal", "vertical"]) -> None:
|
|
11
|
+
self.mode = mode
|
|
12
|
+
|
|
13
|
+
def _validate(self, video1: Video, video2: Video) -> tuple[Video, Video]:
|
|
14
|
+
video1, video2 = self._align_shapes(video1, video2)
|
|
15
|
+
video1, video2 = self._align_fps(video1, video2)
|
|
16
|
+
video1, video2 = self._align_duration(video1, video2)
|
|
17
|
+
return video1, video2
|
|
18
|
+
|
|
19
|
+
def _align_fps(self, video1: Video, video2: Video) -> tuple[Video, Video]:
|
|
20
|
+
if video1.fps > video2.fps:
|
|
21
|
+
video1 = ResampleFPS(fps=video2.fps).apply(video1)
|
|
22
|
+
elif video1.fps < video2.fps:
|
|
23
|
+
video2 = ResampleFPS(fps=video1.fps).apply(video2)
|
|
24
|
+
return (video1, video2)
|
|
25
|
+
|
|
26
|
+
def _align_shapes(self, video1: Video, video2: Video) -> tuple[Video, Video]:
|
|
27
|
+
if self.mode == "horizontal":
|
|
28
|
+
video2 = Resize(height=video1.metadata.height).apply(video2)
|
|
29
|
+
elif self.mode == "vertical":
|
|
30
|
+
video2 = Resize(width=video1.metadata.width).apply(video2)
|
|
31
|
+
return (video1, video2)
|
|
32
|
+
|
|
33
|
+
def _align_duration(self, video1: Video, video2: Video) -> tuple[Video, Video]:
|
|
34
|
+
if len(video1.frames) > len(video2.frames):
|
|
35
|
+
video1 = video1[: len(video2.frames)]
|
|
36
|
+
elif len(video1.frames) < len(video2.frames):
|
|
37
|
+
video2 = video2[: len(video1.frames)]
|
|
38
|
+
return (video1, video2)
|
|
39
|
+
|
|
40
|
+
def apply(self, videos: tuple[Video, Video]) -> Video:
|
|
41
|
+
videos = self._validate(*videos)
|
|
42
|
+
axis = 1 if self.mode == "vertical" else 2
|
|
43
|
+
new_frames = np.concatenate((videos[0].frames, videos[1].frames), axis=axis)
|
|
44
|
+
new_audio = videos[0].audio.overlay(videos[1].audio)
|
|
45
|
+
return Video(frames=new_frames, fps=videos[0].fps, audio=new_audio)
|
|
@@ -134,15 +134,20 @@ class VideoMetadata:
|
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
class Video:
|
|
137
|
-
def __init__(self):
|
|
138
|
-
self.
|
|
139
|
-
self.
|
|
140
|
-
|
|
137
|
+
def __init__(self, frames: np.ndarray, fps: int | float, audio: Audio | None = None):
|
|
138
|
+
self.frames = frames
|
|
139
|
+
self.fps = fps
|
|
140
|
+
if audio:
|
|
141
|
+
self.audio = audio
|
|
142
|
+
else:
|
|
143
|
+
self.audio = Audio.create_silent(
|
|
144
|
+
duration_seconds=round(self.total_seconds, 2), stereo=True, sample_rate=44100
|
|
145
|
+
)
|
|
141
146
|
|
|
142
147
|
@classmethod
|
|
143
|
-
def from_path(
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
def from_path(
|
|
149
|
+
cls, path: str, read_batch_size: int = 100, start_second: float | None = None, end_second: float | None = None
|
|
150
|
+
) -> Video:
|
|
146
151
|
try:
|
|
147
152
|
# Get video metadata using VideoMetadata.from_path
|
|
148
153
|
metadata = VideoMetadata.from_path(path)
|
|
@@ -151,24 +156,56 @@ class Video:
|
|
|
151
156
|
height = metadata.height
|
|
152
157
|
fps = metadata.fps
|
|
153
158
|
total_frames = metadata.frame_count
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
total_duration = metadata.total_seconds
|
|
160
|
+
|
|
161
|
+
# Validate time bounds
|
|
162
|
+
if start_second is not None and start_second < 0:
|
|
163
|
+
raise ValueError("start_second must be non-negative")
|
|
164
|
+
if end_second is not None and end_second > total_duration:
|
|
165
|
+
raise ValueError(f"end_second ({end_second}) exceeds video duration ({total_duration})")
|
|
166
|
+
if start_second is not None and end_second is not None and start_second >= end_second:
|
|
167
|
+
raise ValueError("start_second must be less than end_second")
|
|
168
|
+
|
|
169
|
+
# Calculate frame indices for the desired segment
|
|
170
|
+
start_frame = int(start_second * fps) if start_second is not None else 0
|
|
171
|
+
end_frame = int(end_second * fps) if end_second is not None else total_frames
|
|
172
|
+
|
|
173
|
+
# Ensure we don't exceed bounds
|
|
174
|
+
start_frame = max(0, start_frame)
|
|
175
|
+
end_frame = min(total_frames, end_frame)
|
|
176
|
+
segment_frames = end_frame - start_frame
|
|
177
|
+
|
|
178
|
+
# Set up FFmpeg command for raw video extraction with time bounds
|
|
156
179
|
ffmpeg_cmd = [
|
|
157
180
|
"ffmpeg",
|
|
158
181
|
"-i",
|
|
159
182
|
path,
|
|
160
|
-
"-f",
|
|
161
|
-
"rawvideo",
|
|
162
|
-
"-pix_fmt",
|
|
163
|
-
"rgb24",
|
|
164
|
-
"-vsync",
|
|
165
|
-
"0",
|
|
166
|
-
"-vcodec",
|
|
167
|
-
"rawvideo",
|
|
168
|
-
"-y",
|
|
169
|
-
"pipe:1",
|
|
170
183
|
]
|
|
171
184
|
|
|
185
|
+
# Add seek and duration options if specified
|
|
186
|
+
if start_second is not None:
|
|
187
|
+
ffmpeg_cmd.extend(["-ss", str(start_second)])
|
|
188
|
+
if end_second is not None and start_second is not None:
|
|
189
|
+
duration = end_second - start_second
|
|
190
|
+
ffmpeg_cmd.extend(["-t", str(duration)])
|
|
191
|
+
elif end_second is not None:
|
|
192
|
+
ffmpeg_cmd.extend(["-t", str(end_second)])
|
|
193
|
+
|
|
194
|
+
ffmpeg_cmd.extend(
|
|
195
|
+
[
|
|
196
|
+
"-f",
|
|
197
|
+
"rawvideo",
|
|
198
|
+
"-pix_fmt",
|
|
199
|
+
"rgb24",
|
|
200
|
+
"-vsync",
|
|
201
|
+
"0",
|
|
202
|
+
"-vcodec",
|
|
203
|
+
"rawvideo",
|
|
204
|
+
"-y",
|
|
205
|
+
"pipe:1",
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
|
|
172
209
|
# Start FFmpeg process
|
|
173
210
|
process = subprocess.Popen(
|
|
174
211
|
ffmpeg_cmd,
|
|
@@ -180,12 +217,13 @@ class Video:
|
|
|
180
217
|
# Calculate frame size in bytes
|
|
181
218
|
frame_size = width * height * 3 # 3 bytes per pixel for RGB
|
|
182
219
|
|
|
183
|
-
# Pre-allocate numpy array for
|
|
184
|
-
frames = np.empty((
|
|
220
|
+
# Pre-allocate numpy array for segment frames
|
|
221
|
+
frames = np.empty((segment_frames, height, width, 3), dtype=np.uint8)
|
|
185
222
|
|
|
186
223
|
# Read frames in batches
|
|
187
|
-
|
|
188
|
-
|
|
224
|
+
frames_read = 0
|
|
225
|
+
for frame_idx in range(0, segment_frames, read_batch_size):
|
|
226
|
+
batch_end = min(frame_idx + read_batch_size, segment_frames)
|
|
189
227
|
batch_size = batch_end - frame_idx
|
|
190
228
|
|
|
191
229
|
# Read batch of frames
|
|
@@ -195,10 +233,19 @@ class Video:
|
|
|
195
233
|
|
|
196
234
|
# Convert raw bytes to numpy array and reshape
|
|
197
235
|
batch_frames = np.frombuffer(raw_data, dtype=np.uint8)
|
|
198
|
-
batch_frames = batch_frames.reshape(-1, height, width, 3)
|
|
199
236
|
|
|
200
|
-
#
|
|
201
|
-
|
|
237
|
+
# Handle case where we might get fewer frames than expected
|
|
238
|
+
actual_frames = len(batch_frames) // (height * width * 3)
|
|
239
|
+
if actual_frames > 0:
|
|
240
|
+
batch_frames = batch_frames[: actual_frames * height * width * 3]
|
|
241
|
+
batch_frames = batch_frames.reshape(-1, height, width, 3)
|
|
242
|
+
|
|
243
|
+
# Store batch in pre-allocated array
|
|
244
|
+
end_idx = frame_idx + actual_frames
|
|
245
|
+
frames[frame_idx:end_idx] = batch_frames
|
|
246
|
+
frames_read += actual_frames
|
|
247
|
+
else:
|
|
248
|
+
break
|
|
202
249
|
|
|
203
250
|
# Clean up FFmpeg process
|
|
204
251
|
process.stdout.close() # type: ignore
|
|
@@ -206,21 +253,28 @@ class Video:
|
|
|
206
253
|
process.wait()
|
|
207
254
|
|
|
208
255
|
if process.returncode != 0:
|
|
209
|
-
|
|
256
|
+
stderr_output = process.stderr.read().decode() if process.stderr else "Unknown error"
|
|
257
|
+
raise ValueError(f"FFmpeg error: {stderr_output}")
|
|
210
258
|
|
|
211
|
-
|
|
212
|
-
|
|
259
|
+
# Trim frames array if we read fewer frames than expected
|
|
260
|
+
if frames_read < segment_frames:
|
|
261
|
+
frames = frames[:frames_read] # type: ignore[assignment]
|
|
213
262
|
|
|
214
|
-
# Load audio
|
|
263
|
+
# Load audio for the specified segment
|
|
215
264
|
try:
|
|
216
|
-
|
|
265
|
+
audio = Audio.from_file(path)
|
|
266
|
+
# Slice audio to match the video segment
|
|
267
|
+
if start_second is not None or end_second is not None:
|
|
268
|
+
audio_start = start_second if start_second is not None else 0
|
|
269
|
+
audio_end = end_second if end_second is not None else audio.metadata.duration_seconds
|
|
270
|
+
audio = audio.slice(start_seconds=audio_start, end_seconds=audio_end)
|
|
217
271
|
except Exception:
|
|
218
272
|
print(f"No audio found for `{path}`, adding silent track!")
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
)
|
|
273
|
+
# Create silent audio for the segment duration
|
|
274
|
+
segment_duration = len(frames) / fps
|
|
275
|
+
audio = Audio.create_silent(duration_seconds=round(segment_duration, 2), stereo=True, sample_rate=44100)
|
|
222
276
|
|
|
223
|
-
return
|
|
277
|
+
return cls(frames=frames, fps=fps, audio=audio)
|
|
224
278
|
|
|
225
279
|
except VideoMetadataError as e:
|
|
226
280
|
raise ValueError(f"Error getting video metadata: {e}")
|
|
@@ -231,32 +285,23 @@ class Video:
|
|
|
231
285
|
|
|
232
286
|
@classmethod
|
|
233
287
|
def from_frames(cls, frames: np.ndarray, fps: float) -> Video:
|
|
234
|
-
new_vid = cls()
|
|
235
288
|
if frames.ndim != 4:
|
|
236
289
|
raise ValueError(f"Unsupported number of dimensions: {frames.shape}!")
|
|
237
290
|
elif frames.shape[-1] == 4:
|
|
238
291
|
frames = frames[:, :, :, :3]
|
|
239
292
|
elif frames.shape[-1] != 3:
|
|
240
293
|
raise ValueError(f"Unsupported number of dimensions: {frames.shape}!")
|
|
241
|
-
|
|
242
|
-
new_vid.fps = fps
|
|
243
|
-
new_vid.audio = Audio.create_silent(
|
|
244
|
-
duration_seconds=round(new_vid.total_seconds, 2), stereo=True, sample_rate=44100
|
|
245
|
-
)
|
|
246
|
-
return new_vid
|
|
294
|
+
return cls(frames=frames, fps=fps)
|
|
247
295
|
|
|
248
296
|
@classmethod
|
|
249
297
|
def from_image(cls, image: np.ndarray, fps: float = 24.0, length_seconds: float = 1.0) -> Video:
|
|
250
|
-
new_vid = cls()
|
|
251
298
|
if len(image.shape) == 3:
|
|
252
299
|
image = np.expand_dims(image, axis=0)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
new_vid.audio = Audio.create_silent(duration_seconds=length_seconds, stereo=True, sample_rate=44100)
|
|
256
|
-
return new_vid
|
|
300
|
+
frames = np.repeat(image, round(length_seconds * fps), axis=0)
|
|
301
|
+
return cls(frames=frames, fps=fps)
|
|
257
302
|
|
|
258
303
|
def copy(self) -> Video:
|
|
259
|
-
copied = Video
|
|
304
|
+
copied = Video.from_frames(self.frames.copy(), self.fps)
|
|
260
305
|
copied.audio = self.audio # Audio objects are immutable, no need to copy
|
|
261
306
|
return copied
|
|
262
307
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|