videopython 0.43.1__tar.gz → 0.44.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {videopython-0.43.1 → videopython-0.44.0}/PKG-INFO +2 -2
  2. {videopython-0.43.1 → videopython-0.44.0}/README.md +1 -1
  3. {videopython-0.43.1 → videopython-0.44.0}/pyproject.toml +1 -1
  4. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/dubber.py +1 -3
  5. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/effects.py +3 -3
  6. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/transforms.py +5 -23
  7. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/analyzer.py +2 -3
  8. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/audio/audio.py +2 -2
  9. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/audio_ops.py +6 -8
  10. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/effects.py +39 -120
  11. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/operation.py +22 -87
  12. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/streaming.py +7 -218
  13. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/transcription_overlay.py +9 -72
  14. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/transforms.py +21 -230
  15. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/video_edit.py +27 -176
  16. {videopython-0.43.1 → videopython-0.44.0}/.gitignore +0 -0
  17. {videopython-0.43.1 → videopython-0.44.0}/LICENSE +0 -0
  18. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/__init__.py +0 -0
  19. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/__init__.py +0 -0
  20. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/_device.py +0 -0
  21. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/_optional.py +0 -0
  22. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/_predictor.py +0 -0
  23. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/_revisions.py +0 -0
  24. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  25. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/config.py +0 -0
  26. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  27. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/loudness.py +0 -0
  28. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/models.py +0 -0
  29. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  30. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/quality.py +0 -0
  31. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/remux.py +0 -0
  32. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/timing.py +0 -0
  33. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  34. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/__init__.py +0 -0
  35. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/_tts_backend.py +0 -0
  36. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/audio.py +0 -0
  37. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/image.py +0 -0
  38. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/qwen3.py +0 -0
  39. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/translation.py +0 -0
  40. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/video.py +0 -0
  41. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/__init__.py +0 -0
  42. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/audio.py +0 -0
  43. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/faces.py +0 -0
  44. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/image.py +0 -0
  45. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/objects.py +0 -0
  46. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/separation.py +0 -0
  47. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/temporal.py +0 -0
  48. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
  49. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/models.py +0 -0
  50. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
  51. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/stages.py +0 -0
  52. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/audio/__init__.py +0 -0
  53. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/audio/analysis.py +0 -0
  54. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/__init__.py +0 -0
  55. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/_dimensions.py +0 -0
  56. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/_ffmpeg.py +0 -0
  57. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/_video_io.py +0 -0
  58. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/description.py +0 -0
  59. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/draw_detections.py +0 -0
  60. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/exceptions.py +0 -0
  61. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  62. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  63. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  64. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  65. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  66. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  67. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  68. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  69. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  70. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  71. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/__init__.py +0 -0
  72. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/transcription.py +0 -0
  73. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/video.py +0 -0
  74. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/__init__.py +0 -0
  75. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/_ass.py +0 -0
  76. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/_easing.py +0 -0
  77. {videopython-0.43.1 → videopython-0.44.0}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.43.1
3
+ Version: 0.44.0
4
4
  Summary: Minimal video generation and processing library.
5
5
  Project-URL: Homepage, https://videopython.com
6
6
  Project-URL: Repository, https://github.com/bartwojtowicz/videopython/
@@ -133,7 +133,7 @@ edit.validate() # dry-run via metadata, no frames loaded
133
133
  edit.run_to_file("output.mp4") # streams ffmpeg decode → effects → encode
134
134
  ```
135
135
 
136
- `run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. Use `edit.run()` to get a `Video` back in memory instead.
136
+ `run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. If you need the frames back in memory, load the rendered file: `Video.from_path(str(edit.run_to_file("output.mp4")))`.
137
137
 
138
138
  ### AI generation
139
139
 
@@ -47,7 +47,7 @@ edit.validate() # dry-run via metadata, no frames loaded
47
47
  edit.run_to_file("output.mp4") # streams ffmpeg decode → effects → encode
48
48
  ```
49
49
 
50
- `run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. Use `edit.run()` to get a `Video` back in memory instead.
50
+ `run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. If you need the frames back in memory, load the rendered file: `Video.from_path(str(edit.run_to_file("output.mp4")))`.
51
51
 
52
52
  ### AI generation
53
53
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.43.1"
3
+ version = "0.44.0"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -239,9 +239,7 @@ class VideoDubber:
239
239
  video_duration = video.total_seconds
240
240
 
241
241
  if video_duration > speech_duration:
242
- from videopython.editing.transforms import CutSeconds
243
-
244
- output_video = CutSeconds(start=0, end=speech_duration).apply(video)
242
+ output_video = video[: round(speech_duration * video.fps)]
245
243
  else:
246
244
  output_video = video
247
245
 
