videopython 0.40.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.40.0 → videopython-0.42.0}/PKG-INFO +3 -3
- {videopython-0.40.0 → videopython-0.42.0}/README.md +2 -2
- {videopython-0.40.0 → videopython-0.42.0}/pyproject.toml +1 -1
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/transforms.py +107 -81
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/__init__.py +0 -9
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/exceptions.py +2 -15
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/__init__.py +32 -1
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/video.py +6 -2
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/__init__.py +3 -2
- videopython-0.42.0/src/videopython/editing/_ass.py +254 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/effects.py +2 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/operation.py +91 -4
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/streaming.py +290 -44
- videopython-0.42.0/src/videopython/editing/transcription_overlay.py +398 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/transforms.py +225 -166
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/video_edit.py +332 -218
- videopython-0.40.0/src/videopython/base/image_text.py +0 -1217
- videopython-0.40.0/src/videopython/editing/transcription_overlay.py +0 -591
- {videopython-0.40.0 → videopython-0.42.0}/.gitignore +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/LICENSE +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/effects.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/audio/audio.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/description.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/transcription.py +0 -0
- {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.40.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}"
|
|
@@ -14,19 +14,16 @@ from .draw_detections import DetectionStyle, class_color, draw_detections
|
|
|
14
14
|
from .exceptions import (
|
|
15
15
|
AudioError,
|
|
16
16
|
AudioLoadError,
|
|
17
|
-
OutOfBoundsError,
|
|
18
17
|
PlanError,
|
|
19
18
|
PlanErrorCode,
|
|
20
19
|
PlanRepair,
|
|
21
20
|
PlanValidationError,
|
|
22
|
-
TextRenderError,
|
|
23
21
|
TransformError,
|
|
24
22
|
VideoError,
|
|
25
23
|
VideoLoadError,
|
|
26
24
|
VideoMetadataError,
|
|
27
25
|
VideoPythonError,
|
|
28
26
|
)
|
|
29
|
-
from .image_text import AnchorPoint, ImageText, TextAlign
|
|
30
27
|
from .transcription import Transcription, TranscriptionSegment, TranscriptionWord
|
|
31
28
|
from .video import FrameIterator, Video, VideoMetadata
|
|
32
29
|
|
|
@@ -43,17 +40,11 @@ __all__ = [
|
|
|
43
40
|
"AudioError",
|
|
44
41
|
"AudioLoadError",
|
|
45
42
|
"TransformError",
|
|
46
|
-
"TextRenderError",
|
|
47
|
-
"OutOfBoundsError",
|
|
48
43
|
# Structured plan validation / repair
|
|
49
44
|
"PlanError",
|
|
50
45
|
"PlanErrorCode",
|
|
51
46
|
"PlanValidationError",
|
|
52
47
|
"PlanRepair",
|
|
53
|
-
# Text rendering primitives
|
|
54
|
-
"ImageText",
|
|
55
|
-
"TextAlign",
|
|
56
|
-
"AnchorPoint",
|
|
57
48
|
# Detection overlay renderer (AI-free)
|
|
58
49
|
"draw_detections",
|
|
59
50
|
"DetectionStyle",
|
|
@@ -66,18 +66,6 @@ class TransformError(VideoPythonError):
|
|
|
66
66
|
pass
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
class TextRenderError(VideoPythonError):
|
|
70
|
-
"""Base exception for text rendering errors."""
|
|
71
|
-
|
|
72
|
-
pass
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class OutOfBoundsError(TextRenderError):
|
|
76
|
-
"""Raised when text would be rendered outside image bounds."""
|
|
77
|
-
|
|
78
|
-
pass
|
|
79
|
-
|
|
80
|
-
|
|
81
69
|
class PlanErrorCode(str, Enum):
|
|
82
70
|
"""Machine-readable failure classes raised while validating a ``VideoEdit``.
|
|
83
71
|
|
|
@@ -103,9 +91,8 @@ class PlanErrorCode(str, Enum):
|
|
|
103
91
|
# Assembly / structural.
|
|
104
92
|
UNKNOWN_OP = "unknown_op"
|
|
105
93
|
CONCAT_MISMATCH = "concat_mismatch"
|
|
106
|
-
SUBTITLE_UNFITTABLE = "subtitle_unfittable"
|
|
107
94
|
POST_OP_REQUIRES_CONTEXT = "post_op_requires_context"
|
|
108
|
-
# Streaming
|
|
95
|
+
# Streaming: unstreamable op at its plan position (always reported).
|
|
109
96
|
STREAMING_FALLBACK = "streaming_fallback"
|
|
110
97
|
|
|
111
98
|
|
|
@@ -116,7 +103,7 @@ class PlanError:
|
|
|
116
103
|
``location`` is a path into the plan (e.g. ``'segments[1].operations[0]'``);
|
|
117
104
|
the remaining fields are populated when meaningful for the ``code``.
|
|
118
105
|
``detail`` carries a short human-readable cause when the code alone is not
|
|
119
|
-
actionable (e.g. *why* an op
|
|
106
|
+
actionable (e.g. *why* an op cannot stream at its plan position) -- prose meant for
|
|
120
107
|
LLM refine-loop feedback, not for branching.
|
|
121
108
|
"""
|
|
122
109
|
|
|
@@ -12,10 +12,18 @@ unreadable font:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
from importlib.resources import as_file, files
|
|
15
|
+
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
from PIL import ImageFont
|
|
17
18
|
|
|
18
|
-
__all__ = [
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BUNDLED_FONTS",
|
|
21
|
+
"BUNDLED_FONT_FAMILIES",
|
|
22
|
+
"DEFAULT_FONT_FILENAME",
|
|
23
|
+
"FONT_NAMES",
|
|
24
|
+
"bundled_fonts_dir",
|
|
25
|
+
"load_font",
|
|
26
|
+
]
|
|
19
27
|
|
|
20
28
|
DEFAULT_FONT_FILENAME = "DejaVuSans.ttf"
|
|
21
29
|
|
|
@@ -31,6 +39,29 @@ BUNDLED_FONTS: dict[str, str] = {
|
|
|
31
39
|
|
|
32
40
|
FONT_NAMES: list[str] = sorted(BUNDLED_FONTS)
|
|
33
41
|
|
|
42
|
+
# Registered NAME (or None for the default) -> (family name, is-bold-face),
|
|
43
|
+
# exactly as declared in each file's name table. Renderers that match fonts by
|
|
44
|
+
# family name -- libass via the ffmpeg ``subtitles=`` filter's ``fontsdir`` --
|
|
45
|
+
# need these, not the filenames. Verified against the bundled .ttf files
|
|
46
|
+
# (PIL ``ImageFont.getname()``); a test pins them.
|
|
47
|
+
BUNDLED_FONT_FAMILIES: dict[str | None, tuple[str, bool]] = {
|
|
48
|
+
None: ("DejaVu Sans", False),
|
|
49
|
+
"poppins-bold": ("Poppins", True),
|
|
50
|
+
"lato-bold": ("Lato", True),
|
|
51
|
+
"anton": ("Anton", False),
|
|
52
|
+
"bebas-neue": ("Bebas Neue", False),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def bundled_fonts_dir() -> Path:
|
|
57
|
+
"""Filesystem directory holding the bundled .ttf files.
|
|
58
|
+
|
|
59
|
+
For ffmpeg/libass ``fontsdir`` wiring, which needs a real directory path.
|
|
60
|
+
Standard (non-zip) installs ship the package unpacked, so the resources
|
|
61
|
+
root is a plain directory.
|
|
62
|
+
"""
|
|
63
|
+
return Path(str(files(__package__)))
|
|
64
|
+
|
|
34
65
|
|
|
35
66
|
def _try_truetype(path: str, font_size: int) -> ImageFont.FreeTypeFont | None:
|
|
36
67
|
try:
|
|
@@ -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
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from ._ass import AnchorPoint
|
|
1
2
|
from .effects import (
|
|
2
3
|
Blur,
|
|
3
4
|
ChromaticAberration,
|
|
@@ -32,7 +33,6 @@ from .transforms import (
|
|
|
32
33
|
FreezeFrame,
|
|
33
34
|
ResampleFPS,
|
|
34
35
|
Resize,
|
|
35
|
-
Reverse,
|
|
36
36
|
SilenceRemoval,
|
|
37
37
|
SpeedChange,
|
|
38
38
|
)
|
|
@@ -53,7 +53,6 @@ __all__ = [
|
|
|
53
53
|
"Crop",
|
|
54
54
|
"CropMode",
|
|
55
55
|
"SpeedChange",
|
|
56
|
-
"Reverse",
|
|
57
56
|
"FreezeFrame",
|
|
58
57
|
"SilenceRemoval",
|
|
59
58
|
# Effects
|
|
@@ -83,6 +82,8 @@ __all__ = [
|
|
|
83
82
|
# Plan runner
|
|
84
83
|
"VideoEdit",
|
|
85
84
|
"SegmentConfig",
|
|
85
|
+
# Subtitle placement
|
|
86
|
+
"AnchorPoint",
|
|
86
87
|
# Streamability report
|
|
87
88
|
"StreamabilityReport",
|
|
88
89
|
"OpStreamability",
|