videopython 0.44.1__tar.gz → 0.46.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.44.1 → videopython-0.46.0}/PKG-INFO +2 -2
  2. {videopython-0.44.1 → videopython-0.46.0}/README.md +1 -1
  3. {videopython-0.44.1 → videopython-0.46.0}/pyproject.toml +1 -1
  4. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/exceptions.py +1 -1
  5. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/effects.py +439 -0
  6. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/operation.py +28 -7
  7. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/streaming.py +215 -190
  8. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/transforms.py +9 -0
  9. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/video_edit.py +171 -81
  10. {videopython-0.44.1 → videopython-0.46.0}/.gitignore +0 -0
  11. {videopython-0.44.1 → videopython-0.46.0}/LICENSE +0 -0
  12. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/__init__.py +0 -0
  13. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/__init__.py +0 -0
  14. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/_device.py +0 -0
  15. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/_optional.py +0 -0
  16. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/_predictor.py +0 -0
  17. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/_revisions.py +0 -0
  18. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  19. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/config.py +0 -0
  20. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/dubber.py +0 -0
  21. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  22. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/loudness.py +0 -0
  23. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/models.py +0 -0
  24. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  25. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/quality.py +0 -0
  26. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/remux.py +0 -0
  27. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/timing.py +0 -0
  28. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  29. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/effects.py +0 -0
  30. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/generation/__init__.py +0 -0
  31. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/generation/_tts_backend.py +0 -0
  32. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/generation/audio.py +0 -0
  33. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/generation/image.py +0 -0
  34. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/generation/qwen3.py +0 -0
  35. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/generation/translation.py +0 -0
  36. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/generation/video.py +0 -0
  37. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/transforms.py +0 -0
  38. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/understanding/__init__.py +0 -0
  39. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/understanding/audio.py +0 -0
  40. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/understanding/faces.py +0 -0
  41. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/understanding/image.py +0 -0
  42. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/understanding/objects.py +0 -0
  43. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/understanding/separation.py +0 -0
  44. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/understanding/temporal.py +0 -0
  45. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
  46. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  47. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/video_analysis/models.py +0 -0
  48. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
  49. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/ai/video_analysis/stages.py +0 -0
  50. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/audio/__init__.py +0 -0
  51. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/audio/analysis.py +0 -0
  52. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/audio/audio.py +0 -0
  53. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/__init__.py +0 -0
  54. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/_dimensions.py +0 -0
  55. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/_ffmpeg.py +0 -0
  56. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/_video_io.py +0 -0
  57. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/description.py +0 -0
  58. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/draw_detections.py +0 -0
  59. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  60. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  61. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  62. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  63. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  64. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  65. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  66. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  67. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  68. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  69. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/fonts/__init__.py +0 -0
  70. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/transcription.py +0 -0
  71. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/base/video.py +0 -0
  72. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/__init__.py +0 -0
  73. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/_ass.py +0 -0
  74. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/_easing.py +0 -0
  75. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/audio_ops.py +0 -0
  76. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/editing/transcription_overlay.py +0 -0
  77. {videopython-0.44.1 → videopython-0.46.0}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.44.1
