videopython 0.43.1__tar.gz → 0.44.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.43.1 → videopython-0.44.0}/PKG-INFO +2 -2
- {videopython-0.43.1 → videopython-0.44.0}/README.md +1 -1
- {videopython-0.43.1 → videopython-0.44.0}/pyproject.toml +1 -1
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/dubber.py +1 -3
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/effects.py +3 -3
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/transforms.py +5 -23
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/analyzer.py +2 -3
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/audio/audio.py +2 -2
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/audio_ops.py +6 -8
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/effects.py +39 -120
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/operation.py +22 -87
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/streaming.py +7 -218
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/transcription_overlay.py +9 -72
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/transforms.py +21 -230
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/video_edit.py +27 -176
- {videopython-0.43.1 → videopython-0.44.0}/.gitignore +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/LICENSE +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/_optional.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/_predictor.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/_revisions.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/_tts_backend.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/description.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/exceptions.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/transcription.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/base/video.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/_ass.py +0 -0
- {videopython-0.43.1 → videopython-0.44.0}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.43.1 → videopython-0.44.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.44.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/
|
|
@@ -133,7 +133,7 @@ edit.validate() # dry-run via metadata, no frames loaded
|
|
|
133
133
|
edit.run_to_file("output.mp4") # streams ffmpeg decode → effects → encode
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
`run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources.
|
|
136
|
+
`run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. If you need the frames back in memory, load the rendered file: `Video.from_path(str(edit.run_to_file("output.mp4")))`.
|
|
137
137
|
|
|
138
138
|
### AI generation
|
|
139
139
|
|
|
@@ -47,7 +47,7 @@ edit.validate() # dry-run via metadata, no frames loaded
|
|
|
47
47
|
edit.run_to_file("output.mp4") # streams ffmpeg decode → effects → encode
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
`run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources.
|
|
50
|
+
`run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. If you need the frames back in memory, load the rendered file: `Video.from_path(str(edit.run_to_file("output.mp4")))`.
|
|
51
51
|
|
|
52
52
|
### AI generation
|
|
53
53
|
|
|
@@ -239,9 +239,7 @@ class VideoDubber:
|
|
|
239
239
|
video_duration = video.total_seconds
|
|
240
240
|
|
|
241
241
|
if video_duration > speech_duration:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
output_video = CutSeconds(start=0, end=speech_duration).apply(video)
|
|
242
|
+
output_video = video[: round(speech_duration * video.fps)]
|
|
245
243
|
else:
|
|
246
244
|
output_video = video
|
|
247
245
|
|
|
@@ -33,9 +33,9 @@ class ObjectDetectionOverlay(Effect):
|
|
|
33
33
|
*memory*-bound: ``"streamable"`` here means bounded memory, not bounded
|
|
34
34
|
compute. On long clips, cap cost with ``window`` (limit the time range),
|
|
35
35
|
a larger ``detection_interval``, a ``class_filter``, and/or the smaller
|
|
36
|
-
``model_size``.
|
|
37
|
-
overridden
|
|
38
|
-
|
|
36
|
+
``model_size``. Only ``streaming_init`` and ``process_frame`` are
|
|
37
|
+
overridden; the streaming engine drives that contract for bounded-memory
|
|
38
|
+
execution.
|
|
39
39
|
"""
|
|
40
40
|
|
|
41
41
|
op: Literal["object_detection_overlay"] = "object_detection_overlay"
|
|
@@ -15,7 +15,7 @@ from tqdm import tqdm
|
|
|
15
15
|
|
|
16
16
|
from videopython.ai.understanding.faces import FaceTracker
|
|
17
17
|
from videopython.base._dimensions import floor_to_even
|
|
18
|
-
from videopython.base.video import FrameIterator,
|
|
18
|
+
from videopython.base.video import FrameIterator, VideoMetadata
|
|
19
19
|
from videopython.editing._ass import escape_filter_value
|
|
20
20
|
from videopython.editing.operation import FilterCtx, OpCategory, Operation
|
|
21
21
|
|
|
@@ -94,8 +94,8 @@ class FaceTrackingCrop(Operation):
|
|
|
94
94
|
"""Output ``(width, height)`` -- the fixed crop-window size.
|
|
95
95
|
|
|
96
96
|
The largest ``target_aspect`` box that fits the frame, even-floored.
|
|
97
|
-
A pure function of the input dimensions, shared by
|
|
98
|
-
:meth:`predict_metadata
|
|
97
|
+
A pure function of the input dimensions, shared by
|
|
98
|
+
:meth:`predict_metadata` and :meth:`to_ffmpeg_filter`, so the
|
|
99
99
|
dry-run cannot disagree with the render.
|
|
100
100
|
"""
|
|
101
101
|
target_ratio = self.target_aspect[0] / self.target_aspect[1]
|
|
@@ -131,9 +131,8 @@ class FaceTrackingCrop(Operation):
|
|
|
131
131
|
"""Per-frame crop top-left positions for a fixed-size crop window.
|
|
132
132
|
|
|
133
133
|
The single source of the tracking math (detection cadence, EMA
|
|
134
|
-
smoothing, framing offset, speed clamp, frame clamping),
|
|
135
|
-
|
|
136
|
-
two paths cannot drift.
|
|
134
|
+
smoothing, framing offset, speed clamp, frame clamping), run by the
|
|
135
|
+
compile-time detection pass to build the per-frame crop command file.
|
|
137
136
|
"""
|
|
138
137
|
out_w, out_h = self._resolved_output_dims(frame_w, frame_h)
|
|
139
138
|
tracker = FaceTracker(
|
|
@@ -166,23 +165,6 @@ class FaceTrackingCrop(Operation):
|
|
|
166
165
|
positions.append(default)
|
|
167
166
|
return positions
|
|
168
167
|
|
|
169
|
-
def apply(self, video: Video) -> Video:
|
|
170
|
-
h, w = video.frame_shape[:2]
|
|
171
|
-
out_w, out_h = self._resolved_output_dims(w, h)
|
|
172
|
-
logger.info(
|
|
173
|
-
"Face tracking crop: %dx%d -> %dx%d (%d:%d, framing=%s)",
|
|
174
|
-
w,
|
|
175
|
-
h,
|
|
176
|
-
out_w,
|
|
177
|
-
out_h,
|
|
178
|
-
self.target_aspect[0],
|
|
179
|
-
self.target_aspect[1],
|
|
180
|
-
self.framing_rule,
|
|
181
|
-
)
|
|
182
|
-
positions = self._track_crop_positions(tqdm(video.frames, desc="Face tracking crop"), w, h)
|
|
183
|
-
video.frames = np.stack([video.frames[i][y : y + out_h, x : x + out_w] for i, (x, y) in enumerate(positions)])
|
|
184
|
-
return video
|
|
185
|
-
|
|
186
168
|
def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
|
|
187
169
|
"""Compile the face track to a per-frame ``crop`` position command file.
|
|
188
170
|
|
|
@@ -331,9 +331,8 @@ class VideoAnalyzer:
|
|
|
331
331
|
return None
|
|
332
332
|
if source_path is not None:
|
|
333
333
|
return Video.from_path(str(source_path), start_second=start_second, end_second=end_second)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
return CutSeconds(start=start_second, end=end_second).apply(stages.require_video(video))
|
|
334
|
+
v = stages.require_video(video)
|
|
335
|
+
return v[round(start_second * v.fps) : round(end_second * v.fps)]
|
|
337
336
|
|
|
338
337
|
def _default_scene_boundaries(self, metadata: VideoMetadata) -> list[SceneBoundary]:
|
|
339
338
|
if metadata.total_seconds <= 0 or metadata.frame_count <= 0:
|
|
@@ -23,8 +23,8 @@ def atempo_chain(speed: float) -> list[str]:
|
|
|
23
23
|
factor outside that range is decomposed into a chain of capped stages
|
|
24
24
|
(``atempo=2.0`` repeated for speedups, ``atempo=0.5`` for slowdowns) times
|
|
25
25
|
a final remainder stage. The single source of truth shared by
|
|
26
|
-
:meth:`Audio.time_stretch` (the in-memory WAV round-trip used by
|
|
27
|
-
|
|
26
|
+
:meth:`Audio.time_stretch` (the in-memory WAV round-trip used by dubbing's
|
|
27
|
+
timing alignment) and ``SpeedChange.to_ffmpeg_audio_filter`` (the streaming
|
|
28
28
|
filter graph), so both stretch by exactly the same chain. Returns an empty
|
|
29
29
|
list for ``speed == 1`` (a no-op the caller maps to ``anull``).
|
|
30
30
|
"""
|
|
@@ -7,9 +7,8 @@ assembled timeline, not of any one segment. It is mixed in a FINAL ffmpeg pass
|
|
|
7
7
|
assembled program audio plus the bed input.
|
|
8
8
|
|
|
9
9
|
:class:`MusicBed` is a frozen, closed pydantic model carried on
|
|
10
|
-
:attr:`VideoEdit.music_bed`.
|
|
11
|
-
single :func:`build_music_bed_filter_complex` builder
|
|
12
|
-
file paths cannot diverge on the mix.
|
|
10
|
+
:attr:`VideoEdit.music_bed`. ``run_to_file`` routes through the
|
|
11
|
+
single :func:`build_music_bed_filter_complex` builder for the mix.
|
|
13
12
|
|
|
14
13
|
Ducking is *transcription-derived* and deterministic: when ``duck`` is set the
|
|
15
14
|
bed is lowered under the speech windows derived from the context transcription
|
|
@@ -122,7 +121,7 @@ class MusicBed(BaseModel):
|
|
|
122
121
|
|
|
123
122
|
Mirrors :meth:`ImageOverlay.predict_metadata`: a cheap ffprobe header
|
|
124
123
|
probe (no decode) catches a missing / non-audio file at validate time,
|
|
125
|
-
before ``
|
|
124
|
+
before ``run_to_file()`` would crash mid-stream after assembling the program.
|
|
126
125
|
"""
|
|
127
126
|
try:
|
|
128
127
|
info = _ffmpeg.probe(self.source)
|
|
@@ -147,8 +146,8 @@ class MusicBed(BaseModel):
|
|
|
147
146
|
bed is scaled to ``gain``, faded, ducked under ``speech`` (when given and
|
|
148
147
|
``duck`` is set), then pinned to exactly ``program_seconds`` (``atrim``
|
|
149
148
|
end + ``apad`` whole_dur) so a looped bed neither truncates early nor
|
|
150
|
-
extends the output past the program.
|
|
151
|
-
mix
|
|
149
|
+
extends the output past the program. Used by the file
|
|
150
|
+
mix path via :func:`build_music_bed_filter_complex`.
|
|
152
151
|
"""
|
|
153
152
|
stages: list[str] = [f"volume={self.gain:.6f}"]
|
|
154
153
|
if self.fade_in > 0:
|
|
@@ -178,8 +177,7 @@ def build_music_bed_filter_complex(
|
|
|
178
177
|
) -> tuple[list[str], list[str], str]:
|
|
179
178
|
"""Compile the music-bed input args + ``filter_complex`` mix graph.
|
|
180
179
|
|
|
181
|
-
The single source of the bed mix,
|
|
182
|
-
``run_to_file`` (file pass) so the two cannot diverge. Returns
|
|
180
|
+
The single source of the bed mix, used by ``run_to_file`` (file pass). Returns
|
|
183
181
|
``(input_args, graph_statements, out_label)``:
|
|
184
182
|
|
|
185
183
|
- ``input_args`` are the ffmpeg ``-i`` argv for the bed: ``-stream_loop -1``
|
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
An ``Effect`` is an ``Operation`` that preserves video shape and frame count.
|
|
4
4
|
Subclasses implement the streaming contract -- :meth:`Effect.process_frame`
|
|
5
5
|
(plus :meth:`Effect.streaming_init` for any precomputed state) -- which is the
|
|
6
|
-
single source of truth for the effect's pixel logic
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
``
|
|
12
|
-
|
|
13
|
-
override :meth:`Effect.apply` directly so the audio splice stays coherent with
|
|
14
|
-
the window. Anchored RGBA overlays (``TextOverlay``, ``ImageOverlay``) share
|
|
15
|
-
their placement and blend via :class:`_AnchoredOverlay`.
|
|
6
|
+
single source of truth for the effect's pixel logic and the only execution
|
|
7
|
+
path. Audio effects (``Fade``, ``VolumeAdjust``) instead express their gain
|
|
8
|
+
envelope via :meth:`Effect.to_ffmpeg_audio_filter`. Effects that need
|
|
9
|
+
run-time validation beyond the field constraints (``FullImageOverlay``)
|
|
10
|
+
override :meth:`Effect.predict_metadata`. Anchored RGBA overlays
|
|
11
|
+
(``TextOverlay``, ``ImageOverlay``) share their placement and blend via
|
|
12
|
+
:class:`_AnchoredOverlay`.
|
|
16
13
|
"""
|
|
17
14
|
|
|
18
15
|
from __future__ import annotations
|
|
@@ -26,7 +23,6 @@ import cv2
|
|
|
26
23
|
import numpy as np
|
|
27
24
|
from PIL import Image, ImageDraw, ImageFont
|
|
28
25
|
from pydantic import Field, PrivateAttr, model_validator
|
|
29
|
-
from tqdm import tqdm
|
|
30
26
|
|
|
31
27
|
from videopython.base.description import BoundingBox
|
|
32
28
|
from videopython.base.exceptions import PlanError, PlanErrorCode, PlanValidationError
|
|
@@ -35,8 +31,7 @@ from videopython.editing._easing import ease, ease_out
|
|
|
35
31
|
from videopython.editing.operation import Effect, FilterCtx
|
|
36
32
|
|
|
37
33
|
if TYPE_CHECKING:
|
|
38
|
-
from videopython.
|
|
39
|
-
from videopython.base.video import Video, VideoMetadata
|
|
34
|
+
from videopython.base.video import VideoMetadata
|
|
40
35
|
|
|
41
36
|
logger = logging.getLogger(__name__)
|
|
42
37
|
|
|
@@ -121,24 +116,23 @@ class FullImageOverlay(Effect):
|
|
|
121
116
|
fade_alpha = 1.0 if dist_from_end >= self._stream_fade_frames else dist_from_end / self._stream_fade_frames
|
|
122
117
|
return self._overlay_frame(frame, fade_alpha)
|
|
123
118
|
|
|
124
|
-
def
|
|
119
|
+
def predict_metadata(self, meta: VideoMetadata, **_context: Any) -> VideoMetadata:
|
|
120
|
+
"""Reject an overlay that cannot composite onto this video at run time.
|
|
121
|
+
|
|
122
|
+
Two failures ``run_to_file()`` cannot survive are caught at ``validate()``
|
|
123
|
+
time instead of mid-stream: (a) the overlay's pixel dimensions must match
|
|
124
|
+
the video frame exactly (this op is full-frame, unlike
|
|
125
|
+
:class:`ImageOverlay`), and (b) the combined fade-in + fade-out cannot
|
|
126
|
+
exceed the clip length. Both checks come from the deleted eager path; the
|
|
127
|
+
overlay header is read once here (no per-frame work).
|
|
128
|
+
"""
|
|
125
129
|
overlay = self._load_overlay()
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
n = len(video.frames)
|
|
133
|
-
num_fade_frames = round(self.fade_time * video.fps) if self.fade_time > 0 else 0
|
|
134
|
-
for i in tqdm(range(n), desc="Overlaying frames"):
|
|
135
|
-
if num_fade_frames == 0:
|
|
136
|
-
video.frames[i] = self._overlay_frame(video.frames[i])
|
|
137
|
-
else:
|
|
138
|
-
dist_from_end = min(i, n - i)
|
|
139
|
-
fade_alpha = 1.0 if dist_from_end >= num_fade_frames else dist_from_end / num_fade_frames
|
|
140
|
-
video.frames[i] = self._overlay_frame(video.frames[i], fade_alpha)
|
|
141
|
-
return video
|
|
130
|
+
frame_shape = (meta.height, meta.width, 3)
|
|
131
|
+
if frame_shape != overlay[:, :, :3].shape:
|
|
132
|
+
raise ValueError(f"Mismatch of overlay shape `{overlay.shape}` with video shape: `{frame_shape}`!")
|
|
133
|
+
if not (0 <= 2 * self.fade_time <= meta.total_seconds):
|
|
134
|
+
raise ValueError(f"Video is only {meta.total_seconds}s long, but fade time is {self.fade_time}s!")
|
|
135
|
+
return meta
|
|
142
136
|
|
|
143
137
|
|
|
144
138
|
class Blur(Effect):
|
|
@@ -326,18 +320,6 @@ class Vignette(Effect):
|
|
|
326
320
|
assert self._stream_mask_3d is not None
|
|
327
321
|
return (frame.astype(np.float32) * self._stream_mask_3d).astype(np.uint8)
|
|
328
322
|
|
|
329
|
-
def _apply(self, video: Video, **_context: Any) -> Video:
|
|
330
|
-
logger.info("Applying vignette effect...")
|
|
331
|
-
height, width = video.frame_shape[:2]
|
|
332
|
-
if self._mask is None or self._mask.shape != (height, width):
|
|
333
|
-
self._mask = self._create_mask(height, width)
|
|
334
|
-
mask_3d = self._mask[:, :, np.newaxis]
|
|
335
|
-
batch_size = 64
|
|
336
|
-
for start in range(0, len(video.frames), batch_size):
|
|
337
|
-
end = min(start + batch_size, len(video.frames))
|
|
338
|
-
video.frames[start:end] = (video.frames[start:end].astype(np.float32) * mask_3d).astype(np.uint8)
|
|
339
|
-
return video
|
|
340
|
-
|
|
341
323
|
|
|
342
324
|
class KenBurns(Effect):
|
|
343
325
|
"""Cinematic pan-and-zoom that smoothly animates between two crop regions.
|
|
@@ -473,29 +455,6 @@ class Fade(Effect):
|
|
|
473
455
|
return frame
|
|
474
456
|
return (frame.astype(np.float32) * a).astype(np.uint8)
|
|
475
457
|
|
|
476
|
-
def apply(self, video: Video, **context: Any) -> Video:
|
|
477
|
-
start_s, stop_s = self._resolved_window(video.total_seconds)
|
|
478
|
-
start_f = round(start_s * video.fps)
|
|
479
|
-
end_f = round(stop_s * video.fps)
|
|
480
|
-
n_effect = end_f - start_f
|
|
481
|
-
alpha = self._fade_envelope(n_effect, video.fps)
|
|
482
|
-
|
|
483
|
-
batch_size = 64
|
|
484
|
-
for batch_start in range(0, n_effect, batch_size):
|
|
485
|
-
batch_end = min(batch_start + batch_size, n_effect)
|
|
486
|
-
batch_alpha = alpha[batch_start:batch_end, np.newaxis, np.newaxis, np.newaxis]
|
|
487
|
-
if np.all(batch_alpha == 1.0):
|
|
488
|
-
continue
|
|
489
|
-
abs_start = start_f + batch_start
|
|
490
|
-
abs_end = start_f + batch_end
|
|
491
|
-
video.frames[abs_start:abs_end] = (video.frames[abs_start:abs_end].astype(np.float32) * batch_alpha).astype(
|
|
492
|
-
np.uint8
|
|
493
|
-
)
|
|
494
|
-
|
|
495
|
-
if video.audio is not None and not video.audio.is_silent:
|
|
496
|
-
self._audio_apply(video.audio, start_s, stop_s)
|
|
497
|
-
return video
|
|
498
|
-
|
|
499
458
|
def _curve_expr(self, progress: str) -> str:
|
|
500
459
|
"""ffmpeg gain sub-expression for a 0->1 ramp ``progress``, per ``self.curve``.
|
|
501
460
|
|
|
@@ -513,8 +472,9 @@ class Fade(Effect):
|
|
|
513
472
|
"""Express the fade's gain envelope as a windowed ``volume`` expression.
|
|
514
473
|
|
|
515
474
|
The audio twin of the video fade (which scales pixels by the same alpha
|
|
516
|
-
envelope). It must mirror
|
|
517
|
-
the ramp applies only WITHIN ``[start, stop]``
|
|
475
|
+
envelope in :meth:`process_frame`). It must mirror
|
|
476
|
+
:func:`_fade_envelope`: the ramp applies only WITHIN ``[start, stop]``
|
|
477
|
+
and the gain is 1.0
|
|
518
478
|
everywhere else -- so a *windowed* fade-out returns to full volume after
|
|
519
479
|
the window (matching the video, which resumes full brightness), instead
|
|
520
480
|
of staying muted. Native ``afade`` cannot express this (it holds 0
|
|
@@ -531,8 +491,8 @@ class Fade(Effect):
|
|
|
531
491
|
if stop_s <= start_s:
|
|
532
492
|
return None
|
|
533
493
|
window_len = stop_s - start_s
|
|
534
|
-
# Halve the ramp for in_out so the lead and trailing ramps cannot
|
|
535
|
-
#
|
|
494
|
+
# Halve the ramp for in_out so the lead and trailing ramps cannot
|
|
495
|
+
# overlap; a clamped half-window keeps them disjoint.
|
|
536
496
|
both = self.mode == "in_out"
|
|
537
497
|
ramp = min(self.duration, window_len / 2 if both else window_len)
|
|
538
498
|
if ramp <= 0:
|
|
@@ -550,19 +510,6 @@ class Fade(Effect):
|
|
|
550
510
|
expr = f"if({cond},{gain},{expr})"
|
|
551
511
|
return f"volume=volume='{expr}':eval=frame"
|
|
552
512
|
|
|
553
|
-
def _audio_apply(self, audio: Audio, start_s: float, stop_s: float) -> None:
|
|
554
|
-
"""Eager (in-memory) fade for ``Video.apply``; streaming uses ``afade``."""
|
|
555
|
-
sample_rate = audio.metadata.sample_rate
|
|
556
|
-
audio_start = round(start_s * sample_rate)
|
|
557
|
-
audio_end = min(round(stop_s * sample_rate), len(audio.data))
|
|
558
|
-
alpha = self._fade_envelope(audio_end - audio_start, sample_rate)
|
|
559
|
-
|
|
560
|
-
if audio.data.ndim == 1:
|
|
561
|
-
audio.data[audio_start:audio_end] *= alpha
|
|
562
|
-
else:
|
|
563
|
-
audio.data[audio_start:audio_end] *= alpha[:, np.newaxis]
|
|
564
|
-
np.clip(audio.data, -1.0, 1.0, out=audio.data)
|
|
565
|
-
|
|
566
513
|
|
|
567
514
|
class VolumeAdjust(Effect):
|
|
568
515
|
"""Changes audio volume within a time range without affecting video frames."""
|
|
@@ -585,12 +532,6 @@ class VolumeAdjust(Effect):
|
|
|
585
532
|
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
|
|
586
533
|
return frame
|
|
587
534
|
|
|
588
|
-
def apply(self, video: Video, **context: Any) -> Video:
|
|
589
|
-
start_s, stop_s = self._resolved_window(video.total_seconds)
|
|
590
|
-
if video.audio is not None and not video.audio.is_silent:
|
|
591
|
-
self._audio_apply(video.audio, start_s, stop_s)
|
|
592
|
-
return video
|
|
593
|
-
|
|
594
535
|
def to_ffmpeg_audio_filter(self, ctx: FilterCtx) -> str | None:
|
|
595
536
|
"""Apply the volume change over the window via the ``volume`` filter.
|
|
596
537
|
|
|
@@ -598,8 +539,8 @@ class VolumeAdjust(Effect):
|
|
|
598
539
|
multiplier compiles to ``volume=<v>:enable='between(t,start,stop)'``.
|
|
599
540
|
When ``ramp_duration>0`` the gain is a time-piecewise expression
|
|
600
541
|
(``volume=eval=frame``) that ramps ``1 -> volume`` over the first
|
|
601
|
-
``ramp_duration`` of the window and back over the last,
|
|
602
|
-
|
|
542
|
+
``ramp_duration`` of the window and back over the last, following the
|
|
543
|
+
``1 + (volume-1)*sqrt(t)`` edge-ramp shape. The window resolves
|
|
603
544
|
against the segment duration (``ctx.frame_count / ctx.fps``); ``None``
|
|
604
545
|
for a degenerate window or a no-op (``volume == 1`` with no ramp).
|
|
605
546
|
"""
|
|
@@ -632,35 +573,13 @@ class VolumeAdjust(Effect):
|
|
|
632
573
|
)
|
|
633
574
|
return f"volume=volume='{expr}':eval=frame"
|
|
634
575
|
|
|
635
|
-
def _audio_apply(self, audio: Audio, start_s: float, stop_s: float) -> None:
|
|
636
|
-
"""Eager (in-memory) volume change for ``Video.apply``; streaming uses ``volume``."""
|
|
637
|
-
sample_rate = audio.metadata.sample_rate
|
|
638
|
-
start_sample = round(start_s * sample_rate)
|
|
639
|
-
end_sample = min(round(stop_s * sample_rate), len(audio.data))
|
|
640
|
-
n_samples = end_sample - start_sample
|
|
641
|
-
envelope = np.full(n_samples, self.volume, dtype=np.float32)
|
|
642
|
-
|
|
643
|
-
if self.ramp_duration > 0:
|
|
644
|
-
ramp_samples = min(round(self.ramp_duration * sample_rate), n_samples // 2)
|
|
645
|
-
if ramp_samples > 0:
|
|
646
|
-
t = np.linspace(0, 1, ramp_samples, dtype=np.float32)
|
|
647
|
-
envelope[:ramp_samples] = 1.0 + (self.volume - 1.0) * np.sqrt(t)
|
|
648
|
-
t = np.linspace(1, 0, ramp_samples, dtype=np.float32)
|
|
649
|
-
envelope[-ramp_samples:] = 1.0 + (self.volume - 1.0) * np.sqrt(t)
|
|
650
|
-
|
|
651
|
-
if audio.data.ndim == 1:
|
|
652
|
-
audio.data[start_sample:end_sample] *= envelope
|
|
653
|
-
else:
|
|
654
|
-
audio.data[start_sample:end_sample] *= envelope[:, np.newaxis]
|
|
655
|
-
np.clip(audio.data, -1.0, 1.0, out=audio.data)
|
|
656
|
-
|
|
657
576
|
|
|
658
577
|
class _AnchoredOverlay(Effect):
|
|
659
578
|
"""Shared base for anchored RGBA overlays (:class:`TextOverlay`, :class:`ImageOverlay`).
|
|
660
579
|
|
|
661
|
-
Owns anchored placement, off-frame clipping, and alpha blending
|
|
662
|
-
|
|
663
|
-
parity-hole class of bug fixed in 0.34.1). Subclasses declare their own
|
|
580
|
+
Owns anchored placement, off-frame clipping, and alpha blending in one
|
|
581
|
+
``_blend_params`` source of truth (consolidated when the eager/streaming
|
|
582
|
+
parity-hole class of bug was fixed in 0.34.1). Subclasses declare their own
|
|
664
583
|
``position``/``anchor`` field defaults and implement
|
|
665
584
|
:meth:`_overlay_for_frame` to produce the RGBA bitmap; everything
|
|
666
585
|
downstream is shared. It declares no ``op`` ``Literal``, so it is an
|
|
@@ -723,11 +642,11 @@ class _AnchoredOverlay(Effect):
|
|
|
723
642
|
def _blend_params(
|
|
724
643
|
self, frame_w: int, frame_h: int
|
|
725
644
|
) -> tuple[np.ndarray, np.ndarray, tuple[int, int, int, int]] | None:
|
|
726
|
-
"""Placement + blend inputs
|
|
645
|
+
"""Placement + blend inputs for the streaming path.
|
|
727
646
|
|
|
728
|
-
Single source of truth
|
|
729
|
-
parity-hole class of bug fixed in 0.34.1. Returns
|
|
730
|
-
overlay lands fully off-frame (the effect is a no-op).
|
|
647
|
+
Single source of truth for placement and blending (consolidated when the
|
|
648
|
+
eager/stream parity-hole class of bug was fixed in 0.34.1). Returns
|
|
649
|
+
``None`` when the overlay lands fully off-frame (the effect is a no-op).
|
|
731
650
|
"""
|
|
732
651
|
overlay = self._overlay_for_frame(frame_w, frame_h)
|
|
733
652
|
oh, ow = overlay.shape[:2]
|
|
@@ -942,7 +861,7 @@ class ImageOverlay(_AnchoredOverlay):
|
|
|
942
861
|
def predict_metadata(self, meta: VideoMetadata, **_context: Any) -> VideoMetadata:
|
|
943
862
|
"""Reject only a missing/unreadable ``source`` (see :meth:`Operation.predict_metadata`).
|
|
944
863
|
|
|
945
|
-
An unreadable source is the one failure ``
|
|
864
|
+
An unreadable source is the one failure ``run_to_file()`` cannot survive -- it
|
|
946
865
|
would raise mid-stream after expensive frame decode -- so it is caught
|
|
947
866
|
at ``validate()`` time, symmetric with ``TranscriptionOverlay``.
|
|
948
867
|
Geometry (oversized / off-frame) is deliberately *not* checked here: it
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Every editing primitive is an ``Operation`` subclass -- a Pydantic model whose
|
|
4
4
|
fields ARE the JSON wire format. Validation, schema, and serialisation come for
|
|
5
|
-
free; subclasses just declare fields and implement
|
|
6
|
-
via ``__pydantic_init_subclass__`` builds the
|
|
7
|
-
modules are imported.
|
|
5
|
+
free; subclasses just declare fields and implement the streaming contract.
|
|
6
|
+
Auto-registration via ``__pydantic_init_subclass__`` builds the
|
|
7
|
+
``op_id -> class`` registry as modules are imported.
|
|
8
8
|
|
|
9
9
|
Subclass contract::
|
|
10
10
|
|
|
@@ -23,7 +23,6 @@ Subclass contract::
|
|
|
23
23
|
width: int | None = Field(None, gt=0)
|
|
24
24
|
height: int | None = Field(None, gt=0)
|
|
25
25
|
|
|
26
|
-
def apply(self, video: Video) -> Video: ...
|
|
27
26
|
def predict_metadata(self, meta: VideoMetadata) -> VideoMetadata: ...
|
|
28
27
|
def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None: ...
|
|
29
28
|
"""
|
|
@@ -38,10 +37,9 @@ from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, NamedTuple,
|
|
|
38
37
|
|
|
39
38
|
import numpy as np
|
|
40
39
|
from pydantic import BaseModel, ConfigDict, Discriminator, Field, TypeAdapter
|
|
41
|
-
from tqdm import tqdm
|
|
42
40
|
|
|
43
41
|
if TYPE_CHECKING:
|
|
44
|
-
from videopython.base.video import
|
|
42
|
+
from videopython.base.video import VideoMetadata
|
|
45
43
|
|
|
46
44
|
__all__ = [
|
|
47
45
|
"OpCategory",
|
|
@@ -72,8 +70,9 @@ class TimeRange(BaseModel):
|
|
|
72
70
|
*shape*; the numeric bounds (``>= 0``, ``stop >= start``, in-duration) are
|
|
73
71
|
owned by :meth:`VideoEdit.validate` / :meth:`VideoEdit.check`, which report
|
|
74
72
|
them as structured, collectable, repairable :class:`PlanError`s instead of
|
|
75
|
-
aborting at ``from_dict``.
|
|
76
|
-
run time, so a plan run without validation
|
|
73
|
+
aborting at ``from_dict``. The window is still clamped to
|
|
74
|
+
``min(stop, total_seconds)`` at run time, so a plan run without validation
|
|
75
|
+
degrades rather than crashes.
|
|
77
76
|
"""
|
|
78
77
|
|
|
79
78
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
@@ -238,8 +237,8 @@ class Operation(BaseModel):
|
|
|
238
237
|
wire and the registry key. Subclasses may override the ``category``,
|
|
239
238
|
``streamable``, and ``requires`` ClassVars.
|
|
240
239
|
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
``predict_metadata`` defaults to identity; ``to_ffmpeg_filter`` defaults to
|
|
241
|
+
``None`` (no filter compilation).
|
|
243
242
|
"""
|
|
244
243
|
|
|
245
244
|
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
@@ -380,24 +379,14 @@ class Operation(BaseModel):
|
|
|
380
379
|
"""
|
|
381
380
|
return _strip_llm_hidden(cls.model_json_schema())
|
|
382
381
|
|
|
383
|
-
def apply(self, video: Video) -> Video:
|
|
384
|
-
"""Run this operation on ``video``.
|
|
385
|
-
|
|
386
|
-
The runner passes pipeline-context values listed in ``cls.requires``
|
|
387
|
-
as keyword arguments (e.g. ``transcription=...``). Subclasses that
|
|
388
|
-
declare ``requires`` widen the signature accordingly -- e.g.
|
|
389
|
-
``def apply(self, video, transcription=None) -> Video``.
|
|
390
|
-
"""
|
|
391
|
-
raise NotImplementedError(f"{type(self).__name__}.apply not implemented")
|
|
392
|
-
|
|
393
382
|
def predict_metadata(self, meta: VideoMetadata) -> VideoMetadata:
|
|
394
383
|
"""Predict output metadata from input metadata. Default: identity.
|
|
395
384
|
|
|
396
385
|
Run during ``VideoEdit.validate()``'s dry-run, before any frames are
|
|
397
386
|
decoded. Beyond predicting shape, this is the fail-fast gate, and it
|
|
398
387
|
has one contract: **reject exactly the plans that would otherwise crash
|
|
399
|
-
or do unrecoverable / expensive work in**
|
|
400
|
-
anything ``
|
|
388
|
+
or do unrecoverable / expensive work in** ``run_to_file()``;
|
|
389
|
+
anything ``run_to_file()`` can absorb by graceful degradation is NOT rejected.
|
|
401
390
|
``TranscriptionOverlay`` rejects un-fittable subtitles (they used to
|
|
402
391
|
crash mid-render); ``TextOverlay``/``ImageOverlay`` do not reject
|
|
403
392
|
off-frame geometry (it clips to a valid no-op). Keep the check
|
|
@@ -450,18 +439,16 @@ class Effect(Operation):
|
|
|
450
439
|
|
|
451
440
|
Subclasses implement the streaming contract -- :meth:`process_frame` (and
|
|
452
441
|
:meth:`streaming_init` for any precomputed per-stream state) -- which is the
|
|
453
|
-
single source of truth for the effect's pixel logic. The
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
or audio handling (``Fade``/``VolumeAdjust`` override :meth:`apply` outright
|
|
464
|
-
so the audio splice stays coherent with the window).
|
|
442
|
+
single source of truth for the effect's pixel logic. The streaming engine
|
|
443
|
+
in ``editing/streaming.py`` drives that contract for bounded-memory
|
|
444
|
+
execution, resolving :attr:`window` against the segment timeline so frames
|
|
445
|
+
outside the window pass through untouched.
|
|
446
|
+
|
|
447
|
+
Effects that compile to a native ffmpeg filter instead set
|
|
448
|
+
:attr:`compiles_to_filter` and implement :meth:`to_ffmpeg_filter` (and, for
|
|
449
|
+
audio-coupled effects like ``Fade``/``VolumeAdjust``,
|
|
450
|
+
:meth:`to_ffmpeg_audio_filter`) so the window stays coherent across the
|
|
451
|
+
decode/encode graph.
|
|
465
452
|
"""
|
|
466
453
|
|
|
467
454
|
category: ClassVar[OpCategory] = OpCategory.EFFECT
|
|
@@ -493,67 +480,15 @@ class Effect(Operation):
|
|
|
493
480
|
"""
|
|
494
481
|
return False
|
|
495
482
|
|
|
496
|
-
def apply(self, video: Video, **context: Any) -> Video:
|
|
497
|
-
from videopython.base.video import Video as _Video
|
|
498
|
-
|
|
499
|
-
original_shape = video.video_shape
|
|
500
|
-
|
|
501
|
-
if self.window is None or (self.window.start is None and self.window.stop is None):
|
|
502
|
-
result = self._apply(video, **context)
|
|
503
|
-
else:
|
|
504
|
-
start_s, stop_s = self._resolved_window(video.total_seconds)
|
|
505
|
-
start_f = round(start_s * video.fps)
|
|
506
|
-
end_f = round(stop_s * video.fps)
|
|
507
|
-
inner = self._apply(video[start_f:end_f], **context)
|
|
508
|
-
old_audio = video.audio
|
|
509
|
-
result = _Video.from_frames(
|
|
510
|
-
np.r_["0,2", video.frames[:start_f], inner.frames, video.frames[end_f:]],
|
|
511
|
-
fps=video.fps,
|
|
512
|
-
)
|
|
513
|
-
result.audio = old_audio
|
|
514
|
-
|
|
515
|
-
if result.video_shape != original_shape:
|
|
516
|
-
raise RuntimeError(
|
|
517
|
-
f"{type(self).__name__} changed video shape from {original_shape} "
|
|
518
|
-
f"to {result.video_shape}; effects must preserve shape and frame count."
|
|
519
|
-
)
|
|
520
|
-
return result
|
|
521
|
-
|
|
522
483
|
def predict_metadata(self, meta: VideoMetadata, **_context: Any) -> VideoMetadata:
|
|
523
484
|
"""Effects preserve shape and frame count, so the prediction is identity.
|
|
524
485
|
|
|
525
486
|
Accepts ``**_context`` so requires-aware effects (``TranscriptionOverlay``)
|
|
526
487
|
validate without subclasses needing to override just to widen the
|
|
527
|
-
signature. Mirrors :meth:`Effect.
|
|
488
|
+
signature. Mirrors :meth:`Effect.streaming_init`'s ``**_context`` accept-all.
|
|
528
489
|
"""
|
|
529
490
|
return meta
|
|
530
491
|
|
|
531
|
-
def _resolved_window(self, total_seconds: float) -> tuple[float, float]:
|
|
532
|
-
win = self.window or TimeRange()
|
|
533
|
-
start_s = 0.0 if win.start is None else float(win.start)
|
|
534
|
-
stop_s = total_seconds if win.stop is None else float(win.stop)
|
|
535
|
-
start_s = min(start_s, total_seconds)
|
|
536
|
-
stop_s = min(stop_s, total_seconds)
|
|
537
|
-
if stop_s < start_s:
|
|
538
|
-
raise ValueError(f"Effect stop ({stop_s}) must be >= start ({start_s})")
|
|
539
|
-
return start_s, stop_s
|
|
540
|
-
|
|
541
|
-
def _apply(self, video: Video, **context: Any) -> Video:
|
|
542
|
-
"""Apply the effect to ``video`` in memory by replaying the streaming path.
|
|
543
|
-
|
|
544
|
-
Runs :meth:`streaming_init` once, then :meth:`process_frame` over every
|
|
545
|
-
frame in order -- the same logic streaming uses, so eager and streaming
|
|
546
|
-
cannot drift. ``context`` carries resolved ``requires`` values through
|
|
547
|
-
to :meth:`streaming_init`, mirroring the streaming scheduler.
|
|
548
|
-
Subclasses that need a genuinely different eager path (extra
|
|
549
|
-
validation, batched vectorisation) override this.
|
|
550
|
-
"""
|
|
551
|
-
height, width = video.frame_shape[:2]
|
|
552
|
-
self.streaming_init(len(video.frames), video.fps, width, height, **context)
|
|
553
|
-
for i in tqdm(range(len(video.frames)), desc=type(self).__name__):
|
|
554
|
-
video.frames[i] = self.process_frame(video.frames[i], i)
|
|
555
|
-
return video
|
|
556
|
-
|
|
557
492
|
def streaming_init(self, total_frames: int, fps: float, width: int, height: int, **_context: Any) -> None:
|
|
558
493
|
"""Hook for per-stream precomputation (per-frame alphas, sigma curves...).
|
|
559
494
|
|