videopython 0.45.0__tar.gz → 0.47.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.45.0 → videopython-0.47.0}/PKG-INFO +2 -2
  2. {videopython-0.45.0 → videopython-0.47.0}/README.md +1 -1
  3. {videopython-0.45.0 → videopython-0.47.0}/pyproject.toml +1 -1
  4. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/effects.py +1 -2
  5. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/transforms.py +0 -1
  6. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/effects.py +87 -20
  7. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/operation.py +42 -11
  8. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/streaming.py +216 -199
  9. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/transcription_overlay.py +0 -1
  10. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/transforms.py +9 -6
  11. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/video_edit.py +165 -87
  12. {videopython-0.45.0 → videopython-0.47.0}/.gitignore +0 -0
  13. {videopython-0.45.0 → videopython-0.47.0}/LICENSE +0 -0
  14. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/__init__.py +0 -0
  15. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/__init__.py +0 -0
  16. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/_device.py +0 -0
  17. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/_optional.py +0 -0
  18. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/_predictor.py +0 -0
  19. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/_revisions.py +0 -0
  20. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  21. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/config.py +0 -0
  22. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/dubber.py +0 -0
  23. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  24. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/loudness.py +0 -0
  25. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/models.py +0 -0
  26. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  27. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/quality.py +0 -0
  28. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/remux.py +0 -0
  29. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/timing.py +0 -0
  30. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  31. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/__init__.py +0 -0
  32. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/_tts_backend.py +0 -0
  33. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/audio.py +0 -0
  34. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/image.py +0 -0
  35. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/qwen3.py +0 -0
  36. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/translation.py +0 -0
  37. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/video.py +0 -0
  38. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/__init__.py +0 -0
  39. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/audio.py +0 -0
  40. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/faces.py +0 -0
  41. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/image.py +0 -0
  42. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/objects.py +0 -0
  43. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/separation.py +0 -0
  44. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/temporal.py +0 -0
  45. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
  46. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  47. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/models.py +0 -0
  48. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
  49. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/stages.py +0 -0
  50. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/audio/__init__.py +0 -0
  51. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/audio/analysis.py +0 -0
  52. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/audio/audio.py +0 -0
  53. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/__init__.py +0 -0
  54. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/_dimensions.py +0 -0
  55. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/_ffmpeg.py +0 -0
  56. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/_video_io.py +0 -0
  57. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/description.py +0 -0
  58. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/draw_detections.py +0 -0
  59. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/exceptions.py +0 -0
  60. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  61. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  62. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  63. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  64. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  65. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  66. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  67. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  68. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  69. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  70. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/__init__.py +0 -0
  71. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/transcription.py +0 -0
  72. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/video.py +0 -0
  73. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/__init__.py +0 -0
  74. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/_ass.py +0 -0
  75. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/_easing.py +0 -0
  76. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/audio_ops.py +0 -0
  77. {videopython-0.45.0 → videopython-0.47.0}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.45.0
3
+ Version: 0.47.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.45.0"
3
+ version = "0.47.0"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -9,7 +9,7 @@ AI-free renderer in :mod:`videopython.base.draw_detections`.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from typing import Any, ClassVar, Literal
12
+ from typing import Any, Literal
13
13
 
14
14
  import numpy as np
15
15
  from pydantic import Field, PrivateAttr
@@ -39,7 +39,6 @@ class ObjectDetectionOverlay(Effect):
39
39
  """
40
40
 
41
41
  op: Literal["object_detection_overlay"] = "object_detection_overlay"
42
- streamable: ClassVar[bool] = True
43
42
 
44
43
  confidence_threshold: float = Field(0.5, ge=0, le=1, description="Minimum detection confidence to draw a box, 0-1.")
45
44
  class_filter: list[str] | None = Field(
@@ -44,7 +44,6 @@ class FaceTrackingCrop(Operation):
44
44
 
45
45
  op: Literal["face_crop"] = "face_crop"
46
46
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
47
- streamable: ClassVar[bool] = True
48
47
  compiles_from_source: ClassVar[bool] = True
49
48
 
50
49
  target_aspect: tuple[int, int] = Field((9, 16), description="Output aspect ratio as (width, height).")
@@ -15,6 +15,7 @@ override :meth:`Effect.predict_metadata`. Anchored RGBA overlays
15
15
  from __future__ import annotations
16
16
 
17
17
  import logging
18
+ import tempfile
18
19
  from io import BytesIO
19
20
  from pathlib import Path
20
21
  from typing import TYPE_CHECKING, Any, ClassVar, Literal
@@ -24,6 +25,7 @@ import numpy as np
24
25
  from PIL import Image, ImageDraw, ImageFont
25
26
  from pydantic import Field, PrivateAttr, model_validator
26
27
 
28
+ from videopython.base._ffmpeg import escape_filter_value
27
29
  from videopython.base.description import BoundingBox
28
30
  from videopython.base.exceptions import PlanError, PlanErrorCode, PlanValidationError
29
31
  from videopython.base.fonts import load_font
@@ -70,7 +72,6 @@ class FullImageOverlay(Effect):
70
72
  """
