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.
- {videopython-0.25.7 → videopython-0.26.0}/PKG-INFO +1 -1
- {videopython-0.25.7 → videopython-0.26.0}/pyproject.toml +1 -1
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/effects.py +279 -142
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/registry.py +12 -3
- videopython-0.26.0/src/videopython/base/streaming.py +280 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/video.py +27 -5
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/editing/video_edit.py +284 -3
- {videopython-0.25.7 → videopython-0.26.0}/.gitignore +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/LICENSE +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/README.md +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/registry.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/inpainter.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/models.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/segmenter.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/swapping/swapper.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/transforms.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/ai/video_analysis.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/audio/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/audio/analysis.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/audio/audio.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/combine.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/description.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/exceptions.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/progress.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/scene.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/text/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/text/overlay.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/text/transcription.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/transforms.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/transitions.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/base/utils.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/editing/multicam.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/editing/premiere_xml.py +0 -0
- {videopython-0.25.7 → videopython-0.26.0}/src/videopython/py.typed +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
|
-
from
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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 =
|
|
151
|
-
[self._overlay(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
244
|
+
return np.full(n_frames, max_sigma)
|
|
219
245
|
elif self.mode == "ascending":
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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
|
|