@@ -33,9 +33,9 @@ class ObjectDetectionOverlay(Effect):
33
33
  *memory*-bound: ``"streamable"`` here means bounded memory, not bounded
34
34
  compute. On long clips, cap cost with ``window`` (limit the time range),
35
35
  a larger ``detection_interval``, a ``class_filter``, and/or the smaller
36
- ``model_size``. Because only ``streaming_init`` and ``process_frame`` are
37
- overridden, the base ``Effect._apply`` replays the identical contract for
38
- in-memory execution, so eager and streaming results cannot drift.
36
+ ``model_size``. Only ``streaming_init`` and ``process_frame`` are
37
+ overridden; the streaming engine drives that contract for bounded-memory
38
+ execution.
39
39
  """
40
40
 
41
41
  op: Literal["object_detection_overlay"] = "object_detection_overlay"
@@ -15,7 +15,7 @@ from tqdm import tqdm
15
15
 
16
16
  from videopython.ai.understanding.faces import FaceTracker
17
17
  from videopython.base._dimensions import floor_to_even
18
- from videopython.base.video import FrameIterator, Video, VideoMetadata
18
+ from videopython.base.video import FrameIterator, VideoMetadata
19
19
  from videopython.editing._ass import escape_filter_value
20
20
  from videopython.editing.operation import FilterCtx, OpCategory, Operation
21
21
 
@@ -94,8 +94,8 @@ class FaceTrackingCrop(Operation):
94
94
  """Output ``(width, height)`` -- the fixed crop-window size.
95
95
 
96
96
  The largest ``target_aspect`` box that fits the frame, even-floored.
97
- A pure function of the input dimensions, shared by :meth:`apply`,
98
- :meth:`predict_metadata`, and :meth:`to_ffmpeg_filter`, so the
97
+ A pure function of the input dimensions, shared by
98
+ :meth:`predict_metadata` and :meth:`to_ffmpeg_filter`, so the
99
99
  dry-run cannot disagree with the render.
100
100
  """
101
101
  target_ratio = self.target_aspect[0] / self.target_aspect[1]
@@ -131,9 +131,8 @@ class FaceTrackingCrop(Operation):
131
131
  """Per-frame crop top-left positions for a fixed-size crop window.
132
132
 
133
133
  The single source of the tracking math (detection cadence, EMA
134
- smoothing, framing offset, speed clamp, frame clamping), shared by
135
- the eager :meth:`apply` and the compile-time detection pass so the
136
- two paths cannot drift.
134
+ smoothing, framing offset, speed clamp, frame clamping), run by the
135
+ compile-time detection pass to build the per-frame crop command file.
137
136
  """
138
137
  out_w, out_h = self._resolved_output_dims(frame_w, frame_h)
139
138
  tracker = FaceTracker(
@@ -166,23 +165,6 @@ class FaceTrackingCrop(Operation):
166
165
  positions.append(default)
167
166
  return positions
168
167
 
169
- def apply(self, video: Video) -> Video:
170
- h, w = video.frame_shape[:2]
171
- out_w, out_h = self._resolved_output_dims(w, h)
172
- logger.info(
173
- "Face tracking crop: %dx%d -> %dx%d (%d:%d, framing=%s)",
174
- w,
175
- h,
176
- out_w,
177
- out_h,
178
- self.target_aspect[0],
179
- self.target_aspect[1],
180
- self.framing_rule,
181
- )
182
- positions = self._track_crop_positions(tqdm(video.frames, desc="Face tracking crop"), w, h)
183
- video.frames = np.stack([video.frames[i][y : y + out_h, x : x + out_w] for i, (x, y) in enumerate(positions)])
184
- return video
185
-
186
168
  def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
187
169
  """Compile the face track to a per-frame ``crop`` position command file.
188
170
 
@@ -331,9 +331,8 @@ class VideoAnalyzer:
331
331
  return None
332
332
  if source_path is not None:
333
333
  return Video.from_path(str(source_path), start_second=start_second, end_second=end_second)
334
- from videopython.editing.transforms import CutSeconds
335
-
336
- return CutSeconds(start=start_second, end=end_second).apply(stages.require_video(video))
334
+ v = stages.require_video(video)
335
+ return v[round(start_second * v.fps) : round(end_second * v.fps)]
337
336
 
338
337
  def _default_scene_boundaries(self, metadata: VideoMetadata) -> list[SceneBoundary]:
339
338
  if metadata.total_seconds <= 0 or metadata.frame_count <= 0:
@@ -23,8 +23,8 @@ def atempo_chain(speed: float) -> list[str]:
23
23
  factor outside that range is decomposed into a chain of capped stages
24
24
  (``atempo=2.0`` repeated for speedups, ``atempo=0.5`` for slowdowns) times
