videopython 0.46.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.46.0 → videopython-0.47.0}/PKG-INFO +1 -1
- {videopython-0.46.0 → videopython-0.47.0}/pyproject.toml +1 -1
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/effects.py +1 -2
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/transforms.py +0 -1
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/effects.py +0 -372
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/operation.py +30 -20
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/streaming.py +18 -26
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/transcription_overlay.py +0 -1
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/transforms.py +0 -6
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/video_edit.py +12 -24
- {videopython-0.46.0 → videopython-0.47.0}/.gitignore +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/LICENSE +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/README.md +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/_optional.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/_predictor.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/_revisions.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/_tts_backend.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/audio/audio.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/description.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/exceptions.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/transcription.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/video.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/_ass.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/audio_ops.py +0 -0
- {videopython-0.46.0 → videopython-0.47.0}/src/videopython/py.typed +0 -0
|
@@ -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,7 +15,6 @@ override :meth:`Effect.predict_metadata`. Anchored RGBA overlays
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import logging
|
|
18
|
-
import math
|
|
19
18
|
import tempfile
|
|
20
19
|
from io import BytesIO
|
|
21
20
|
from pathlib import Path
|
|
@@ -73,7 +72,6 @@ class FullImageOverlay(Effect):
|
|
|
73
72
|
"""
|
|
74
73
|
|
|
75
74
|
op: Literal["full_image_overlay"] = "full_image_overlay"
|
|
76
|
-
streamable: ClassVar[bool] = True
|
|
77
75
|
llm_exposed: ClassVar[bool] = False
|
|
78
76
|
|
|
79
77
|
source: Path = Field(
|
|
@@ -143,7 +141,6 @@ class Blur(Effect):
|
|
|
143
141
|
"""Applies Gaussian blur that can stay constant or ramp up/down over the clip."""
|
|
144
142
|
|
|
145
143
|
op: Literal["blur_effect"] = "blur_effect"
|
|
146
|
-
streamable: ClassVar[bool] = True
|
|
147
144
|
|
|
148
145
|
mode: Literal["constant", "ascending", "descending"] = Field(
|
|
149
146
|
description=(
|
|
@@ -192,7 +189,6 @@ class Zoom(Effect):
|
|
|
192
189
|
"""Progressively zooms into or out of the frame center over the clip duration."""
|
|
193
190
|
|
|
194
191
|
op: Literal["zoom_effect"] = "zoom_effect"
|
|
195
|
-
streamable: ClassVar[bool] = True
|
|
196
192
|
|
|
197
193
|
zoom_factor: float = Field(
|
|
198
194
|
gt=1,
|
|
@@ -228,47 +224,11 @@ class Zoom(Effect):
|
|
|
228
224
|
cropped = frame[round(y) : round(y + h), round(x) : round(x + w)]
|
|
229
225
|
return cv2.resize(cropped, (width, height))
|
|
230
226
|
|
|
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
|
-
|
|
265
227
|
|
|
266
228
|
class ColorGrading(Effect):
|
|
267
229
|
"""Adjusts color properties: brightness, contrast, saturation, and temperature."""
|
|
268
230
|
|
|
269
231
|
op: Literal["color_adjust"] = "color_adjust"
|
|
270
|
-
streamable: ClassVar[bool] = True
|
|
271
|
-
filter_needs_rgb24: ClassVar[bool] = True
|
|
272
232
|
|
|
273
233
|
brightness: float = Field(
|
|
274
234
|
0.0,
|
|
@@ -316,80 +276,11 @@ class ColorGrading(Effect):
|
|
|
316
276
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
317
277
|
return self._grade_frame(frame)
|
|
318
278
|
|
|
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
|
-
|
|
386
279
|
|
|
387
280
|
class Vignette(Effect):
|
|
388
281
|
"""Darkens the edges of the frame, drawing attention to the center."""
|
|
389
282
|
|
|
390
283
|
op: Literal["vignette"] = "vignette"
|
|
391
|
-
streamable: ClassVar[bool] = True
|
|
392
|
-
filter_needs_rgb24: ClassVar[bool] = True
|
|
393
284
|
|
|
394
285
|
strength: float = Field(
|
|
395
286
|
0.5,
|
|
@@ -427,38 +318,6 @@ class Vignette(Effect):
|
|
|
427
318
|
assert self._stream_mask_3d is not None
|
|
428
319
|
return (frame.astype(np.float32) * self._stream_mask_3d).astype(np.uint8)
|
|
429
320
|
|
|
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
|
-
|
|
462
321
|
|
|
463
322
|
class KenBurns(Effect):
|
|
464
323
|
"""Cinematic pan-and-zoom that smoothly animates between two crop regions.
|
|
@@ -469,7 +328,6 @@ class KenBurns(Effect):
|
|
|
469
328
|
"""
|
|
470
329
|
|
|
471
330
|
op: Literal["ken_burns"] = "ken_burns"
|
|
472
|
-
streamable: ClassVar[bool] = True
|
|
473
331
|
|
|
474
332
|
start_region: BoundingBox = Field(
|
|
475
333
|
description="Starting crop region as a BoundingBox with normalized 0-1 coordinates."
|
|
@@ -549,7 +407,6 @@ class Fade(Effect):
|
|
|
549
407
|
"""Fades video and audio to or from black."""
|
|
550
408
|
|
|
551
409
|
op: Literal["fade"] = "fade"
|
|
552
|
-
streamable: ClassVar[bool] = True
|
|
553
410
|
audio_coupled: ClassVar[bool] = True
|
|
554
411
|
|
|
555
412
|
mode: Literal["in", "out", "in_out"] = Field(
|
|
@@ -651,7 +508,6 @@ class VolumeAdjust(Effect):
|
|
|
651
508
|
"""Changes audio volume within a time range without affecting video frames."""
|
|
652
509
|
|
|
653
510
|
op: Literal["volume_adjust"] = "volume_adjust"
|
|
654
|
-
streamable: ClassVar[bool] = True
|
|
655
511
|
audio_coupled: ClassVar[bool] = True
|
|
656
512
|
|
|
657
513
|
volume: float = Field(
|
|
@@ -828,7 +684,6 @@ class TextOverlay(_AnchoredOverlay):
|
|
|
828
684
|
"""Draws text on video frames, with auto word-wrap and optional background box."""
|
|
829
685
|
|
|
830
686
|
op: Literal["text_overlay"] = "text_overlay"
|
|
831
|
-
streamable: ClassVar[bool] = True
|
|
832
687
|
|
|
833
688
|
text: str = Field(min_length=1, description=r"The string to display. Use \n for line breaks.")
|
|
834
689
|
position: tuple[float, float] = Field(
|
|
@@ -1036,7 +891,6 @@ class ImageOverlay(_AnchoredOverlay):
|
|
|
1036
891
|
"""
|
|
1037
892
|
|
|
1038
893
|
op: Literal["image_overlay"] = "image_overlay"
|
|
1039
|
-
streamable: ClassVar[bool] = True
|
|
1040
894
|
llm_exposed: ClassVar[bool] = False
|
|
1041
895
|
|
|
1042
896
|
source: Path = Field(
|
|
@@ -1158,7 +1012,6 @@ class Shake(Effect):
|
|
|
1158
1012
|
"""
|
|
1159
1013
|
|
|
1160
1014
|
op: Literal["shake"] = "shake"
|
|
1161
|
-
streamable: ClassVar[bool] = True
|
|
1162
1015
|
|
|
1163
1016
|
intensity_px: float = Field(
|
|
1164
1017
|
gt=0,
|
|
@@ -1224,7 +1077,6 @@ class PunchIn(Effect):
|
|
|
1224
1077
|
"""
|
|
1225
1078
|
|
|
1226
1079
|
op: Literal["punch_in"] = "punch_in"
|
|
1227
|
-
streamable: ClassVar[bool] = True
|
|
1228
1080
|
|
|
1229
1081
|
zoom_factor: float = Field(
|
|
1230
1082
|
gt=1.0,
|
|
@@ -1287,7 +1139,6 @@ class Flash(Effect):
|
|
|
1287
1139
|
"""
|
|
1288
1140
|
|
|
1289
1141
|
op: Literal["flash"] = "flash"
|
|
1290
|
-
streamable: ClassVar[bool] = True
|
|
1291
1142
|
|
|
1292
1143
|
color: tuple[int, int, int] = Field(
|
|
1293
1144
|
(255, 255, 255),
|
|
@@ -1351,8 +1202,6 @@ class ChromaticAberration(Effect):
|
|
|
1351
1202
|
"""
|
|
1352
1203
|
|
|
1353
1204
|
op: Literal["chromatic_aberration"] = "chromatic_aberration"
|
|
1354
|
-
streamable: ClassVar[bool] = True
|
|
1355
|
-
filter_needs_rgb24: ClassVar[bool] = True
|
|
1356
1205
|
|
|
1357
1206
|
shift_px: int = Field(
|
|
1358
1207
|
gt=0,
|
|
@@ -1410,52 +1259,6 @@ class ChromaticAberration(Effect):
|
|
|
1410
1259
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1411
1260
|
return self._aberrate(frame)
|
|
1412
1261
|
|
|
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
|
-
|
|
1459
1262
|
|
|
1460
1263
|
class Glitch(Effect):
|
|
1461
1264
|
"""Random horizontal slice displacement + channel offsets for a digital-corruption look.
|
|
@@ -1466,7 +1269,6 @@ class Glitch(Effect):
|
|
|
1466
1269
|
"""
|
|
1467
1270
|
|
|
1468
1271
|
op: Literal["glitch"] = "glitch"
|
|
1469
|
-
streamable: ClassVar[bool] = True
|
|
1470
1272
|
|
|
1471
1273
|
intensity: float = Field(
|
|
1472
1274
|
0.5,
|
|
@@ -1528,7 +1330,6 @@ class FilmGrain(Effect):
|
|
|
1528
1330
|
"""
|
|
1529
1331
|
|
|
1530
1332
|
op: Literal["film_grain"] = "film_grain"
|
|
1531
|
-
streamable: ClassVar[bool] = True
|
|
1532
1333
|
|
|
1533
1334
|
intensity: float = Field(
|
|
1534
1335
|
0.1,
|
|
@@ -1558,31 +1359,6 @@ class FilmGrain(Effect):
|
|
|
1558
1359
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1559
1360
|
return self._grain_frame(frame, frame_index)
|
|
1560
1361
|
|
|
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
|
-
|
|
1586
1362
|
|
|
1587
1363
|
class Sharpen(Effect):
|
|
1588
1364
|
"""Unsharp-mask sharpening: blur the frame and subtract from itself with weight.
|
|
@@ -1592,7 +1368,6 @@ class Sharpen(Effect):
|
|
|
1592
1368
|
"""
|
|
1593
1369
|
|
|
1594
1370
|
op: Literal["sharpen"] = "sharpen"
|
|
1595
|
-
streamable: ClassVar[bool] = True
|
|
1596
1371
|
|
|
1597
1372
|
amount: float = Field(
|
|
1598
1373
|
1.0,
|
|
@@ -1625,29 +1400,6 @@ class Sharpen(Effect):
|
|
|
1625
1400
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1626
1401
|
return self._sharpen_frame(frame)
|
|
1627
1402
|
|
|
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
|
-
|
|
1651
1403
|
|
|
1652
1404
|
class Pixelate(Effect):
|
|
1653
1405
|
"""Mosaic blocks: downscale + nearest-neighbour upscale, optionally limited to a region.
|
|
@@ -1657,7 +1409,6 @@ class Pixelate(Effect):
|
|
|
1657
1409
|
"""
|
|
1658
1410
|
|
|
1659
1411
|
op: Literal["pixelate"] = "pixelate"
|
|
1660
|
-
streamable: ClassVar[bool] = True
|
|
1661
1412
|
|
|
1662
1413
|
block_size: int = Field(
|
|
1663
1414
|
gt=1,
|
|
@@ -1697,22 +1448,6 @@ class Pixelate(Effect):
|
|
|
1697
1448
|
return self._pixelate_frame(frame, self._stream_region_px)
|
|
1698
1449
|
|
|
1699
1450
|
|
|
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
|
-
|
|
1716
1451
|
class MirrorFlip(Effect):
|
|
1717
1452
|
"""Flip frames or reflect one half onto the other.
|
|
1718
1453
|
|
|
@@ -1722,8 +1457,6 @@ class MirrorFlip(Effect):
|
|
|
1722
1457
|
"""
|
|
1723
1458
|
|
|
1724
1459
|
op: Literal["mirror_flip"] = "mirror_flip"
|
|
1725
|
-
streamable: ClassVar[bool] = True
|
|
1726
|
-
filter_needs_rgb24: ClassVar[bool] = True
|
|
1727
1460
|
|
|
1728
1461
|
mode: Literal[
|
|
1729
1462
|
"horizontal",
|
|
@@ -1767,54 +1500,6 @@ class MirrorFlip(Effect):
|
|
|
1767
1500
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1768
1501
|
return self._flip_frame(frame)
|
|
1769
1502
|
|
|
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
|
-
|
|
1818
1503
|
|
|
1819
1504
|
class Kaleidoscope(Effect):
|
|
1820
1505
|
"""N-way radial mirror around the frame center.
|
|
@@ -1825,8 +1510,6 @@ class Kaleidoscope(Effect):
|
|
|
1825
1510
|
"""
|
|
1826
1511
|
|
|
1827
1512
|
op: Literal["kaleidoscope"] = "kaleidoscope"
|
|
1828
|
-
streamable: ClassVar[bool] = True
|
|
1829
|
-
filter_needs_rgb24: ClassVar[bool] = True
|
|
1830
1513
|
|
|
1831
1514
|
segments: int = Field(
|
|
1832
1515
|
6,
|
|
@@ -1875,58 +1558,3 @@ class Kaleidoscope(Effect):
|
|
|
1875
1558
|
|
|
1876
1559
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
1877
1560
|
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
|
|
@@ -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
|
|
@@ -269,12 +267,11 @@ class Operation(BaseModel):
|
|
|
269
267
|
"""Whether this op is engine-internal and must NOT be a chain op.
|
|
270
268
|
|
|
271
269
|
``CutSeconds``/``CutFrames`` trim a segment, but trimming is the segment's own
|
|
272
|
-
``start``/``end`` mechanism -- the engine constructs them directly. They
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
the registry: they cannot appear in a plan's ``operations`` list or the LLM
|
|
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
|
|
276
273
|
schema, while direct construction (``CutSeconds(start=..., end=...)``) still
|
|
277
|
-
works. Default False (a normal
|
|
274
|
+
works. Default False (a normal chain op)."""
|
|
278
275
|
time_fields: ClassVar[tuple[BoundedTimeField, ...]] = ()
|
|
279
276
|
"""Time-valued (seconds) fields :meth:`VideoEdit.repair` may clamp into range.
|
|
280
277
|
|
|
@@ -449,6 +446,19 @@ class Operation(BaseModel):
|
|
|
449
446
|
"""
|
|
450
447
|
return None
|
|
451
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
|
+
|
|
452
462
|
|
|
453
463
|
class Effect(Operation):
|
|
454
464
|
"""Operation that preserves shape and frame count, driven by per-frame streaming.
|
|
@@ -470,17 +480,6 @@ class Effect(Operation):
|
|
|
470
480
|
category: ClassVar[OpCategory] = OpCategory.EFFECT
|
|
471
481
|
audio_coupled: ClassVar[bool] = False
|
|
472
482
|
"""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."""
|
|
484
483
|
|
|
485
484
|
window: TimeRange | None = Field(
|
|
486
485
|
None,
|
|
@@ -527,3 +526,14 @@ class Effect(Operation):
|
|
|
527
526
|
``frame_index`` is 0-based within this effect's active window.
|
|
528
527
|
"""
|
|
529
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
|
|
@@ -110,11 +110,12 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
|
|
|
110
110
|
``location_prefix`` is the plan path entries are stamped under
|
|
111
111
|
(``segments[i].operations`` or ``post_operations``).
|
|
112
112
|
|
|
113
|
-
``
|
|
114
|
-
|
|
115
|
-
the
|
|
116
|
-
is caught at runtime by the
|
|
117
|
-
``_compile_streaming_plans``. Purely
|
|
113
|
+
A transform streams iff it overrides ``to_ffmpeg_filter`` and an effect iff it
|
|
114
|
+
overrides ``process_frame`` or sets ``compiles_to_filter`` (``op.streams()``);
|
|
115
|
+
the one divergence structure cannot express -- the override exists but the
|
|
116
|
+
filter compiles to ``None`` at this position -- is caught at runtime by the
|
|
117
|
+
``STREAMING_UNSUPPORTED`` raise in ``_compile_streaming_plans``. Purely
|
|
118
|
+
structural: no disk access, no context.
|
|
118
119
|
"""
|
|
119
120
|
entries: list[OpStreamability] = []
|
|
120
121
|
seen_effect = False
|
|
@@ -123,7 +124,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
|
|
|
123
124
|
for j, op in enumerate(ops):
|
|
124
125
|
location = f"{location_prefix}[{j}]"
|
|
125
126
|
if isinstance(op, Effect):
|
|
126
|
-
if op.
|
|
127
|
+
if op.requires and seen_duration_change:
|
|
127
128
|
# The builder rejects context-consuming ops behind a
|
|
128
129
|
# duration-changing transform: segment-local context is
|
|
129
130
|
# not re-mapped through the time warp yet.
|
|
@@ -140,7 +141,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
|
|
|
140
141
|
)
|
|
141
142
|
)
|
|
142
143
|
continue
|
|
143
|
-
if op.
|
|
144
|
+
if op.compiles_to_filter:
|
|
144
145
|
# Filter-class effect (add_subtitles, vignette, ...): joins the
|
|
145
146
|
# decode filter chain at this plan position -- or the encode
|
|
146
147
|
# chain when frame effects precede it -- so it streams in plan
|
|
@@ -150,13 +151,16 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
|
|
|
150
151
|
if seen_effect:
|
|
151
152
|
seen_encode_stage = True
|
|
152
153
|
continue
|
|
153
|
-
if not op.
|
|
154
|
+
if not op.streams():
|
|
155
|
+
# Defensive: a custom Effect that overrides neither process_frame
|
|
156
|
+
# nor to_ffmpeg_filter (no built-in op does). compiles_to_filter
|
|
157
|
+
# was handled above, so this is "no process_frame".
|
|
154
158
|
entries.append(
|
|
155
159
|
OpStreamability(
|
|
156
160
|
location,
|
|
157
161
|
op.op,
|
|
158
162
|
StreamingClass.UNSTREAMABLE,
|
|
159
|
-
reason="effect has no streaming implementation (
|
|
163
|
+
reason="effect has no streaming implementation (no process_frame or to_ffmpeg_filter)",
|
|
160
164
|
)
|
|
161
165
|
)
|
|
162
166
|
elif seen_encode_stage:
|
|
@@ -189,7 +193,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
|
|
|
189
193
|
),
|
|
190
194
|
)
|
|
191
195
|
)
|
|
192
|
-
elif op.requires and not op.
|
|
196
|
+
elif op.requires and not op.streams():
|
|
193
197
|
entries.append(
|
|
194
198
|
OpStreamability(
|
|
195
199
|
location,
|
|
@@ -198,7 +202,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
|
|
|
198
202
|
reason=(f"transform requires runtime context {sorted(op.requires)} and has no streaming strategy"),
|
|
199
203
|
)
|
|
200
204
|
)
|
|
201
|
-
elif op.
|
|
205
|
+
elif op.streams() and op.compiles_from_source and (seen_effect or seen_encode_stage):
|
|
202
206
|
entries.append(
|
|
203
207
|
OpStreamability(
|
|
204
208
|
location,
|
|
@@ -210,7 +214,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
|
|
|
210
214
|
),
|
|
211
215
|
)
|
|
212
216
|
)
|
|
213
|
-
elif op.
|
|
217
|
+
elif op.streams():
|
|
214
218
|
# Transforms compile to filters at any plan position: the decode
|
|
215
219
|
# chain normally, the encode chain (after every process_frame) when
|
|
216
220
|
# frame effects precede them.
|
|
@@ -269,22 +273,12 @@ class EffectScheduleEntry:
|
|
|
269
273
|
re-based onto the segment-local timeline by the plan builder), forwarded
|
|
270
274
|
as keyword arguments to ``streaming_init``. Empty for context-free
|
|
271
275
|
effects.
|
|
272
|
-
|
|
273
|
-
For a post-operation folded across a multi-segment plan, the effect's
|
|
274
|
-
window lives on the assembled (concatenated) timeline: ``index_offset``
|
|
275
|
-
is the number of window frames consumed by previous segments (so
|
|
276
|
-
``process_frame`` indices continue across the concat boundary) and
|
|
277
|
-
``total_effect_frames`` is the global window length ``streaming_init``
|
|
278
|
-
must size envelopes to. Defaults describe the ordinary segment-local
|
|
279
|
-
case.
|
|
280
276
|
"""
|
|
281
277
|
|
|
282
278
|
effect: Effect
|
|
283
279
|
start_frame: int
|
|
284
280
|
end_frame: int # exclusive
|
|
285
281
|
context: dict[str, Any] = field(default_factory=dict)
|
|
286
|
-
index_offset: int = 0
|
|
287
|
-
total_effect_frames: int | None = None
|
|
288
282
|
|
|
289
283
|
|
|
290
284
|
@dataclass
|
|
@@ -751,9 +745,7 @@ def _stream_segment_framewise(
|
|
|
751
745
|
# so a context-requiring effect with missing context fails before any
|
|
752
746
|
# frame work on this segment.
|
|
753
747
|
for entry in plan.effect_schedule:
|
|
754
|
-
n_effect_frames =
|
|
755
|
-
entry.total_effect_frames if entry.total_effect_frames is not None else entry.end_frame - entry.start_frame
|
|
756
|
-
)
|
|
748
|
+
n_effect_frames = entry.end_frame - entry.start_frame
|
|
757
749
|
entry.effect.streaming_init(
|
|
758
750
|
n_effect_frames, plan.output_fps, plan.output_width, plan.output_height, **entry.context
|
|
759
751
|
)
|
|
@@ -794,7 +786,7 @@ def _stream_segment_framewise(
|
|
|
794
786
|
# sized to the estimate stay in bounds.
|
|
795
787
|
open_ended = entry.end_frame >= total_frames
|
|
796
788
|
if entry.start_frame <= frame_count and (frame_count < entry.end_frame or open_ended):
|
|
797
|
-
local_index =
|
|
789
|
+
local_index = min(frame_count, entry.end_frame - 1) - entry.start_frame
|
|
798
790
|
frame = entry.effect.process_frame(frame, local_index)
|
|
799
791
|
|
|
800
792
|
encoder.write_frame(frame)
|
|
@@ -107,7 +107,6 @@ class TranscriptionOverlay(Effect):
|
|
|
107
107
|
"""
|
|
108
108
|
|
|
109
109
|
op: Literal["add_subtitles"] = "add_subtitles"
|
|
110
|
-
streamable: ClassVar[bool] = True
|
|
111
110
|
requires: ClassVar[tuple[str, ...]] = ("transcription",)
|
|
112
111
|
|
|
113
112
|
# ---- primary, resolution-independent surface ----
|
|
@@ -146,7 +146,6 @@ class Resize(Operation):
|
|
|
146
146
|
|
|
147
147
|
op: Literal["resize"] = "resize"
|
|
148
148
|
category: ClassVar[OpCategory] = OpCategory.TRANSFORM
|
|
149
|
-
streamable: ClassVar[bool] = True
|
|
150
149
|
|
|
151
150
|
width: int | None = Field(None, gt=0, description="Target width in pixels, or None to maintain aspect ratio.")
|
|
152
151
|
height: int | None = Field(None, gt=0, description="Target height in pixels, or None to maintain aspect ratio.")
|
|
@@ -187,7 +186,6 @@ class ResampleFPS(Operation):
|
|
|
187
186
|
|
|
188
187
|
op: Literal["resample_fps"] = "resample_fps"
|
|
189
188
|
category: ClassVar[OpCategory] = OpCategory.TRANSFORM
|
|
190
|
-
streamable: ClassVar[bool] = True
|
|
191
189
|
|
|
192
190
|
fps: float = Field(gt=0, description="Target frames per second.")
|
|
193
191
|
|
|
@@ -212,7 +210,6 @@ class Crop(Operation):
|
|
|
212
210
|
|
|
213
211
|
op: Literal["crop"] = "crop"
|
|
214
212
|
category: ClassVar[OpCategory] = OpCategory.TRANSFORM
|
|
215
|
-
streamable: ClassVar[bool] = True
|
|
216
213
|
|
|
217
214
|
width: int | float = Field(description="Crop width in pixels (int) or fraction in (0, 1] of source width.")
|
|
218
215
|
height: int | float = Field(description="Crop height in pixels (int) or fraction in (0, 1] of source height.")
|
|
@@ -280,7 +277,6 @@ class SpeedChange(Operation):
|
|
|
280
277
|
|
|
281
278
|
op: Literal["speed_change"] = "speed_change"
|
|
282
279
|
category: ClassVar[OpCategory] = OpCategory.TRANSFORM
|
|
283
|
-
streamable: ClassVar[bool] = True
|
|
284
280
|
changes_duration: ClassVar[bool] = True
|
|
285
281
|
|
|
286
282
|
speed: float = Field(gt=0, description="Playback speed multiplier. 2.0 = twice as fast, 0.5 = half speed.")
|
|
@@ -385,7 +381,6 @@ class FreezeFrame(Operation):
|
|
|
385
381
|
|
|
386
382
|
op: Literal["freeze_frame"] = "freeze_frame"
|
|
387
383
|
category: ClassVar[OpCategory] = OpCategory.TRANSFORM
|
|
388
|
-
streamable: ClassVar[bool] = True
|
|
389
384
|
changes_duration: ClassVar[bool] = True
|
|
390
385
|
# `timestamp` indexes a frame, so it must be strictly < the clip duration;
|
|
391
386
|
# repair clamps an out-of-range value to the last frame.
|
|
@@ -510,7 +505,6 @@ class SilenceRemoval(Operation):
|
|
|
510
505
|
|
|
511
506
|
op: Literal["silence_removal"] = "silence_removal"
|
|
512
507
|
category: ClassVar[OpCategory] = OpCategory.TRANSFORM
|
|
513
|
-
streamable: ClassVar[bool] = True
|
|
514
508
|
changes_duration: ClassVar[bool] = True
|
|
515
509
|
requires: ClassVar[tuple[str, ...]] = ("transcription",)
|
|
516
510
|
|
|
@@ -1954,9 +1954,9 @@ class VideoEdit(BaseModel):
|
|
|
1954
1954
|
|
|
1955
1955
|
The single admission point for :meth:`run_to_file`. Unstreamable shapes raise
|
|
1956
1956
|
:class:`PlanValidationError` with the streamability report's
|
|
1957
|
-
structured errors; a builder/report drift (e.g.
|
|
1958
|
-
|
|
1959
|
-
|
|
1957
|
+
structured errors; a builder/report drift (e.g. an op whose
|
|
1958
|
+
``to_ffmpeg_filter`` returns ``None`` at its plan position despite
|
|
1959
|
+
overriding it) raises with a generic ``STREAMING_UNSUPPORTED``.
|
|
1960
1960
|
On raise, any compile-time temp files already created are deleted;
|
|
1961
1961
|
on success the caller owns ``plan.owned_temp_files``.
|
|
1962
1962
|
"""
|
|
@@ -2073,7 +2073,6 @@ class VideoEdit(BaseModel):
|
|
|
2073
2073
|
post_af_filters: list[str] = []
|
|
2074
2074
|
audio_idx = 0
|
|
2075
2075
|
duration_changed = False
|
|
2076
|
-
decode_needs_rgb = False
|
|
2077
2076
|
|
|
2078
2077
|
def compile_audio_twin(op: Operation, ctx: FilterCtx, encode_stage: bool) -> None:
|
|
2079
2078
|
"""Append the op's audio-domain filter at the same stage the video
|
|
@@ -2100,7 +2099,7 @@ class VideoEdit(BaseModel):
|
|
|
2100
2099
|
try:
|
|
2101
2100
|
for op in segment.operations:
|
|
2102
2101
|
if isinstance(op, Effect):
|
|
2103
|
-
if not op.
|
|
2102
|
+
if not op.streams():
|
|
2104
2103
|
abandon()
|
|
2105
2104
|
return None
|
|
2106
2105
|
if op.requires and duration_changed:
|
|
@@ -2129,7 +2128,6 @@ class VideoEdit(BaseModel):
|
|
|
2129
2128
|
post_vf_filters.append(filter_expr)
|
|
2130
2129
|
else:
|
|
2131
2130
|
vf_filters.append(filter_expr)
|
|
2132
|
-
decode_needs_rgb = decode_needs_rgb or op.filter_needs_rgb24
|
|
2133
2131
|
# Audio twin at the same stage (add_subtitles has
|
|
2134
2132
|
# none today; kept coupled for extensibility).
|
|
2135
2133
|
compile_audio_twin(op, ctx, encode_stage_effect)
|
|
@@ -2155,7 +2153,7 @@ class VideoEdit(BaseModel):
|
|
|
2155
2153
|
effect_ctx = make_ctx()
|
|
2156
2154
|
compile_audio_twin(op, effect_ctx, encode_stage=False)
|
|
2157
2155
|
continue
|
|
2158
|
-
if op.requires and (duration_changed or not op.
|
|
2156
|
+
if op.requires and (duration_changed or not op.streams()):
|
|
2159
2157
|
# A context-requiring transform can stream only when its
|
|
2160
2158
|
# filter compile can consume the context (silence_removal
|
|
2161
2159
|
# does, via FilterCtx.context) -- but not after a
|
|
@@ -2165,15 +2163,13 @@ class VideoEdit(BaseModel):
|
|
|
2165
2163
|
# streamability report.
|
|
2166
2164
|
abandon()
|
|
2167
2165
|
return None
|
|
2168
|
-
# Non-effect transform:
|
|
2169
|
-
#
|
|
2170
|
-
#
|
|
2171
|
-
#
|
|
2172
|
-
#
|
|
2173
|
-
#
|
|
2174
|
-
|
|
2175
|
-
# STREAMING_UNSUPPORTED raise in _compile_streaming_plans.
|
|
2176
|
-
if not op.streamable:
|
|
2166
|
+
# Non-effect transform: streams iff it compiles a filter. Checked
|
|
2167
|
+
# structurally (op.streams() == overrides to_ffmpeg_filter) so the
|
|
2168
|
+
# streamability report and the builder cannot disagree; the
|
|
2169
|
+
# remaining drift class (overrides to_ffmpeg_filter but it compiles
|
|
2170
|
+
# to None here) is caught by the STREAMING_UNSUPPORTED raise in
|
|
2171
|
+
# _compile_streaming_plans.
|
|
2172
|
+
if not op.streams():
|
|
2177
2173
|
abandon()
|
|
2178
2174
|
return None
|
|
2179
2175
|
encode_stage = bool(effect_schedule or post_vf_filters)
|
|
@@ -2213,14 +2209,6 @@ class VideoEdit(BaseModel):
|
|
|
2213
2209
|
abandon()
|
|
2214
2210
|
raise
|
|
2215
2211
|
|
|
2216
|
-
# A decode-stage filter-effect that reads RGB (geq r/g/b, rgbashift)
|
|
2217
|
-
# must see rgb24, not the native yuv decode stream; prepend one
|
|
2218
|
-
# conversion ahead of the whole decode chain (encode-stage effects
|
|
2219
|
-
# already receive rgb24 from the frame pipe). Idempotent: only when an
|
|
2220
|
-
# rgb-reading effect landed at decode and no conversion leads already.
|
|
2221
|
-
if decode_needs_rgb and not (vf_filters and vf_filters[0] == "format=rgb24"):
|
|
2222
|
-
vf_filters.insert(0, "format=rgb24")
|
|
2223
|
-
|
|
2224
2212
|
pipe = pipe_meta or running
|
|
2225
2213
|
# Final output duration after every transform (decode + encode stage),
|
|
2226
2214
|
# used to pin the audio graph length to the video timeline. `running`
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|