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.

Files changed (27) hide show
  1. {videopython-0.4.0 → videopython-0.4.1}/PKG-INFO +15 -2
  2. {videopython-0.4.0 → videopython-0.4.1}/pyproject.toml +34 -9
  3. videopython-0.4.1/src/videopython/base/combine.py +45 -0
  4. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/video.py +93 -48
  5. {videopython-0.4.0 → videopython-0.4.1}/.gitignore +0 -0
  6. {videopython-0.4.0 → videopython-0.4.1}/LICENSE +0 -0
  7. {videopython-0.4.0 → videopython-0.4.1}/README.md +0 -0
  8. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/__init__.py +0 -0
  9. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/__init__.py +0 -0
  10. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/generation/__init__.py +0 -0
  11. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/generation/audio.py +0 -0
  12. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/generation/image.py +0 -0
  13. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/generation/video.py +0 -0
  14. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/understanding/__init__.py +0 -0
  15. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/ai/understanding/transcribe.py +0 -0
  16. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/__init__.py +0 -0
  17. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/compose.py +0 -0
  18. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/effects.py +0 -0
  19. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/exceptions.py +0 -0
  20. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/transcription.py +0 -0
  21. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/transforms.py +0 -0
  22. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/base/transitions.py +0 -0
  23. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/py.typed +0 -0
  24. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/utils/__init__.py +0 -0
  25. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/utils/common.py +0 -0
  26. {videopython-0.4.0 → videopython-0.4.1}/src/videopython/utils/image.py +0 -0
  27. {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.0
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.0"
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 = ["python", "videopython", "video", "movie", "opencv", "generation", "editing"]
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", # pycodestyle errors
81
- "F", # pyflakes
82
- "I", # isort
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.fps = None
139
- self.frames = None
140
- self.audio = None
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(cls, path: str, read_batch_size: int = 100) -> Video:
144
- new_vid = cls()
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
- # Set up FFmpeg command for raw video extraction
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 all frames
184
- frames = np.empty((total_frames, height, width, 3), dtype=np.uint8)
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
- for frame_idx in range(0, total_frames, read_batch_size):
188
- batch_end = min(frame_idx + read_batch_size, total_frames)
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
- # Store batch in pre-allocated array
201
- frames[frame_idx:batch_end] = batch_frames
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
- raise ValueError(f"FFmpeg error: {process.stderr.read().decode()}") # type: ignore
256
+ stderr_output = process.stderr.read().decode() if process.stderr else "Unknown error"
257
+ raise ValueError(f"FFmpeg error: {stderr_output}")
210
258
 
211
- new_vid.frames = frames
212
- new_vid.fps = fps
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
- new_vid.audio = Audio.from_file(path)
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
- new_vid.audio = Audio.create_silent(
220
- duration_seconds=round(new_vid.total_seconds, 2), stereo=True, sample_rate=44100
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 new_vid
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
- new_vid.frames = frames
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
- new_vid.frames = np.repeat(image, round(length_seconds * fps), axis=0)
254
- new_vid.fps = fps
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().from_frames(self.frames.copy(), self.fps)
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