25
25
  a final remainder stage. The single source of truth shared by
26
- :meth:`Audio.time_stretch` (the in-memory WAV round-trip used by the eager
27
- ``apply`` path) and ``SpeedChange.to_ffmpeg_audio_filter`` (the streaming
26
+ :meth:`Audio.time_stretch` (the in-memory WAV round-trip used by dubbing's
27
+ timing alignment) and ``SpeedChange.to_ffmpeg_audio_filter`` (the streaming
28
28
  filter graph), so both stretch by exactly the same chain. Returns an empty
29
29
  list for ``speed == 1`` (a no-op the caller maps to ``anull``).
30
30
  """
@@ -7,9 +7,8 @@ assembled timeline, not of any one segment. It is mixed in a FINAL ffmpeg pass
7
7
  assembled program audio plus the bed input.
8
8
 
9
9
  :class:`MusicBed` is a frozen, closed pydantic model carried on
10
- :attr:`VideoEdit.music_bed`. Both ``run`` and ``run_to_file`` route through the
11
- single :func:`build_music_bed_filter_complex` builder so the in-memory and
12
- file paths cannot diverge on the mix.
10
+ :attr:`VideoEdit.music_bed`. ``run_to_file`` routes through the
11
+ single :func:`build_music_bed_filter_complex` builder for the mix.
13
12
 
14
13
  Ducking is *transcription-derived* and deterministic: when ``duck`` is set the
15
14
  bed is lowered under the speech windows derived from the context transcription
@@ -122,7 +121,7 @@ class MusicBed(BaseModel):
122
121
 
123
122
  Mirrors :meth:`ImageOverlay.predict_metadata`: a cheap ffprobe header
124
123
  probe (no decode) catches a missing / non-audio file at validate time,
125
- before ``run()`` would crash mid-stream after assembling the program.
124
+ before ``run_to_file()`` would crash mid-stream after assembling the program.
126
125
  """
127
126
  try:
128
127
  info = _ffmpeg.probe(self.source)
@@ -147,8 +146,8 @@ class MusicBed(BaseModel):
147
146
  bed is scaled to ``gain``, faded, ducked under ``speech`` (when given and
148
147
  ``duck`` is set), then pinned to exactly ``program_seconds`` (``atrim``
149
148
  end + ``apad`` whole_dur) so a looped bed neither truncates early nor
150
- extends the output past the program. Shared by the in-memory and file
151
- mix paths via :func:`build_music_bed_filter_complex`.
149
+ extends the output past the program. Used by the file
150
+ mix path via :func:`build_music_bed_filter_complex`.
152
151
  """
153
152
  stages: list[str] = [f"volume={self.gain:.6f}"]
154
153
  if self.fade_in > 0:
@@ -178,8 +177,7 @@ def build_music_bed_filter_complex(
178
177
  ) -> tuple[list[str], list[str], str]:
179
178
  """Compile the music-bed input args + ``filter_complex`` mix graph.
180
179
 
181
- The single source of the bed mix, shared by ``run`` (in-memory PCM pass) and
182
- ``run_to_file`` (file pass) so the two cannot diverge. Returns
180
+ The single source of the bed mix, used by ``run_to_file`` (file pass). Returns
183
181
  ``(input_args, graph_statements, out_label)``:
184
182
 
185
183
  - ``input_args`` are the ffmpeg ``-i`` argv for the bed: ``-stream_loop -1``
@@ -3,16 +3,13 @@
3
3
  An ``Effect`` is an ``Operation`` that preserves video shape and frame count.
4
4
  Subclasses implement the streaming contract -- :meth:`Effect.process_frame`
5
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`.
6
+ single source of truth for the effect's pixel logic and the only execution
7
+ path. Audio effects (``Fade``, ``VolumeAdjust``) instead express their gain
8
+ envelope via :meth:`Effect.to_ffmpeg_audio_filter`. Effects that need
9
+ run-time validation beyond the field constraints (``FullImageOverlay``)
10
+ override :meth:`Effect.predict_metadata`. Anchored RGBA overlays
11
+ (``TextOverlay``, ``ImageOverlay``) share their placement and blend via
12
+ :class:`_AnchoredOverlay`.
16
13
  """
17
14
 
18
15
  from __future__ import annotations
@@ -26,7 +23,6 @@ import cv2
26
23
  import numpy as np
27
24
  from PIL import Image, ImageDraw, ImageFont
28
25
  from pydantic import Field, PrivateAttr, model_validator
29
- from tqdm import tqdm
30
26
 
31
27
  from videopython.base.description import BoundingBox
32
28
  from videopython.base.exceptions import PlanError, PlanErrorCode, PlanValidationError
@@ -35,8 +31,7 @@ from videopython.editing._easing import ease, ease_out
35
31
  from videopython.editing.operation import Effect, FilterCtx
36
32
 
37
33
  if TYPE_CHECKING:
38
- from videopython.audio import Audio
39
- from videopython.base.video import Video, VideoMetadata
34
+ from videopython.base.video import VideoMetadata
40
35
 
41
36
  logger = logging.getLogger(__name__)
42
37
 
@@ -121,24 +116,23 @@ class FullImageOverlay(Effect):
121
116
  fade_alpha = 1.0 if dist_from_end >= self._stream_fade_frames else dist_from_end / self._stream_fade_frames
122
117
  return self._overlay_frame(frame, fade_alpha)
123
118
 
124
- def _apply(self, video: Video, **_context: Any) -> Video:
119
+ def predict_metadata(self, meta: VideoMetadata, **_context: Any) -> VideoMetadata:
120
+ """Reject an overlay that cannot composite onto this video at run time.
121
+
122
+ Two failures ``run_to_file()`` cannot survive are caught at ``validate()``
123
+ time instead of mid-stream: (a) the overlay's pixel dimensions must match
124
+ the video frame exactly (this op is full-frame, unlike
125
+ :class:`ImageOverlay`), and (b) the combined fade-in + fade-out cannot
126
+ exceed the clip length. Both checks come from the deleted eager path; the
127
+ overlay header is read once here (no per-frame work).
128
+ """
125
129
  overlay = self._load_overlay()
126
- if video.frame_shape != overlay[:, :, :3].shape:
127
- raise ValueError(f"Mismatch of overlay shape `{overlay.shape}` with video shape: `{video.frame_shape}`!")
128
- if not (0 <= 2 * self.fade_time <= video.total_seconds):
129
- raise ValueError(f"Video is only {video.total_seconds}s long, but fade time is {self.fade_time}s!")
130
-
131
- logger.info("Overlaying video...")
132
- n = len(video.frames)
133
- num_fade_frames = round(self.fade_time * video.fps) if self.fade_time > 0 else 0
134
- for i in tqdm(range(n), desc="Overlaying frames"):
135
- if num_fade_frames == 0:
136
- video.frames[i] = self._overlay_frame(video.frames[i])
137
- else:
138
- dist_from_end = min(i, n - i)
139
- fade_alpha = 1.0 if dist_from_end >= num_fade_frames else dist_from_end / num_fade_frames
140
- video.frames[i] = self._overlay_frame(video.frames[i], fade_alpha)
141
- return video
130
+ frame_shape = (meta.height, meta.width, 3)
131
+ if frame_shape != overlay[:, :, :3].shape:
132
+ raise ValueError(f"Mismatch of overlay shape `{overlay.shape}` with video shape: `{frame_shape}`!")
133
+ if not (0 <= 2 * self.fade_time <= meta.total_seconds):
134
+ raise ValueError(f"Video is only {meta.total_seconds}s long, but fade time is {self.fade_time}s!")
135
+ return meta
142
136
 
143
137
 
144
138
  class Blur(Effect):
@@ -326,18 +320,6 @@ class Vignette(Effect):
326
320
  assert self._stream_mask_3d is not None
327
321
  return (frame.astype(np.float32) * self._stream_mask_3d).astype(np.uint8)
328
322
 
329
- def _apply(self, video: Video, **_context: Any) -> Video:
330
- logger.info("Applying vignette effect...")
331
- height, width = video.frame_shape[:2]
332
- if self._mask is None or self._mask.shape != (height, width):
333
- self._mask = self._create_mask(height, width)
334
- mask_3d = self._mask[:, :, np.newaxis]
335
- batch_size = 64
336
- for start in range(0, len(video.frames), batch_size):
337
- end = min(start + batch_size, len(video.frames))
338
- video.frames[start:end] = (video.frames[start:end].astype(np.float32) * mask_3d).astype(np.uint8)
339
- return video
340
-
341
323
 
342
324
  class KenBurns(Effect):
343
325
  """Cinematic pan-and-zoom that smoothly animates between two crop regions.
@@ -473,29 +455,6 @@ class Fade(Effect):
473
455
  return frame
474
456
  return (frame.astype(np.float32) * a).astype(np.uint8)
475
457
 
476
- def apply(self, video: Video, **context: Any) -> Video:
477
- start_s, stop_s = self._resolved_window(video.total_seconds)
478
- start_f = round(start_s * video.fps)
479
- end_f = round(stop_s * video.fps)
480
- n_effect = end_f - start_f
481
- alpha = self._fade_envelope(n_effect, video.fps)
482
-
483
- batch_size = 64
484
- for batch_start in range(0, n_effect, batch_size):
485
- batch_end = min(batch_start + batch_size, n_effect)
486
- batch_alpha = alpha[batch_start:batch_end, np.newaxis, np.newaxis, np.newaxis]
487
- if np.all(batch_alpha == 1.0):
488
- continue
489
- abs_start = start_f + batch_start
490
- abs_end = start_f + batch_end
491
- video.frames[abs_start:abs_end] = (video.frames[abs_start:abs_end].astype(np.float32) * batch_alpha).astype(
492
- np.uint8
493
- )
494
-
495
- if video.audio is not None and not video.audio.is_silent:
496
- self._audio_apply(video.audio, start_s, stop_s)
497
- return video
498
-
499
458
  def _curve_expr(self, progress: str) -> str:
500
459
  """ffmpeg gain sub-expression for a 0->1 ramp ``progress``, per ``self.curve``.
501
460
 
@@ -513,8 +472,9 @@ class Fade(Effect):
513
472
  """Express the fade's gain envelope as a windowed ``volume`` expression.
514
473
 
515
474
  The audio twin of the video fade (which scales pixels by the same alpha
516
- envelope). It must mirror :func:`_fade_envelope` / :meth:`_audio_apply`:
517
- the ramp applies only WITHIN ``[start, stop]`` and the gain is 1.0
475
+ envelope in :meth:`process_frame`). It must mirror
476
+ :func:`_fade_envelope`: the ramp applies only WITHIN ``[start, stop]``
477
+ and the gain is 1.0
518
478
  everywhere else -- so a *windowed* fade-out returns to full volume after
519
479
  the window (matching the video, which resumes full brightness), instead
520
480
  of staying muted. Native ``afade`` cannot express this (it holds 0
@@ -531,8 +491,8 @@ class Fade(Effect):
531
491
  if stop_s <= start_s:
532
492
  return None
533
493
  window_len = stop_s - start_s
534
- # Halve the ramp for in_out so the lead and trailing ramps cannot overlap
535
- # (the eager path takes their min; a clamped half-window avoids it).
494
+ # Halve the ramp for in_out so the lead and trailing ramps cannot
495
+ # overlap; a clamped half-window keeps them disjoint.
536
496
  both = self.mode == "in_out"
537
497
  ramp = min(self.duration, window_len / 2 if both else window_len)
538
498
  if ramp <= 0:
@@ -550,19 +510,6 @@ class Fade(Effect):
550
510
  expr = f"if({cond},{gain},{expr})"
551
511
  return f"volume=volume='{expr}':eval=frame"
552
512
 
553
- def _audio_apply(self, audio: Audio, start_s: float, stop_s: float) -> None:
554
- """Eager (in-memory) fade for ``Video.apply``; streaming uses ``afade``."""
555
- sample_rate = audio.metadata.sample_rate
556
- audio_start = round(start_s * sample_rate)
557
- audio_end = min(round(stop_s * sample_rate), len(audio.data))
558
- alpha = self._fade_envelope(audio_end - audio_start, sample_rate)
559
-
560
- if audio.data.ndim == 1:
561
- audio.data[audio_start:audio_end] *= alpha
562
- else:
563
- audio.data[audio_start:audio_end] *= alpha[:, np.newaxis]
564
- np.clip(audio.data, -1.0, 1.0, out=audio.data)
565
-
566
513
 
567
514
  class VolumeAdjust(Effect):
568
515
  """Changes audio volume within a time range without affecting video frames."""
@@ -585,12 +532,6 @@ class VolumeAdjust(Effect):
585
532
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
586
533
  return frame
587
534
 
588
- def apply(self, video: Video, **context: Any) -> Video:
589
- start_s, stop_s = self._resolved_window(video.total_seconds)
590
- if video.audio is not None and not video.audio.is_silent:
591
- self._audio_apply(video.audio, start_s, stop_s)
592
- return video
593
-
594
535
  def to_ffmpeg_audio_filter(self, ctx: FilterCtx) -> str | None:
595
536
  """Apply the volume change over the window via the ``volume`` filter.
596
537
 
@@ -598,8 +539,8 @@ class VolumeAdjust(Effect):
598
539
  multiplier compiles to ``volume=<v>:enable='between(t,start,stop)'``.
599
540
  When ``ramp_duration>0`` the gain is a time-piecewise expression
600
541
  (``volume=eval=frame``) that ramps ``1 -> volume`` over the first
601
- ``ramp_duration`` of the window and back over the last, mirroring the
602
- eager ``1 + (volume-1)*sqrt(t)`` edge ramps. The window resolves
542
+ ``ramp_duration`` of the window and back over the last, following the
543
+ ``1 + (volume-1)*sqrt(t)`` edge-ramp shape. The window resolves
603
544
  against the segment duration (``ctx.frame_count / ctx.fps``); ``None``
604
545
  for a degenerate window or a no-op (``volume == 1`` with no ramp).
605
546
  """
@@ -632,35 +573,13 @@ class VolumeAdjust(Effect):
632
573
  )
633
574
  return f"volume=volume='{expr}':eval=frame"
634
575
 
635
- def _audio_apply(self, audio: Audio, start_s: float, stop_s: float) -> None:
636
- """Eager (in-memory) volume change for ``Video.apply``; streaming uses ``volume``."""
637
- sample_rate = audio.metadata.sample_rate
638
- start_sample = round(start_s * sample_rate)
639
- end_sample = min(round(stop_s * sample_rate), len(audio.data))
640
- n_samples = end_sample - start_sample
641
- envelope = np.full(n_samples, self.volume, dtype=np.float32)
642
-
643
- if self.ramp_duration > 0:
644
- ramp_samples = min(round(self.ramp_duration * sample_rate), n_samples // 2)
645
- if ramp_samples > 0:
646
- t = np.linspace(0, 1, ramp_samples, dtype=np.float32)
647
- envelope[:ramp_samples] = 1.0 + (self.volume - 1.0) * np.sqrt(t)
648
- t = np.linspace(1, 0, ramp_samples, dtype=np.float32)
649
- envelope[-ramp_samples:] = 1.0 + (self.volume - 1.0) * np.sqrt(t)
650
-
651
- if audio.data.ndim == 1:
652
- audio.data[start_sample:end_sample] *= envelope
653
- else:
654
- audio.data[start_sample:end_sample] *= envelope[:, np.newaxis]
655
- np.clip(audio.data, -1.0, 1.0, out=audio.data)
656
-
657
576
 
658
577
  class _AnchoredOverlay(Effect):
659
578
  """Shared base for anchored RGBA overlays (:class:`TextOverlay`, :class:`ImageOverlay`).
660
579
 
661
- Owns anchored placement, off-frame clipping, and alpha blending, so the
662
- eager and streaming paths share one ``_blend_params`` source of truth (the
663
- parity-hole class of bug fixed in 0.34.1). Subclasses declare their own
580
+ Owns anchored placement, off-frame clipping, and alpha blending in one
581
+ ``_blend_params`` source of truth (consolidated when the eager/streaming
582
+ parity-hole class of bug was fixed in 0.34.1). Subclasses declare their own
664
583
  ``position``/``anchor`` field defaults and implement
665
584
  :meth:`_overlay_for_frame` to produce the RGBA bitmap; everything
666
585
  downstream is shared. It declares no ``op`` ``Literal``, so it is an
@@ -723,11 +642,11 @@ class _AnchoredOverlay(Effect):
723
642
  def _blend_params(
724
643
  self, frame_w: int, frame_h: int
725
644
  ) -> tuple[np.ndarray, np.ndarray, tuple[int, int, int, int]] | None:
726
- """Placement + blend inputs shared by the eager and streaming paths.
645
+ """Placement + blend inputs for the streaming path.
727
646
 
728
- Single source of truth so the two paths cannot drift -- the eager/stream
729
- parity-hole class of bug fixed in 0.34.1. Returns ``None`` when the
730
- overlay lands fully off-frame (the effect is a no-op).
647
+ Single source of truth for placement and blending (consolidated when the
648
+ eager/stream parity-hole class of bug was fixed in 0.34.1). Returns
649
+ ``None`` when the overlay lands fully off-frame (the effect is a no-op).
731
650
  """
732
651
  overlay = self._overlay_for_frame(frame_w, frame_h)
733
652
  oh, ow = overlay.shape[:2]
@@ -942,7 +861,7 @@ class ImageOverlay(_AnchoredOverlay):
942
861
  def predict_metadata(self, meta: VideoMetadata, **_context: Any) -> VideoMetadata:
943
862
  """Reject only a missing/unreadable ``source`` (see :meth:`Operation.predict_metadata`).
944
863
 
945
- An unreadable source is the one failure ``run()`` cannot survive -- it
864
+ An unreadable source is the one failure ``run_to_file()`` cannot survive -- it
946
865
  would raise mid-stream after expensive frame decode -- so it is caught
947
866
  at ``validate()`` time, symmetric with ``TranscriptionOverlay``.
948
867
  Geometry (oversized / off-frame) is deliberately *not* checked here: it
@@ -2,9 +2,9 @@
2
2
 
3
3
  Every editing primitive is an ``Operation`` subclass -- a Pydantic model whose
4
4
  fields ARE the JSON wire format. Validation, schema, and serialisation come for
5
- free; subclasses just declare fields and implement ``apply``. Auto-registration
6
- via ``__pydantic_init_subclass__`` builds the ``op_id -> class`` registry as
7
- modules are imported.
5
+ free; subclasses just declare fields and implement the streaming contract.
6
+ Auto-registration via ``__pydantic_init_subclass__`` builds the
7
+ ``op_id -> class`` registry as modules are imported.
8
8
 
9
9
  Subclass contract::
10
10
 
@@ -23,7 +23,6 @@ Subclass contract::
23
23
  width: int | None = Field(None, gt=0)
24
24
  height: int | None = Field(None, gt=0)
25
25
 
26
- def apply(self, video: Video) -> Video: ...
27
26
  def predict_metadata(self, meta: VideoMetadata) -> VideoMetadata: ...
28
27
  def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None: ...
29
28
  """
@@ -38,10 +37,9 @@ from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, NamedTuple,
38
37
 
39
38
  import numpy as np
40
39
  from pydantic import BaseModel, ConfigDict, Discriminator, Field, TypeAdapter
41
- from tqdm import tqdm
42
40
 
43
41
  if TYPE_CHECKING:
44
- from videopython.base.video import Video, VideoMetadata
42
+ from videopython.base.video import VideoMetadata
45
43
 
46
44
  __all__ = [
47
45
  "OpCategory",
@@ -72,8 +70,9 @@ class TimeRange(BaseModel):
72
70
  *shape*; the numeric bounds (``>= 0``, ``stop >= start``, in-duration) are
73
71
  owned by :meth:`VideoEdit.validate` / :meth:`VideoEdit.check`, which report
74
72
  them as structured, collectable, repairable :class:`PlanError`s instead of
75
- aborting at ``from_dict``. :meth:`Effect._resolved_window` still clamps at
76
- run time, so a plan run without validation degrades rather than crashes.
73
+ aborting at ``from_dict``. The window is still clamped to
74
+ ``min(stop, total_seconds)`` at run time, so a plan run without validation
75
+ degrades rather than crashes.
77
76
  """
78
77
 
79
78
  model_config = ConfigDict(extra="forbid", frozen=True)
@@ -238,8 +237,8 @@ class Operation(BaseModel):
238
237
  wire and the registry key. Subclasses may override the ``category``,
239
238
  ``streamable``, and ``requires`` ClassVars.
240
239
 
241
- The default ``apply`` raises ``NotImplementedError``; ``predict_metadata``
242
- defaults to identity; ``to_ffmpeg_filter`` defaults to ``None`` (no filter compilation).
240
+ ``predict_metadata`` defaults to identity; ``to_ffmpeg_filter`` defaults to
241
+ ``None`` (no filter compilation).
243
242
  """
244
243
 
245
244
  model_config = ConfigDict(extra="forbid", validate_assignment=True)
@@ -380,24 +379,14 @@ class Operation(BaseModel):
380
379
  """
381
380
  return _strip_llm_hidden(cls.model_json_schema())
382
381
 
383
- def apply(self, video: Video) -> Video:
384
- """Run this operation on ``video``.
385
-
386
- The runner passes pipeline-context values listed in ``cls.requires``
387
- as keyword arguments (e.g. ``transcription=...``). Subclasses that
388
- declare ``requires`` widen the signature accordingly -- e.g.
389
- ``def apply(self, video, transcription=None) -> Video``.
390
- """
391
- raise NotImplementedError(f"{type(self).__name__}.apply not implemented")
392
-
393
382
  def predict_metadata(self, meta: VideoMetadata) -> VideoMetadata:
394
383
  """Predict output metadata from input metadata. Default: identity.
395
384
 
396
385
  Run during ``VideoEdit.validate()``'s dry-run, before any frames are
397
386
  decoded. Beyond predicting shape, this is the fail-fast gate, and it
398
387
  has one contract: **reject exactly the plans that would otherwise crash
399
- or do unrecoverable / expensive work in** :meth:`apply` **/** ``run()``;
400
- anything ``run()`` can absorb by graceful degradation is NOT rejected.
388
+ or do unrecoverable / expensive work in** ``run_to_file()``;
389
+ anything ``run_to_file()`` can absorb by graceful degradation is NOT rejected.
401
390
  ``TranscriptionOverlay`` rejects un-fittable subtitles (they used to
402
391
  crash mid-render); ``TextOverlay``/``ImageOverlay`` do not reject
403
392
  off-frame geometry (it clips to a valid no-op). Keep the check
@@ -450,18 +439,16 @@ class Effect(Operation):
450
439
 
451
440
  Subclasses implement the streaming contract -- :meth:`process_frame` (and
452
441
  :meth:`streaming_init` for any precomputed per-stream state) -- which is the
453
- single source of truth for the effect's pixel logic. The base
454
- :meth:`_apply` runs that same contract over the in-memory frames, so
455
- in-memory execution comes for free; the same code path feeds
456
- ``editing/streaming.py`` for bounded-memory streaming. The base
457
- :meth:`apply` resolves :attr:`window`, slices the video, runs ``_apply`` on
458
- the slice, splices the result back, and asserts the shape-preserving
459
- invariant.
460
-
461
- Override :meth:`_apply` only when eager execution must genuinely differ from
462
- a frame-by-frame replay -- e.g. extra validation, a batched vectorisation,
463
- or audio handling (``Fade``/``VolumeAdjust`` override :meth:`apply` outright
464
- so the audio splice stays coherent with the window).
442
+ single source of truth for the effect's pixel logic. The streaming engine
443
+ in ``editing/streaming.py`` drives that contract for bounded-memory
444
+ execution, resolving :attr:`window` against the segment timeline so frames
445
+ outside the window pass through untouched.
446
+
447
+ Effects that compile to a native ffmpeg filter instead set
448
+ :attr:`compiles_to_filter` and implement :meth:`to_ffmpeg_filter` (and, for
449
+ audio-coupled effects like ``Fade``/``VolumeAdjust``,
450
+ :meth:`to_ffmpeg_audio_filter`) so the window stays coherent across the
451
+ decode/encode graph.
465
452
  """
466
453
 
467
454
  category: ClassVar[OpCategory] = OpCategory.EFFECT
@@ -493,67 +480,15 @@ class Effect(Operation):
493
480
  """
494
481
  return False
495
482
 
496
- def apply(self, video: Video, **context: Any) -> Video:
497
- from videopython.base.video import Video as _Video
498
-
499
- original_shape = video.video_shape
500
-
501
- if self.window is None or (self.window.start is None and self.window.stop is None):
502
- result = self._apply(video, **context)
503
- else:
504
- start_s, stop_s = self._resolved_window(video.total_seconds)
505
- start_f = round(start_s * video.fps)
506
- end_f = round(stop_s * video.fps)
507
- inner = self._apply(video[start_f:end_f], **context)
508
- old_audio = video.audio
509
- result = _Video.from_frames(
510
- np.r_["0,2", video.frames[:start_f], inner.frames, video.frames[end_f:]],
511
- fps=video.fps,
512
- )
513
- result.audio = old_audio
514
-
515
- if result.video_shape != original_shape:
516
- raise RuntimeError(
517
- f"{type(self).__name__} changed video shape from {original_shape} "
518
- f"to {result.video_shape}; effects must preserve shape and frame count."
519
- )
520
- return result
521
-
522
483
  def predict_metadata(self, meta: VideoMetadata, **_context: Any) -> VideoMetadata:
523
484
  """Effects preserve shape and frame count, so the prediction is identity.
524
485
 
525
486
  Accepts ``**_context`` so requires-aware effects (``TranscriptionOverlay``)
526
487
  validate without subclasses needing to override just to widen the
527
- signature. Mirrors :meth:`Effect.apply`'s ``**context`` accept-all.
488
+ signature. Mirrors :meth:`Effect.streaming_init`'s ``**_context`` accept-all.
528
489
  """
529
490
  return meta
530
491
 
531
- def _resolved_window(self, total_seconds: float) -> tuple[float, float]:
532
- win = self.window or TimeRange()
533
- start_s = 0.0 if win.start is None else float(win.start)
534
- stop_s = total_seconds if win.stop is None else float(win.stop)
535
- start_s = min(start_s, total_seconds)
536
- stop_s = min(stop_s, total_seconds)
537
- if stop_s < start_s:
538
- raise ValueError(f"Effect stop ({stop_s}) must be >= start ({start_s})")
539
- return start_s, stop_s
540
-
541
- def _apply(self, video: Video, **context: Any) -> Video:
542
- """Apply the effect to ``video`` in memory by replaying the streaming path.
543
-
544
- Runs :meth:`streaming_init` once, then :meth:`process_frame` over every
545
- frame in order -- the same logic streaming uses, so eager and streaming
546
- cannot drift. ``context`` carries resolved ``requires`` values through
547
- to :meth:`streaming_init`, mirroring the streaming scheduler.
548
- Subclasses that need a genuinely different eager path (extra
549
- validation, batched vectorisation) override this.
550
- """
551
- height, width = video.frame_shape[:2]
552
- self.streaming_init(len(video.frames), video.fps, width, height, **context)
553
- for i in tqdm(range(len(video.frames)), desc=type(self).__name__):
554
- video.frames[i] = self.process_frame(video.frames[i], i)
555
- return video
556
-
557
492
  def streaming_init(self, total_frames: int, fps: float, width: int, height: int, **_context: Any) -> None:
558
493
  """Hook for per-stream precomputation (per-frame alphas, sigma curves...).
559
494