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.
Files changed (74) hide show
  1. {videopython-0.40.0 → videopython-0.42.0}/PKG-INFO +3 -3
  2. {videopython-0.40.0 → videopython-0.42.0}/README.md +2 -2
  3. {videopython-0.40.0 → videopython-0.42.0}/pyproject.toml +1 -1
  4. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/transforms.py +107 -81
  5. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/__init__.py +0 -9
  6. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/exceptions.py +2 -15
  7. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/__init__.py +32 -1
  8. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/video.py +6 -2
  9. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/__init__.py +3 -2
  10. videopython-0.42.0/src/videopython/editing/_ass.py +254 -0
  11. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/effects.py +2 -0
  12. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/operation.py +91 -4
  13. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/streaming.py +290 -44
  14. videopython-0.42.0/src/videopython/editing/transcription_overlay.py +398 -0
  15. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/transforms.py +225 -166
  16. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/video_edit.py +332 -218
  17. videopython-0.40.0/src/videopython/base/image_text.py +0 -1217
  18. videopython-0.40.0/src/videopython/editing/transcription_overlay.py +0 -591
  19. {videopython-0.40.0 → videopython-0.42.0}/.gitignore +0 -0
  20. {videopython-0.40.0 → videopython-0.42.0}/LICENSE +0 -0
  21. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/__init__.py +0 -0
  22. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/__init__.py +0 -0
  23. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/_device.py +0 -0
  24. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  25. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/config.py +0 -0
  26. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/dubber.py +0 -0
  27. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  28. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/loudness.py +0 -0
  29. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/models.py +0 -0
  30. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  31. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/quality.py +0 -0
  32. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/remux.py +0 -0
  33. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/timing.py +0 -0
  34. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  35. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/effects.py +0 -0
  36. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/__init__.py +0 -0
  37. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/audio.py +0 -0
  38. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/image.py +0 -0
  39. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/qwen3.py +0 -0
  40. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/translation.py +0 -0
  41. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/generation/video.py +0 -0
  42. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/__init__.py +0 -0
  43. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/audio.py +0 -0
  44. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/faces.py +0 -0
  45. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/image.py +0 -0
  46. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/objects.py +0 -0
  47. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/separation.py +0 -0
  48. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/understanding/temporal.py +0 -0
  49. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
  50. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  51. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/models.py +0 -0
  52. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
  53. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/stages.py +0 -0
  54. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/audio/__init__.py +0 -0
  55. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/audio/analysis.py +0 -0
  56. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/audio/audio.py +0 -0
  57. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/_dimensions.py +0 -0
  58. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/_ffmpeg.py +0 -0
  59. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/_video_io.py +0 -0
  60. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/description.py +0 -0
  61. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/draw_detections.py +0 -0
  62. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  63. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  64. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  65. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  66. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  67. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  68. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  69. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  70. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  71. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  72. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/base/transcription.py +0 -0
  73. {videopython-0.40.0 → videopython-0.42.0}/src/videopython/editing/_easing.py +0 -0
  74. {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.40.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`, `ImageText`, `Transcription`, and shared result types (`BoundingBox`, `FaceTrack`, `SceneBoundary`, ...). No AI dependencies.
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, reverse, freeze, silence removal) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
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`, `ImageText`, `Transcription`, and shared result types (`BoundingBox`, `FaceTrack`, `SceneBoundary`, ...). No AI dependencies.
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, reverse, freeze, silence removal) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.40.0"
3
+ version = "0.42.0"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -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.operation import OpCategory, Operation
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
- Supports GPU acceleration for faster processing with optional frame sampling
33
- and simple cinematographic framing rules (headroom / thirds) plus optional
34
- movement speed clamping.
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", description="Behavior when no face detected."
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)`` after the crop + resize.
94
+ """Output ``(width, height)`` -- the fixed crop-window size.
81
95
 
82
- Every frame is resized to this size regardless of the per-frame face
83
- position, so it is a pure function of the input dimensions and
84
- ``target_aspect``. Single source of truth shared by :meth:`apply` and
85
- :meth:`predict_metadata` (mirrors ``Resize._resolve_dims`` /
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 _calculate_crop_region(
125
+ def _track_crop_positions(
113
126
  self,
114
- face_cx: float,
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
- center_position: tuple[float, float] | None = None,
121
- ) -> tuple[int, int, int, int]:
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
- def apply(self, video: Video) -> Video:
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
- framing_label,
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
- new_frames = []
180
- for i in tqdm(range(len(video.frames)), desc="Face tracking crop"):
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
- if face_info:
185
- cx, cy, fw, fh = face_info
186
- target_position = self._apply_framing_offset(cx, cy, fh)
187
- current_position = self._clamp_speed(current_position, target_position)
188
- crop = self._calculate_crop_region(cx, cy, fw, fh, w, h, center_position=current_position)
189
- last_crop = crop
190
- else:
191
- if self.fallback == "center":
192
- crop = (default_x, default_y, out_w, out_h)
193
- elif self.fallback == "last_position":
194
- crop = last_crop
195
- else: # full_frame
196
- crop = (0, 0, w, h)
197
-
198
- x, y, cw, ch = crop
199
- cropped = frame[y : y + ch, x : x + cw]
200
- if cropped.shape[1] != out_w or cropped.shape[0] != out_h:
201
- cropped = cv2.resize(cropped, (out_w, out_h), interpolation=cv2.INTER_AREA)
202
- new_frames.append(cropped)
203
-
204
- video.frames = np.array(new_frames, dtype=np.uint8)
205
- return video
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 strictness (opt-in via strict_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 forces the eager fallback) -- prose meant for
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__ = ["BUNDLED_FONTS", "DEFAULT_FONT_FILENAME", "FONT_NAMES", "load_font"]
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
- cmd.extend(["-i", str(self.path)])
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",