videopython 0.43.1__tar.gz → 0.44.1__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 (77) hide show
  1. {videopython-0.43.1 → videopython-0.44.1}/PKG-INFO +2 -2
  2. {videopython-0.43.1 → videopython-0.44.1}/README.md +1 -1
  3. {videopython-0.43.1 → videopython-0.44.1}/pyproject.toml +1 -1
  4. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/__init__.py +5 -14
  5. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/_optional.py +40 -0
  6. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/__init__.py +5 -14
  7. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/dubber.py +1 -3
  8. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/effects.py +3 -3
  9. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/__init__.py +5 -14
  10. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/_tts_backend.py +1 -1
  11. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/transforms.py +6 -24
  12. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/__init__.py +5 -14
  13. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/analyzer.py +2 -3
  14. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/audio/audio.py +2 -2
  15. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/_ffmpeg.py +11 -0
  16. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/exceptions.py +1 -5
  17. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/video.py +17 -0
  18. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/_ass.py +1 -12
  19. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/audio_ops.py +31 -12
  20. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/effects.py +47 -130
  21. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/operation.py +22 -87
  22. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/streaming.py +11 -222
  23. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/transcription_overlay.py +11 -73
  24. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/transforms.py +24 -259
  25. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/video_edit.py +117 -273
  26. {videopython-0.43.1 → videopython-0.44.1}/.gitignore +0 -0
  27. {videopython-0.43.1 → videopython-0.44.1}/LICENSE +0 -0
  28. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/__init__.py +0 -0
  29. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/_device.py +0 -0
  30. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/_predictor.py +0 -0
  31. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/_revisions.py +0 -0
  32. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/config.py +0 -0
  33. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  34. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/loudness.py +0 -0
  35. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/models.py +0 -0
  36. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/pipeline.py +0 -0
  37. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/quality.py +0 -0
  38. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/remux.py +0 -0
  39. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/timing.py +0 -0
  40. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  41. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/audio.py +0 -0
  42. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/image.py +0 -0
  43. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/qwen3.py +0 -0
  44. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/translation.py +0 -0
  45. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/video.py +0 -0
  46. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/audio.py +0 -0
  47. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/faces.py +0 -0
  48. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/image.py +0 -0
  49. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/objects.py +0 -0
  50. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/separation.py +0 -0
  51. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/temporal.py +0 -0
  52. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/__init__.py +0 -0
  53. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/models.py +0 -0
  54. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/sampling.py +0 -0
  55. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/stages.py +0 -0
  56. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/audio/__init__.py +0 -0
  57. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/audio/analysis.py +0 -0
  58. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/__init__.py +0 -0
  59. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/_dimensions.py +0 -0
  60. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/_video_io.py +0 -0
  61. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/description.py +0 -0
  62. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/draw_detections.py +0 -0
  63. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  64. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  65. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  66. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  67. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  68. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  69. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  70. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  71. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  72. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  73. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/__init__.py +0 -0
  74. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/transcription.py +0 -0
  75. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/__init__.py +0 -0
  76. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/_easing.py +0 -0
  77. {videopython-0.43.1 → videopython-0.44.1}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.43.1
3
+ Version: 0.44.1
4
4
  Summary: Minimal video generation and processing library.
5
5
  Project-URL: Homepage, https://videopython.com
6
6
  Project-URL: Repository, https://github.com/bartwojtowicz/videopython/
@@ -133,7 +133,7 @@ edit.validate() # dry-run via metadata, no frames loaded
133
133
  edit.run_to_file("output.mp4") # streams ffmpeg decode → effects → encode
134
134
  ```
135
135
 
136
- `run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. Use `edit.run()` to get a `Video` back in memory instead.
136
+ `run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. If you need the frames back in memory, load the rendered file: `Video.from_path(str(edit.run_to_file("output.mp4")))`.
137
137
 
138
138
  ### AI generation
139
139
 
@@ -47,7 +47,7 @@ edit.validate() # dry-run via metadata, no frames loaded
47
47
  edit.run_to_file("output.mp4") # streams ffmpeg decode → effects → encode
48
48
  ```
49
49
 