71
73
 
72
74
  op: Literal["full_image_overlay"] = "full_image_overlay"
73
- streamable: ClassVar[bool] = True
74
75
  llm_exposed: ClassVar[bool] = False
75
76
 
76
77
  source: Path = Field(
@@ -140,7 +141,6 @@ class Blur(Effect):
140
141
  """Applies Gaussian blur that can stay constant or ramp up/down over the clip."""
141
142
 
142
143
  op: Literal["blur_effect"] = "blur_effect"
143
- streamable: ClassVar[bool] = True
144
144
 
145
145
  mode: Literal["constant", "ascending", "descending"] = Field(
146
146
  description=(
@@ -189,7 +189,6 @@ class Zoom(Effect):
189
189
  """Progressively zooms into or out of the frame center over the clip duration."""
190
190
 
191
191
  op: Literal["zoom_effect"] = "zoom_effect"
192
- streamable: ClassVar[bool] = True
193
192
 
194
193
  zoom_factor: float = Field(
195
194
  gt=1,
@@ -230,7 +229,6 @@ class ColorGrading(Effect):
230
229
  """Adjusts color properties: brightness, contrast, saturation, and temperature."""
231
230
 
232
231
  op: Literal["color_adjust"] = "color_adjust"
233
- streamable: ClassVar[bool] = True
234
232
 
235
233
  brightness: float = Field(
236
234
  0.0,
@@ -283,7 +281,6 @@ class Vignette(Effect):
283
281
  """Darkens the edges of the frame, drawing attention to the center."""
284
282
 
285
283
  op: Literal["vignette"] = "vignette"
286
- streamable: ClassVar[bool] = True
287
284
 
288
285
  strength: float = Field(
289
286
  0.5,
@@ -331,7 +328,6 @@ class KenBurns(Effect):
331
328
  """
332
329
 
333
330
  op: Literal["ken_burns"] = "ken_burns"
334
- streamable: ClassVar[bool] = True
335
331
 
336
332
  start_region: BoundingBox = Field(
337
333
  description="Starting crop region as a BoundingBox with normalized 0-1 coordinates."
@@ -411,7 +407,6 @@ class Fade(Effect):
411
407
  """Fades video and audio to or from black."""
412
408
 
413
409
  op: Literal["fade"] = "fade"
414
- streamable: ClassVar[bool] = True
415
410
  audio_coupled: ClassVar[bool] = True
416
411
 
417
412
  mode: Literal["in", "out", "in_out"] = Field(
@@ -513,7 +508,6 @@ class VolumeAdjust(Effect):
513
508
  """Changes audio volume within a time range without affecting video frames."""
514
509
 
515
510
  op: Literal["volume_adjust"] = "volume_adjust"
516
- streamable: ClassVar[bool] = True
517
511
  audio_coupled: ClassVar[bool] = True
518
512
 
519
513
  volume: float = Field(
@@ -690,7 +684,6 @@ class TextOverlay(_AnchoredOverlay):
690
684
  """Draws text on video frames, with auto word-wrap and optional background box."""
691
685
 
692
686
  op: Literal["text_overlay"] = "text_overlay"
693
- streamable: ClassVar[bool] = True
694
687
 
695
688
  text: str = Field(min_length=1, description=r"The string to display. Use \n for line breaks.")
696
689
  position: tuple[float, float] = Field(
@@ -791,6 +784,91 @@ class TextOverlay(_AnchoredOverlay):
791
784
  self._rendered = self._render_text_image(frame_width, frame_height)
792
785
  return self._rendered
793
786
 
787
+ def _resolve_fontfile(self) -> Path | None:
788
+ """Filesystem path to the .ttf ``drawtext`` should render with, or ``None``.
789
+
790
+ Mirrors :func:`load_font`'s resolution down to a real file: a bundled
791
+ NAME -> its bundled .ttf; an explicit ``font_filename`` path that exists;
792
+ else the bundled DejaVu Sans default. ``None`` only when even DejaVu is
793
+ unreachable -- then ``load_font`` falls back to PIL's built-in bitmap
794
+ font, which ``drawtext`` cannot consume, so the op stays frame-only.
795
+ """
796
+ from videopython.base.fonts import BUNDLED_FONTS, DEFAULT_FONT_FILENAME, bundled_fonts_dir
797
+
798
+ name = self.font_filename or self.font
799
+ fonts_dir = bundled_fonts_dir()
800
+ if name:
801
+ bundled = BUNDLED_FONTS.get(name)
802
+ if bundled is not None:
803
+ candidate = fonts_dir / bundled
804
+ if candidate.is_file():
805
+ return candidate
806
+ candidate = Path(name)
807
+ if candidate.is_file():
808
+ return candidate
809
+ default = fonts_dir / DEFAULT_FONT_FILENAME
810
+ return default if default.is_file() else None
811
+
812
+ @property
813
+ def compiles_to_filter(self) -> bool:
814
+ """Compile to ``drawtext`` when a real TrueType file resolves, else frame-only."""
815
+ return self._resolve_fontfile() is not None
816
+
817
+ def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
818
+ """Compile the anchored, word-wrapped text overlay to one ``drawtext`` entry.
819
+
820
+ The text is pre-wrapped with the SAME PIL metrics (:meth:`_wrap_text`)
821
+ the numpy twin uses and written to a ``textfile`` (registered on
822
+ ``ctx.owned_files`` for the runner to delete), so line breaks match
823
+ exactly -- drawtext has no pixel word-wrap. The box is
824
+ ``box=1:boxcolor=...:boxborderw=<padding>`` placed at the anchored
825
+ position :meth:`_compute_position` computes. Faithful *visual* twin
826
+ (freetype vs PIL metrics diverge a few px), re-baselined to ffmpeg.
827
+ Comma-join safe: every comma-bearing value is wrapped via
828
+ :func:`escape_filter_value`. yuv-safe (drawtext needs no rgb24).
829
+ """
830
+ fontfile = self._resolve_fontfile()
831
+ if fontfile is None:
832
+ return None
833
+
834
+ font = self._get_font()
835
+ max_px = int(self.max_width * ctx.width)
836
+ wrapped = self._wrap_text(self.text, font, max_px)
837
+
838
+ bbox = ImageDraw.Draw(Image.new("RGBA", (1, 1))).multiline_textbbox((0, 0), wrapped, font=font)
839
+ text_w = bbox[2] - bbox[0]
840
+ text_h = bbox[3] - bbox[1]
841
+ pad = self.background_padding
842
+ box_x, box_y = self._compute_position(ctx.width, ctx.height, text_w + 2 * pad, text_h + 2 * pad)
843
+
844
+ tmp = tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8")
845
+ try:
846
+ tmp.write(wrapped)
847
+ finally:
848
+ tmp.close()
849
+ textfile = Path(tmp.name)
850
+ ctx.owned_files.append(textfile)
851
+
852
+ r, g, b = self.text_color
853
+ parts = [
854
+ f"textfile={escape_filter_value(str(textfile))}",
855
+ f"fontfile={escape_filter_value(str(fontfile))}",
856
+ f"fontsize={self.font_size}",
857
+ f"fontcolor={escape_filter_value(f'0x{r:02x}{g:02x}{b:02x}')}",
858
+ f"x={box_x + pad}",
859
+ f"y={box_y + pad}",
860
+ ]
861
+ if self.background_color is not None:
862
+ br, bg, bb, ba = self.background_color
863
+ parts.append("box=1")
864
+ parts.append(f"boxcolor={escape_filter_value(f'0x{br:02x}{bg:02x}{bb:02x}@{ba / 255:.4f}')}")
865
+ parts.append(f"boxborderw={pad}")
866
+ if self.window is not None:
867
+ start = 0.0 if self.window.start is None else float(self.window.start)
868
+ stop = ctx.frame_count / ctx.fps if self.window.stop is None else float(self.window.stop)
869
+ parts.append(f"enable={escape_filter_value(f'between(t,{start:g},{stop:g})')}")
870
+ return "drawtext=" + ":".join(parts)
871
+
794
872
 
795
873
  class ImageOverlay(_AnchoredOverlay):
796
874
  """Composites a scaled image at an anchored position on every frame in the window.
@@ -813,7 +891,6 @@ class ImageOverlay(_AnchoredOverlay):
813
891
  """
814
892
 
815
893
  op: Literal["image_overlay"] = "image_overlay"
816
- streamable: ClassVar[bool] = True
817
894
  llm_exposed: ClassVar[bool] = False
818
895
 
819
896
  source: Path = Field(
@@ -935,7 +1012,6 @@ class Shake(Effect):
935
1012
  """
936
1013
 
937
1014
  op: Literal["shake"] = "shake"
938
- streamable: ClassVar[bool] = True
939
1015
 
940
1016
  intensity_px: float = Field(
941
1017
  gt=0,
@@ -1001,7 +1077,6 @@ class PunchIn(Effect):
1001
1077
  """
1002
1078
 
1003
1079
  op: Literal["punch_in"] = "punch_in"
1004
- streamable: ClassVar[bool] = True
1005
1080
 
1006
1081
  zoom_factor: float = Field(
1007
1082
  gt=1.0,
@@ -1064,7 +1139,6 @@ class Flash(Effect):
1064
1139
  """
1065
1140
 
1066
1141
  op: Literal["flash"] = "flash"
1067
- streamable: ClassVar[bool] = True
1068
1142
 
1069
1143
  color: tuple[int, int, int] = Field(
1070
1144
  (255, 255, 255),
@@ -1128,7 +1202,6 @@ class ChromaticAberration(Effect):
1128
1202
  """
1129
1203
 
1130
1204
  op: Literal["chromatic_aberration"] = "chromatic_aberration"
1131
- streamable: ClassVar[bool] = True
1132
1205
 
1133
1206
  shift_px: int = Field(
1134
1207
  gt=0,
@@ -1196,7 +1269,6 @@ class Glitch(Effect):
1196
1269
  """
1197
1270
 
1198
1271
  op: Literal["glitch"] = "glitch"
1199
- streamable: ClassVar[bool] = True
1200
1272
 
1201
1273
  intensity: float = Field(
1202
1274
  0.5,
@@ -1258,7 +1330,6 @@ class FilmGrain(Effect):
1258
1330
  """
1259
1331
 
1260
1332
  op: Literal["film_grain"] = "film_grain"
1261
- streamable: ClassVar[bool] = True
1262
1333
 
1263
1334
  intensity: float = Field(
1264
1335
  0.1,
@@ -1297,7 +1368,6 @@ class Sharpen(Effect):
1297
1368
  """
1298
1369
 
1299
1370
  op: Literal["sharpen"] = "sharpen"
1300
- streamable: ClassVar[bool] = True
1301
1371
 
1302
1372
  amount: float = Field(
1303
1373
  1.0,
@@ -1339,7 +1409,6 @@ class Pixelate(Effect):
1339
1409
  """
1340
1410
 
1341
1411
  op: Literal["pixelate"] = "pixelate"
1342
- streamable: ClassVar[bool] = True
1343
1412
 
1344
1413
  block_size: int = Field(
1345
1414
  gt=1,
@@ -1388,7 +1457,6 @@ class MirrorFlip(Effect):
1388
1457
  """
1389
1458
 
1390
1459
  op: Literal["mirror_flip"] = "mirror_flip"
1391
- streamable: ClassVar[bool] = True
1392
1460
 
1393
1461
  mode: Literal[
1394
1462
  "horizontal",
@@ -1442,7 +1510,6 @@ class Kaleidoscope(Effect):
1442
1510
  """
1443
1511
 
1444
1512
  op: Literal["kaleidoscope"] = "kaleidoscope"
1445
- streamable: ClassVar[bool] = True
1446
1513
 
1447
1514
  segments: int = Field(
1448
1515
  6,
@@ -18,7 +18,6 @@ Subclass contract::
18
18
 
19
19
  op: Literal["resize"] = "resize"
20
20
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
21
- streamable: ClassVar[bool] = True
22
21
 
23
22
  width: int | None = Field(None, gt=0)
24
23
  height: int | None = Field(None, gt=0)
@@ -234,8 +233,8 @@ class Operation(BaseModel):
234
233
 
235
234
  Concrete subclasses MUST declare an ``op`` field with a single-value
236
235
  ``Literal[str]`` annotation; that value is the discriminator on the JSON
237
- wire and the registry key. Subclasses may override the ``category``,
238
- ``streamable``, and ``requires`` ClassVars.
236
+ wire and the registry key. Subclasses may override the ``category`` and
237
+ ``requires`` ClassVars.
239
238
 
240
239
  ``predict_metadata`` defaults to identity; ``to_ffmpeg_filter`` defaults to
241
240
  ``None`` (no filter compilation).
@@ -246,7 +245,6 @@ class Operation(BaseModel):
246
245
  op: str
247
246
 
248
247
  category: ClassVar[OpCategory] = OpCategory.SPECIAL
249
- streamable: ClassVar[bool] = False
250
248
  requires: ClassVar[tuple[str, ...]] = ()
251
249
  compiles_from_source: ClassVar[bool] = False
252
250
  """Whether the op's filter compile decodes the source itself (face_crop's
@@ -265,6 +263,15 @@ class Operation(BaseModel):
265
263
  mirrors the same rule.
266
264
  """
267
265
  llm_exposed: ClassVar[bool] = True
266
+ internal_only: ClassVar[bool] = False
267
+ """Whether this op is engine-internal and must NOT be a chain op.
268
+
269
+ ``CutSeconds``/``CutFrames`` trim a segment, but trimming is the segment's own
270
+ ``start``/``end`` mechanism -- the engine constructs them directly. They have
271
+ no ffmpeg filter and no ``process_frame``, so this flag keeps them OUT of the
272
+ registry: they cannot appear in a plan's ``operations`` list or the LLM
273
+ schema, while direct construction (``CutSeconds(start=..., end=...)``) still
274
+ works. Default False (a normal chain op)."""
268
275
  time_fields: ClassVar[tuple[BoundedTimeField, ...]] = ()
269
276
  """Time-valued (seconds) fields :meth:`VideoEdit.repair` may clamp into range.
270
277
 
@@ -293,6 +300,12 @@ class Operation(BaseModel):
293
300
  raise TypeError(f"{cls.__name__}.op must be Literal of a single str, got {literal_values!r}")
294
301
  op_id = literal_values[0]
295
302
 
303
+ if cls.internal_only:
304
+ # Engine-internal op (CutSeconds/CutFrames): the op Literal is still
305
+ # validated above, but the op is kept out of the registry so it
306
+ # cannot be resolved as a chain op or exposed to the LLM.
307
+ return
308
+
296
309
  existing = Operation._registry.get(op_id)
297
310
  if existing is not None and existing is not cls:
298
311
  raise ValueError(
@@ -433,6 +446,19 @@ class Operation(BaseModel):
433
446
  """
434
447
  return None
435
448
 
449
+ def streams(self) -> bool:
450
+ """Whether this op streams in O(1) memory at its plan position.
451
+
452
+ Structural replacement for the former ``streamable`` ClassVar: a transform
453
+ streams iff it overrides :meth:`to_ffmpeg_filter`. :class:`Effect` widens
454
+ this (a frame effect streams via ``process_frame``; a filter effect via
455
+ ``compiles_to_filter``). The one case structure cannot express -- the
456
+ override exists but the filter compiles to ``None`` at this position -- is
457
+ caught at runtime by the ``STREAMING_UNSUPPORTED`` raise in
458
+ ``VideoEdit._compile_streaming_plans``.
459
+ """
460
+ return type(self).to_ffmpeg_filter is not Operation.to_ffmpeg_filter
461
+
436
462
 
437
463
  class Effect(Operation):
438
464
  """Operation that preserves shape and frame count, driven by per-frame streaming.
@@ -453,13 +479,7 @@ class Effect(Operation):
453
479
 
454
480
  category: ClassVar[OpCategory] = OpCategory.EFFECT
455
481
  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
- """
482
+ """Whether the effect mutates audio alongside pixels (``afade``/``volume``)."""
463
483
 
464
484
  window: TimeRange | None = Field(
465
485
  None,
@@ -506,3 +526,14 @@ class Effect(Operation):
506
526
  ``frame_index`` is 0-based within this effect's active window.
507
527
  """
508
528
  raise NotImplementedError(f"{type(self).__name__} does not support streaming")
529
+
530
+ def streams(self) -> bool:
531
+ """An effect streams via per-frame Python (``process_frame``) or a filter.
532
+
533
+ Frame effects override :meth:`process_frame`; filter effects
534
+ (``add_subtitles``, ``vignette``, ...) instead set
535
+ :attr:`compiles_to_filter` and implement :meth:`to_ffmpeg_filter`.
536
+ ``add_subtitles`` streams *only* via the filter path (it does not override
537
+ ``process_frame``), so ``compiles_to_filter`` is consulted per-instance.
538
+ """
539
+ return type(self).process_frame is not Effect.process_frame or self.compiles_to_filter