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.
Files changed (69) hide show
  1. {videopython-0.36.0 → videopython-0.36.1}/PKG-INFO +1 -1
  2. {videopython-0.36.0 → videopython-0.36.1}/pyproject.toml +1 -1
  3. videopython-0.36.1/src/videopython/editing/_easing.py +43 -0
  4. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/effects.py +140 -310
  5. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/operation.py +29 -10
  6. {videopython-0.36.0 → videopython-0.36.1}/.gitignore +0 -0
  7. {videopython-0.36.0 → videopython-0.36.1}/LICENSE +0 -0
  8. {videopython-0.36.0 → videopython-0.36.1}/README.md +0 -0
  9. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/__init__.py +0 -0
  10. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/__init__.py +0 -0
  11. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/_device.py +0 -0
  12. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/__init__.py +0 -0
  13. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/config.py +0 -0
  14. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/dubber.py +0 -0
  15. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  16. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/loudness.py +0 -0
  17. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/models.py +0 -0
  18. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/pipeline.py +0 -0
  19. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/quality.py +0 -0
  20. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/remux.py +0 -0
  21. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/timing.py +0 -0
  22. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  23. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/__init__.py +0 -0
  24. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/audio.py +0 -0
  25. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/image.py +0 -0
  26. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/qwen3.py +0 -0
  27. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/translation.py +0 -0
  28. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/generation/video.py +0 -0
  29. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/transforms.py +0 -0
  30. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/__init__.py +0 -0
  31. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/audio.py +0 -0
  32. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/faces.py +0 -0
  33. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/image.py +0 -0
  34. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/separation.py +0 -0
  35. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/understanding/temporal.py +0 -0
  36. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/__init__.py +0 -0
  37. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  38. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/models.py +0 -0
  39. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/sampling.py +0 -0
  40. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/ai/video_analysis/stages.py +0 -0
  41. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/audio/__init__.py +0 -0
  42. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/audio/analysis.py +0 -0
  43. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/audio/audio.py +0 -0
  44. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/__init__.py +0 -0
  45. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/_dimensions.py +0 -0
  46. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/_ffmpeg.py +0 -0
  47. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/_video_io.py +0 -0
  48. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/description.py +0 -0
  49. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/exceptions.py +0 -0
  50. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  51. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  52. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  53. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  54. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  55. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  56. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  57. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  58. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  59. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  60. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/fonts/__init__.py +0 -0
  61. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/image_text.py +0 -0
  62. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/transcription.py +0 -0
  63. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/base/video.py +0 -0
  64. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/__init__.py +0 -0
  65. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/streaming.py +0 -0
  66. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/transcription_overlay.py +0 -0
  67. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/transforms.py +0 -0
  68. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/editing/video_edit.py +0 -0
  69. {videopython-0.36.0 → videopython-0.36.1}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.36.0
3
+ Version: 0.36.1
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.36.0"
3
+ version = "0.36.1"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -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 override :meth:`Effect._apply` for in-memory execution and may
5
- additionally override :meth:`Effect.streaming_init` / :meth:`Effect.process_frame`
6
- for bounded-memory streaming via ``editing/streaming.py``.
7
-
8
- Effects that need to modify audio (``Fade``, ``VolumeAdjust``) override
9
- :meth:`Effect.apply` directly so the audio splice can stay coherent with the
10
- window the base ``Effect.apply`` only splices frames, restoring the original
11
- audio after ``_apply`` returns.
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
- t = i / max(1, n_frames - 1)
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 TextOverlay(Effect):
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 _compute_position(self, frame_width: int, frame_height: int, img_w: int, img_h: int) -> tuple[int, int]:
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(frame_w, frame_h)
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
- src_x = max(0, -x)
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 _blend_params(
946
- self, frame_w: int, frame_h: int
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
- Single source of truth so the two paths cannot drift -- the
951
- eager/stream parity-hole class of bug fixed in 0.34.1. Returns ``None``
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
- ease = 1.0 - (1.0 - t) * (1.0 - t) # ease_out
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
- ease = 1.0 - (1.0 - t) * (1.0 - t)
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, with optional streaming.
266
-
267
- Subclasses override :meth:`_apply` for in-memory execution and may
268
- additionally override :meth:`streaming_init` / :meth:`process_frame` for
269
- bounded-memory streaming via ``editing/streaming.py``. The base
270
- :meth:`apply` resolves :attr:`window`, slices the video, runs
271
- ``_apply`` on the slice, splices the result back, and asserts the
272
- shape-preserving invariant.
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. Override in subclasses."""
329
- raise NotImplementedError(f"{type(self).__name__}._apply not implemented")
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