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.
- {videopython-0.45.0 → videopython-0.47.0}/PKG-INFO +2 -2
- {videopython-0.45.0 → videopython-0.47.0}/README.md +1 -1
- {videopython-0.45.0 → videopython-0.47.0}/pyproject.toml +1 -1
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/effects.py +1 -2
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/transforms.py +0 -1
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/effects.py +87 -20
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/operation.py +42 -11
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/streaming.py +216 -199
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/transcription_overlay.py +0 -1
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/transforms.py +9 -6
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/video_edit.py +165 -87
- {videopython-0.45.0 → videopython-0.47.0}/.gitignore +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/LICENSE +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/_optional.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/_predictor.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/_revisions.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/_tts_backend.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/audio/audio.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/description.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/exceptions.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/transcription.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/base/video.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/_ass.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.45.0 → videopython-0.47.0}/src/videopython/editing/audio_ops.py +0 -0
- {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.
|
|
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 (
|
|
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 (
|
|
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
|
|
|
@@ -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,
|
|
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
|
-
``
|
|
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
|