videopython 0.25.7__tar.gz → 0.26.0__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.
Files changed (55) hide show
  1. {videopython-0.25.7 → videopython-0.26.0}/PKG-INFO +1 -1
  2. {videopython-0.25.7 → videopython-0.26.0}/pyproject.toml +1 -1
  3. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/effects.py +279 -142
  4. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/registry.py +12 -3
  5. videopython-0.26.0/src/videopython/base/streaming.py +280 -0
  6. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/video.py +27 -5
  7. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/editing/video_edit.py +284 -3
  8. {videopython-0.25.7 → videopython-0.26.0}/.gitignore +0 -0
  9. {videopython-0.25.7 → videopython-0.26.0}/LICENSE +0 -0
  10. {videopython-0.25.7 → videopython-0.26.0}/README.md +0 -0
  11. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/__init__.py +0 -0
  12. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/__init__.py +0 -0
  13. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/_device.py +0 -0
  14. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  15. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/dubber.py +0 -0
  16. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/models.py +0 -0
  17. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  18. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/timing.py +0 -0
  19. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/__init__.py +0 -0
  20. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/audio.py +0 -0
  21. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/image.py +0 -0
  22. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/translation.py +0 -0
  23. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/video.py +0 -0
  24. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/registry.py +0 -0
  25. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/__init__.py +0 -0
  26. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/inpainter.py +0 -0
  27. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/models.py +0 -0
  28. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/segmenter.py +0 -0
  29. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/swapper.py +0 -0
  30. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/transforms.py +0 -0
  31. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/__init__.py +0 -0
  32. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/audio.py +0 -0
  33. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/image.py +0 -0
  34. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/separation.py +0 -0
  35. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/temporal.py +0 -0
  36. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/video_analysis.py +0 -0
  37. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/__init__.py +0 -0
  38. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/audio/__init__.py +0 -0
  39. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/audio/analysis.py +0 -0
  40. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/audio/audio.py +0 -0
  41. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/combine.py +0 -0
  42. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/description.py +0 -0
  43. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/exceptions.py +0 -0
  44. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/progress.py +0 -0
  45. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/scene.py +0 -0
  46. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/text/__init__.py +0 -0
  47. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/text/overlay.py +0 -0
  48. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/text/transcription.py +0 -0
  49. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/transforms.py +0 -0
  50. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/transitions.py +0 -0
  51. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/utils.py +0 -0
  52. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/editing/__init__.py +0 -0
  53. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/editing/multicam.py +0 -0
  54. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/editing/premiere_xml.py +0 -0
  55. {videopython-0.25.7 → videopython-0.26.0}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.25.7
3
+ Version: 0.26.0
4
4
  Summary: Minimal video generation and processing library.
5
5
  Project-URL: Homepage, https://videopython.com
6
6
  Project-URL: Repository, https://github.com/bartwojtowicz/videopython/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.25.7"
3
+ version = "0.26.0"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -1,8 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
- from multiprocessing import Pool
5
- from typing import TYPE_CHECKING, Literal
4
+ from typing import TYPE_CHECKING, ClassVar, Literal
6
5
 
7
6
  import cv2
8
7
  import numpy as np
@@ -11,10 +10,8 @@ from PIL import Image, ImageDraw, ImageFont
11
10
  from videopython.base.progress import log, progress_iter
12
11
  from videopython.base.video import Video
13
12
 
14
- # Minimum frames before using multiprocessing (Pool overhead isn't worth it below this)
15
- MIN_FRAMES_FOR_MULTIPROCESSING = 100
16
-
17
13
  if TYPE_CHECKING:
14
+ from videopython.base.audio import Audio
18
15
  from videopython.base.description import BoundingBox
19
16
 