3
+ Version: 0.46.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/
@@ -158,7 +158,7 @@ See the [LLM Integration Guide](https://videopython.com/guides/llm-integration/)
158
158
 
159
159
  - **`videopython.base`** — `Video`, `VideoMetadata`, `FrameIterator`, `Transcription`, and shared result types (`BoundingBox`, `FaceTrack`, `SceneBoundary`, ...). No AI dependencies.
160
160
  - **`videopython.audio`** — `Audio` with overlay, concat, normalize, time-stretch, silence detection, segment classification.
161
- - **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (cut, resize, crop, fps, speed, freeze, silence removal) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
161
+ - **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (resize, crop, fps, speed, freeze, silence removal; cutting is the segment's own start/end) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
162
162
  - **`videopython.ai`** *(install with `[ai]`)* — generation (`TextToVideo`, `ImageToVideo`, `TextToImage`, `TextToSpeech`, `TextToMusic`), understanding (`AudioToText`, `AudioClassifier`, `SceneVLM`, `FaceTracker`, `ObjectDetector`, `SemanticSceneDetector`), the `FaceTrackingCrop` transform, the `ObjectDetectionOverlay` effect (per-frame bounding boxes + labels), and the full-pipeline `VideoAnalyzer`.
163
163
  - **`videopython.ai.dubbing`** — `VideoDubber` for voice-cloned revoicing with timing sync.
164
164
 
@@ -72,7 +72,7 @@ See the [LLM Integration Guide](https://videopython.com/guides/llm-integration/)
72
72
 
73
73
  - **`videopython.base`** — `Video`, `VideoMetadata`, `FrameIterator`, `Transcription`, and shared result types (`BoundingBox`, `FaceTrack`, `SceneBoundary`, ...). No AI dependencies.
74
74
  - **`videopython.audio`** — `Audio` with overlay, concat, normalize, time-stretch, silence detection, segment classification.
75
- - **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (cut, resize, crop, fps, speed, freeze, silence removal) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
75
+ - **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (resize, crop, fps, speed, freeze, silence removal; cutting is the segment's own start/end) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
76
76
  - **`videopython.ai`** *(install with `[ai]`)* — generation (`TextToVideo`, `ImageToVideo`, `TextToImage`, `TextToSpeech`, `TextToMusic`), understanding (`AudioToText`, `AudioClassifier`, `SceneVLM`, `FaceTracker`, `ObjectDetector`, `SemanticSceneDetector`), the `FaceTrackingCrop` transform, the `ObjectDetectionOverlay` effect (per-frame bounding boxes + labels), and the full-pipeline `VideoAnalyzer`.
77
77
  - **`videopython.ai.dubbing`** — `VideoDubber` for voice-cloned revoicing with timing sync.
78
78
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.44.1"
3
+ version = "0.46.0"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -96,7 +96,7 @@ class PlanErrorCode(str, Enum):
96
96
  TRANSITION_TOO_LONG = "transition_too_long"
97
97
  MUSIC_BED_DUCK_MULTISEGMENT = "music_bed_duck_multisegment"
98
98
  # Streaming: unstreamable op at its plan position (always reported).
99
- STREAMING_FALLBACK = "streaming_fallback"
99
+ STREAMING_UNSUPPORTED = "streaming_unsupported"
100
100
 
101
101
 
102
102
  @dataclass
@@ -15,6 +15,8 @@ override :meth:`Effect.predict_metadata`. Anchored RGBA overlays
15
15
  from __future__ import annotations
16
16
 
17
17
  import logging
18
+ import math
19
+ import tempfile
18
20
  from io import BytesIO
19
21
  from pathlib import Path
20
22
  from typing import TYPE_CHECKING, Any, ClassVar, Literal
@@ -24,6 +26,7 @@ import numpy as np
24
26
  from PIL import Image, ImageDraw, ImageFont
25
27
  from pydantic import Field, PrivateAttr, model_validator
26
28
 
29
+ from videopython.base._ffmpeg import escape_filter_value
27
30
  from videopython.base.description import BoundingBox
28
31
  from videopython.base.exceptions import PlanError, PlanErrorCode, PlanValidationError
29
32
  from videopython.base.fonts import load_font
@@ -225,12 +228,47 @@ class Zoom(Effect):
225
228
  cropped = frame[round(y) : round(y + h), round(x) : round(x + w)]
226
229
  return cv2.resize(cropped, (width, height))
227
230
 
231
+ @property
232
+ def compiles_to_filter(self) -> bool:
233
+ # Only an unwindowed, whole-clip zoom is a single comma-join-safe -vf
234
+ # entry. A windowed zoom (frames outside the window pass through) cannot
235
+ # be one zoompan, so it stays frame-only.
236
+ return self.window is None
237
+
238
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
239
+ """Whole-clip center zoom via ffmpeg ``zoompan``.
240
+
241
+ Matches the numpy twin: the crop SIZE ramps linearly between the full
242
+ frame and ``width // zoom_factor`` and the frame is rescaled to the
243
+ original size, so the per-frame zoom is ``iw/crop_w``. The trailing
244
+ ``setpts=N/(fps*TB)`` is LOAD-BEARING: zoompan reinterprets PTS at its
245
+ own framerate, and without re-deriving PTS from the output frame number
246
+ the decode-stage ``fps=`` resampler multiplies the frame count. yuv-safe.
247
+ """
248
+ if ctx.frame_count <= 0:
249
+ return None
250
+ n = ctx.frame_count
251
+ w, h = ctx.width, ctx.height
252
+ small_w = w // self.zoom_factor
253
+ if self.mode == "out":
254
+ cw0, cw1 = float(small_w), float(w)
255
+ else: # in
256
+ cw0, cw1 = float(w), float(small_w)
257
+ denom = max(n - 1, 1)
258
+ z_expr = f"iw/({cw0:.6f}+({cw1 - cw0:.6f})*on/{denom})"
259
+ return (
260
+ f"zoompan=z='{z_expr}'"
261
+ f":x='iw/2-iw/zoom/2':y='ih/2-ih/zoom/2'"
262
+ f":d=1:s={w}x{h}:fps={ctx.fps},setpts=N/({ctx.fps}*TB)"
263
+ )
264
+
228
265
 
229
266
  class ColorGrading(Effect):
230
267
  """Adjusts color properties: brightness, contrast, saturation, and temperature."""
231
268
 
232
269
  op: Literal["color_adjust"] = "color_adjust"
233
270
  streamable: ClassVar[bool] = True
271
+ filter_needs_rgb24: ClassVar[bool] = True
234
272
 
235
273
  brightness: float = Field(
236
274
  0.0,
@@ -278,12 +316,80 @@ class ColorGrading(Effect):
278
316
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
279
317
  return self._grade_frame(frame)
280
318
 
319
+ @property
320
+ def _is_noop(self) -> bool:
321
+ return self.brightness == 0.0 and self.contrast == 1.0 and self.saturation == 1.0 and self.temperature == 0.0
322
+
323
+ def _channel_expr(self, src: str, temp_offset: float) -> str:
324
+ """Per-channel ``geq`` expression mirroring :meth:`_grade_frame`.
325
+
326
+ ``src`` is the geq accessor for the channel being computed
327
+ (``r(X,Y)``/``g(X,Y)``/``b(X,Y)``); ``temp_offset`` is the additive
328
+ temperature shift for this channel (``+t`` on red, ``0`` on green, ``-t``
329
+ on blue). The pipeline runs on normalised 0..1 values: brightness add,
330
+ contrast about 0.5 (unclipped, matching numpy), a cv2-equivalent HSV
331
+ "toward-max" saturation scale, the temperature shift, then a final clamp.
332
+ """
333
+ norm = {"r": "(r(X,Y)/255)", "g": "(g(X,Y)/255)", "b": "(b(X,Y)/255)"}
334
+
335
+ def bc(v: str) -> str:
336
+ return f"((({v}+{self.brightness:g})-0.5)*{self.contrast:g}+0.5)"
337
+
338
+ graded = bc(f"({src}/255)")
339
+ if self.saturation != 1.0:
340
+ # cv2 RGB->HSV scales saturation toward V=max(R,G,B), clipping S at 1:
341
+ # each channel C maps to V-(V-C)*ratio with ratio=min(sat, V/chroma).
342
+ # The HSV roundtrip clips inputs to 0..1 first.
343
+ rc = f"clip({bc(norm['r'])},0,1)"
344
+ gc = f"clip({bc(norm['g'])},0,1)"
345
+ bcl = f"clip({bc(norm['b'])},0,1)"
346
+ src_c = f"clip({graded},0,1)"
347
+ v = f"max(max({rc},{gc}),{bcl})"
348
+ mn = f"min(min({rc},{gc}),{bcl})"
349
+ chroma = f"({v}-{mn})"
350
+ ratio = f"if(gt({chroma},0),min({self.saturation:g},{v}/{chroma}),1)"
351
+ graded = f"({v}-({v}-{src_c})*({ratio}))"
352
+ if temp_offset != 0.0:
353
+ graded = f"({graded}+({temp_offset:g}))"
354
+ return f"clip(({graded})*255,0,255)"
355
+
356
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
357
+ """Compile the colour grade to a single ``geq`` ``-vf`` entry.
358
+
359
+ Mirrors :meth:`_grade_frame` per channel (brightness, contrast,
360
+ cv2-equivalent HSV saturation, temperature) on normalised RGB, faithful
361
+ to the numpy path to within uint8 rounding (geq rounds; numpy truncates).
362
+ All commas live inside single-quoted per-channel expressions, so the
363
+ entry is comma-join safe. A no-op grade returns ``None``. ``self.window``
364
+ appends a timeline ``enable='between(t,start,stop)'``.
365
+ """
366
+ if self._is_noop:
367
+ return None
368
+ temp_shift = self.temperature * 0.1
369
+ r = self._channel_expr("r(X,Y)", temp_shift)
370
+ g = self._channel_expr("g(X,Y)", 0.0)
371
+ b = self._channel_expr("b(X,Y)", -temp_shift)
372
+ expr = f"geq=r='{r}':g='{g}':b='{b}'"
373
+ if self.window is not None:
374
+ stop = ctx.frame_count / ctx.fps if ctx.fps > 0 and ctx.frame_count > 0 else None
375
+ start_s = 0.0 if self.window.start is None else float(self.window.start)
376
+ stop_s = stop if self.window.stop is None else float(self.window.stop)
377
+ if stop_s is not None:
378
+ expr += f":enable='between(t,{start_s:g},{stop_s:g})'"
379
+ return expr
380
+
381
+ @property
382
+ def compiles_to_filter(self) -> bool:
383
+ """A no-op grade emits no filter; any active field compiles to one ``geq`` entry."""
384
+ return not self._is_noop
385
+
281
386
 
282
387
  class Vignette(Effect):
283
388
  """Darkens the edges of the frame, drawing attention to the center."""
284
389
 
285
390
  op: Literal["vignette"] = "vignette"
286
391
  streamable: ClassVar[bool] = True
392
+ filter_needs_rgb24: ClassVar[bool] = True
287
393
 
288
394
  strength: float = Field(
289
395
  0.5,
@@ -321,6 +427,38 @@ class Vignette(Effect):
321
427
  assert self._stream_mask_3d is not None
322
428
  return (frame.astype(np.float32) * self._stream_mask_3d).astype(np.uint8)
323
429
 
430
+ @property
431
+ def compiles_to_filter(self) -> bool:
432
+ return True
433
+
434
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
435
+ """Compile the radial darkening to a per-channel ``geq`` expression.
436
+
437
+ Reproduces :meth:`_create_mask` exactly in ffmpeg's expression language:
438
+ ``geq`` exposes per-pixel ``X``/``Y`` and the plane size ``W``/``H``, so
439
+ the normalized coordinates ``Xn = 2*X/(W-1) - 1`` / ``Yn = 2*Y/(H-1) - 1``
440
+ mirror ``np.linspace(-1, 1, ...)``, and the mask is
441
+ ``1 - clip(hypot(Xn, Yn)/radius - 0.5, 0, 1) * 2*strength`` applied to every
442
+ channel. ``clip`` is spelled with ``min``/``max`` and explicit products so
443
+ the expression carries no commas of its own -- only the quoted ``r(X,Y)``
444
+ lookups and the ``enable`` clause do, and those quotes survive the runner's
445
+ comma-join of the ``-vf`` chain. ``geq`` has native timeline support, so a
446
+ windowed vignette gates on ``enable`` and passes other frames through.
447
+ """
448
+ xn = "((2*X/(W-1))-1)"
449
+ yn = "((2*Y/(H-1))-1)"
450
+ falloff = 2.0 * self.strength
451
+ mask = f"(1-min(max(sqrt({xn}*{xn}+{yn}*{yn})/{self.radius:.6f}-0.5,0),1)*{falloff:.6f})"
452
+ r = f"r='r(X,Y)*{mask}'"
453
+ g = f"g='g(X,Y)*{mask}'"
454
+ b = f"b='b(X,Y)*{mask}'"
455
+ filt = f"geq={r}:{g}:{b}"
456
+ if self.window is not None:
457
+ start_s = 0.0 if self.window.start is None else float(self.window.start)
458
+ stop_s = ctx.frame_count / ctx.fps if self.window.stop is None else float(self.window.stop)
459
+ filt += f":enable='between(t,{start_s:.6f},{stop_s:.6f})'"
460
+ return filt
461
+
324
462
 
325
463
  class KenBurns(Effect):
326
464
  """Cinematic pan-and-zoom that smoothly animates between two crop regions.
@@ -791,6 +929,91 @@ class TextOverlay(_AnchoredOverlay):
791
929
  self._rendered = self._render_text_image(frame_width, frame_height)
792
930
  return self._rendered
793
931
 
932
+ def _resolve_fontfile(self) -> Path | None:
933
+ """Filesystem path to the .ttf ``drawtext`` should render with, or ``None``.
934
+
935
+ Mirrors :func:`load_font`'s resolution down to a real file: a bundled
936
+ NAME -> its bundled .ttf; an explicit ``font_filename`` path that exists;
937
+ else the bundled DejaVu Sans default. ``None`` only when even DejaVu is
938
+ unreachable -- then ``load_font`` falls back to PIL's built-in bitmap
939
+ font, which ``drawtext`` cannot consume, so the op stays frame-only.
940
+ """
941
+ from videopython.base.fonts import BUNDLED_FONTS, DEFAULT_FONT_FILENAME, bundled_fonts_dir
942
+
943
+ name = self.font_filename or self.font
944
+ fonts_dir = bundled_fonts_dir()
945
+ if name:
946
+ bundled = BUNDLED_FONTS.get(name)
947
+ if bundled is not None:
948
+ candidate = fonts_dir / bundled
949
+ if candidate.is_file():
950
+ return candidate
951
+ candidate = Path(name)
952
+ if candidate.is_file():
953
+ return candidate
954
+ default = fonts_dir / DEFAULT_FONT_FILENAME
955
+ return default if default.is_file() else None
956
+
957
+ @property
958
+ def compiles_to_filter(self) -> bool:
959
+ """Compile to ``drawtext`` when a real TrueType file resolves, else frame-only."""
960
+ return self._resolve_fontfile() is not None
961
+
962
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
963
+ """Compile the anchored, word-wrapped text overlay to one ``drawtext`` entry.
964
+
965
+ The text is pre-wrapped with the SAME PIL metrics (:meth:`_wrap_text`)
966
+ the numpy twin uses and written to a ``textfile`` (registered on
967
+ ``ctx.owned_files`` for the runner to delete), so line breaks match
968
+ exactly -- drawtext has no pixel word-wrap. The box is
969
+ ``box=1:boxcolor=...:boxborderw=<padding>`` placed at the anchored
970
+ position :meth:`_compute_position` computes. Faithful *visual* twin
971
+ (freetype vs PIL metrics diverge a few px), re-baselined to ffmpeg.
972
+ Comma-join safe: every comma-bearing value is wrapped via
973
+ :func:`escape_filter_value`. yuv-safe (drawtext needs no rgb24).
974
+ """
975
+ fontfile = self._resolve_fontfile()
976
+ if fontfile is None:
977
+ return None
978
+
979
+ font = self._get_font()
980
+ max_px = int(self.max_width * ctx.width)
981
+ wrapped = self._wrap_text(self.text, font, max_px)
982
+
983
+ bbox = ImageDraw.Draw(Image.new("RGBA", (1, 1))).multiline_textbbox((0, 0), wrapped, font=font)
984
+ text_w = bbox[2] - bbox[0]
985
+ text_h = bbox[3] - bbox[1]
986
+ pad = self.background_padding
987
+ box_x, box_y = self._compute_position(ctx.width, ctx.height, text_w + 2 * pad, text_h + 2 * pad)
988
+
989
+ tmp = tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8")
990
+ try:
991
+ tmp.write(wrapped)
992
+ finally:
993
+ tmp.close()
994
+ textfile = Path(tmp.name)
995
+ ctx.owned_files.append(textfile)
996
+
997
+ r, g, b = self.text_color
998
+ parts = [
999
+ f"textfile={escape_filter_value(str(textfile))}",
1000
+ f"fontfile={escape_filter_value(str(fontfile))}",
1001
+ f"fontsize={self.font_size}",
1002
+ f"fontcolor={escape_filter_value(f'0x{r:02x}{g:02x}{b:02x}')}",
1003
+ f"x={box_x + pad}",
1004
+ f"y={box_y + pad}",
1005
+ ]
1006
+ if self.background_color is not None:
1007
+ br, bg, bb, ba = self.background_color
1008
+ parts.append("box=1")
1009
+ parts.append(f"boxcolor={escape_filter_value(f'0x{br:02x}{bg:02x}{bb:02x}@{ba / 255:.4f}')}")
1010
+ parts.append(f"boxborderw={pad}")
1011
+ if self.window is not None:
1012
+ start = 0.0 if self.window.start is None else float(self.window.start)
1013
+ stop = ctx.frame_count / ctx.fps if self.window.stop is None else float(self.window.stop)
1014
+ parts.append(f"enable={escape_filter_value(f'between(t,{start:g},{stop:g})')}")
1015
+ return "drawtext=" + ":".join(parts)
1016
+
794
1017
 
795
1018
  class ImageOverlay(_AnchoredOverlay):
796
1019
  """Composites a scaled image at an anchored position on every frame in the window.
@@ -1129,6 +1352,7 @@ class ChromaticAberration(Effect):
1129
1352
 
1130
1353
  op: Literal["chromatic_aberration"] = "chromatic_aberration"
1131
1354
  streamable: ClassVar[bool] = True
1355
+ filter_needs_rgb24: ClassVar[bool] = True
1132
1356
 
1133
1357
  shift_px: int = Field(
1134
1358
  gt=0,
@@ -1186,6 +1410,52 @@ class ChromaticAberration(Effect):
1186
1410
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1187
1411
  return self._aberrate(frame)
1188
1412
 
1413
+ def _enable_suffix(self, ctx: FilterCtx) -> str:
1414
+ """``:enable='between(t,START,STOP)'`` for a windowed effect, else ``""``.
1415
+
1416
+ START defaults to 0.0 and STOP to the segment duration
1417
+ (``ctx.frame_count / ctx.fps``); an unset window yields no suffix. The
1418
+ clause is single-quoted, so its inner comma survives the runner's
1419
+ ``",".join`` into the ``-vf`` chain.
1420
+ """
1421
+ if self.window is None:
1422
+ return ""
1423
+ total_seconds = ctx.frame_count / ctx.fps if ctx.fps > 0 and ctx.frame_count > 0 else 0.0
1424
+ start_s = 0.0 if self.window.start is None else float(self.window.start)
1425
+ stop_s = total_seconds if self.window.stop is None else float(self.window.stop)
1426
+ return f":enable='between(t,{start_s:.6f},{stop_s:.6f})'"
1427
+
1428
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1429
+ """Compile the channel split to a single native ``-vf`` entry.
1430
+
1431
+ ``horizontal``/``vertical`` become ``rgbashift`` (R by ``+shift_px``,
1432
+ B by ``-shift_px`` along the axis, ``edge=smear`` == BORDER_REPLICATE).
1433
+ ``radial`` becomes one ``geq`` sampling R/B from a scale about the frame
1434
+ center (matches bilinear ``cv2.remap`` to ~4/255). Green is untouched.
1435
+ Every inner comma sits inside single-quoted ``geq`` expressions or the
1436
+ ``enable`` clause, so the entry is comma-join-safe.
1437
+ """
1438
+ enable = self._enable_suffix(ctx)
1439
+ if self.mode == "horizontal":
1440
+ return f"rgbashift=rh={self.shift_px}:bh={-self.shift_px}:edge=smear{enable}"
1441
+ if self.mode == "vertical":
1442
+ return f"rgbashift=rv={self.shift_px}:bv={-self.shift_px}:edge=smear{enable}"
1443
+ # radial: reproduce the cv2.remap scale about the center via geq.
1444
+ cx, cy = ctx.width / 2.0, ctx.height / 2.0
1445
+ max_d = float(max(ctx.width, ctx.height))
1446
+ scale_r = 1.0 - self.shift_px / max_d
1447
+ scale_b = 1.0 + self.shift_px / max_d
1448
+ rx = f"((X-{cx:.6f})*{scale_r:.9f}+{cx:.6f})"
1449
+ ry = f"((Y-{cy:.6f})*{scale_r:.9f}+{cy:.6f})"
1450
+ bx = f"((X-{cx:.6f})*{scale_b:.9f}+{cx:.6f})"
1451
+ by = f"((Y-{cy:.6f})*{scale_b:.9f}+{cy:.6f})"
1452
+ return f"geq=r='r({rx}\\,{ry})':b='b({bx}\\,{by})'{enable}"
1453
+
1454
+ @property
1455
+ def compiles_to_filter(self) -> bool:
1456
+ """Every mode compiles to a single, comma-join-safe ``-vf`` entry."""
1457
+ return True
1458
+
1189
1459
 
1190
1460
  class Glitch(Effect):
1191
1461
  """Random horizontal slice displacement + channel offsets for a digital-corruption look.
@@ -1288,6 +1558,31 @@ class FilmGrain(Effect):
1288
1558
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1289
1559
  return self._grain_frame(frame, frame_index)
1290
1560
 
1561
+ @property
1562
+ def compiles_to_filter(self) -> bool:
1563
+ # Only unwindowed monochrome (luma-only) grain compiles to a single
1564
+ # comma-join-safe `noise` entry. The per-channel variant and any windowed
1565
+ # grain stay on the numpy frame path.
1566
+ return self.monochrome and self.window is None
1567
+
1568
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1569
+ """Compile luma-only grain to ffmpeg's temporal ``noise`` filter.
1570
+
1571
+ A statistical (NOT pixel-identical) twin of :meth:`process_frame`:
1572
+ ffmpeg's RNG differs from numpy's, so the grain is a different but
1573
+ distributionally-matched draw -- equivalent in look for a random grain.
1574
+ ``c0_flags=t`` adds fresh temporal noise to component 0 (Y in the
1575
+ pipeline's yuv420p) each frame; ``c0_strength`` is ffmpeg's 0-100 scale,
1576
+ which the numpy std (``intensity*255``) maps onto by the empirically-fit
1577
+ ``strength = intensity * 255 / 0.57`` (clamped to [1, 100]) so the
1578
+ injected per-pixel std matches in the real yuv pipeline. yuv-safe.
1579
+ """
1580
+ if not self.monochrome:
1581
+ return None
1582
+ strength = int(round(self.intensity * 255.0 / 0.57))
1583
+ strength = max(1, min(100, strength))
1584
+ return f"noise=c0_seed={self.seed}:c0_strength={strength}:c0_flags=t"
1585
+
1291
1586
 
1292
1587
  class Sharpen(Effect):
1293
1588
  """Unsharp-mask sharpening: blur the frame and subtract from itself with weight.
@@ -1330,6 +1625,29 @@ class Sharpen(Effect):
1330
1625
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1331
1626
  return self._sharpen_frame(frame)
1332
1627
 
1628
+ @property
1629
+ def compiles_to_filter(self) -> bool:
1630
+ # The whole-frame, windowless case maps cleanly to a single comma-join
1631
+ # safe `unsharp` -vf entry. A `window` would need an enable= gate that
1632
+ # `unsharp` does not support, so windowed sharpening stays frame-only;
1633
+ # `unsharp` also caps the combined matrix, so kernel_size > 13 stays too.
1634
+ return self.window is None and self.kernel_size <= 13
1635
+
1636
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1637
+ """Native unsharp-mask sharpening via ffmpeg's ``unsharp`` filter.
1638
+
1639
+ The numpy twin blends ``out = in + amount*(in - gaussian_blur(in))``.
1640
+ ``unsharp=lx:ly:la`` is the same unsharp-mask form with the same
1641
+ ``amount`` (``la``) weighting; its kernel is a box rather than a Gaussian,
1642
+ so the result is a faithful visual twin (re-baseline-to-ffmpeg), not
1643
+ bit-identical. ``amount == 0`` is a no-op (``None`` -> the runner keeps
1644
+ the frame). ``unsharp`` is yuv-safe, so no ``format=rgb24`` is needed.
1645
+ """
1646
+ if self.amount == 0:
1647
+ return None
1648
+ k = self.kernel_size
1649
+ return f"unsharp={k}:{k}:{self.amount:.6f}:{k}:{k}:{self.amount:.6f}"
1650
+
1333
1651
 
1334
1652
  class Pixelate(Effect):
1335
1653
  """Mosaic blocks: downscale + nearest-neighbour upscale, optionally limited to a region.
@@ -1379,6 +1697,22 @@ class Pixelate(Effect):
1379
1697
  return self._pixelate_frame(frame, self._stream_region_px)
1380
1698
 
1381
1699
 
1700
+ def _mirror_geq(coord: str, src_x: str, src_y: str) -> str:
1701
+ """One comma-join-safe ``geq`` entry that reflects a half-frame.
1702
+
1703
+ ``coord`` is the per-pixel predicate (e.g. ``gte(X,W/2)``); where it holds
1704
+ the pixel samples its mirror ``(src_x, src_y)``, elsewhere it keeps
1705
+ ``(X, Y)``. Per-channel (r/g/b) so geq runs in RGB. The function-call commas
1706
+ live inside single-quoted plane expressions, so the whole entry survives
1707
+ ``",".join`` into the ``-vf`` chain.
1708
+ """
1709
+
1710
+ def plane(channel: str) -> str:
1711
+ return f"if({coord},{channel}({src_x},{src_y}),{channel}(X,Y))"
1712
+
1713
+ return f"geq=r='{plane('r')}':g='{plane('g')}':b='{plane('b')}'"
1714
+
1715
+
1382
1716
  class MirrorFlip(Effect):
1383
1717
  """Flip frames or reflect one half onto the other.
1384
1718
 
@@ -1389,6 +1723,7 @@ class MirrorFlip(Effect):
1389
1723
 
1390
1724
  op: Literal["mirror_flip"] = "mirror_flip"
1391
1725
  streamable: ClassVar[bool] = True
1726
+ filter_needs_rgb24: ClassVar[bool] = True
1392
1727
 
1393
1728
  mode: Literal[
1394
1729
  "horizontal",
@@ -1432,6 +1767,54 @@ class MirrorFlip(Effect):
1432
1767
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1433
1768
  return self._flip_frame(frame)
1434
1769
 
1770
+ def _enable_suffix(self, ctx: FilterCtx) -> str:
1771
+ """``enable='between(t,START,STOP)'`` for the window, or '' when full-clip.
1772
+
1773
+ START defaults to 0.0 and STOP to the segment duration
1774
+ (``ctx.frame_count / ctx.fps``). Returns the bare ``key=value`` (no
1775
+ leading separator); the caller joins it with the right separator.
1776
+ """
1777
+ win = self.window
1778
+ if win is None:
1779
+ return ""
1780
+ stop = ctx.frame_count / ctx.fps if win.stop is None else float(win.stop)
1781
+ start = 0.0 if win.start is None else float(win.start)
1782
+ return f"enable='between(t,{start:.6f},{stop:.6f})'"
1783
+
1784
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1785
+ """Compile to one comma-join-safe ``-vf`` entry mirroring :meth:`process_frame`.
1786
+
1787
+ ``horizontal`` / ``vertical`` map to native ``hflip`` / ``vflip``
1788
+ (bit-exact). The ``mirror_*`` modes reflect one half onto the other via a
1789
+ single per-channel ``geq`` expression: each output pixel samples its
1790
+ mirror (``W-1-X`` / ``H-1-Y``) on the reflected half and itself on the
1791
+ kept half, matching the numpy ``w//2`` / ``h//2`` split for both even and
1792
+ odd dimensions. A window appends an ``enable=`` option (``=`` for the
1793
+ option-less flips, ``:`` after the ``geq`` options).
1794
+ """
1795
+ if self.mode == "horizontal":
1796
+ base, sep = "hflip", "="
1797
+ elif self.mode == "vertical":
1798
+ base, sep = "vflip", "="
1799
+ else:
1800
+ sep = ":"
1801
+ if self.mode == "mirror_left":
1802
+ coord, src_x, src_y = "gte(X,W/2)", "W-1-X", "Y"
1803
+ elif self.mode == "mirror_right":
1804
+ coord, src_x, src_y = "lt(X,W/2)", "W-1-X", "Y"
1805
+ elif self.mode == "mirror_top":
1806
+ coord, src_x, src_y = "gte(Y,H/2)", "X", "H-1-Y"
1807
+ else: # mirror_bottom
1808
+ coord, src_x, src_y = "lt(Y,H/2)", "X", "H-1-Y"
1809
+ base = _mirror_geq(coord, src_x, src_y)
1810
+
1811
+ enable = self._enable_suffix(ctx)
1812
+ return f"{base}{sep}{enable}" if enable else base
1813
+
1814
+ @property
1815
+ def compiles_to_filter(self) -> bool:
1816
+ return True
1817
+
1435
1818
 
1436
1819
  class Kaleidoscope(Effect):
1437
1820
  """N-way radial mirror around the frame center.
@@ -1443,6 +1826,7 @@ class Kaleidoscope(Effect):
1443
1826
 
1444
1827
  op: Literal["kaleidoscope"] = "kaleidoscope"
1445
1828
  streamable: ClassVar[bool] = True
1829
+ filter_needs_rgb24: ClassVar[bool] = True
1446
1830
 
1447
1831
  segments: int = Field(
1448
1832
  6,
@@ -1491,3 +1875,58 @@ class Kaleidoscope(Effect):
1491
1875
 
1492
1876
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1493
1877
  return self._kaleidoscope_frame(frame)
1878
+
1879
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1880
+ """Compile the radial mirror to a single per-pixel ``geq`` remap.
1881
+
1882
+ Mirrors :meth:`_build_maps`/:meth:`process_frame`. For each output pixel
1883
+ ``geq`` recovers polar coordinates about ``((W-1)/2, (H-1)/2)``, folds
1884
+ the angle into one wedge (``2*pi/segments``) the same way the numpy path
1885
+ does (``mod`` then reflect across the half-wedge), and samples with
1886
+ ``interpolation=bilinear``. Out-of-frame sample coordinates are reflected
1887
+ back in-bounds (registers 8/9) to reproduce ``cv2.BORDER_REFLECT``. One
1888
+ ``-vf`` node, comma-join-safe.
1889
+ """
1890
+ if ctx.width <= 0 or ctx.height <= 0:
1891
+ return None
1892
+ width, height = ctx.width, ctx.height
1893
+ cx = (width - 1) / 2.0
1894
+ cy = (height - 1) / 2.0
1895
+ wedge = 2.0 * math.pi / self.segments
1896
+ half = wedge * 0.5
1897
+ pw = 2 * width
1898
+ ph = 2 * height
1899
+ ao = self.angle_offset
1900
+
1901
+ common = (
1902
+ f"st(1,X-{cx:.8f});"
1903
+ f"st(2,Y-{cy:.8f});"
1904
+ f"st(3,hypot(ld(1),ld(2)));"
1905
+ f"st(4,atan2(ld(2),ld(1))-{ao:.8f});"
1906
+ f"st(5,mod(ld(4),{wedge:.10f}));"
1907
+ f"st(5,if(lt(ld(5),0),ld(5)+{wedge:.10f},ld(5)));"
1908
+ f"st(6,if(gt(ld(5),{half:.10f}),{wedge:.10f}-ld(5),ld(5)));"
1909
+ f"st(7,ld(6)+{ao:.8f});"
1910
+ f"st(8,mod({cx:.8f}+ld(3)*cos(ld(7)),{pw}));"
1911
+ f"st(8,if(lt(ld(8),0),ld(8)+{pw},ld(8)));"
1912
+ f"st(8,if(gte(ld(8),{width}),{pw - 1}-ld(8),ld(8)));"
1913
+ f"st(9,mod({cy:.8f}+ld(3)*sin(ld(7)),{ph}));"
1914
+ f"st(9,if(lt(ld(9),0),ld(9)+{ph},ld(9)));"
1915
+ f"st(9,if(gte(ld(9),{height}),{ph - 1}-ld(9),ld(9)));"
1916
+ )
1917
+ expr = (
1918
+ f"geq=interpolation=bilinear:"
1919
+ f"r='{common}r(ld(8),ld(9))':"
1920
+ f"g='{common}g(ld(8),ld(9))':"
1921
+ f"b='{common}b(ld(8),ld(9))'"
1922
+ )
1923
+ if self.window is not None:
1924
+ stop = ctx.frame_count / ctx.fps if ctx.fps > 0 and ctx.frame_count > 0 else 0.0
1925
+ start_s = self.window.start if self.window.start is not None else 0.0
1926
+ stop_s = self.window.stop if self.window.stop is not None else stop
1927
+ expr += f":enable='between(t,{start_s:.6f},{stop_s:.6f})'"
1928
+ return expr
1929
+
1930
+ @property
1931
+ def compiles_to_filter(self) -> bool:
1932
+ return True
@@ -265,6 +265,16 @@ class Operation(BaseModel):
265
265
  mirrors the same rule.
266
266
  """
267
267
  llm_exposed: ClassVar[bool] = True
268
+ internal_only: ClassVar[bool] = False
269
+ """Whether this op is engine-internal and must NOT be a chain op.
270
+
271
+ ``CutSeconds``/``CutFrames`` trim a segment, but trimming is the segment's own
272
+ ``start``/``end`` mechanism -- the engine constructs them directly. They are
273
+ non-streamable (no ffmpeg filter, no ``process_frame``), so to honor
274
+ "every registered op is streamable" they set this flag and are kept OUT of
275
+ the registry: they cannot appear in a plan's ``operations`` list or the LLM
276
+ schema, while direct construction (``CutSeconds(start=..., end=...)``) still
277
+ works. Default False (a normal, streamable chain op)."""
268
278
  time_fields: ClassVar[tuple[BoundedTimeField, ...]] = ()
269
279
  """Time-valued (seconds) fields :meth:`VideoEdit.repair` may clamp into range.
270
280
 
@@ -293,6 +303,12 @@ class Operation(BaseModel):
293
303
  raise TypeError(f"{cls.__name__}.op must be Literal of a single str, got {literal_values!r}")
294
304
  op_id = literal_values[0]
295
305
 
306
+ if cls.internal_only:
307
+ # Engine-internal op (CutSeconds/CutFrames): the op Literal is still
308
+ # validated above, but the op is kept out of the registry so it
309
+ # cannot be resolved as a chain op or exposed to the LLM.
310
+ return
311
+
296
312
  existing = Operation._registry.get(op_id)
297
313
  if existing is not None and existing is not cls:
298
314
  raise ValueError(
@@ -453,13 +469,18 @@ class Effect(Operation):
453
469
 
454
470
  category: ClassVar[OpCategory] = OpCategory.EFFECT
455
471
  audio_coupled: ClassVar[bool] = False
456
- """Whether the effect mutates audio alongside pixels (``afade``/``volume``).
457
-
458
- Audio-coupled effects cannot fold as post-operations across segment
459
- boundaries: each segment's audio is processed independently, so a gain
460
- envelope spanning a concat boundary would restart mid-ramp. The plan
461
- builder and the streamability report both consult this.
462
- """
472
+ """Whether the effect mutates audio alongside pixels (``afade``/``volume``)."""
473
+ filter_needs_rgb24: ClassVar[bool] = False
474
+ """Whether :meth:`to_ffmpeg_filter` reads RGB pixel values and so needs the
475
+ decode chain converted to ``rgb24`` first.
476
+
477
+ ``geq``'s ``r``/``g``/``b`` accessors and ``rgbashift`` read RGB channels;
478
+ on the native yuv decode stream they read garbage. When a decode-stage
479
+ effect sets this, the plan builder prepends ``format=rgb24`` to the segment's
480
+ decode filter chain (encode-stage effects already receive rgb24 from the
481
+ frame pipe). Default False -- geometry/overlay filters (``hflip``,
482
+ ``subtitles=``) work in any pixel format. Mirrors the rgb24 frames the numpy
483
+ ``process_frame`` twin operated on."""
463
484
 
464
485
  window: TimeRange | None = Field(
465
486
  None,