videopython 0.36.0__tar.gz → 0.36.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.
- {videopython-0.36.0 → videopython-0.36.1}/PKG-INFO +1 -1
- {videopython-0.36.0 → videopython-0.36.1}/pyproject.toml +1 -1
- videopython-0.36.1/src/videopython/editing/_easing.py +43 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/effects.py +140 -310
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/operation.py +29 -10
- {videopython-0.36.0 → videopython-0.36.1}/.gitignore +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/LICENSE +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/README.md +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/_device.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/transforms.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/audio/audio.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/description.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/exceptions.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/image_text.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/transcription.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/video.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/streaming.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/transcription_overlay.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/transforms.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/video_edit.py +0 -0
- {videopython-0.36.0 → videopython-0.36.1}/src/videopython/py.typed +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared easing curves for time-based effects.
|
|
2
|
+
|
|
3
|
+
Each function maps normalized progress ``t`` in ``[0, 1]`` to an eased value in
|
|
4
|
+
``[0, 1]``. Functions are vectorized -- pass a numpy array (e.g. an envelope of
|
|
5
|
+
per-frame progress) and get an array back. Used by effects that animate a
|
|
6
|
+
parameter over their window (``KenBurns``, ``PunchIn``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
EasingMode = Literal["linear", "ease_in", "ease_out", "ease_in_out"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ease_in(t: np.ndarray) -> np.ndarray:
|
|
19
|
+
"""Quadratic ease-in: starts slow, accelerates."""
|
|
20
|
+
return t * t
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ease_out(t: np.ndarray) -> np.ndarray:
|
|
24
|
+
"""Quadratic ease-out: starts fast, decelerates."""
|
|
25
|
+
return 1.0 - (1.0 - t) * (1.0 - t)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ease_in_out(t: np.ndarray) -> np.ndarray:
|
|
29
|
+
"""Quadratic ease-in-out: slow at both ends, fastest in the middle."""
|
|
30
|
+
return np.where(t < 0.5, 2.0 * t * t, 1.0 - 2.0 * (1.0 - t) * (1.0 - t))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ease(t: np.ndarray, mode: EasingMode) -> np.ndarray:
|
|
34
|
+
"""Apply the easing curve named by ``mode`` to ``t``."""
|
|
35
|
+
if mode == "linear":
|
|
36
|
+
return t
|
|
37
|
+
if mode == "ease_in":
|
|
38
|
+
return ease_in(t)
|
|
39
|
+
if mode == "ease_out":
|
|
40
|
+
return ease_out(t)
|
|
41
|
+
if mode == "ease_in_out":
|
|
42
|
+
return ease_in_out(t)
|
|
43
|
+
raise ValueError(f"Unknown easing mode: {mode!r}")
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"""Effect Operations.
|
|
2
2
|
|
|
3
3
|
An ``Effect`` is an ``Operation`` that preserves video shape and frame count.
|
|
4
|
-
Subclasses
|
|
5
|
-
|
|
6
|
-
for
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
Subclasses implement the streaming contract -- :meth:`Effect.process_frame`
|
|
5
|
+
(plus :meth:`Effect.streaming_init` for any precomputed state) -- which is the
|
|
6
|
+
single source of truth for the effect's pixel logic. The base
|
|
7
|
+
:meth:`Effect._apply` replays that contract over the in-memory frames, so
|
|
8
|
+
in-memory execution comes for free and cannot drift from streaming.
|
|
9
|
+
|
|
10
|
+
A few effects override the eager path deliberately: ``FullImageOverlay`` and
|
|
11
|
+
``Vignette`` keep a hand-written :meth:`Effect._apply` (extra validation /
|
|
12
|
+
batched vectorisation), and the audio effects (``Fade``, ``VolumeAdjust``)
|
|
13
|
+
override :meth:`Effect.apply` directly so the audio splice stays coherent with
|
|
14
|
+
the window. Anchored RGBA overlays (``TextOverlay``, ``ImageOverlay``) share
|
|
15
|
+
their placement and blend via :class:`_AnchoredOverlay`.
|
|
12
16
|
"""
|
|
13
17
|
|
|
14
18
|
from __future__ import annotations
|
|
@@ -26,6 +30,7 @@ from tqdm import tqdm
|
|
|
26
30
|
|
|
27
31
|
from videopython.base.description import BoundingBox
|
|
28
32
|
from videopython.base.fonts import load_font
|
|
33
|
+
from videopython.editing._easing import ease, ease_out
|
|
29
34
|
from videopython.editing.operation import Effect
|
|
30
35
|
|
|
31
36
|
if TYPE_CHECKING:
|
|
@@ -183,14 +188,6 @@ class Blur(Effect):
|
|
|
183
188
|
idx = min(frame_index, len(self._stream_sigmas) - 1)
|
|
184
189
|
return self._blur_frame(frame, self._stream_sigmas[idx])
|
|
185
190
|
|
|
186
|
-
def _apply(self, video: Video) -> Video:
|
|
187
|
-
n_frames = len(video.frames)
|
|
188
|
-
sigmas = self._compute_sigmas(n_frames)
|
|
189
|
-
logger.info(f"Applying {self.mode} blur...")
|
|
190
|
-
for i in tqdm(range(n_frames), desc="Blurring"):
|
|
191
|
-
video.frames[i] = self._blur_frame(video.frames[i], sigmas[i])
|
|
192
|
-
return video
|
|
193
|
-
|
|
194
191
|
|
|
195
192
|
class Zoom(Effect):
|
|
196
193
|
"""Progressively zooms into or out of the frame center over the clip duration."""
|
|
@@ -232,19 +229,6 @@ class Zoom(Effect):
|
|
|
232
229
|
cropped = frame[round(y) : round(y + h), round(x) : round(x + w)]
|
|
233
230
|
return cv2.resize(cropped, (width, height))
|
|
234
231
|
|
|
235
|
-
def _apply(self, video: Video) -> Video:
|
|
236
|
-
n_frames = len(video.frames)
|
|
237
|
-
width = video.metadata.width
|
|
238
|
-
height = video.metadata.height
|
|
239
|
-
crops = self._crop_sizes(n_frames, width, height)
|
|
240
|
-
for i in tqdm(range(n_frames), desc="Zooming"):
|
|
241
|
-
w, h = crops[i]
|
|
242
|
-
x = width / 2 - w / 2
|
|
243
|
-
y = height / 2 - h / 2
|
|
244
|
-
cropped = video.frames[i][round(y) : round(y + h), round(x) : round(x + w)]
|
|
245
|
-
video.frames[i] = cv2.resize(cropped, (width, height))
|
|
246
|
-
return video
|
|
247
|
-
|
|
248
232
|
|
|
249
233
|
class ColorGrading(Effect):
|
|
250
234
|
"""Adjusts color properties: brightness, contrast, saturation, and temperature."""
|
|
@@ -298,12 +282,6 @@ class ColorGrading(Effect):
|
|
|
298
282
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
299
283
|
return self._grade_frame(frame)
|
|
300
284
|
|
|
301
|
-
def _apply(self, video: Video) -> Video:
|
|
302
|
-
logger.info("Applying color grading...")
|
|
303
|
-
for i in tqdm(range(len(video.frames)), desc="Color grading"):
|
|
304
|
-
video.frames[i] = self._grade_frame(video.frames[i])
|
|
305
|
-
return video
|
|
306
|
-
|
|
307
285
|
|
|
308
286
|
class Vignette(Effect):
|
|
309
287
|
"""Darkens the edges of the frame, drawing attention to the center."""
|
|
@@ -398,18 +376,6 @@ class KenBurns(Effect):
|
|
|
398
376
|
raise ValueError(f"{name} extends beyond image bounds!")
|
|
399
377
|
return self
|
|
400
378
|
|
|
401
|
-
def _ease(self, t: float) -> float:
|
|
402
|
-
if self.easing == "linear":
|
|
403
|
-
return t
|
|
404
|
-
if self.easing == "ease_in":
|
|
405
|
-
return t * t
|
|
406
|
-
if self.easing == "ease_out":
|
|
407
|
-
return 1 - (1 - t) * (1 - t)
|
|
408
|
-
# ease_in_out
|
|
409
|
-
if t < 0.5:
|
|
410
|
-
return 2 * t * t
|
|
411
|
-
return 1 - 2 * (1 - t) * (1 - t)
|
|
412
|
-
|
|
413
379
|
def _crop_and_scale_frame(
|
|
414
380
|
self, frame: np.ndarray, x: int, y: int, crop_w: int, crop_h: int, target_w: int, target_h: int
|
|
415
381
|
) -> np.ndarray:
|
|
@@ -427,9 +393,9 @@ class KenBurns(Effect):
|
|
|
427
393
|
eh = int(self.end_region.height * height)
|
|
428
394
|
|
|
429
395
|
regions = np.empty((n_frames, 4), dtype=np.int32)
|
|
396
|
+
eased = ease(np.arange(n_frames, dtype=np.float64) / max(1, n_frames - 1), self.easing)
|
|
430
397
|
for i in range(n_frames):
|
|
431
|
-
|
|
432
|
-
et = self._ease(t)
|
|
398
|
+
et = float(eased[i])
|
|
433
399
|
crop_w = int(sw + (ew - sw) * et)
|
|
434
400
|
crop_h = int(sh + (eh - sh) * et)
|
|
435
401
|
x = max(0, min(int(sx + (ex - sx) * et), width - crop_w))
|
|
@@ -448,16 +414,6 @@ class KenBurns(Effect):
|
|
|
448
414
|
x, y, cw, ch = self._stream_regions[idx]
|
|
449
415
|
return self._crop_and_scale_frame(frame, x, y, cw, ch, self._stream_target_w, self._stream_target_h)
|
|
450
416
|
|
|
451
|
-
def _apply(self, video: Video) -> Video:
|
|
452
|
-
n_frames = len(video.frames)
|
|
453
|
-
height, width = video.frame_shape[:2]
|
|
454
|
-
regions = self._precompute_regions(n_frames, width, height)
|
|
455
|
-
logger.info("Applying Ken Burns effect...")
|
|
456
|
-
for i in tqdm(range(n_frames), desc="Ken Burns"):
|
|
457
|
-
x, y, cw, ch = regions[i]
|
|
458
|
-
video.frames[i] = self._crop_and_scale_frame(video.frames[i], x, y, cw, ch, width, height)
|
|
459
|
-
return video
|
|
460
|
-
|
|
461
417
|
|
|
462
418
|
def _compute_curve(t: np.ndarray, curve: str) -> np.ndarray:
|
|
463
419
|
if curve == "sqrt":
|
|
@@ -599,7 +555,121 @@ class VolumeAdjust(Effect):
|
|
|
599
555
|
np.clip(audio.data, -1.0, 1.0, out=audio.data)
|
|
600
556
|
|
|
601
557
|
|
|
602
|
-
class
|
|
558
|
+
class _AnchoredOverlay(Effect):
|
|
559
|
+
"""Shared base for anchored RGBA overlays (:class:`TextOverlay`, :class:`ImageOverlay`).
|
|
560
|
+
|
|
561
|
+
Owns anchored placement, off-frame clipping, and alpha blending, so the
|
|
562
|
+
eager and streaming paths share one ``_blend_params`` source of truth (the
|
|
563
|
+
parity-hole class of bug fixed in 0.34.1). Subclasses declare their own
|
|
564
|
+
``position``/``anchor`` field defaults and implement
|
|
565
|
+
:meth:`_overlay_for_frame` to produce the RGBA bitmap; everything
|
|
566
|
+
downstream is shared. It declares no ``op`` ``Literal``, so it is an
|
|
567
|
+
abstract intermediate and is never registered (see
|
|
568
|
+
``Operation.__pydantic_init_subclass__``).
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
position: tuple[float, float] = Field(
|
|
572
|
+
(0.5, 0.5),
|
|
573
|
+
description=(
|
|
574
|
+
"Where to place the overlay as normalized (x, y) coordinates. "
|
|
575
|
+
"(0, 0) = top-left corner, (1, 1) = bottom-right corner."
|
|
576
|
+
),
|
|
577
|
+
)
|
|
578
|
+
anchor: Literal["center", "top_left", "top_center", "bottom_center", "bottom_left", "bottom_right"] = Field(
|
|
579
|
+
"center",
|
|
580
|
+
description="Which point of the overlay box sits at the position coordinate.",
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
_stream_noop: bool = PrivateAttr(default=False)
|
|
584
|
+
_stream_alpha: np.ndarray | None = PrivateAttr(default=None)
|
|
585
|
+
_stream_rgb: np.ndarray | None = PrivateAttr(default=None)
|
|
586
|
+
_stream_dst: tuple[int, int, int, int] = PrivateAttr(default=(0, 0, 0, 0))
|
|
587
|
+
|
|
588
|
+
@model_validator(mode="after")
|
|
589
|
+
def _validate_position(self) -> _AnchoredOverlay:
|
|
590
|
+
if not (0.0 <= self.position[0] <= 1.0 and 0.0 <= self.position[1] <= 1.0):
|
|
591
|
+
raise ValueError("position values must be in range [0, 1]")
|
|
592
|
+
return self
|
|
593
|
+
|
|
594
|
+
def _overlay_for_frame(self, frame_width: int, frame_height: int) -> np.ndarray:
|
|
595
|
+
"""Return the RGBA (uint8) bitmap to composite onto a ``frame_width`` x ``frame_height`` frame.
|
|
596
|
+
|
|
597
|
+
``TextOverlay`` renders text; ``ImageOverlay`` resizes / rasterises its
|
|
598
|
+
source. Implemented by subclasses.
|
|
599
|
+
"""
|
|
600
|
+
raise NotImplementedError(f"{type(self).__name__}._overlay_for_frame not implemented")
|
|
601
|
+
|
|
602
|
+
def _overlay_opacity(self) -> float:
|
|
603
|
+
"""Extra opacity multiplier applied to the overlay's own alpha. Default: opaque."""
|
|
604
|
+
return 1.0
|
|
605
|
+
|
|
606
|
+
def _compute_position(self, frame_width: int, frame_height: int, img_w: int, img_h: int) -> tuple[int, int]:
|
|
607
|
+
px = int(self.position[0] * frame_width)
|
|
608
|
+
py = int(self.position[1] * frame_height)
|
|
609
|
+
|
|
610
|
+
if self.anchor == "center":
|
|
611
|
+
return px - img_w // 2, py - img_h // 2
|
|
612
|
+
if self.anchor == "top_left":
|
|
613
|
+
return px, py
|
|
614
|
+
if self.anchor == "top_center":
|
|
615
|
+
return px - img_w // 2, py
|
|
616
|
+
if self.anchor == "bottom_center":
|
|
617
|
+
return px - img_w // 2, py - img_h
|
|
618
|
+
if self.anchor == "bottom_left":
|
|
619
|
+
return px, py - img_h
|
|
620
|
+
# bottom_right
|
|
621
|
+
return px - img_w, py - img_h
|
|
622
|
+
|
|
623
|
+
def _blend_params(
|
|
624
|
+
self, frame_w: int, frame_h: int
|
|
625
|
+
) -> tuple[np.ndarray, np.ndarray, tuple[int, int, int, int]] | None:
|
|
626
|
+
"""Placement + blend inputs shared by the eager and streaming paths.
|
|
627
|
+
|
|
628
|
+
Single source of truth so the two paths cannot drift -- the eager/stream
|
|
629
|
+
parity-hole class of bug fixed in 0.34.1. Returns ``None`` when the
|
|
630
|
+
overlay lands fully off-frame (the effect is a no-op).
|
|
631
|
+
"""
|
|
632
|
+
overlay = self._overlay_for_frame(frame_w, frame_h)
|
|
633
|
+
oh, ow = overlay.shape[:2]
|
|
634
|
+
x, y = self._compute_position(frame_w, frame_h, ow, oh)
|
|
635
|
+
|
|
636
|
+
src_x = max(0, -x)
|
|
637
|
+
src_y = max(0, -y)
|
|
638
|
+
dst_x = max(0, x)
|
|
639
|
+
dst_y = max(0, y)
|
|
640
|
+
paste_w = min(ow - src_x, frame_w - dst_x)
|
|
641
|
+
paste_h = min(oh - src_y, frame_h - dst_y)
|
|
642
|
+
|
|
643
|
+
if paste_w <= 0 or paste_h <= 0:
|
|
644
|
+
return None
|
|
645
|
+
|
|
646
|
+
region = overlay[src_y : src_y + paste_h, src_x : src_x + paste_w]
|
|
647
|
+
alpha = (region[:, :, 3:4].astype(np.float32) / 255.0) * self._overlay_opacity()
|
|
648
|
+
rgb = region[:, :, :3].astype(np.float32)
|
|
649
|
+
return alpha, rgb, (dst_y, dst_x, paste_h, paste_w)
|
|
650
|
+
|
|
651
|
+
def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
|
|
652
|
+
params = self._blend_params(width, height)
|
|
653
|
+
if params is None:
|
|
654
|
+
self._stream_noop = True
|
|
655
|
+
return
|
|
656
|
+
self._stream_noop = False
|
|
657
|
+
self._stream_alpha, self._stream_rgb, self._stream_dst = params
|
|
658
|
+
|
|
659
|
+
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
660
|
+
if self._stream_noop:
|
|
661
|
+
return frame
|
|
662
|
+
assert self._stream_alpha is not None and self._stream_rgb is not None
|
|
663
|
+
dy, dx, ph, pw = self._stream_dst
|
|
664
|
+
region = frame[dy : dy + ph, dx : dx + pw]
|
|
665
|
+
blended = (
|
|
666
|
+
self._stream_rgb * self._stream_alpha + region.astype(np.float32) * (1.0 - self._stream_alpha)
|
|
667
|
+
).astype(np.uint8)
|
|
668
|
+
frame[dy : dy + ph, dx : dx + pw] = blended
|
|
669
|
+
return frame
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
class TextOverlay(_AnchoredOverlay):
|
|
603
673
|
"""Draws text on video frames, with auto word-wrap and optional background box."""
|
|
604
674
|
|
|
605
675
|
op: Literal["text_overlay"] = "text_overlay"
|
|
@@ -651,16 +721,6 @@ class TextOverlay(Effect):
|
|
|
651
721
|
)
|
|
652
722
|
|
|
653
723
|
_rendered: np.ndarray | None = PrivateAttr(default=None)
|
|
654
|
-
_stream_noop: bool = PrivateAttr(default=False)
|
|
655
|
-
_stream_alpha: np.ndarray | None = PrivateAttr(default=None)
|
|
656
|
-
_stream_rgb: np.ndarray | None = PrivateAttr(default=None)
|
|
657
|
-
_stream_dst: tuple[int, int, int, int] = PrivateAttr(default=(0, 0, 0, 0))
|
|
658
|
-
|
|
659
|
-
@model_validator(mode="after")
|
|
660
|
-
def _validate_position(self) -> TextOverlay:
|
|
661
|
-
if not (0.0 <= self.position[0] <= 1.0 and 0.0 <= self.position[1] <= 1.0):
|
|
662
|
-
raise ValueError("position values must be in range [0, 1]")
|
|
663
|
-
return self
|
|
664
724
|
|
|
665
725
|
def _get_font(self) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
666
726
|
return load_font(self.font_filename or self.font, self.font_size)
|
|
@@ -709,88 +769,13 @@ class TextOverlay(Effect):
|
|
|
709
769
|
|
|
710
770
|
return np.array(img, dtype=np.uint8)
|
|
711
771
|
|
|
712
|
-
def
|
|
713
|
-
px = int(self.position[0] * frame_width)
|
|
714
|
-
py = int(self.position[1] * frame_height)
|
|
715
|
-
|
|
716
|
-
if self.anchor == "center":
|
|
717
|
-
return px - img_w // 2, py - img_h // 2
|
|
718
|
-
if self.anchor == "top_left":
|
|
719
|
-
return px, py
|
|
720
|
-
if self.anchor == "top_center":
|
|
721
|
-
return px - img_w // 2, py
|
|
722
|
-
if self.anchor == "bottom_center":
|
|
723
|
-
return px - img_w // 2, py - img_h
|
|
724
|
-
if self.anchor == "bottom_left":
|
|
725
|
-
return px, py - img_h
|
|
726
|
-
# bottom_right
|
|
727
|
-
return px - img_w, py - img_h
|
|
728
|
-
|
|
729
|
-
def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
|
|
730
|
-
if self._rendered is None:
|
|
731
|
-
self._rendered = self._render_text_image(width, height)
|
|
732
|
-
oh, ow = self._rendered.shape[:2]
|
|
733
|
-
x, y = self._compute_position(width, height, ow, oh)
|
|
734
|
-
src_x = max(0, -x)
|
|
735
|
-
src_y = max(0, -y)
|
|
736
|
-
dst_x = max(0, x)
|
|
737
|
-
dst_y = max(0, y)
|
|
738
|
-
paste_w = min(ow - src_x, width - dst_x)
|
|
739
|
-
paste_h = min(oh - src_y, height - dst_y)
|
|
740
|
-
if paste_w <= 0 or paste_h <= 0:
|
|
741
|
-
self._stream_noop = True
|
|
742
|
-
return
|
|
743
|
-
self._stream_noop = False
|
|
744
|
-
overlay_region = self._rendered[src_y : src_y + paste_h, src_x : src_x + paste_w]
|
|
745
|
-
self._stream_alpha = overlay_region[:, :, 3:4].astype(np.float32) / 255.0
|
|
746
|
-
self._stream_rgb = overlay_region[:, :, :3].astype(np.float32)
|
|
747
|
-
self._stream_dst = (dst_y, dst_x, paste_h, paste_w)
|
|
748
|
-
|
|
749
|
-
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
750
|
-
if self._stream_noop:
|
|
751
|
-
return frame
|
|
752
|
-
assert self._stream_alpha is not None and self._stream_rgb is not None
|
|
753
|
-
dy, dx, ph, pw = self._stream_dst
|
|
754
|
-
region = frame[dy : dy + ph, dx : dx + pw]
|
|
755
|
-
blended = (
|
|
756
|
-
self._stream_rgb * self._stream_alpha + region.astype(np.float32) * (1.0 - self._stream_alpha)
|
|
757
|
-
).astype(np.uint8)
|
|
758
|
-
frame[dy : dy + ph, dx : dx + pw] = blended
|
|
759
|
-
return frame
|
|
760
|
-
|
|
761
|
-
def _apply(self, video: Video) -> Video:
|
|
762
|
-
frame_h, frame_w = video.frame_shape[:2]
|
|
772
|
+
def _overlay_for_frame(self, frame_width: int, frame_height: int) -> np.ndarray:
|
|
763
773
|
if self._rendered is None:
|
|
764
|
-
self._rendered = self._render_text_image(
|
|
774
|
+
self._rendered = self._render_text_image(frame_width, frame_height)
|
|
775
|
+
return self._rendered
|
|
765
776
|
|
|
766
|
-
overlay_rgba = self._rendered
|
|
767
|
-
oh, ow = overlay_rgba.shape[:2]
|
|
768
|
-
x, y = self._compute_position(frame_w, frame_h, ow, oh)
|
|
769
777
|
|
|
770
|
-
|
|
771
|
-
src_y = max(0, -y)
|
|
772
|
-
dst_x = max(0, x)
|
|
773
|
-
dst_y = max(0, y)
|
|
774
|
-
paste_w = min(ow - src_x, frame_w - dst_x)
|
|
775
|
-
paste_h = min(oh - src_y, frame_h - dst_y)
|
|
776
|
-
|
|
777
|
-
if paste_w <= 0 or paste_h <= 0:
|
|
778
|
-
return video
|
|
779
|
-
|
|
780
|
-
overlay_region = overlay_rgba[src_y : src_y + paste_h, src_x : src_x + paste_w]
|
|
781
|
-
alpha = overlay_region[:, :, 3:4].astype(np.float32) / 255.0
|
|
782
|
-
overlay_rgb = overlay_region[:, :, :3].astype(np.float32)
|
|
783
|
-
|
|
784
|
-
logger.info("Applying text overlay...")
|
|
785
|
-
for frame in tqdm(video.frames, desc="Text overlay"):
|
|
786
|
-
region = frame[dst_y : dst_y + paste_h, dst_x : dst_x + paste_w]
|
|
787
|
-
blended = (overlay_rgb * alpha + region.astype(np.float32) * (1.0 - alpha)).astype(np.uint8)
|
|
788
|
-
frame[dst_y : dst_y + paste_h, dst_x : dst_x + paste_w] = blended
|
|
789
|
-
|
|
790
|
-
return video
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
class ImageOverlay(Effect):
|
|
778
|
+
class ImageOverlay(_AnchoredOverlay):
|
|
794
779
|
"""Composites a scaled image at an anchored position on every frame in the window.
|
|
795
780
|
|
|
796
781
|
A resolution-independent watermark / logo / brand mark. Unlike
|
|
@@ -850,16 +835,6 @@ class ImageOverlay(Effect):
|
|
|
850
835
|
|
|
851
836
|
_overlay_rgba: np.ndarray | None = PrivateAttr(default=None)
|
|
852
837
|
_svg_cache: dict[int, np.ndarray] = PrivateAttr(default_factory=dict)
|
|
853
|
-
_stream_noop: bool = PrivateAttr(default=False)
|
|
854
|
-
_stream_alpha: np.ndarray | None = PrivateAttr(default=None)
|
|
855
|
-
_stream_rgb: np.ndarray | None = PrivateAttr(default=None)
|
|
856
|
-
_stream_dst: tuple[int, int, int, int] = PrivateAttr(default=(0, 0, 0, 0))
|
|
857
|
-
|
|
858
|
-
@model_validator(mode="after")
|
|
859
|
-
def _validate_position(self) -> ImageOverlay:
|
|
860
|
-
if not (0.0 <= self.position[0] <= 1.0 and 0.0 <= self.position[1] <= 1.0):
|
|
861
|
-
raise ValueError("position values must be in range [0, 1]")
|
|
862
|
-
return self
|
|
863
838
|
|
|
864
839
|
def _is_svg(self) -> bool:
|
|
865
840
|
return self.source.suffix.lower() == ".svg"
|
|
@@ -909,25 +884,6 @@ class ImageOverlay(Effect):
|
|
|
909
884
|
self._overlay_rgba = np.array(img, dtype=np.uint8)
|
|
910
885
|
return self._overlay_rgba
|
|
911
886
|
|
|
912
|
-
def _compute_position(self, frame_width: int, frame_height: int, img_w: int, img_h: int) -> tuple[int, int]:
|
|
913
|
-
# Copied verbatim from TextOverlay: ImageOverlay's anchor Literal is
|
|
914
|
-
# deliberately the same set, so the geometry is shared by construction.
|
|
915
|
-
px = int(self.position[0] * frame_width)
|
|
916
|
-
py = int(self.position[1] * frame_height)
|
|
917
|
-
|
|
918
|
-
if self.anchor == "center":
|
|
919
|
-
return px - img_w // 2, py - img_h // 2
|
|
920
|
-
if self.anchor == "top_left":
|
|
921
|
-
return px, py
|
|
922
|
-
if self.anchor == "top_center":
|
|
923
|
-
return px - img_w // 2, py
|
|
924
|
-
if self.anchor == "bottom_center":
|
|
925
|
-
return px - img_w // 2, py - img_h
|
|
926
|
-
if self.anchor == "bottom_left":
|
|
927
|
-
return px, py - img_h
|
|
928
|
-
# bottom_right
|
|
929
|
-
return px - img_w, py - img_h
|
|
930
|
-
|
|
931
887
|
def _resized_overlay(self, frame_w: int) -> np.ndarray:
|
|
932
888
|
target_w = max(1, round(self.scale * frame_w))
|
|
933
889
|
if self._is_svg():
|
|
@@ -942,67 +898,11 @@ class ImageOverlay(Effect):
|
|
|
942
898
|
resized = Image.fromarray(overlay).resize((target_w, target_h), Image.LANCZOS)
|
|
943
899
|
return np.array(resized, dtype=np.uint8)
|
|
944
900
|
|
|
945
|
-
def
|
|
946
|
-
self
|
|
947
|
-
) -> tuple[np.ndarray, np.ndarray, tuple[int, int, int, int]] | None:
|
|
948
|
-
"""Placement + blend inputs shared by the eager and streaming paths.
|
|
901
|
+
def _overlay_for_frame(self, frame_width: int, frame_height: int) -> np.ndarray:
|
|
902
|
+
return self._resized_overlay(frame_width)
|
|
949
903
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
when the overlay lands fully off-frame (the effect is a no-op).
|
|
953
|
-
"""
|
|
954
|
-
overlay = self._resized_overlay(frame_w)
|
|
955
|
-
oh, ow = overlay.shape[:2]
|
|
956
|
-
x, y = self._compute_position(frame_w, frame_h, ow, oh)
|
|
957
|
-
|
|
958
|
-
src_x = max(0, -x)
|
|
959
|
-
src_y = max(0, -y)
|
|
960
|
-
dst_x = max(0, x)
|
|
961
|
-
dst_y = max(0, y)
|
|
962
|
-
paste_w = min(ow - src_x, frame_w - dst_x)
|
|
963
|
-
paste_h = min(oh - src_y, frame_h - dst_y)
|
|
964
|
-
|
|
965
|
-
if paste_w <= 0 or paste_h <= 0:
|
|
966
|
-
return None
|
|
967
|
-
|
|
968
|
-
region = overlay[src_y : src_y + paste_h, src_x : src_x + paste_w]
|
|
969
|
-
alpha = (region[:, :, 3:4].astype(np.float32) / 255.0) * self.opacity
|
|
970
|
-
rgb = region[:, :, :3].astype(np.float32)
|
|
971
|
-
return alpha, rgb, (dst_y, dst_x, paste_h, paste_w)
|
|
972
|
-
|
|
973
|
-
def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
|
|
974
|
-
params = self._blend_params(width, height)
|
|
975
|
-
if params is None:
|
|
976
|
-
self._stream_noop = True
|
|
977
|
-
return
|
|
978
|
-
self._stream_noop = False
|
|
979
|
-
self._stream_alpha, self._stream_rgb, self._stream_dst = params
|
|
980
|
-
|
|
981
|
-
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
982
|
-
if self._stream_noop:
|
|
983
|
-
return frame
|
|
984
|
-
assert self._stream_alpha is not None and self._stream_rgb is not None
|
|
985
|
-
dy, dx, ph, pw = self._stream_dst
|
|
986
|
-
region = frame[dy : dy + ph, dx : dx + pw]
|
|
987
|
-
blended = (
|
|
988
|
-
self._stream_rgb * self._stream_alpha + region.astype(np.float32) * (1.0 - self._stream_alpha)
|
|
989
|
-
).astype(np.uint8)
|
|
990
|
-
frame[dy : dy + ph, dx : dx + pw] = blended
|
|
991
|
-
return frame
|
|
992
|
-
|
|
993
|
-
def _apply(self, video: Video) -> Video:
|
|
994
|
-
frame_h, frame_w = video.frame_shape[:2]
|
|
995
|
-
params = self._blend_params(frame_w, frame_h)
|
|
996
|
-
if params is None:
|
|
997
|
-
return video
|
|
998
|
-
alpha, rgb, (dy, dx, ph, pw) = params
|
|
999
|
-
|
|
1000
|
-
logger.info("Applying image overlay...")
|
|
1001
|
-
for frame in tqdm(video.frames, desc="Image overlay"):
|
|
1002
|
-
region = frame[dy : dy + ph, dx : dx + pw]
|
|
1003
|
-
blended = (rgb * alpha + region.astype(np.float32) * (1.0 - alpha)).astype(np.uint8)
|
|
1004
|
-
frame[dy : dy + ph, dx : dx + pw] = blended
|
|
1005
|
-
return video
|
|
904
|
+
def _overlay_opacity(self) -> float:
|
|
905
|
+
return self.opacity
|
|
1006
906
|
|
|
1007
907
|
|
|
1008
908
|
class Shake(Effect):
|
|
@@ -1070,13 +970,6 @@ class Shake(Effect):
|
|
|
1070
970
|
dx, dy = self._stream_offsets[idx]
|
|
1071
971
|
return self._shake_frame(frame, float(dx), float(dy))
|
|
1072
972
|
|
|
1073
|
-
def _apply(self, video: Video) -> Video:
|
|
1074
|
-
offsets = self._compute_offsets(len(video.frames), video.fps)
|
|
1075
|
-
for i in tqdm(range(len(video.frames)), desc="Shaking"):
|
|
1076
|
-
dx, dy = offsets[i]
|
|
1077
|
-
video.frames[i] = self._shake_frame(video.frames[i], float(dx), float(dy))
|
|
1078
|
-
return video
|
|
1079
|
-
|
|
1080
973
|
|
|
1081
974
|
class PunchIn(Effect):
|
|
1082
975
|
"""Snap-zoom emphasis: rapidly zooms into the center, holds, optionally releases.
|
|
@@ -1113,13 +1006,11 @@ class PunchIn(Effect):
|
|
|
1113
1006
|
attack = min(self.attack_frames, n_frames)
|
|
1114
1007
|
if attack > 0:
|
|
1115
1008
|
t = np.linspace(0.0, 1.0, attack, dtype=np.float32)
|
|
1116
|
-
|
|
1117
|
-
zooms[:attack] = 1.0 + (self.zoom_factor - 1.0) * ease
|
|
1009
|
+
zooms[:attack] = 1.0 + (self.zoom_factor - 1.0) * ease_out(t)
|
|
1118
1010
|
release = min(self.release_frames, n_frames - attack)
|
|
1119
1011
|
if release > 0:
|
|
1120
1012
|
t = np.linspace(1.0, 0.0, release, dtype=np.float32)
|
|
1121
|
-
|
|
1122
|
-
zooms[-release:] = 1.0 + (self.zoom_factor - 1.0) * ease
|
|
1013
|
+
zooms[-release:] = 1.0 + (self.zoom_factor - 1.0) * ease_out(t)
|
|
1123
1014
|
return zooms
|
|
1124
1015
|
|
|
1125
1016
|
def _zoom_frame(self, frame: np.ndarray, zoom: float, width: int, height: int) -> np.ndarray:
|
|
@@ -1142,14 +1033,6 @@ class PunchIn(Effect):
|
|
|
1142
1033
|
idx = min(frame_index, len(self._stream_zooms) - 1)
|
|
1143
1034
|
return self._zoom_frame(frame, float(self._stream_zooms[idx]), self._stream_width, self._stream_height)
|
|
1144
1035
|
|
|
1145
|
-
def _apply(self, video: Video) -> Video:
|
|
1146
|
-
n = len(video.frames)
|
|
1147
|
-
height, width = video.frame_shape[:2]
|
|
1148
|
-
zooms = self._zoom_envelope(n)
|
|
1149
|
-
for i in tqdm(range(n), desc="Punching in"):
|
|
1150
|
-
video.frames[i] = self._zoom_frame(video.frames[i], float(zooms[i]), width, height)
|
|
1151
|
-
return video
|
|
1152
|
-
|
|
1153
1036
|
|
|
1154
1037
|
class Flash(Effect):
|
|
1155
1038
|
"""Solid-color frame flash that fades in over ``attack_frames`` and out over ``decay_frames``.
|
|
@@ -1215,17 +1098,6 @@ class Flash(Effect):
|
|
|
1215
1098
|
return frame
|
|
1216
1099
|
return (frame.astype(np.float32) * (1.0 - a) + self._stream_color * a).astype(np.uint8)
|
|
1217
1100
|
|
|
1218
|
-
def _apply(self, video: Video) -> Video:
|
|
1219
|
-
n = len(video.frames)
|
|
1220
|
-
alpha = self._alpha_envelope(n)
|
|
1221
|
-
color = np.array(self.color, dtype=np.float32)
|
|
1222
|
-
for i in tqdm(range(n), desc="Flashing"):
|
|
1223
|
-
a = float(alpha[i])
|
|
1224
|
-
if a <= 0:
|
|
1225
|
-
continue
|
|
1226
|
-
video.frames[i] = (video.frames[i].astype(np.float32) * (1.0 - a) + color * a).astype(np.uint8)
|
|
1227
|
-
return video
|
|
1228
|
-
|
|
1229
1101
|
|
|
1230
1102
|
class ChromaticAberration(Effect):
|
|
1231
1103
|
"""Splits R and B channels by ``shift_px`` to mimic lens chromatic aberration.
|
|
@@ -1293,14 +1165,6 @@ class ChromaticAberration(Effect):
|
|
|
1293
1165
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1294
1166
|
return self._aberrate(frame)
|
|
1295
1167
|
|
|
1296
|
-
def _apply(self, video: Video) -> Video:
|
|
1297
|
-
h, w = video.frame_shape[:2]
|
|
1298
|
-
if self.mode == "radial":
|
|
1299
|
-
self._stream_maps = self._build_radial_maps(w, h)
|
|
1300
|
-
for i in tqdm(range(len(video.frames)), desc="Chromatic aberration"):
|
|
1301
|
-
video.frames[i] = self._aberrate(video.frames[i])
|
|
1302
|
-
return video
|
|
1303
|
-
|
|
1304
1168
|
|
|
1305
1169
|
class Glitch(Effect):
|
|
1306
1170
|
"""Random horizontal slice displacement + channel offsets for a digital-corruption look.
|
|
@@ -1363,11 +1227,6 @@ class Glitch(Effect):
|
|
|
1363
1227
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1364
1228
|
return self._glitch_frame(frame, frame_index)
|
|
1365
1229
|
|
|
1366
|
-
def _apply(self, video: Video) -> Video:
|
|
1367
|
-
for i in tqdm(range(len(video.frames)), desc="Glitching"):
|
|
1368
|
-
video.frames[i] = self._glitch_frame(video.frames[i], i)
|
|
1369
|
-
return video
|
|
1370
|
-
|
|
1371
1230
|
|
|
1372
1231
|
class FilmGrain(Effect):
|
|
1373
1232
|
"""Additive Gaussian noise simulating film grain.
|
|
@@ -1408,11 +1267,6 @@ class FilmGrain(Effect):
|
|
|
1408
1267
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1409
1268
|
return self._grain_frame(frame, frame_index)
|
|
1410
1269
|
|
|
1411
|
-
def _apply(self, video: Video) -> Video:
|
|
1412
|
-
for i in tqdm(range(len(video.frames)), desc="Adding grain"):
|
|
1413
|
-
video.frames[i] = self._grain_frame(video.frames[i], i)
|
|
1414
|
-
return video
|
|
1415
|
-
|
|
1416
1270
|
|
|
1417
1271
|
class Sharpen(Effect):
|
|
1418
1272
|
"""Unsharp-mask sharpening: blur the frame and subtract from itself with weight.
|
|
@@ -1455,11 +1309,6 @@ class Sharpen(Effect):
|
|
|
1455
1309
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1456
1310
|
return self._sharpen_frame(frame)
|
|
1457
1311
|
|
|
1458
|
-
def _apply(self, video: Video) -> Video:
|
|
1459
|
-
for i in tqdm(range(len(video.frames)), desc="Sharpening"):
|
|
1460
|
-
video.frames[i] = self._sharpen_frame(video.frames[i])
|
|
1461
|
-
return video
|
|
1462
|
-
|
|
1463
1312
|
|
|
1464
1313
|
class Pixelate(Effect):
|
|
1465
1314
|
"""Mosaic blocks: downscale + nearest-neighbour upscale, optionally limited to a region.
|
|
@@ -1508,13 +1357,6 @@ class Pixelate(Effect):
|
|
|
1508
1357
|
assert self._stream_region_px is not None
|
|
1509
1358
|
return self._pixelate_frame(frame, self._stream_region_px)
|
|
1510
1359
|
|
|
1511
|
-
def _apply(self, video: Video) -> Video:
|
|
1512
|
-
h, w = video.frame_shape[:2]
|
|
1513
|
-
region = self._resolve_region(w, h)
|
|
1514
|
-
for i in tqdm(range(len(video.frames)), desc="Pixelating"):
|
|
1515
|
-
video.frames[i] = self._pixelate_frame(video.frames[i], region)
|
|
1516
|
-
return video
|
|
1517
|
-
|
|
1518
1360
|
|
|
1519
1361
|
class MirrorFlip(Effect):
|
|
1520
1362
|
"""Flip frames or reflect one half onto the other.
|
|
@@ -1569,11 +1411,6 @@ class MirrorFlip(Effect):
|
|
|
1569
1411
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1570
1412
|
return self._flip_frame(frame)
|
|
1571
1413
|
|
|
1572
|
-
def _apply(self, video: Video) -> Video:
|
|
1573
|
-
for i in tqdm(range(len(video.frames)), desc="Mirroring"):
|
|
1574
|
-
video.frames[i] = self._flip_frame(video.frames[i])
|
|
1575
|
-
return video
|
|
1576
|
-
|
|
1577
1414
|
|
|
1578
1415
|
class Kaleidoscope(Effect):
|
|
1579
1416
|
"""N-way radial mirror around the frame center.
|
|
@@ -1633,10 +1470,3 @@ class Kaleidoscope(Effect):
|
|
|
1633
1470
|
|
|
1634
1471
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1635
1472
|
return self._kaleidoscope_frame(frame)
|
|
1636
|
-
|
|
1637
|
-
def _apply(self, video: Video) -> Video:
|
|
1638
|
-
h, w = video.frame_shape[:2]
|
|
1639
|
-
self._stream_map_x, self._stream_map_y = self._build_maps(w, h)
|
|
1640
|
-
for i in tqdm(range(len(video.frames)), desc="Kaleidoscope"):
|
|
1641
|
-
video.frames[i] = self._kaleidoscope_frame(video.frames[i])
|
|
1642
|
-
return video
|
|
@@ -36,6 +36,7 @@ from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, Union, get_
|
|
|
36
36
|
|
|
37
37
|
import numpy as np
|
|
38
38
|
from pydantic import BaseModel, ConfigDict, Discriminator, Field, TypeAdapter, model_validator
|
|
39
|
+
from tqdm import tqdm
|
|
39
40
|
|
|
40
41
|
if TYPE_CHECKING:
|
|
41
42
|
from videopython.base.video import Video, VideoMetadata
|
|
@@ -262,14 +263,22 @@ class Operation(BaseModel):
|
|
|
262
263
|
|
|
263
264
|
|
|
264
265
|
class Effect(Operation):
|
|
265
|
-
"""Operation that preserves shape and frame count,
|
|
266
|
-
|
|
267
|
-
Subclasses
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
:meth:`
|
|
271
|
-
|
|
272
|
-
|
|
266
|
+
"""Operation that preserves shape and frame count, driven by per-frame streaming.
|
|
267
|
+
|
|
268
|
+
Subclasses implement the streaming contract -- :meth:`process_frame` (and
|
|
269
|
+
:meth:`streaming_init` for any precomputed per-stream state) -- which is the
|
|
270
|
+
single source of truth for the effect's pixel logic. The base
|
|
271
|
+
:meth:`_apply` runs that same contract over the in-memory frames, so
|
|
272
|
+
in-memory execution comes for free; the same code path feeds
|
|
273
|
+
``editing/streaming.py`` for bounded-memory streaming. The base
|
|
274
|
+
:meth:`apply` resolves :attr:`window`, slices the video, runs ``_apply`` on
|
|
275
|
+
the slice, splices the result back, and asserts the shape-preserving
|
|
276
|
+
invariant.
|
|
277
|
+
|
|
278
|
+
Override :meth:`_apply` only when eager execution must genuinely differ from
|
|
279
|
+
a frame-by-frame replay -- e.g. extra validation, a batched vectorisation,
|
|
280
|
+
or audio handling (``Fade``/``VolumeAdjust`` override :meth:`apply` outright
|
|
281
|
+
so the audio splice stays coherent with the window).
|
|
273
282
|
"""
|
|
274
283
|
|
|
275
284
|
category: ClassVar[OpCategory] = OpCategory.EFFECT
|
|
@@ -325,8 +334,18 @@ class Effect(Operation):
|
|
|
325
334
|
return start_s, stop_s
|
|
326
335
|
|
|
327
336
|
def _apply(self, video: Video) -> Video:
|
|
328
|
-
"""Apply the effect to ``video`` in memory
|
|
329
|
-
|
|
337
|
+
"""Apply the effect to ``video`` in memory by replaying the streaming path.
|
|
338
|
+
|
|
339
|
+
Runs :meth:`streaming_init` once, then :meth:`process_frame` over every
|
|
340
|
+
frame in order -- the same logic streaming uses, so eager and streaming
|
|
341
|
+
cannot drift. Subclasses that need a genuinely different eager path
|
|
342
|
+
(extra validation, batched vectorisation) override this.
|
|
343
|
+
"""
|
|
344
|
+
height, width = video.frame_shape[:2]
|
|
345
|
+
self.streaming_init(len(video.frames), video.fps, width, height)
|
|
346
|
+
for i in tqdm(range(len(video.frames)), desc=type(self).__name__):
|
|
347
|
+
video.frames[i] = self.process_frame(video.frames[i], i)
|
|
348
|
+
return video
|
|
330
349
|
|
|
331
350
|
def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
|
|
332
351
|
"""Hook for per-stream precomputation (per-frame alphas, sigma curves...).
|
|
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
|
|
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
|
|
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
|