20
17
  __all__ = [
@@ -54,6 +51,27 @@ class Effect(ABC):
54
51
  The effect must not change the number of frames and the shape of the frames.
55
52
  """
56
53
 
54
+ supports_streaming: ClassVar[bool] = False
55
+
56
+ def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
57
+ """Called once before streaming begins to precompute per-frame parameters.
58
+
59
+ Override in subclasses that need precomputation (e.g., per-frame alpha
60
+ arrays, sigma schedules, crop regions).
61
+ """
62
+
63
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
64
+ """Process a single frame in streaming mode.
65
+
66
+ Args:
67
+ frame: Single RGB frame (H, W, 3) uint8.
68
+ frame_index: 0-based index within this effect's active range.
69
+
70
+ Returns:
71
+ Processed frame, same shape and dtype.
72
+ """
73
+ raise NotImplementedError(f"{type(self).__name__} does not support streaming")
74
+
57
75
  def apply(self, video: Video, start: float | None = None, stop: float | None = None) -> Video:
58
76
  """Apply the effect to a video, optionally within a time range.
59
77
 
@@ -70,24 +88,30 @@ class Effect(ABC):
70
88
  Only set when the effect should end before the video does.
71
89
  """
72
90
  original_shape = video.video_shape
73
- start_s, stop_s = _resolve_time_range(start, stop, video.total_seconds)
74
- # Apply effect on video slice
75
- effect_start_frame = round(start_s * video.fps)
76
- effect_end_frame = round(stop_s * video.fps)
77
- video_with_effect = self._apply(video[effect_start_frame:effect_end_frame])
78
- old_audio = video.audio
79
- video = Video.from_frames(
80
- np.r_[
81
- "0,2",
82
- video.frames[:effect_start_frame],
83
- video_with_effect.frames,
84
- video.frames[effect_end_frame:],
85
- ],
86
- fps=video.fps,
87
- )
88
- video.audio = old_audio
91
+
92
+ if start is None and stop is None:
93
+ # Full-range: apply directly without slicing or np.r_ reassembly.
94
+ video = self._apply(video)
95
+ else:
96
+ start_s, stop_s = _resolve_time_range(start, stop, video.total_seconds)
97
+ # Apply effect on video slice
98
+ effect_start_frame = round(start_s * video.fps)
99
+ effect_end_frame = round(stop_s * video.fps)
100
+ video_with_effect = self._apply(video[effect_start_frame:effect_end_frame])
101
+ old_audio = video.audio
102
+ video = Video.from_frames(
103
+ np.r_[
104
+ "0,2",
105
+ video.frames[:effect_start_frame],
106
+ video_with_effect.frames,
107
+ video.frames[effect_end_frame:],
108
+ ],
109
+ fps=video.fps,
110
+ )
111
+ video.audio = old_audio
112
+
89
113
  # Check if dimensions didn't change
90
- if not video.video_shape == original_shape:
114
+ if video.video_shape != original_shape:
91
115
  raise RuntimeError("The effect must not change the number of frames and the shape of the frames!")
92
116
 
93
117
  return video
@@ -104,6 +128,8 @@ class FullImageOverlay(Effect):
104
128
  transparency via RGBA images and an overall opacity control.
105
129
  """
106
130
 
131
+ supports_streaming: ClassVar[bool] = True
132
+
107
133
  def __init__(self, overlay_image: np.ndarray, alpha: float | None = None, fade_time: float = 0.0):
108
134
  """Initialize image overlay effect.
109
135
 
@@ -137,6 +163,17 @@ class FullImageOverlay(Effect):
137
163
  img_pil.paste(overlay_pil, (0, 0), overlay_pil)
138
164
  return np.array(img_pil)
139
165
 
166
+ def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
167
+ self._stream_total = total_frames
168
+ self._stream_fade_frames = round(self.fade_time * fps) if self.fade_time > 0 else 0
169
+
170
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
171
+ if self._stream_fade_frames == 0:
172
+ return self._overlay(frame)
173
+ dist_from_end = min(frame_index, self._stream_total - 1 - frame_index)
174
+ fade_alpha = 1.0 if dist_from_end >= self._stream_fade_frames else dist_from_end / self._stream_fade_frames
175
+ return self._overlay(frame, fade_alpha)
176
+
140
177
  def _apply(self, video: Video) -> Video:
141
178
  if not video.frame_shape == self.overlay[:, :, :3].shape:
142
179
  raise ValueError(
@@ -147,28 +184,23 @@ class FullImageOverlay(Effect):
147
184
 
148
185
  log("Overlaying video...")
149
186
  if self.fade_time == 0:
150
- video.frames = np.array(
151
- [self._overlay(frame) for frame in progress_iter(video.frames, desc="Overlaying frames")],
152
- dtype=np.uint8,
153
- )
187
+ for i in progress_iter(range(len(video.frames)), desc="Overlaying frames"):
188
+ video.frames[i] = self._overlay(video.frames[i])
154
189
  else:
155
190
  num_video_frames = len(video.frames)
156
191
  num_fade_frames = round(self.fade_time * video.fps)
157
- new_frames = []
158
- for i, frame in enumerate(progress_iter(video.frames, desc="Overlaying frames")):
192
+ for i in progress_iter(range(num_video_frames), desc="Overlaying frames"):
159
193
  frames_dist_from_end = min(i, num_video_frames - i)
160
- if frames_dist_from_end >= num_fade_frames:
161
- fade_alpha = 1.0
162
- else:
163
- fade_alpha = frames_dist_from_end / num_fade_frames
164
- new_frames.append(self._overlay(frame, fade_alpha))
165
- video.frames = np.array(new_frames, dtype=np.uint8)
194
+ fade_alpha = 1.0 if frames_dist_from_end >= num_fade_frames else frames_dist_from_end / num_fade_frames
195
+ video.frames[i] = self._overlay(video.frames[i], fade_alpha)
166
196
  return video
167
197
 
168
198
 
169
199
  class Blur(Effect):
170
200
  """Applies Gaussian blur that can stay constant or ramp up/down over the clip."""
171
201
 
202
+ supports_streaming: ClassVar[bool] = True
203
+
172
204
  def __init__(
173
205
  self,
174
206
  mode: Literal["constant", "ascending", "descending"],
@@ -203,48 +235,43 @@ class Blur(Effect):
203
235
  """
204
236
  return cv2.GaussianBlur(frame, self.kernel_size, sigma)
205
237
 
206
- def _apply(self, video: Video) -> Video:
207
- n_frames = len(video.frames)
208
-
209
- # Calculate base sigma from kernel size (OpenCV formula)
238
+ def _compute_sigmas(self, n_frames: int) -> np.ndarray:
239
+ """Compute per-frame sigma values based on mode."""
210
240
  base_sigma = 0.3 * ((self.kernel_size[0] - 1) * 0.5 - 1) + 0.8
211
-
212
- # Multiple blur iterations with sigma S approximate single blur with sigma S*sqrt(iterations)
213
- # This is much faster than iterative application
214
241
  max_sigma = base_sigma * np.sqrt(self.iterations)
215
242
 
216
- # Calculate sigma for each frame based on mode
217
243
  if self.mode == "constant":
218
- sigmas = np.full(n_frames, max_sigma)
244
+ return np.full(n_frames, max_sigma)
219
245
  elif self.mode == "ascending":
220
- # Linearly increase blur intensity from start to end
221
- iteration_ratios = np.linspace(1 / n_frames, 1.0, n_frames)
222
- sigmas = base_sigma * np.sqrt(np.maximum(1, np.round(iteration_ratios * self.iterations)))
246
+ ratios = np.linspace(1 / n_frames, 1.0, n_frames)
223
247
  elif self.mode == "descending":
224
- # Linearly decrease blur intensity from start to end
225
- iteration_ratios = np.linspace(1.0, 1 / n_frames, n_frames)
226
- sigmas = base_sigma * np.sqrt(np.maximum(1, np.round(iteration_ratios * self.iterations)))
248
+ ratios = np.linspace(1.0, 1 / n_frames, n_frames)
227
249
  else:
228
250
  raise ValueError(f"Unknown mode: `{self.mode}`.")
251
+ return base_sigma * np.sqrt(np.maximum(1, np.round(ratios * self.iterations)))
229
252
 
230
- log(f"Applying {self.mode} blur...")
253
+ def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
254
+ self._stream_sigmas = self._compute_sigmas(total_frames)
231
255
 
232
- if n_frames >= MIN_FRAMES_FOR_MULTIPROCESSING:
233
- with Pool() as pool:
234
- new_frames = pool.starmap(
235
- self._blur_frame,
236
- [(frame, sigma) for frame, sigma in zip(video.frames, sigmas)],
237
- )
238
- else:
239
- new_frames = [self._blur_frame(frame, sigma) for frame, sigma in zip(video.frames, sigmas)]
256
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
257
+ idx = min(frame_index, len(self._stream_sigmas) - 1)
258
+ return self._blur_frame(frame, self._stream_sigmas[idx])
259
+
260
+ def _apply(self, video: Video) -> Video:
261
+ n_frames = len(video.frames)
262
+ sigmas = self._compute_sigmas(n_frames)
240
263
 
241
- video.frames = np.array(new_frames, dtype=np.uint8)
264
+ log(f"Applying {self.mode} blur...")
265
+ for i in progress_iter(range(n_frames), desc="Blurring"):
266
+ video.frames[i] = self._blur_frame(video.frames[i], sigmas[i])
242
267
  return video
243
268
 
244
269
 
245
270
  class Zoom(Effect):
246
271
  """Progressively zooms into or out of the frame center over the clip duration."""
247
272
 
273
+ supports_streaming: ClassVar[bool] = True
274
+
248
275
  def __init__(self, zoom_factor: float, mode: Literal["in", "out"]):
249
276
  """Initialize zoom effect.
250
277
 
@@ -259,50 +286,51 @@ class Zoom(Effect):
259
286
  self.zoom_factor = zoom_factor
260
287
  self.mode = mode
261
288
 
289
+ def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
290
+ crop_w = np.linspace(width // self.zoom_factor, width, total_frames)
291
+ crop_h = np.linspace(height // self.zoom_factor, height, total_frames)
292
+ if self.mode == "in":
293
+ crop_w, crop_h = crop_w[::-1], crop_h[::-1]
294
+ self._stream_crops = np.stack([crop_w, crop_h], axis=1)
295
+ self._stream_width = width
296
+ self._stream_height = height
297
+
298
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
299
+ idx = min(frame_index, len(self._stream_crops) - 1)
300
+ w, h = self._stream_crops[idx]
301
+ width, height = self._stream_width, self._stream_height
302
+ x = width / 2 - w / 2
303
+ y = height / 2 - h / 2
304
+ cropped = frame[round(y) : round(y + h), round(x) : round(x + w)]
305
+ return cv2.resize(cropped, (width, height))
306
+
262
307
  def _apply(self, video: Video) -> Video:
263
308
  n_frames = len(video.frames)
264
- new_frames = []
265
-
266
309
  width = video.metadata.width
267
310
  height = video.metadata.height
268
- crop_sizes_w, crop_sizes_h = (
269
- np.linspace(width // self.zoom_factor, width, n_frames),
270
- np.linspace(height // self.zoom_factor, height, n_frames),
271
- )
311
+ crop_sizes_w = np.linspace(width // self.zoom_factor, width, n_frames)
312
+ crop_sizes_h = np.linspace(height // self.zoom_factor, height, n_frames)
272
313
 
273
314
  if self.mode == "in":
274
- for frame, w, h in progress_iter(
275
- zip(video.frames, reversed(crop_sizes_w), reversed(crop_sizes_h)),
276
- desc="Zooming",
277
- total=n_frames,
278
- ):
279
- x = width / 2 - w / 2
280
- y = height / 2 - h / 2
281
-
282
- cropped_frame = frame[round(y) : round(y + h), round(x) : round(x + w)]
283
- zoomed_frame = cv2.resize(cropped_frame, (width, height))
284
- new_frames.append(zoomed_frame)
285
- elif self.mode == "out":
286
- for frame, w, h in progress_iter(
287
- zip(video.frames, crop_sizes_w, crop_sizes_h),
288
- desc="Zooming",
289
- total=n_frames,
290
- ):
291
- x = width / 2 - w / 2
292
- y = height / 2 - h / 2
293
-
294
- cropped_frame = frame[round(y) : round(y + h), round(x) : round(x + w)]
295
- zoomed_frame = cv2.resize(cropped_frame, (width, height))
296
- new_frames.append(zoomed_frame)
297
- else:
315
+ crop_sizes_w = crop_sizes_w[::-1]
316
+ crop_sizes_h = crop_sizes_h[::-1]
317
+ elif self.mode != "out":
298
318
  raise ValueError(f"Unknown mode: `{self.mode}`.")
299
- video.frames = np.asarray(new_frames)
319
+
320
+ for i in progress_iter(range(n_frames), desc="Zooming", total=n_frames):
321
+ w, h = crop_sizes_w[i], crop_sizes_h[i]
322
+ x = width / 2 - w / 2
323
+ y = height / 2 - h / 2
324
+ cropped_frame = video.frames[i][round(y) : round(y + h), round(x) : round(x + w)]
325
+ video.frames[i] = cv2.resize(cropped_frame, (width, height))
300
326
  return video
301
327
 
302
328
 
303
329
  class ColorGrading(Effect):
304
330
  """Adjusts color properties: brightness, contrast, saturation, and temperature."""
305
331
 
332
+ supports_streaming: ClassVar[bool] = True
333
+
306
334
  def __init__(
307
335
  self,
308
336
  brightness: float = 0.0,
@@ -368,23 +396,21 @@ class ColorGrading(Effect):
368
396
  img = np.clip(img * 255, 0, 255).astype(np.uint8)
369
397
  return img
370
398
 
399
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
400
+ return self._grade_frame(frame)
401
+
371
402
  def _apply(self, video: Video) -> Video:
372
403
  log("Applying color grading...")
373
- n_frames = len(video.frames)
374
-
375
- if n_frames >= MIN_FRAMES_FOR_MULTIPROCESSING:
376
- with Pool() as pool:
377
- new_frames = pool.map(self._grade_frame, video.frames)
378
- else:
379
- new_frames = [self._grade_frame(frame) for frame in video.frames]
380
-
381
- video.frames = np.array(new_frames, dtype=np.uint8)
404
+ for i in progress_iter(range(len(video.frames)), desc="Color grading"):
405
+ video.frames[i] = self._grade_frame(video.frames[i])
382
406
  return video
383
407
 
384
408
 
385
409
  class Vignette(Effect):
386
410
  """Darkens the edges of the frame, drawing attention to the center."""
387
411
 
412
+ supports_streaming: ClassVar[bool] = True
413
+
388
414
  def __init__(self, strength: float = 0.5, radius: float = 1.0):
389
415
  """Initialize vignette effect.
390
416
 
@@ -418,6 +444,14 @@ class Vignette(Effect):
418
444
 
419
445
  return mask.astype(np.float32)
420
446
 
447
+ def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
448
+ if self._mask is None or self._mask.shape != (height, width):
449
+ self._mask = self._create_mask(height, width)
450
+ self._stream_mask_3d = self._mask[:, :, np.newaxis]
451
+
452
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
453
+ return (frame.astype(np.float32) * self._stream_mask_3d).astype(np.uint8)
454
+
421
455
  def _apply(self, video: Video) -> Video:
422
456
  log("Applying vignette effect...")
423
457
  height, width = video.frame_shape[:2]
@@ -426,11 +460,12 @@ class Vignette(Effect):
426
460
  if self._mask is None or self._mask.shape != (height, width):
427
461
  self._mask = self._create_mask(height, width)
428
462
 
429
- # Apply mask to all frames using vectorized operation
430
- # mask_3d shape: (height, width, 1), frames shape: (n_frames, height, width, 3)
431
- # Broadcasting handles the multiplication across all frames and channels
463
+ # Apply mask in batches to avoid allocating a full float32 copy of all frames
432
464
  mask_3d = self._mask[:, :, np.newaxis]
433
- video.frames = (video.frames.astype(np.float32) * mask_3d).astype(np.uint8)
465
+ batch_size = 64
466
+ for start in range(0, len(video.frames), batch_size):
467
+ end = min(start + batch_size, len(video.frames))
468
+ video.frames[start:end] = (video.frames[start:end].astype(np.float32) * mask_3d).astype(np.uint8)
434
469
  return video
435
470
 
436
471
 
@@ -442,6 +477,8 @@ class KenBurns(Effect):
442
477
  across a scene.
443
478
  """
444
479
 
480
+ supports_streaming: ClassVar[bool] = True
481
+
445
482
  def __init__(
446
483
  self,
447
484
  start_region: "BoundingBox",
@@ -516,6 +553,38 @@ class KenBurns(Effect):
516
553
  cropped = frame[y : y + crop_h, x : x + crop_w]
517
554
  return cv2.resize(cropped, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
518
555
 
556
+ def _precompute_regions(self, n_frames: int, width: int, height: int) -> np.ndarray:
557
+ """Precompute (x, y, crop_w, crop_h) for each frame."""
558
+ sx = int(self.start_region.x * width)
559
+ sy = int(self.start_region.y * height)
560
+ sw = int(self.start_region.width * width)
561
+ sh = int(self.start_region.height * height)
562
+ ex = int(self.end_region.x * width)
563
+ ey = int(self.end_region.y * height)
564
+ ew = int(self.end_region.width * width)
565
+ eh = int(self.end_region.height * height)
566
+
567
+ regions = np.empty((n_frames, 4), dtype=np.int32)
568
+ for i in range(n_frames):
569
+ t = i / max(1, n_frames - 1)
570
+ et = self._ease(t)
571
+ crop_w = int(sw + (ew - sw) * et)
572
+ crop_h = int(sh + (eh - sh) * et)
573
+ x = max(0, min(int(sx + (ex - sx) * et), width - crop_w))
574
+ y = max(0, min(int(sy + (ey - sy) * et), height - crop_h))
575
+ regions[i] = (x, y, crop_w, crop_h)
576
+ return regions
577
+
578
+ def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
579
+ self._stream_regions = self._precompute_regions(total_frames, width, height)
580
+ self._stream_target_w = width
581
+ self._stream_target_h = height
582
+
583
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
584
+ idx = min(frame_index, len(self._stream_regions) - 1)
585
+ x, y, cw, ch = self._stream_regions[idx]
586
+ return self._crop_and_scale_frame(frame, x, y, cw, ch, self._stream_target_w, self._stream_target_h)
587
+
519
588
  def _apply(self, video: Video) -> Video:
520
589
  n_frames = len(video.frames)
521
590
  height, width = video.frame_shape[:2]
@@ -533,8 +602,7 @@ class KenBurns(Effect):
533
602
  end_h = int(self.end_region.height * height)
534
603
 
535
604
  log("Applying Ken Burns effect...")
536
- new_frames = []
537
- for i, frame in enumerate(progress_iter(video.frames, desc="Ken Burns")):
605
+ for i in progress_iter(range(n_frames), desc="Ken Burns"):
538
606
  t = i / max(1, n_frames - 1) # Normalized time [0, 1]
539
607
  eased_t = self._ease(t)
540
608
 
@@ -548,10 +616,7 @@ class KenBurns(Effect):
548
616
  x = max(0, min(x, width - crop_w))
549
617
  y = max(0, min(y, height - crop_h))
550
618
 
551
- new_frame = self._crop_and_scale_frame(frame, x, y, crop_w, crop_h, target_w, target_h)
552
- new_frames.append(new_frame)
553
-
554
- video.frames = np.array(new_frames, dtype=np.uint8)
619
+ video.frames[i] = self._crop_and_scale_frame(video.frames[i], x, y, crop_w, crop_h, target_w, target_h)
555
620
  return video
556
621
 
557
622
 
@@ -568,6 +633,8 @@ def _compute_curve(t: np.ndarray, curve: str) -> np.ndarray:
568
633
  class Fade(Effect):
569
634
  """Fades video and audio to or from black."""
570
635
 
636
+ supports_streaming: ClassVar[bool] = True
637
+
571
638
  def __init__(
572
639
  self,
573
640
  mode: Literal["in", "out", "in_out"],
@@ -592,6 +659,28 @@ class Fade(Effect):
592
659
  self.duration = duration
593
660
  self.curve = curve
594
661
 
662
+ def _compute_alpha(self, n_frames: int, fps: float) -> np.ndarray:
663
+ """Compute per-frame alpha values for the video fade."""
664
+ fade_frames = min(round(self.duration * fps), n_frames)
665
+ alpha = np.ones(n_frames, dtype=np.float32)
666
+ if self.mode in ("in", "in_out"):
667
+ t = np.linspace(0, 1, fade_frames, dtype=np.float32)
668
+ alpha[:fade_frames] = _compute_curve(t, self.curve)
669
+ if self.mode in ("out", "in_out"):
670
+ t = np.linspace(1, 0, fade_frames, dtype=np.float32)
671
+ alpha[-fade_frames:] = np.minimum(alpha[-fade_frames:], _compute_curve(t, self.curve))
672
+ return alpha
673
+
674
+ def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
675
+ self._stream_alpha = self._compute_alpha(total_frames, fps)
676
+
677
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
678
+ idx = min(frame_index, len(self._stream_alpha) - 1)
679
+ a = self._stream_alpha[idx]
680
+ if a == 1.0:
681
+ return frame
682
+ return (frame.astype(np.float32) * a).astype(np.uint8)
683
+
595
684
  def apply(self, video: Video, start: float | None = None, stop: float | None = None) -> Video:
596
685
  """Apply fade effect to video and audio.
597
686
 
@@ -612,21 +701,22 @@ class Fade(Effect):
612
701
  effect_start_frame = round(start_s * video.fps)
613
702
  effect_end_frame = round(stop_s * video.fps)
614
703
  n_effect_frames = effect_end_frame - effect_start_frame
615
- fade_frames = min(round(self.duration * video.fps), n_effect_frames)
616
704
 
617
- # Build per-frame alpha array (1.0 = fully visible, 0.0 = black)
618
- alpha = np.ones(n_effect_frames, dtype=np.float32)
619
- if self.mode in ("in", "in_out"):
620
- t = np.linspace(0, 1, fade_frames, dtype=np.float32)
621
- alpha[:fade_frames] = _compute_curve(t, self.curve)
622
- if self.mode in ("out", "in_out"):
623
- t = np.linspace(1, 0, fade_frames, dtype=np.float32)
624
- alpha[-fade_frames:] = np.minimum(alpha[-fade_frames:], _compute_curve(t, self.curve))
705
+ alpha = self._compute_alpha(n_effect_frames, video.fps)
625
706
 
626
- # Apply to video frames
627
- frames = video.frames[effect_start_frame:effect_end_frame]
628
- alpha_3d = alpha[:, np.newaxis, np.newaxis, np.newaxis]
629
- video.frames[effect_start_frame:effect_end_frame] = (frames.astype(np.float32) * alpha_3d).astype(np.uint8)
707
+ # Apply to video frames in batches to avoid a full float32 copy
708
+ batch_size = 64
709
+ for batch_start in range(0, n_effect_frames, batch_size):
710
+ batch_end = min(batch_start + batch_size, n_effect_frames)
711
+ batch_alpha = alpha[batch_start:batch_end, np.newaxis, np.newaxis, np.newaxis]
712
+ # Skip batch if all alphas are 1.0 (no change needed)
713
+ if np.all(batch_alpha == 1.0):
714
+ continue
715
+ abs_start = effect_start_frame + batch_start
716
+ abs_end = effect_start_frame + batch_end
717
+ video.frames[abs_start:abs_end] = (video.frames[abs_start:abs_end].astype(np.float32) * batch_alpha).astype(
718
+ np.uint8
719
+ )
630
720
 
631
721
  # Verify shape invariant
632
722
  if video.video_shape != original_shape:
@@ -634,29 +724,38 @@ class Fade(Effect):
634
724
 
635
725
  # Apply to audio
636
726
  if video.audio is not None and not video.audio.is_silent:
637
- sample_rate = video.audio.metadata.sample_rate
638
- audio_start = round(start_s * sample_rate)
639
- audio_end = min(round(stop_s * sample_rate), len(video.audio.data))
640
- n_audio_samples = audio_end - audio_start
641
- fade_samples = min(round(self.duration * sample_rate), n_audio_samples)
642
-
643
- audio_alpha = np.ones(n_audio_samples, dtype=np.float32)
644
- if self.mode in ("in", "in_out"):
645
- t = np.linspace(0, 1, fade_samples, dtype=np.float32)
646
- audio_alpha[:fade_samples] = _compute_curve(t, self.curve)
647
- if self.mode in ("out", "in_out"):
648
- t = np.linspace(1, 0, fade_samples, dtype=np.float32)
649
- audio_alpha[-fade_samples:] = np.minimum(audio_alpha[-fade_samples:], _compute_curve(t, self.curve))
650
-
651
- audio_data = video.audio.data
652
- if audio_data.ndim == 1:
653
- audio_data[audio_start:audio_end] *= audio_alpha
654
- else:
655
- audio_data[audio_start:audio_end] *= audio_alpha[:, np.newaxis]
656
- np.clip(audio_data, -1.0, 1.0, out=audio_data)
727
+ self.apply_audio(video.audio, start_s, stop_s)
657
728
 
658
729
  return video
659
730
 
731
+ def apply_audio(self, audio: Audio, start_s: float, stop_s: float) -> None:
732
+ """Apply fade to audio data in-place.
733
+
734
+ Args:
735
+ audio: Audio object to modify.
736
+ start_s: Start time in seconds.
737
+ stop_s: Stop time in seconds.
738
+ """
739
+ sample_rate = audio.metadata.sample_rate
740
+ audio_start = round(start_s * sample_rate)
741
+ audio_end = min(round(stop_s * sample_rate), len(audio.data))
742
+ n_samples = audio_end - audio_start
743
+ fade_samples = min(round(self.duration * sample_rate), n_samples)
744
+
745
+ alpha = np.ones(n_samples, dtype=np.float32)
746
+ if self.mode in ("in", "in_out"):
747
+ t = np.linspace(0, 1, fade_samples, dtype=np.float32)
748
+ alpha[:fade_samples] = _compute_curve(t, self.curve)
749
+ if self.mode in ("out", "in_out"):
750
+ t = np.linspace(1, 0, fade_samples, dtype=np.float32)
751
+ alpha[-fade_samples:] = np.minimum(alpha[-fade_samples:], _compute_curve(t, self.curve))
752
+
753
+ if audio.data.ndim == 1:
754
+ audio.data[audio_start:audio_end] *= alpha
755
+ else:
756
+ audio.data[audio_start:audio_end] *= alpha[:, np.newaxis]
757
+ np.clip(audio.data, -1.0, 1.0, out=audio.data)
758
+
660
759
  def _apply(self, video: Video) -> Video:
661
760
  raise NotImplementedError("Fade overrides apply() directly")
662
761
 
@@ -668,6 +767,11 @@ class AudioEffect(Effect):
668
767
  without modification. Overrides apply() to skip frame processing.
669
768
  """
670
769
 
770
+ supports_streaming: ClassVar[bool] = True
771
+
772
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
773
+ return frame # Audio effects don't touch frames
774
+
671
775
  def _apply(self, video: Video) -> Video:
672
776
  raise NotImplementedError("AudioEffect does not process frames -- use _apply_audio()")
673
777
 
@@ -751,6 +855,8 @@ class VolumeAdjust(AudioEffect):
751
855
  class TextOverlay(Effect):
752
856
  """Draws text on video frames, with auto word-wrap and optional background box."""
753
857
 
858
+ supports_streaming: ClassVar[bool] = True
859
+
754
860
  def __init__(
755
861
  self,
756
862
  text: str,
@@ -875,6 +981,37 @@ class TextOverlay(Effect):
875
981
  return px - img_w, py - img_h
876
982
  return px - img_w // 2, py - img_h // 2
877
983
 
984
+ def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
985
+ if self._rendered is None:
986
+ self._rendered = self._render_text_image(width, height)
987
+ oh, ow = self._rendered.shape[:2]
988
+ x, y = self._compute_position(width, height, ow, oh)
989
+ src_x = max(0, -x)
990
+ src_y = max(0, -y)
991
+ dst_x = max(0, x)
992
+ dst_y = max(0, y)
993
+ paste_w = min(ow - src_x, width - dst_x)
994
+ paste_h = min(oh - src_y, height - dst_y)
995
+ if paste_w <= 0 or paste_h <= 0:
996
+ self._stream_noop = True
997
+ return
998
+ self._stream_noop = False
999
+ overlay_region = self._rendered[src_y : src_y + paste_h, src_x : src_x + paste_w]
1000
+ self._stream_alpha = overlay_region[:, :, 3:4].astype(np.float32) / 255.0
1001
+ self._stream_rgb = overlay_region[:, :, :3].astype(np.float32)
1002
+ self._stream_dst = (dst_y, dst_x, paste_h, paste_w)
1003
+
1004
+ def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1005
+ if self._stream_noop:
1006
+ return frame
1007
+ dy, dx, ph, pw = self._stream_dst
1008
+ region = frame[dy : dy + ph, dx : dx + pw]
1009
+ blended = (
1010
+ self._stream_rgb * self._stream_alpha + region.astype(np.float32) * (1.0 - self._stream_alpha)
1011
+ ).astype(np.uint8)
1012
+ frame[dy : dy + ph, dx : dx + pw] = blended
1013
+ return frame
1014
+
878
1015
  def _apply(self, video: Video) -> Video:
879
1016
  frame_h, frame_w = video.frame_shape[:2]
880
1017