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.
Files changed (72) hide show
  1. {videopython-0.41.0 → videopython-0.42.0}/PKG-INFO +3 -3
  2. {videopython-0.41.0 → videopython-0.42.0}/README.md +2 -2
  3. {videopython-0.41.0 → videopython-0.42.0}/pyproject.toml +1 -1
  4. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/transforms.py +107 -81
  5. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/exceptions.py +2 -2
  6. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/video.py +6 -2
  7. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/__init__.py +0 -2
  8. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/effects.py +2 -0
  9. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/operation.py +62 -2
  10. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/streaming.py +239 -51
  11. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/transcription_overlay.py +5 -4
  12. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/transforms.py +225 -166
  13. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/video_edit.py +253 -200
  14. {videopython-0.41.0 → videopython-0.42.0}/.gitignore +0 -0
  15. {videopython-0.41.0 → videopython-0.42.0}/LICENSE +0 -0
  16. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/__init__.py +0 -0
  17. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/__init__.py +0 -0
  18. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/_device.py +0 -0
  19. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  20. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/config.py +0 -0
  21. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/dubber.py +0 -0
  22. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  23. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/loudness.py +0 -0
  24. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/models.py +0 -0
  25. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  26. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/quality.py +0 -0
  27. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/remux.py +0 -0
  28. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/timing.py +0 -0
  29. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  30. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/effects.py +0 -0
  31. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/__init__.py +0 -0
  32. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/audio.py +0 -0
  33. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/image.py +0 -0
  34. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/qwen3.py +0 -0
  35. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/translation.py +0 -0
  36. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/generation/video.py +0 -0
  37. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/__init__.py +0 -0
  38. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/audio.py +0 -0
  39. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/faces.py +0 -0
  40. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/image.py +0 -0
  41. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/objects.py +0 -0
  42. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/separation.py +0 -0
  43. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/understanding/temporal.py +0 -0
  44. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
  45. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  46. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/models.py +0 -0
  47. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
  48. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/ai/video_analysis/stages.py +0 -0
  49. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/audio/__init__.py +0 -0
  50. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/audio/analysis.py +0 -0
  51. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/audio/audio.py +0 -0
  52. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/__init__.py +0 -0
  53. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/_dimensions.py +0 -0
  54. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/_ffmpeg.py +0 -0
  55. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/_video_io.py +0 -0
  56. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/description.py +0 -0
  57. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/draw_detections.py +0 -0
  58. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  59. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  60. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  61. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  62. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  63. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  64. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  65. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  66. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  67. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  68. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/fonts/__init__.py +0 -0
  69. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/base/transcription.py +0 -0
  70. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/_ass.py +0 -0
  71. {videopython-0.41.0 → videopython-0.42.0}/src/videopython/editing/_easing.py +0 -0
  72. {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.41.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.41.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}"
@@ -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 strictness (opt-in via strict_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 forces the eager fallback) -- prose meant for
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
- 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
 
@@ -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`` (eager).
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 eager.
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,