videopython 0.41.0__tar.gz → 0.42.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.41.0 → videopython-0.42.0}/PKG-INFO +3 -3
- {videopython-0.41.0 → videopython-0.42.0}/README.md +2 -2
- {videopython-0.41.0 → videopython-0.42.0}/pyproject.toml +1 -1
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/transforms.py +107 -81
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/exceptions.py +2 -2
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/video.py +6 -2
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/__init__.py +0 -2
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/effects.py +2 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/operation.py +62 -2
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/streaming.py +239 -51
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/transcription_overlay.py +5 -4
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/transforms.py +225 -166
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/video_edit.py +253 -200
- {videopython-0.41.0 → videopython-0.42.0}/.gitignore +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/LICENSE +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/__init__.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/effects.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/audio/audio.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/__init__.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/description.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/transcription.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/_ass.py +0 -0
- {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.41.0 → videopython-0.42.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.42.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/
|
|
@@ -117,9 +117,9 @@ See the [LLM Integration Guide](https://videopython.com/guides/llm-integration/)
|
|
|
117
117
|
|
|
118
118
|
## Features
|
|
119
119
|
|
|
120
|
-
- **`videopython.base`** — `Video`, `VideoMetadata`, `FrameIterator`, `
|
|
120
|
+
- **`videopython.base`** — `Video`, `VideoMetadata`, `FrameIterator`, `Transcription`, and shared result types (`BoundingBox`, `FaceTrack`, `SceneBoundary`, ...). No AI dependencies.
|
|
121
121
|
- **`videopython.audio`** — `Audio` with overlay, concat, normalize, time-stretch, silence detection, segment classification.
|
|
122
|
-
- **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (cut, resize, crop, fps, speed,
|
|
122
|
+
- **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (cut, resize, crop, fps, speed, freeze, silence removal) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
|
|
123
123
|
- **`videopython.ai`** *(install with `[ai]`)* — generation (`TextToVideo`, `ImageToVideo`, `TextToImage`, `TextToSpeech`, `TextToMusic`), understanding (`AudioToText`, `AudioClassifier`, `SceneVLM`, `FaceTracker`, `ObjectDetector`, `SemanticSceneDetector`), the `FaceTrackingCrop` transform, the `ObjectDetectionOverlay` effect (per-frame bounding boxes + labels), and the full-pipeline `VideoAnalyzer`.
|
|
124
124
|
- **`videopython.ai.dubbing`** — `VideoDubber` for voice-cloned revoicing with timing sync.
|
|
125
125
|
|
|
@@ -68,9 +68,9 @@ See the [LLM Integration Guide](https://videopython.com/guides/llm-integration/)
|
|
|
68
68
|
|
|
69
69
|
## Features
|
|
70
70
|
|
|
71
|
-
- **`videopython.base`** — `Video`, `VideoMetadata`, `FrameIterator`, `
|
|
71
|
+
- **`videopython.base`** — `Video`, `VideoMetadata`, `FrameIterator`, `Transcription`, and shared result types (`BoundingBox`, `FaceTrack`, `SceneBoundary`, ...). No AI dependencies.
|
|
72
72
|
- **`videopython.audio`** — `Audio` with overlay, concat, normalize, time-stretch, silence detection, segment classification.
|
|
73
|
-
- **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (cut, resize, crop, fps, speed,
|
|
73
|
+
- **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (cut, resize, crop, fps, speed, freeze, silence removal) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
|
|
74
74
|
- **`videopython.ai`** *(install with `[ai]`)* — generation (`TextToVideo`, `ImageToVideo`, `TextToImage`, `TextToSpeech`, `TextToMusic`), understanding (`AudioToText`, `AudioClassifier`, `SceneVLM`, `FaceTracker`, `ObjectDetector`, `SemanticSceneDetector`), the `FaceTrackingCrop` transform, the `ObjectDetectionOverlay` effect (per-frame bounding boxes + labels), and the full-pipeline `VideoAnalyzer`.
|
|
75
75
|
- **`videopython.ai.dubbing`** — `VideoDubber` for voice-cloned revoicing with timing sync.
|
|
76
76
|
|
|
@@ -3,17 +3,21 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import tempfile
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from pathlib import Path
|
|
6
10
|
from typing import ClassVar, Literal
|
|
7
11
|
|
|
8
|
-
import cv2
|
|
9
12
|
import numpy as np
|
|
10
13
|
from pydantic import Field
|
|
11
14
|
from tqdm import tqdm
|
|
12
15
|
|
|
13
16
|
from videopython.ai.understanding.faces import FaceTracker
|
|
14
17
|
from videopython.base._dimensions import floor_to_even
|
|
15
|
-
from videopython.base.video import Video, VideoMetadata
|
|
16
|
-
from videopython.editing.
|
|
18
|
+
from videopython.base.video import FrameIterator, Video, VideoMetadata
|
|
19
|
+
from videopython.editing._ass import escape_filter_value
|
|
20
|
+
from videopython.editing.operation import FilterCtx, OpCategory, Operation
|
|
17
21
|
|
|
18
22
|
logger = logging.getLogger(__name__)
|
|
19
23
|
|
|
@@ -29,13 +33,19 @@ class FaceTrackingCrop(Operation):
|
|
|
29
33
|
Useful for creating vertical (9:16) content from horizontal (16:9) video
|
|
30
34
|
by tracking the speaker's face and keeping it framed.
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
The crop window has a fixed size -- the largest ``target_aspect`` box
|
|
37
|
+
that fits the frame (also the output size, so no resampling happens) --
|
|
38
|
+
and its position follows the smoothed face track. On the streaming path
|
|
39
|
+
the detection pass runs at plan-compile time over a bounded decode of
|
|
40
|
+
exactly the frames the filter will see, and the track compiles to a
|
|
41
|
+
per-frame ``crop`` position command file (ffmpeg ``sendcmd``): zero
|
|
42
|
+
per-frame Python at render time.
|
|
35
43
|
"""
|
|
36
44
|
|
|
37
45
|
op: Literal["face_crop"] = "face_crop"
|
|
38
46
|
category: ClassVar[OpCategory] = OpCategory.TRANSFORM
|
|
47
|
+
streamable: ClassVar[bool] = True
|
|
48
|
+
compiles_from_source: ClassVar[bool] = True
|
|
39
49
|
|
|
40
50
|
target_aspect: tuple[int, int] = Field((9, 16), description="Output aspect ratio as (width, height).")
|
|
41
51
|
face_selection: Literal["largest", "centered", "index"] = Field(
|
|
@@ -58,7 +68,11 @@ class FaceTrackingCrop(Operation):
|
|
|
58
68
|
smoothing: float = Field(0.8, ge=0, le=1, description="Position smoothing factor (0-1, higher = smoother).")
|
|
59
69
|
max_speed: float | None = Field(None, gt=0, description="Optional max camera movement per frame (normalized).")
|
|
60
70
|
fallback: Literal["center", "last_position", "full_frame"] = Field(
|
|
61
|
-
"last_position",
|
|
71
|
+
"last_position",
|
|
72
|
+
description=(
|
|
73
|
+
'Behavior when no face detected. "center" and "full_frame" both center the crop '
|
|
74
|
+
'("full_frame" kept for plan compatibility); "last_position" holds the last tracked crop.'
|
|
75
|
+
),
|
|
62
76
|
)
|
|
63
77
|
detection_interval: int = Field(3, ge=1, description="Frames between face detections.")
|
|
64
78
|
backend: Literal["cpu", "gpu", "auto"] = Field("auto", description='Detection backend - "cpu", "gpu", or "auto".')
|
|
@@ -77,13 +91,12 @@ class FaceTrackingCrop(Operation):
|
|
|
77
91
|
return (face_cx, face_cy - self.headroom)
|
|
78
92
|
|
|
79
93
|
def _resolved_output_dims(self, w: int, h: int) -> tuple[int, int]:
|
|
80
|
-
"""Output ``(width, height)``
|
|
94
|
+
"""Output ``(width, height)`` -- the fixed crop-window size.
|
|
81
95
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
``Crop._resolve_box``), so the dry-run cannot disagree with the render.
|
|
96
|
+
The largest ``target_aspect`` box that fits the frame, even-floored.
|
|
97
|
+
A pure function of the input dimensions, shared by :meth:`apply`,
|
|
98
|
+
:meth:`predict_metadata`, and :meth:`to_ffmpeg_filter`, so the
|
|
99
|
+
dry-run cannot disagree with the render.
|
|
87
100
|
"""
|
|
88
101
|
target_ratio = self.target_aspect[0] / self.target_aspect[1]
|
|
89
102
|
if target_ratio < w / h:
|
|
@@ -109,44 +122,20 @@ class FaceTrackingCrop(Operation):
|
|
|
109
122
|
scale = self.max_speed / distance
|
|
110
123
|
return (current[0] + dx * scale, current[1] + dy * scale)
|
|
111
124
|
|
|
112
|
-
def
|
|
125
|
+
def _track_crop_positions(
|
|
113
126
|
self,
|
|
114
|
-
|
|
115
|
-
face_cy: float,
|
|
116
|
-
face_w: float,
|
|
117
|
-
face_h: float,
|
|
127
|
+
frames: Iterable[np.ndarray],
|
|
118
128
|
frame_w: int,
|
|
119
129
|
frame_h: int,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
target_ratio = self.target_aspect[0] / self.target_aspect[1]
|
|
123
|
-
frame_ratio = frame_w / frame_h
|
|
124
|
-
|
|
125
|
-
if target_ratio < frame_ratio:
|
|
126
|
-
crop_h = floor_to_even(frame_h)
|
|
127
|
-
crop_w = floor_to_even(int(crop_h * target_ratio))
|
|
128
|
-
else:
|
|
129
|
-
crop_w = floor_to_even(frame_w)
|
|
130
|
-
crop_h = floor_to_even(int(crop_w / target_ratio))
|
|
131
|
-
|
|
132
|
-
min_face_dim = max(face_w * frame_w, face_h * frame_h)
|
|
133
|
-
min_crop_dim = min_face_dim * (1 + 2 * self.padding)
|
|
134
|
-
if crop_w < min_crop_dim * target_ratio:
|
|
135
|
-
crop_w = floor_to_even(min(int(min_crop_dim * target_ratio), frame_w))
|
|
136
|
-
crop_h = floor_to_even(min(int(crop_w / target_ratio), frame_h))
|
|
137
|
-
|
|
138
|
-
if center_position is None:
|
|
139
|
-
center_position = self._apply_framing_offset(face_cx, face_cy, face_h)
|
|
140
|
-
|
|
141
|
-
center_x = center_position[0] * frame_w
|
|
142
|
-
center_y = center_position[1] * frame_h
|
|
143
|
-
x = int(center_x - crop_w / 2)
|
|
144
|
-
y = int(center_y - crop_h / 2)
|
|
145
|
-
x = max(0, min(x, frame_w - crop_w))
|
|
146
|
-
y = max(0, min(y, frame_h - crop_h))
|
|
147
|
-
return (x, y, crop_w, crop_h)
|
|
130
|
+
) -> list[tuple[int, int]]:
|
|
131
|
+
"""Per-frame crop top-left positions for a fixed-size crop window.
|
|
148
132
|
|
|
149
|
-
|
|
133
|
+
The single source of the tracking math (detection cadence, EMA
|
|
134
|
+
smoothing, framing offset, speed clamp, frame clamping), shared by
|
|
135
|
+
the eager :meth:`apply` and the compile-time detection pass so the
|
|
136
|
+
two paths cannot drift.
|
|
137
|
+
"""
|
|
138
|
+
out_w, out_h = self._resolved_output_dims(frame_w, frame_h)
|
|
150
139
|
tracker = FaceTracker(
|
|
151
140
|
selection_strategy=self.face_selection,
|
|
152
141
|
face_index=self.face_index,
|
|
@@ -155,16 +144,31 @@ class FaceTrackingCrop(Operation):
|
|
|
155
144
|
backend=self.backend,
|
|
156
145
|
sample_rate=self.sample_rate,
|
|
157
146
|
)
|
|
147
|
+
default = ((frame_w - out_w) // 2, (frame_h - out_h) // 2)
|
|
148
|
+
last = default
|
|
149
|
+
current_position = (0.5, 0.5)
|
|
150
|
+
positions: list[tuple[int, int]] = []
|
|
151
|
+
for i, frame in enumerate(frames):
|
|
152
|
+
face_info = tracker.detect_and_track(frame, i)
|
|
153
|
+
if face_info:
|
|
154
|
+
cx, cy, _fw, fh = face_info
|
|
155
|
+
target = self._apply_framing_offset(cx, cy, fh)
|
|
156
|
+
current_position = self._clamp_speed(current_position, target)
|
|
157
|
+
x = int(current_position[0] * frame_w - out_w / 2)
|
|
158
|
+
y = int(current_position[1] * frame_h - out_h / 2)
|
|
159
|
+
x = max(0, min(x, frame_w - out_w))
|
|
160
|
+
y = max(0, min(y, frame_h - out_h))
|
|
161
|
+
last = (x, y)
|
|
162
|
+
positions.append((x, y))
|
|
163
|
+
elif self.fallback == "last_position":
|
|
164
|
+
positions.append(last)
|
|
165
|
+
else: # "center" / "full_frame" (the latter kept for plan compat)
|
|
166
|
+
positions.append(default)
|
|
167
|
+
return positions
|
|
158
168
|
|
|
169
|
+
def apply(self, video: Video) -> Video:
|
|
159
170
|
h, w = video.frame_shape[:2]
|
|
160
171
|
out_w, out_h = self._resolved_output_dims(w, h)
|
|
161
|
-
|
|
162
|
-
default_x = (w - out_w) // 2
|
|
163
|
-
default_y = (h - out_h) // 2
|
|
164
|
-
last_crop = (default_x, default_y, out_w, out_h)
|
|
165
|
-
current_position = (0.5, 0.5)
|
|
166
|
-
|
|
167
|
-
framing_label = self.framing_rule if self.framing_rule != "offset" else "legacy-offset"
|
|
168
172
|
logger.info(
|
|
169
173
|
"Face tracking crop: %dx%d -> %dx%d (%d:%d, framing=%s)",
|
|
170
174
|
w,
|
|
@@ -173,33 +177,55 @@ class FaceTrackingCrop(Operation):
|
|
|
173
177
|
out_h,
|
|
174
178
|
self.target_aspect[0],
|
|
175
179
|
self.target_aspect[1],
|
|
176
|
-
|
|
180
|
+
self.framing_rule,
|
|
177
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
|
|
178
185
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
frame = video.frames[i]
|
|
182
|
-
face_info = tracker.detect_and_track(frame, i)
|
|
186
|
+
def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
|
|
187
|
+
"""Compile the face track to a per-frame ``crop`` position command file.
|
|
183
188
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
189
|
+
Runs the detection pass at plan-compile time over a bounded decode of
|
|
190
|
+
the segment (through the same decode-stage filter prefix the render
|
|
191
|
+
will use, so the detector sees identical frames), then emits one
|
|
192
|
+
``sendcmd`` interval per frame driving a fixed-size ``crop``. Returns
|
|
193
|
+
``None`` when the input frames are not reproducible at compile time
|
|
194
|
+
(``decode_filters is None`` -- the op sits behind per-frame Python
|
|
195
|
+
effects) or the source is unknown.
|
|
196
|
+
"""
|
|
197
|
+
if ctx.source_path is None or ctx.decode_filters is None or ctx.frame_count <= 0:
|
|
198
|
+
return None
|
|
199
|
+
out_w, out_h = self._resolved_output_dims(ctx.width, ctx.height)
|
|
200
|
+
|
|
201
|
+
with FrameIterator(
|
|
202
|
+
ctx.source_path,
|
|
203
|
+
start_second=ctx.start_second,
|
|
204
|
+
end_second=ctx.end_second,
|
|
205
|
+
vf_filters=list(ctx.decode_filters),
|
|
206
|
+
output_width=ctx.width,
|
|
207
|
+
output_height=ctx.height,
|
|
208
|
+
) as decoder:
|
|
209
|
+
frames = (frame for _, frame in decoder)
|
|
210
|
+
positions = self._track_crop_positions(
|
|
211
|
+
tqdm(frames, desc="Face tracking (compile)", total=ctx.frame_count), ctx.width, ctx.height
|
|
212
|
+
)
|
|
213
|
+
if not positions:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
label = f"fc{uuid.uuid4().hex[:8]}"
|
|
217
|
+
lines = []
|
|
218
|
+
for i, (x, y) in enumerate(positions):
|
|
219
|
+
t0 = i / ctx.fps
|
|
220
|
+
t1 = (i + 1) / ctx.fps
|
|
221
|
+
lines.append(f"{t0:.6f}-{t1:.6f} crop@{label} x {x}, crop@{label} y {y};")
|
|
222
|
+
tmp = tempfile.NamedTemporaryFile("w", suffix=".cmd", delete=False, encoding="utf-8")
|
|
223
|
+
try:
|
|
224
|
+
tmp.write("\n".join(lines) + "\n")
|
|
225
|
+
finally:
|
|
226
|
+
tmp.close()
|
|
227
|
+
cmd_path = Path(tmp.name)
|
|
228
|
+
ctx.owned_files.append(cmd_path)
|
|
229
|
+
|
|
230
|
+
x0, y0 = positions[0]
|
|
231
|
+
return f"sendcmd=f={escape_filter_value(str(cmd_path))},crop@{label}=w={out_w}:h={out_h}:x={x0}:y={y0}"
|
|
@@ -92,7 +92,7 @@ class PlanErrorCode(str, Enum):
|
|
|
92
92
|
UNKNOWN_OP = "unknown_op"
|
|
93
93
|
CONCAT_MISMATCH = "concat_mismatch"
|
|
94
94
|
POST_OP_REQUIRES_CONTEXT = "post_op_requires_context"
|
|
95
|
-
# Streaming
|
|
95
|
+
# Streaming: unstreamable op at its plan position (always reported).
|
|
96
96
|
STREAMING_FALLBACK = "streaming_fallback"
|
|
97
97
|
|
|
98
98
|
|
|
@@ -103,7 +103,7 @@ class PlanError:
|
|
|
103
103
|
``location`` is a path into the plan (e.g. ``'segments[1].operations[0]'``);
|
|
104
104
|
the remaining fields are populated when meaningful for the ``code``.
|
|
105
105
|
``detail`` carries a short human-readable cause when the code alone is not
|
|
106
|
-
actionable (e.g. *why* an op
|
|
106
|
+
actionable (e.g. *why* an op cannot stream at its plan position) -- prose meant for
|
|
107
107
|
LLM refine-loop feedback, not for branching.
|
|
108
108
|
"""
|
|
109
109
|
|
|
@@ -234,12 +234,16 @@ class FrameIterator:
|
|
|
234
234
|
if self.start_second > 0:
|
|
235
235
|
cmd.extend(["-ss", str(self.start_second)])
|
|
236
236
|
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
# Input-side -t: trim the SOURCE segment before the filter chain. As
|
|
238
|
+
# an output option it would instead cap the post-filter duration,
|
|
239
|
+
# silently truncating duration-extending filters (slow-motion setpts,
|
|
240
|
+
# freeze-frame loop).
|
|
239
241
|
if self.end_second is not None:
|
|
240
242
|
duration = self.end_second - self.start_second
|
|
241
243
|
cmd.extend(["-t", str(duration)])
|
|
242
244
|
|
|
245
|
+
cmd.extend(["-i", str(self.path)])
|
|
246
|
+
|
|
243
247
|
if self._vf_filters:
|
|
244
248
|
cmd.extend(["-vf", ",".join(self._vf_filters)])
|
|
245
249
|
|
|
@@ -33,7 +33,6 @@ from .transforms import (
|
|
|
33
33
|
FreezeFrame,
|
|
34
34
|
ResampleFPS,
|
|
35
35
|
Resize,
|
|
36
|
-
Reverse,
|
|
37
36
|
SilenceRemoval,
|
|
38
37
|
SpeedChange,
|
|
39
38
|
)
|
|
@@ -54,7 +53,6 @@ __all__ = [
|
|
|
54
53
|
"Crop",
|
|
55
54
|
"CropMode",
|
|
56
55
|
"SpeedChange",
|
|
57
|
-
"Reverse",
|
|
58
56
|
"FreezeFrame",
|
|
59
57
|
"SilenceRemoval",
|
|
60
58
|
# Effects
|
|
@@ -429,6 +429,7 @@ class Fade(Effect):
|
|
|
429
429
|
|
|
430
430
|
op: Literal["fade"] = "fade"
|
|
431
431
|
streamable: ClassVar[bool] = True
|
|
432
|
+
audio_coupled: ClassVar[bool] = True
|
|
432
433
|
|
|
433
434
|
mode: Literal["in", "out", "in_out"] = Field(
|
|
434
435
|
description=('"in" fades from black at the start, "out" fades to black at the end, "in_out" does both.'),
|
|
@@ -513,6 +514,7 @@ class VolumeAdjust(Effect):
|
|
|
513
514
|
|
|
514
515
|
op: Literal["volume_adjust"] = "volume_adjust"
|
|
515
516
|
streamable: ClassVar[bool] = True
|
|
517
|
+
audio_coupled: ClassVar[bool] = True
|
|
516
518
|
|
|
517
519
|
volume: float = Field(
|
|
518
520
|
1.0,
|
|
@@ -41,6 +41,7 @@ from pydantic import BaseModel, ConfigDict, Discriminator, Field, TypeAdapter
|
|
|
41
41
|
from tqdm import tqdm
|
|
42
42
|
|
|
43
43
|
if TYPE_CHECKING:
|
|
44
|
+
from videopython.audio import Audio
|
|
44
45
|
from videopython.base.video import Video, VideoMetadata
|
|
45
46
|
|
|
46
47
|
__all__ = [
|
|
@@ -102,6 +103,12 @@ class BoundedTimeField(NamedTuple):
|
|
|
102
103
|
class FilterCtx:
|
|
103
104
|
"""Current pipeline state (post-prior-ops) when compiling to ffmpeg.
|
|
104
105
|
|
|
106
|
+
``frame_count`` is the number of frames entering the filter at this chain
|
|
107
|
+
position (the plan builder folds ``predict_metadata`` through the chain),
|
|
108
|
+
so duration-aware compilations (a speed ramp's time-warp expression, a
|
|
109
|
+
freeze's frame indices) can be exact. ``0`` when unknown -- compilations
|
|
110
|
+
that need it must return ``None`` (no filter compilation) in that case.
|
|
111
|
+
|
|
105
112
|
``context`` carries the resolved, segment-local runtime context (the same
|
|
106
113
|
re-based values ``streaming_init`` receives) so a context-consuming op can
|
|
107
114
|
compile itself into the filter chain (e.g. ``add_subtitles`` consuming the
|
|
@@ -110,13 +117,26 @@ class FilterCtx:
|
|
|
110
117
|
``owned_files`` collects temp files a compilation creates (the ``.ass``
|
|
111
118
|
file a ``subtitles=`` entry references); the plan runner deletes them once
|
|
112
119
|
streaming finishes or the plan is abandoned.
|
|
120
|
+
|
|
121
|
+
``source_path``/``start_second``/``end_second`` locate the segment on
|
|
122
|
+
disk, and ``decode_filters`` is the decode-stage filter prefix ahead of
|
|
123
|
+
this op -- together they let a compilation run its own bounded decode
|
|
124
|
+
pass over exactly the frames the filter will see (``face_crop``'s
|
|
125
|
+
detection). ``decode_filters`` is ``None`` when those frames are not
|
|
126
|
+
reproducible at compile time (the op sits at the encode stage, behind
|
|
127
|
+
per-frame Python effects); such compilations must return ``None``.
|
|
113
128
|
"""
|
|
114
129
|
|
|
115
130
|
width: int
|
|
116
131
|
height: int
|
|
117
132
|
fps: float
|
|
133
|
+
frame_count: int = 0
|
|
118
134
|
context: dict[str, Any] = field(default_factory=dict)
|
|
119
135
|
owned_files: list[Path] = field(default_factory=list)
|
|
136
|
+
source_path: Path | None = None
|
|
137
|
+
start_second: float = 0.0
|
|
138
|
+
end_second: float | None = None
|
|
139
|
+
decode_filters: tuple[str, ...] | None = ()
|
|
120
140
|
|
|
121
141
|
|
|
122
142
|
LLM_HIDDEN_KEY = "llm_hidden"
|
|
@@ -213,7 +233,7 @@ class Operation(BaseModel):
|
|
|
213
233
|
``streamable``, and ``requires`` ClassVars.
|
|
214
234
|
|
|
215
235
|
The default ``apply`` raises ``NotImplementedError``; ``predict_metadata``
|
|
216
|
-
defaults to identity; ``to_ffmpeg_filter`` defaults to ``None`` (
|
|
236
|
+
defaults to identity; ``to_ffmpeg_filter`` defaults to ``None`` (no filter compilation).
|
|
217
237
|
"""
|
|
218
238
|
|
|
219
239
|
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
@@ -223,6 +243,22 @@ class Operation(BaseModel):
|
|
|
223
243
|
category: ClassVar[OpCategory] = OpCategory.SPECIAL
|
|
224
244
|
streamable: ClassVar[bool] = False
|
|
225
245
|
requires: ClassVar[tuple[str, ...]] = ()
|
|
246
|
+
compiles_from_source: ClassVar[bool] = False
|
|
247
|
+
"""Whether the op's filter compile decodes the source itself (face_crop's
|
|
248
|
+
detection pass). Such ops cannot sit at the encode stage -- the frames
|
|
249
|
+
behind per-frame Python effects are not reproducible at compile time --
|
|
250
|
+
so both the plan builder and the streamability report reject them as
|
|
251
|
+
UNSTREAMABLE there."""
|
|
252
|
+
changes_duration: ClassVar[bool] = False
|
|
253
|
+
"""Whether the op's output duration differs from its input (speed, freeze).
|
|
254
|
+
|
|
255
|
+
The streaming plan builder folds ``predict_metadata`` through the chain
|
|
256
|
+
either way; this flag additionally gates time-based *context*: a
|
|
257
|
+
context-consuming op scheduled after a duration-changing transform would
|
|
258
|
+
receive timestamps on the wrong timeline, so such plans are rejected as
|
|
259
|
+
UNSTREAMABLE until context re-mapping exists. The streamability report
|
|
260
|
+
mirrors the same rule.
|
|
261
|
+
"""
|
|
226
262
|
llm_exposed: ClassVar[bool] = True
|
|
227
263
|
time_fields: ClassVar[tuple[BoundedTimeField, ...]] = ()
|
|
228
264
|
"""Time-valued (seconds) fields :meth:`VideoEdit.repair` may clamp into range.
|
|
@@ -370,13 +406,29 @@ class Operation(BaseModel):
|
|
|
370
406
|
return meta
|
|
371
407
|
|
|
372
408
|
def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
|
|
373
|
-
"""Compile to an ffmpeg ``-vf`` filter expression, or ``None`` for
|
|
409
|
+
"""Compile to an ffmpeg ``-vf`` filter expression, or ``None`` for no filter compilation.
|
|
374
410
|
|
|
375
411
|
Streamable transforms override this. Effects use ``process_frame``
|
|
376
412
|
instead -- they do not go through ffmpeg filters.
|
|
377
413
|
"""
|
|
378
414
|
return None
|
|
379
415
|
|
|
416
|
+
def transform_audio(self, audio: Audio, output_duration: float, fps: float, **context: Any) -> Audio:
|
|
417
|
+
"""The op's audio-domain twin for the streaming path.
|
|
418
|
+
|
|
419
|
+
Video streams through the ffmpeg filter chain, but segment audio is
|
|
420
|
+
processed in memory (``_load_segment_audio``); a duration-changing
|
|
421
|
+
transform must therefore transform the audio to match
|
|
422
|
+
(``speed_change`` time-stretches, ``freeze_frame`` inserts silence,
|
|
423
|
+
``silence_removal`` cuts the same windows). ``output_duration`` is
|
|
424
|
+
the predicted post-op duration the result must fit; ``context``
|
|
425
|
+
carries the op's resolved ``requires`` values (segment-local), for
|
|
426
|
+
twins that need them. Identity by default -- the runner only replays
|
|
427
|
+
ops that override this. The eager ``apply`` should delegate its audio
|
|
428
|
+
handling here so the two paths cannot drift.
|
|
429
|
+
"""
|
|
430
|
+
return audio
|
|
431
|
+
|
|
380
432
|
|
|
381
433
|
class Effect(Operation):
|
|
382
434
|
"""Operation that preserves shape and frame count, driven by per-frame streaming.
|
|
@@ -398,6 +450,14 @@ class Effect(Operation):
|
|
|
398
450
|
"""
|
|
399
451
|
|
|
400
452
|
category: ClassVar[OpCategory] = OpCategory.EFFECT
|
|
453
|
+
audio_coupled: ClassVar[bool] = False
|
|
454
|
+
"""Whether the effect mutates audio alongside pixels (``_apply_audio``).
|
|
455
|
+
|
|
456
|
+
Audio-coupled effects cannot fold as post-operations across segment
|
|
457
|
+
boundaries: each segment's audio is processed independently, so a gain
|
|
458
|
+
envelope spanning a concat boundary would restart mid-ramp. The plan
|
|
459
|
+
builder and the streamability report both consult this.
|
|
460
|
+
"""
|
|
401
461
|
|
|
402
462
|
window: TimeRange | None = Field(
|
|
403
463
|
None,
|