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