50
- `run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. Use `edit.run()` to get a `Video` back in memory instead.
50
+ `run_to_file()` streams ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. If you need the frames back in memory, load the rendered file: `Video.from_path(str(edit.run_to_file("output.mp4")))`.
51
51
 
52
52
  ### AI generation
53
53
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.43.1"
3
+ version = "0.44.1"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -16,9 +16,10 @@ runtime.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- import importlib
20
19
  from typing import TYPE_CHECKING
21
20
 
21
+ from videopython.ai._optional import lazy_exports
22
+
22
23
  if TYPE_CHECKING:
23
24
  # Redundant aliases mark these as intentional re-exports (mypy/IDE see the
24
25
  # symbols; ruff doesn't flag them as unused). Runtime resolution is lazy via
@@ -43,7 +44,7 @@ if TYPE_CHECKING:
43
44
  # Public symbol -> submodule (relative to this package) that defines it. The
44
45
  # submodule's own (lazy) __init__ resolves the symbol; we only import the
45
46
  # submodule, never its siblings.
46
- _SYMBOL_MODULES: dict[str, str] = {
47
+ _exports: dict[str, str] = {
47
48
  # Generation
48
49
  "TextToVideo": ".generation",
49
50
  "ImageToVideo": ".generation",
@@ -67,16 +68,6 @@ _SYMBOL_MODULES: dict[str, str] = {
67
68
  "VideoAnalyzer": ".video_analysis",
68
69
  }
69
70
 
70
- __all__ = list(_SYMBOL_MODULES)
71
-
72
-
73
- def __getattr__(name: str) -> object:
74
- module_name = _SYMBOL_MODULES.get(name)
75
- if module_name is None:
76
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
77
- module = importlib.import_module(module_name, __name__)
78
- return getattr(module, name)
79
-
71
+ __all__ = list(_exports)
80
72
 
81
- def __dir__() -> list[str]:
82
- return sorted(__all__)
73
+ __getattr__, __dir__ = lazy_exports(__name__, _exports)
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  import importlib
20
20
  from types import ModuleType
21
+ from typing import Callable
21
22
 
22
23
 
23
24
  def require(module: str, extra: str, *, feature: str | None = None) -> ModuleType:
@@ -45,3 +46,42 @@ def require(module: str, extra: str, *, feature: str | None = None) -> ModuleTyp
45
46
  except ImportError as exc:
46
47
  label = feature or module.split(".")[0]
47
48
  raise ImportError(f"{label} requires the '{extra}' extra: pip install 'videopython[{extra}]'") from exc
49
+
50
+
51
+ def lazy_exports(package: str, exports: dict[str, str]) -> tuple[Callable[[str], object], Callable[[], list[str]]]:
52
+ """Build the PEP 562 lazy ``__getattr__``/``__dir__`` pair for a package.
53
+
54
+ Re-exports a set of public symbols lazily: the submodule backing a symbol is
55
+ imported only on first attribute access, so importing the package does not
56
+ pull in any sibling leaf module (this is what keeps the granular ``ai``
57
+ extras independently installable).
58
+
59
+ Args:
60
+ package: The ``__name__`` of the package defining the re-exports. Used
61
+ both for the ``AttributeError`` message and as the anchor for
62
+ resolving relative ``exports`` values.
63
+ exports: Mapping of public symbol name to the module that defines it.
64
+ Values may be relative (``".audio"`` — resolved against ``package``)
65
+ or absolute (``"videopython.ai.dubbing.config"``).
66
+
67
+ Returns:
68
+ A ``(__getattr__, __dir__)`` tuple to assign in the package namespace.
69
+ ``__getattr__`` imports the backing module and returns the symbol,
70
+ raising ``AttributeError`` for unknown names; ``__dir__`` returns the
71
+ sorted export names.
72
+ """
73
+
74
+ def __getattr__(name: str) -> object:
75
+ module_name = exports.get(name)
76
+ if module_name is None:
77
+ raise AttributeError(f"module {package!r} has no attribute {name!r}")
78
+ if module_name.startswith("."):
79
+ module = importlib.import_module(module_name, package)
80
+ else:
81
+ module = importlib.import_module(module_name)
82
+ return getattr(module, name)
83
+
84
+ def __dir__() -> list[str]:
85
+ return sorted(exports)
86
+
87
+ return __getattr__, __dir__
@@ -11,9 +11,10 @@ symbols visible to mypy and IDEs.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import importlib
15
14
  from typing import TYPE_CHECKING
16
15
 
16
+ from videopython.ai._optional import lazy_exports
17
+
17
18
  if TYPE_CHECKING:
18
19
  # Redundant aliases = intentional re-exports (visible to mypy/IDE, not
19
20
  # flagged by ruff). Runtime resolution is lazy via __getattr__ below.
@@ -32,7 +33,7 @@ if TYPE_CHECKING:
32
33
  from videopython.ai.generation.translation import UnsupportedLanguageError as UnsupportedLanguageError
33
34
 
34
35
  # Public symbol -> fully-qualified module that defines it.
35
- _SYMBOL_MODULES: dict[str, str] = {
36
+ _exports: dict[str, str] = {
36
37
  "DubbingConfig": "videopython.ai.dubbing.config",
37
38
  "VideoDubber": "videopython.ai.dubbing.dubber",
38
39
  "DubbingResult": "videopython.ai.dubbing.models",
@@ -48,16 +49,6 @@ _SYMBOL_MODULES: dict[str, str] = {
48
49
  "UnsupportedLanguageError": "videopython.ai.generation.translation",
49
50
  }
50
51
 
51
- __all__ = list(_SYMBOL_MODULES)
52
-
53
-
54
- def __getattr__(name: str) -> object:
55
- module_name = _SYMBOL_MODULES.get(name)
56
- if module_name is None:
57
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
58
- module = importlib.import_module(module_name)
59
- return getattr(module, name)
60
-
52
+ __all__ = list(_exports)
61
53
 
62
- def __dir__() -> list[str]:
63
- return sorted(__all__)
54
+ __getattr__, __dir__ = lazy_exports(__name__, _exports)
@@ -239,9 +239,7 @@ class VideoDubber:
239
239
  video_duration = video.total_seconds
240
240
 
241
241
  if video_duration > speech_duration:
242
- from videopython.editing.transforms import CutSeconds
243
-
244
- output_video = CutSeconds(start=0, end=speech_duration).apply(video)
242
+ output_video = video[: round(speech_duration * video.fps)]
245
243
  else:
246
244
  output_video = video
247
245
 
@@ -33,9 +33,9 @@ class ObjectDetectionOverlay(Effect):
33
33
  *memory*-bound: ``"streamable"`` here means bounded memory, not bounded
34
34
  compute. On long clips, cap cost with ``window`` (limit the time range),
35
35
  a larger ``detection_interval``, a ``class_filter``, and/or the smaller
36
- ``model_size``. Because only ``streaming_init`` and ``process_frame`` are
37
- overridden, the base ``Effect._apply`` replays the identical contract for
38
- in-memory execution, so eager and streaming results cannot drift.
36
+ ``model_size``. Only ``streaming_init`` and ``process_frame`` are
37
+ overridden; the streaming engine drives that contract for bounded-memory
38
+ execution.
39
39
  """
40
40
 
41
41
  op: Literal["object_detection_overlay"] = "object_detection_overlay"
@@ -8,9 +8,10 @@ Each symbol's backing leaf module is imported only on first access, so
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- import importlib
12
11
  from typing import TYPE_CHECKING
13
12
 
13
+ from videopython.ai._optional import lazy_exports
14
+
14
15
  if TYPE_CHECKING:
15
16
  # Redundant aliases = intentional re-exports (visible to mypy/IDE, not
16
17
  # flagged by ruff). Runtime resolution is lazy via __getattr__ below.
@@ -20,7 +21,7 @@ if TYPE_CHECKING:
20
21
  from .video import ImageToVideo as ImageToVideo
21
22
  from .video import TextToVideo as TextToVideo
22
23
 
23
- _SYMBOL_MODULES: dict[str, str] = {
24
+ _exports: dict[str, str] = {
24
25
  "TextToSpeech": ".audio",
25
26
  "TextToMusic": ".audio",
26
27
  "TextToImage": ".image",
@@ -28,16 +29,6 @@ _SYMBOL_MODULES: dict[str, str] = {
28
29
  "TextToVideo": ".video",
29
30
  }
30
31
 
31
- __all__ = list(_SYMBOL_MODULES)
32
-
33
-
34
- def __getattr__(name: str) -> object:
35
- module_name = _SYMBOL_MODULES.get(name)
36
- if module_name is None:
37
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
38
- module = importlib.import_module(module_name, __name__)
39
- return getattr(module, name)
40
-
32
+ __all__ = list(_exports)
41
33
 
42
- def __dir__() -> list[str]:
43
- return sorted(__all__)
34
+ __getattr__, __dir__ = lazy_exports(__name__, _exports)
@@ -10,7 +10,7 @@ local ``TextToSpeech`` (which pulls ``chatterbox-tts`` via the ``[tts]`` extra)
10
10
  satisfies this protocol structurally — no changes needed there. A consumer that
11
11
  can't or won't install chatterbox (e.g. a service running synthesis in a
12
12
  separate process or on a remote/Modal function) supplies its own object
13
- implementing :meth:`SpeechBackend.synthesize` and injects it; the pipeline never
13
+ implementing :meth:`SpeechBackend.generate_audio` and injects it; the pipeline never
14
14
  imports chatterbox in that case. videopython ships ONLY this protocol plus the
15
15
  local backend — no reference remote/HTTP backend.
16
16
  """
@@ -15,8 +15,8 @@ from tqdm import tqdm
15
15
 
16
16
  from videopython.ai.understanding.faces import FaceTracker
17
17
  from videopython.base._dimensions import floor_to_even
18
- from videopython.base.video import FrameIterator, Video, VideoMetadata
19
- from videopython.editing._ass import escape_filter_value
18
+ from videopython.base._ffmpeg import escape_filter_value
19
+ from videopython.base.video import FrameIterator, VideoMetadata
20
20
  from videopython.editing.operation import FilterCtx, OpCategory, Operation
21
21
 
22
22
  logger = logging.getLogger(__name__)
@@ -94,8 +94,8 @@ class FaceTrackingCrop(Operation):
94
94
  """Output ``(width, height)`` -- the fixed crop-window size.
95
95
 
96
96
  The largest ``target_aspect`` box that fits the frame, even-floored.
97
- A pure function of the input dimensions, shared by :meth:`apply`,
98
- :meth:`predict_metadata`, and :meth:`to_ffmpeg_filter`, so the
97
+ A pure function of the input dimensions, shared by
98
+ :meth:`predict_metadata` and :meth:`to_ffmpeg_filter`, so the
99
99
  dry-run cannot disagree with the render.
100
100
  """
101
101
  target_ratio = self.target_aspect[0] / self.target_aspect[1]
@@ -131,9 +131,8 @@ class FaceTrackingCrop(Operation):
131
131
  """Per-frame crop top-left positions for a fixed-size crop window.
132
132
 
133
133
  The single source of the tracking math (detection cadence, EMA
134
- smoothing, framing offset, speed clamp, frame clamping), shared by
135
- the eager :meth:`apply` and the compile-time detection pass so the
136
- two paths cannot drift.
134
+ smoothing, framing offset, speed clamp, frame clamping), run by the
135
+ compile-time detection pass to build the per-frame crop command file.
137
136
  """
138
137
  out_w, out_h = self._resolved_output_dims(frame_w, frame_h)
139
138
  tracker = FaceTracker(
@@ -166,23 +165,6 @@ class FaceTrackingCrop(Operation):
166
165
  positions.append(default)
167
166
  return positions
168
167
 
169
- def apply(self, video: Video) -> Video:
170
- h, w = video.frame_shape[:2]
171
- out_w, out_h = self._resolved_output_dims(w, h)
172
- logger.info(
173
- "Face tracking crop: %dx%d -> %dx%d (%d:%d, framing=%s)",
174
- w,
175
- h,
176
- out_w,
177
- out_h,
178
- self.target_aspect[0],
179
- self.target_aspect[1],
180
- self.framing_rule,
181
- )
182
- positions = self._track_crop_positions(tqdm(video.frames, desc="Face tracking crop"), w, h)
183
- video.frames = np.stack([video.frames[i][y : y + out_h, x : x + out_w] for i, (x, y) in enumerate(positions)])
184
- return video
185
-
186
168
  def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
187
169
  """Compile the face track to a per-frame ``crop`` position command file.
188
170
 
@@ -8,9 +8,10 @@ does not pull in ``audio`` (whisper/pyannote — ``[asr]``). The
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- import importlib
12
11
  from typing import TYPE_CHECKING
13
12
 
13
+ from videopython.ai._optional import lazy_exports
14
+
14
15
  if TYPE_CHECKING:
15
16
  # Redundant aliases = intentional re-exports (visible to mypy/IDE, not
16
17
  # flagged by ruff). Runtime resolution is lazy via __getattr__ below.
@@ -21,7 +22,7 @@ if TYPE_CHECKING:
21
22
  from .objects import ObjectDetector as ObjectDetector
22
23
  from .temporal import SemanticSceneDetector as SemanticSceneDetector
23
24
 
24
- _SYMBOL_MODULES: dict[str, str] = {
25
+ _exports: dict[str, str] = {
25
26
  "AudioToText": ".audio",
26
27
  "AudioClassifier": ".audio",
27
28
  "FaceTracker": ".faces",
@@ -30,16 +31,6 @@ _SYMBOL_MODULES: dict[str, str] = {
30
31
  "SemanticSceneDetector": ".temporal",
31
32
  }
32
33
 
33
- __all__ = list(_SYMBOL_MODULES)
34
-
35
-
36
- def __getattr__(name: str) -> object:
37
- module_name = _SYMBOL_MODULES.get(name)
38
- if module_name is None:
39
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
40
- module = importlib.import_module(module_name, __name__)
41
- return getattr(module, name)
42
-
34
+ __all__ = list(_exports)
43
35
 
44
- def __dir__() -> list[str]:
45
- return sorted(__all__)
36
+ __getattr__, __dir__ = lazy_exports(__name__, _exports)
@@ -331,9 +331,8 @@ class VideoAnalyzer:
331
331
  return None
332
332
  if source_path is not None:
333
333
  return Video.from_path(str(source_path), start_second=start_second, end_second=end_second)
334
- from videopython.editing.transforms import CutSeconds
335
-
336
- return CutSeconds(start=start_second, end=end_second).apply(stages.require_video(video))
334
+ v = stages.require_video(video)
335
+ return v[round(start_second * v.fps) : round(end_second * v.fps)]
337
336
 
338
337
  def _default_scene_boundaries(self, metadata: VideoMetadata) -> list[SceneBoundary]:
339
338
  if metadata.total_seconds <= 0 or metadata.frame_count <= 0:
@@ -23,8 +23,8 @@ def atempo_chain(speed: float) -> list[str]:
23
23
  factor outside that range is decomposed into a chain of capped stages
24
24
  (``atempo=2.0`` repeated for speedups, ``atempo=0.5`` for slowdowns) times
25
25
  a final remainder stage. The single source of truth shared by
26
- :meth:`Audio.time_stretch` (the in-memory WAV round-trip used by the eager
27
- ``apply`` path) and ``SpeedChange.to_ffmpeg_audio_filter`` (the streaming
26
+ :meth:`Audio.time_stretch` (the in-memory WAV round-trip used by dubbing's
27
+ timing alignment) and ``SpeedChange.to_ffmpeg_audio_filter`` (the streaming
28
28
  filter graph), so both stretch by exactly the same chain. Returns an empty
29
29
  list for ``speed == 1`` (a no-op the caller maps to ``anull``).
30
30
  """
@@ -150,3 +150,14 @@ def popen_encode(cmd: Sequence[str]) -> Iterator[subprocess.Popen[bytes]]:
150
150
  _, stderr = proc.communicate()
151
151
  if proc.returncode != 0:
152
152
  raise FFmpegRunError(f"ffmpeg failed (exit {proc.returncode}): {stderr.decode(errors='replace')}")
153
+
154
+
155
+ def escape_filter_value(value: str) -> str:
156
+ """Quote a string for use as an ffmpeg filter option value.
157
+
158
+ Escapes the option-value level metacharacters (``\\``, ``'``, ``:``) and
159
+ wraps the result in single quotes so the filtergraph-level separators
160
+ (``,``, ``;``, ``[``, ``]``) pass through untouched.
161
+ """
162
+ escaped = value.replace("\\", "\\\\").replace("'", "\\'").replace(":", "\\:")
163
+ return f"'{escaped}'"
@@ -116,7 +116,6 @@ class PlanError:
116
116
  field: str | None = None
117
117
  value: float | None = None
118
118
  limit: float | None = None
119
- predicted_duration: float | None = None
120
119
  detail: str | None = None
121
120
 
122
121
  def to_prompt_line(self) -> str:
@@ -131,8 +130,7 @@ class PlanError:
131
130
  ``None`` fields are skipped)::
132
131
 
133
132
  <CODE_NAME> [at <location>] [(op '<op>')]: [<field>=<value>]
134
- [(limit <limit>)] [(predicted duration <predicted_duration>s)]
135
- [-- <detail>]
133
+ [(limit <limit>)] [-- <detail>]
136
134
 
137
135
  Every :class:`PlanErrorCode` yields a non-empty line: the code name is
138
136
  always present, so a code carrying no other fields still renders
@@ -155,8 +153,6 @@ class PlanError:
155
153
  clauses.append(f"value={_fmt_num(self.value)}")
156
154
  if self.limit is not None:
157
155
  clauses.append(f"limit {_fmt_num(self.limit)}")
158
- if self.predicted_duration is not None:
159
- clauses.append(f"predicted duration {_fmt_num(self.predicted_duration)}s")
160
156
 
161
157
  if clauses:
162
158
  line += ": " + ", ".join(clauses)
@@ -177,6 +177,23 @@ class VideoMetadata:
177
177
  total_seconds=round(seconds, 4),
178
178
  )
179
179
 
180
+ def with_frame_count(self, frame_count: int) -> VideoMetadata:
181
+ """Return new metadata with updated frame count.
182
+
183
+ Args:
184
+ frame_count: New frame count.
185
+
186
+ Returns:
187
+ New VideoMetadata with updated frame count and duration.
188
+ """
189
+ return VideoMetadata(
190
+ height=self.height,
191
+ width=self.width,
192
+ fps=self.fps,
193
+ frame_count=frame_count,
194
+ total_seconds=round(frame_count / self.fps, 4),
195
+ )
196
+
180
197
  def with_dimensions(self, width: int, height: int) -> VideoMetadata:
181
198
  """Return new metadata with updated dimensions.
182
199
 
@@ -27,7 +27,7 @@ from enum import Enum
27
27
 
28
28
  from videopython.base.transcription import Transcription
29
29
 
30
- __all__ = ["AnchorPoint", "AssLook", "build_ass", "escape_filter_value"]
30
+ __all__ = ["AnchorPoint", "AssLook", "build_ass"]
31
31
 
32
32
  RGBColor = tuple[int, int, int]
33
33
  RGBAColor = tuple[int, int, int, int]
@@ -117,17 +117,6 @@ def _escape_text(text: str) -> str:
117
117
  return text.replace("\\", "/").replace("{", "(").replace("}", ")").replace("\n", " ")
118
118
 
119
119
 
120
- def escape_filter_value(value: str) -> str:
121
- """Quote a string for use as an ffmpeg filter option value.
122
-
123
- Escapes the option-value level metacharacters (``\\``, ``'``, ``:``) and
124
- wraps the result in single quotes so the filtergraph-level separators
125
- (``,``, ``;``, ``[``, ``]``) pass through untouched.
126
- """
127
- escaped = value.replace("\\", "\\\\").replace("'", "\\'").replace(":", "\\:")
128
- return f"'{escaped}'"
129
-
130
-
131
120
  def _word_state_intervals(
132
121
  start: float, end: float, word_spans: list[tuple[float, float]]
133
122
  ) -> list[tuple[int, int, int | None]]:
@@ -7,9 +7,8 @@ assembled timeline, not of any one segment. It is mixed in a FINAL ffmpeg pass
7
7
  assembled program audio plus the bed input.
8
8
 
9
9
  :class:`MusicBed` is a frozen, closed pydantic model carried on
10
- :attr:`VideoEdit.music_bed`. Both ``run`` and ``run_to_file`` route through the
11
- single :func:`build_music_bed_filter_complex` builder so the in-memory and
12
- file paths cannot diverge on the mix.
10
+ :attr:`VideoEdit.music_bed`. ``run_to_file`` routes through the
11
+ single :func:`build_music_bed_filter_complex` builder for the mix.
13
12
 
14
13
  Ducking is *transcription-derived* and deterministic: when ``duck`` is set the
15
14
  bed is lowered under the speech windows derived from the context transcription
@@ -33,9 +32,33 @@ __all__ = [
33
32
  "MusicBed",
34
33
  "build_music_bed_filter_complex",
35
34
  "duck_volume_expression",
35
+ "volume_envelope",
36
36
  ]
37
37
 
38
38
 
39
+ def volume_envelope(terms: list[tuple[str, str]]) -> str:
40
+ """Build a nested-if gain envelope expression for volume automation.
41
+
42
+ Takes a list of ``(condition, gain_expr)`` tuples and folds them into a
43
+ nested ``if()`` expression evaluated outermost-first (the terms in
44
+ chronological order), with each condition mapping to its gain expression and
45
+ ``"1"`` as the final fallback (no change). The result is wrapped in the
46
+ ffmpeg ``volume`` filter format.
47
+
48
+ Args:
49
+ terms: List of ``(condition_str, gain_expr_str)`` tuples, where each
50
+ condition is an ffmpeg expression (e.g. ``"between(t,0,1)"``) and
51
+ ``gain_expr`` is the volume multiplier for that condition.
52
+
53
+ Returns:
54
+ Complete ffmpeg volume filter string: ``volume=volume='...':eval=frame``.
55
+ """
56
+ expr = "1"
57
+ for cond, gain in reversed(terms):
58
+ expr = f"if({cond},{gain},{expr})"
59
+ return f"volume=volume='{expr}':eval=frame"
60
+
61
+
39
62
  def duck_volume_expression(
40
63
  speech: list[tuple[float, float]],
41
64
  *,
@@ -75,10 +98,7 @@ def duck_volume_expression(
75
98
  # Release: linearly rise from floor back to 1 across [e, rel_end].
76
99
  rel = f"({floor:.6f}+{duck:.6f}*(t-{e:.6f})/{release:.6f})"
77
100
  terms.append((f"between(t,{e:.6f},{rel_end:.6f})", rel))
78
- expr = "1"
79
- for cond, gain in reversed(terms):
80
- expr = f"if({cond},{gain},{expr})"
81
- return f"volume=volume='{expr}':eval=frame"
101
+ return volume_envelope(terms)
82
102
 
83
103
 
84
104
  class MusicBed(BaseModel):
@@ -122,7 +142,7 @@ class MusicBed(BaseModel):
122
142
 
123
143
  Mirrors :meth:`ImageOverlay.predict_metadata`: a cheap ffprobe header
124
144
  probe (no decode) catches a missing / non-audio file at validate time,
125
- before ``run()`` would crash mid-stream after assembling the program.
145
+ before ``run_to_file()`` would crash mid-stream after assembling the program.
126
146
  """
127
147
  try:
128
148
  info = _ffmpeg.probe(self.source)
@@ -147,8 +167,8 @@ class MusicBed(BaseModel):
147
167
  bed is scaled to ``gain``, faded, ducked under ``speech`` (when given and
148
168
  ``duck`` is set), then pinned to exactly ``program_seconds`` (``atrim``
149
169
  end + ``apad`` whole_dur) so a looped bed neither truncates early nor
150
- extends the output past the program. Shared by the in-memory and file
151
- mix paths via :func:`build_music_bed_filter_complex`.
170
+ extends the output past the program. Used by the file
171
+ mix path via :func:`build_music_bed_filter_complex`.
152
172
  """
153
173
  stages: list[str] = [f"volume={self.gain:.6f}"]
154
174
  if self.fade_in > 0:
@@ -178,8 +198,7 @@ def build_music_bed_filter_complex(
178
198
  ) -> tuple[list[str], list[str], str]:
179
199
  """Compile the music-bed input args + ``filter_complex`` mix graph.
180
200
 
181
- The single source of the bed mix, shared by ``run`` (in-memory PCM pass) and
182
- ``run_to_file`` (file pass) so the two cannot diverge. Returns
201
+ The single source of the bed mix, used by ``run_to_file`` (file pass). Returns
183
202
  ``(input_args, graph_statements, out_label)``:
184
203
 
185
204
  - ``input_args`` are the ffmpeg ``-i`` argv for the bed: ``-stream_loop -1``