videopython 0.44.0__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.44.0 → videopython-0.44.1}/PKG-INFO +1 -1
  2. {videopython-0.44.0 → videopython-0.44.1}/pyproject.toml +1 -1
  3. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/__init__.py +5 -14
  4. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/_optional.py +40 -0
  5. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/__init__.py +5 -14
  6. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/__init__.py +5 -14
  7. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/_tts_backend.py +1 -1
  8. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/transforms.py +1 -1
  9. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/__init__.py +5 -14
  10. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/_ffmpeg.py +11 -0
  11. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/exceptions.py +1 -5
  12. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/video.py +17 -0
  13. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/_ass.py +1 -12
  14. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/audio_ops.py +25 -4
  15. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/effects.py +8 -10
  16. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/streaming.py +4 -4
  17. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/transcription_overlay.py +2 -1
  18. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/transforms.py +3 -29
  19. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/video_edit.py +90 -97
  20. {videopython-0.44.0 → videopython-0.44.1}/.gitignore +0 -0
  21. {videopython-0.44.0 → videopython-0.44.1}/LICENSE +0 -0
  22. {videopython-0.44.0 → videopython-0.44.1}/README.md +0 -0
  23. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/__init__.py +0 -0
  24. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/_device.py +0 -0
  25. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/_predictor.py +0 -0
  26. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/_revisions.py +0 -0
  27. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/config.py +0 -0
  28. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/dubber.py +0 -0
  29. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  30. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/loudness.py +0 -0
  31. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/models.py +0 -0
  32. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/pipeline.py +0 -0
  33. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/quality.py +0 -0
  34. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/remux.py +0 -0
  35. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/timing.py +0 -0
  36. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  37. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/effects.py +0 -0
  38. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/audio.py +0 -0
  39. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/image.py +0 -0
  40. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/qwen3.py +0 -0
  41. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/translation.py +0 -0
  42. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/video.py +0 -0
  43. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/audio.py +0 -0
  44. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/faces.py +0 -0
  45. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/image.py +0 -0
  46. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/objects.py +0 -0
  47. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/separation.py +0 -0
  48. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/temporal.py +0 -0
  49. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/__init__.py +0 -0
  50. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  51. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/models.py +0 -0
  52. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/sampling.py +0 -0
  53. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/stages.py +0 -0
  54. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/audio/__init__.py +0 -0
  55. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/audio/analysis.py +0 -0
  56. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/audio/audio.py +0 -0
  57. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/__init__.py +0 -0
  58. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/_dimensions.py +0 -0
  59. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/_video_io.py +0 -0
  60. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/description.py +0 -0
  61. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/draw_detections.py +0 -0
  62. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  63. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  64. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  65. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  66. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  67. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  68. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  69. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  70. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  71. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  72. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/__init__.py +0 -0
  73. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/transcription.py +0 -0
  74. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/__init__.py +0 -0
  75. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/_easing.py +0 -0
  76. {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/operation.py +0 -0
  77. {videopython-0.44.0 → 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.44.0
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.44.0"
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)
@@ -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._ffmpeg import escape_filter_value
18
19
  from videopython.base.video import FrameIterator, VideoMetadata
19
- from videopython.editing._ass import escape_filter_value
20
20
  from videopython.editing.operation import FilterCtx, OpCategory, Operation
21
21
 
22
22
  logger = logging.getLogger(__name__)
@@ -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)
@@ -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]]:
@@ -32,9 +32,33 @@ __all__ = [
32
32
  "MusicBed",
33
33
  "build_music_bed_filter_complex",
34
34
  "duck_volume_expression",
35
+ "volume_envelope",
35
36
  ]
36
37
 
37
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
+
38
62
  def duck_volume_expression(
39
63
  speech: list[tuple[float, float]],
40
64
  *,
@@ -74,10 +98,7 @@ def duck_volume_expression(
74
98
  # Release: linearly rise from floor back to 1 across [e, rel_end].
75
99
  rel = f"({floor:.6f}+{duck:.6f}*(t-{e:.6f})/{release:.6f})"
76
100
  terms.append((f"between(t,{e:.6f},{rel_end:.6f})", rel))
77
- expr = "1"
78
- for cond, gain in reversed(terms):
79
- expr = f"if({cond},{gain},{expr})"
80
- return f"volume=volume='{expr}':eval=frame"
101
+ return volume_envelope(terms)
81
102
 
82
103
 
83
104
  class MusicBed(BaseModel):
@@ -28,6 +28,7 @@ from videopython.base.description import BoundingBox
28
28
  from videopython.base.exceptions import PlanError, PlanErrorCode, PlanValidationError
29
29
  from videopython.base.fonts import load_font
30
30
  from videopython.editing._easing import ease, ease_out
31
+ from videopython.editing.audio_ops import volume_envelope
31
32
  from videopython.editing.operation import Effect, FilterCtx
32
33
 
33
34
  if TYPE_CHECKING:
@@ -505,10 +506,7 @@ class Fade(Effect):
505
506
  if self.mode in ("out", "in_out"):
506
507
  out_start = stop_s - ramp
507
508
  terms.append((f"between(t,{out_start:.6f},{stop_s:.6f})", self._curve_expr(f"({stop_s:.6f}-t)/{ramp:.6f}")))
508
- expr = "1"
509
- for cond, gain in reversed(terms):
510
- expr = f"if({cond},{gain},{expr})"
511
- return f"volume=volume='{expr}':eval=frame"
509
+ return volume_envelope(terms)
512
510
 
513
511
 
514
512
  class VolumeAdjust(Effect):
@@ -566,12 +564,12 @@ class VolumeAdjust(Effect):
566
564
  down_start = stop_s - ramp
567
565
  up = f"(1+({v:.6f}-1)*sqrt((t-{start_s:.6f})/{ramp:.6f}))"
568
566
  down = f"(1+({v:.6f}-1)*sqrt(({stop_s:.6f}-t)/{ramp:.6f}))"
569
- expr = (
570
- f"if(between(t,{start_s:.6f},{up_end:.6f}),{up},"
571
- f"if(between(t,{up_end:.6f},{down_start:.6f}),{v:.6f},"
572
- f"if(between(t,{down_start:.6f},{stop_s:.6f}),{down},1)))"
573
- )
574
- return f"volume=volume='{expr}':eval=frame"
567
+ terms = [
568
+ (f"between(t,{start_s:.6f},{up_end:.6f})", up),
569
+ (f"between(t,{up_end:.6f},{down_start:.6f})", f"{v:.6f}"),
570
+ (f"between(t,{down_start:.6f},{stop_s:.6f})", down),
571
+ ]
572
+ return volume_envelope(terms)
575
573
 
576
574
 
577
575
  class _AnchoredOverlay(Effect):
@@ -115,10 +115,10 @@ def analyze_streamability(
115
115
  ``streamable`` ClassVar as the authoritative declaration (a flag-False
116
116
  transform never streams, even with a working ``to_ffmpeg_filter``); the
117
117
  one divergence the flag cannot express -- flag True but the filter
118
- compiles to ``None`` -- is caught at runtime by the strict-mode drift
119
- guard, and a registry test pins flag-True transforms to an actual
120
- ``to_ffmpeg_filter`` override. Purely structural: no disk access and no
121
- runtime context.
118
+ compiles to ``None`` -- is caught at runtime by the ``STREAMING_FALLBACK``
119
+ raise in ``_compile_streaming_plans``, and a registry test pins flag-True
120
+ transforms to an actual ``to_ffmpeg_filter`` override. Purely structural:
121
+ no disk access and no runtime context.
122
122
 
123
123
  Segment transitions (``SegmentConfig.transition_in``) do not appear here:
124
124
  a transition is a native ffmpeg ``xfade``/``acrossfade`` pass over two
@@ -24,9 +24,10 @@ from typing import ClassVar, Literal
24
24
  from PIL import ImageFont
25
25
  from pydantic import Field
26
26
 
27
+ from videopython.base._ffmpeg import escape_filter_value
27
28
  from videopython.base.fonts import BUNDLED_FONT_FAMILIES, bundled_fonts_dir, load_font
28
29
  from videopython.base.transcription import Transcription
29
- from videopython.editing._ass import AnchorPoint, AssLook, build_ass, escape_filter_value
30
+ from videopython.editing._ass import AnchorPoint, AssLook, build_ass
30
31
  from videopython.editing.operation import Effect, FilterCtx
31
32
 
32
33
  __all__ = ["TranscriptionOverlay", "SubtitleStyle", "SubtitleRegion"]
@@ -128,7 +128,6 @@ class CutSeconds(Operation):
128
128
  field="end",
129
129
  value=self.end,
130
130
  limit=meta.total_seconds,
131
- predicted_duration=meta.total_seconds,
132
131
  )
133
132
  ],
134
133
  )
@@ -369,15 +368,7 @@ class SpeedChange(Operation):
369
368
  )
370
369
  ],
371
370
  )
372
- from videopython.base.video import VideoMetadata as _Meta
373
-
374
- return _Meta(
375
- height=meta.height,
376
- width=meta.width,
377
- fps=meta.fps,
378
- frame_count=new_count,
379
- total_seconds=round(new_count / meta.fps, 4),
380
- )
371
+ return meta.with_frame_count(new_count)
381
372
 
382
373
 
383
374
  class FreezeFrame(Operation):
@@ -487,7 +478,6 @@ class FreezeFrame(Operation):
487
478
  field="timestamp",
488
479
  value=self.timestamp,
489
480
  limit=meta.total_seconds,
490
- predicted_duration=meta.total_seconds,
491
481
  )
492
482
  ],
493
483
  )
@@ -498,15 +488,7 @@ class FreezeFrame(Operation):
498
488
  frame_idx = min(round(self.timestamp * meta.fps), meta.frame_count - 1)
499
489
  replace_end = min(frame_idx + freeze_count, meta.frame_count)
500
490
  new_count = meta.frame_count - (replace_end - frame_idx) + freeze_count
501
- from videopython.base.video import VideoMetadata as _Meta
502
-
503
- return _Meta(
504
- height=meta.height,
505
- width=meta.width,
506
- fps=meta.fps,
507
- frame_count=new_count,
508
- total_seconds=round(new_count / meta.fps, 4),
509
- )
491
+ return meta.with_frame_count(new_count)
510
492
 
511
493
 
512
494
  class SilenceRemoval(Operation):
@@ -636,12 +618,4 @@ class SilenceRemoval(Operation):
636
618
  if keep is None:
637
619
  return meta
638
620
  new_count = sum(e - s for s, e in keep)
639
- from videopython.base.video import VideoMetadata as _Meta
640
-
641
- return _Meta(
642
- height=meta.height,
643
- width=meta.width,
644
- fps=meta.fps,
645
- frame_count=new_count,
646
- total_seconds=round(new_count / meta.fps, 4),
647
- )
621
+ return meta.with_frame_count(new_count)
@@ -73,6 +73,10 @@ assert set(get_args(TransitionType)) == set(TRANSITION_TYPES), "TransitionType L
73
73
  # (``validate``, byte-stable prose) or accumulate them all (``check``).
74
74
  _LocatedError = tuple[str, PlanError]
75
75
 
76
+ # Sentinel marking "caller did not pass decode_filters" so a FilterCtx builder
77
+ # can fall back to FilterCtx's own default instead of forcing a value.
78
+ _DECODE_FILTERS_DEFAULT = object()
79
+
76
80
 
77
81
  def _resolve_operation(value: Any) -> Operation:
78
82
  """BeforeValidator: turn a dict into the right :class:`Operation` subclass.
@@ -282,7 +286,6 @@ def _segment_end_exceeds_source(index: int, seg: SegmentConfig, meta: VideoMetad
282
286
  field="end",
283
287
  value=seg.end,
284
288
  limit=meta.total_seconds,
285
- predicted_duration=meta.total_seconds,
286
289
  )
287
290
 
288
291
 
@@ -391,7 +394,6 @@ def _window_errors(op: Operation, duration: float, location: str) -> list[_Locat
391
394
  field="window.start",
392
395
  value=start,
393
396
  limit=duration,
394
- predicted_duration=duration,
395
397
  ),
396
398
  )
397
399
  )
@@ -406,7 +408,6 @@ def _window_errors(op: Operation, duration: float, location: str) -> list[_Locat
406
408
  field="window.stop",
407
409
  value=stop,
408
410
  limit=duration,
409
- predicted_duration=duration,
410
411
  ),
411
412
  )
412
413
  )
@@ -470,6 +471,35 @@ def _transition_structure_errors(segments: list[SegmentConfig]) -> list[_Located
470
471
  return out
471
472
 
472
473
 
474
+ def _transition_too_long_error(
475
+ index: int,
476
+ spec: TransitionSpec,
477
+ overlap: int,
478
+ limit_frames: int,
479
+ limit_seconds: float,
480
+ remedy: str,
481
+ ) -> _LocatedError:
482
+ """The located ``TRANSITION_TOO_LONG`` error for an overlap that fills a segment.
483
+
484
+ Shared by the validation pass (:func:`_transition_duration_errors`) and the
485
+ pre-decode guard (:meth:`VideoEdit._assert_transitions_runnable`); each call
486
+ site resolves its own ``overlap``/``limit_frames``/``limit_seconds`` from its
487
+ own fps source, then supplies the trailing ``remedy`` prose.
488
+ """
489
+ message = (
490
+ f"Segment {index}: transition_in overlaps {overlap} frames ({spec.duration}s) but must overlap "
491
+ f"fewer frames than the shorter adjacent segment ({limit_frames} frames, {limit_seconds}s); "
492
+ f"{remedy}"
493
+ )
494
+ return message, PlanError(
495
+ code=PlanErrorCode.TRANSITION_TOO_LONG,
496
+ location=f"segments[{index}]",
497
+ field="transition_in.duration",
498
+ value=spec.duration,
499
+ limit=limit_seconds,
500
+ )
501
+
502
+
473
503
  def _transition_duration_errors(
474
504
  segments: list[SegmentConfig],
475
505
  outputs: list[VideoMetadata],
@@ -496,18 +526,13 @@ def _transition_duration_errors(
496
526
  if overlap >= limit_frames:
497
527
  limit_seconds = round(limit_frames / fps, 4) if fps else 0.0
498
528
  out.append(
499
- (
500
- f"Segment {i}: transition_in overlaps {overlap} frames ({spec.duration}s) but must overlap "
501
- f"fewer frames than the shorter adjacent segment ({limit_frames} frames, {limit_seconds}s); "
529
+ _transition_too_long_error(
530
+ i,
531
+ spec,
532
+ overlap,
533
+ limit_frames,
534
+ limit_seconds,
502
535
  "a transition cannot consume a whole segment.",
503
- PlanError(
504
- code=PlanErrorCode.TRANSITION_TOO_LONG,
505
- location=f"segments[{i}]",
506
- field="transition_in.duration",
507
- value=spec.duration,
508
- limit=limit_seconds,
509
- predicted_duration=limit_seconds,
510
- ),
511
536
  )
512
537
  )
513
538
  return out
@@ -891,31 +916,21 @@ class VideoEdit(BaseModel):
891
916
  prop["default"] = []
892
917
  return prop
893
918
 
894
- def _transition_field() -> dict[str, Any]:
895
- # `transition_in` is `TransitionSpec | None`; Pydantic emits it as an
896
- # `anyOf` with a `$ref` to a buried `$defs/TransitionSpec`. Inline a
897
- # self-contained closed object (titles dropped, descriptions kept) so
898
- # the field needs no external `$defs` -- the same self-containment
899
- # the op union relies on.
900
- ts = TransitionSpec.model_json_schema()
901
- ts.pop("title", None)
902
- ts.pop("$defs", None)
903
- for sub in ts.get("properties", {}).values():
919
+ def _optional_model_field(
920
+ inline_model: type[BaseModel], parent: type[BaseModel], field_name: str
921
+ ) -> dict[str, Any]:
922
+ # An optional `Model | None` slot (`transition_in`, `music_bed`):
923
+ # Pydantic emits it as an `anyOf` with a `$ref` to a buried
924
+ # `$defs/Model`. Inline a self-contained closed object (titles
925
+ # dropped, descriptions kept) so the field needs no external `$defs`
926
+ # -- the same self-containment the op union relies on.
927
+ inline = inline_model.model_json_schema()
928
+ inline.pop("title", None)
929
+ inline.pop("$defs", None)
930
+ for sub in inline.get("properties", {}).values():
904
931
  sub.pop("title", None)
905
- prop = _field(SegmentConfig, "transition_in")
906
- prop["anyOf"] = [ts, {"type": "null"}]
907
- return prop
908
-
909
- def _music_bed_field() -> dict[str, Any]:
910
- # `music_bed` is `MusicBed | None`; like `transition_in`, inline a
911
- # self-contained closed object so the field needs no external `$defs`.
912
- mb = MusicBed.model_json_schema()
913
- mb.pop("title", None)
914
- mb.pop("$defs", None)
915
- for sub in mb.get("properties", {}).values():
916
- sub.pop("title", None)
917
- prop = _field(cls, "music_bed")
918
- prop["anyOf"] = [mb, {"type": "null"}]
932
+ prop = _field(parent, field_name)
933
+ prop["anyOf"] = [inline, {"type": "null"}]
919
934
  return prop
920
935
 
921
936
  segment_schema: dict[str, Any] = {
@@ -926,7 +941,7 @@ class VideoEdit(BaseModel):
926
941
  "start": _field(SegmentConfig, "start"),
927
942
  "end": _field(SegmentConfig, "end"),
928
943
  "operations": _array(SegmentConfig, "operations", op_schema),
929
- "transition_in": _transition_field(),
944
+ "transition_in": _optional_model_field(TransitionSpec, SegmentConfig, "transition_in"),
930
945
  },
931
946
  "required": ["source", "start", "end"],
932
947
  "additionalProperties": False,
@@ -942,7 +957,7 @@ class VideoEdit(BaseModel):
942
957
  "post_operations": _array(cls, "post_operations", op_schema),
943
958
  "match_to_lowest_fps": _field(cls, "match_to_lowest_fps"),
944
959
  "match_to_lowest_resolution": _field(cls, "match_to_lowest_resolution"),
945
- "music_bed": _music_bed_field(),
960
+ "music_bed": _optional_model_field(MusicBed, cls, "music_bed"),
946
961
  },
947
962
  "required": ["segments"],
948
963
  "additionalProperties": False,
@@ -1950,6 +1965,28 @@ class VideoEdit(BaseModel):
1950
1965
  for f in owned_files:
1951
1966
  f.unlink(missing_ok=True)
1952
1967
 
1968
+ def make_ctx(decode_filters: tuple[str, ...] | None | object = _DECODE_FILTERS_DEFAULT) -> FilterCtx:
1969
+ """A :class:`FilterCtx` snapshot of the current ``running`` state.
1970
+
1971
+ Captures the per-op pipeline geometry (dims/fps/frame-count) and the
1972
+ segment's location/context every op compiles against. ``decode_filters``
1973
+ is the decode-stage filter prefix ahead of this op; left unset it keeps
1974
+ :class:`FilterCtx`'s own default (the op consumes no decode pass).
1975
+ """
1976
+ kwargs: dict[str, Any] = {
1977
+ "width": running.width,
1978
+ "height": running.height,
1979
+ "fps": running.fps,
1980
+ "frame_count": running.frame_count,
1981
+ "context": seg_context or {},
1982
+ "source_path": segment.source,
1983
+ "start_second": segment.start,
1984
+ "end_second": segment.end,
1985
+ }
1986
+ if decode_filters is not _DECODE_FILTERS_DEFAULT:
1987
+ kwargs["decode_filters"] = decode_filters
1988
+ return FilterCtx(**kwargs)
1989
+
1953
1990
  effect_schedule: list[EffectScheduleEntry] = []
1954
1991
  post_vf_filters: list[str] = []
1955
1992
  af_filters: list[str] = []
@@ -2002,17 +2039,7 @@ class VideoEdit(BaseModel):
2002
2039
  # process_frame. Either way plan order is preserved. A
2003
2040
  # None compile falls through to the frame-effect path.
2004
2041
  encode_stage_effect = bool(effect_schedule or post_vf_filters)
2005
- ctx = FilterCtx(
2006
- width=running.width,
2007
- height=running.height,
2008
- fps=running.fps,
2009
- frame_count=running.frame_count,
2010
- context=seg_context or {},
2011
- source_path=segment.source,
2012
- start_second=segment.start,
2013
- end_second=segment.end,
2014
- decode_filters=None if encode_stage_effect else tuple(vf_filters),
2015
- )
2042
+ ctx = make_ctx(decode_filters=None if encode_stage_effect else tuple(vf_filters))
2016
2043
  filter_expr = op.to_ffmpeg_filter(ctx)
2017
2044
  owned_files.extend(ctx.owned_files)
2018
2045
  if filter_expr is not None:
@@ -2043,16 +2070,7 @@ class VideoEdit(BaseModel):
2043
2070
  # at the decode stage (one after an encode-stage filter is
2044
2071
  # rejected above), so its audio twin joins af_filters; its
2045
2072
  # window resolves against this position's running metadata.
2046
- effect_ctx = FilterCtx(
2047
- width=running.width,
2048
- height=running.height,
2049
- fps=running.fps,
2050
- frame_count=running.frame_count,
2051
- context=seg_context or {},
2052
- source_path=segment.source,
2053
- start_second=segment.start,
2054
- end_second=segment.end,
2055
- )
2073
+ effect_ctx = make_ctx()
2056
2074
  compile_audio_twin(op, effect_ctx, encode_stage=False)
2057
2075
  continue
2058
2076
  if op.requires and (duration_changed or not op.streamable):
@@ -2084,17 +2102,7 @@ class VideoEdit(BaseModel):
2084
2102
  # compile time -- not streamable (rejected as UNSTREAMABLE).
2085
2103
  abandon()
2086
2104
  return None
2087
- ctx = FilterCtx(
2088
- width=running.width,
2089
- height=running.height,
2090
- fps=running.fps,
2091
- frame_count=running.frame_count,
2092
- context=seg_context or {},
2093
- source_path=segment.source,
2094
- start_second=segment.start,
2095
- end_second=segment.end,
2096
- decode_filters=None if encode_stage else tuple(vf_filters),
2097
- )
2105
+ ctx = make_ctx(decode_filters=None if encode_stage else tuple(vf_filters))
2098
2106
  filter_expr = op.to_ffmpeg_filter(ctx)
2099
2107
  owned_files.extend(ctx.owned_files)
2100
2108
  if filter_expr is None:
@@ -2103,8 +2111,8 @@ class VideoEdit(BaseModel):
2103
2111
  if encode_stage:
2104
2112
  # A transform following frame effects joins the encode
2105
2113
  # chain (FrameEncoder -vf), which runs after every
2106
- # process_frame -- plan order is preserved without the
2107
- # whole-plan fallback this shape used to force.
2114
+ # process_frame -- plan order is preserved because this
2115
+ # shape places the filter without the whole-plan fallback.
2108
2116
  pipe_meta = pipe_meta or running
2109
2117
  post_vf_filters.append(filter_expr)
2110
2118
  else:
@@ -2174,24 +2182,15 @@ class VideoEdit(BaseModel):
2174
2182
  limit_frames = min(left_frames, right_frames)
2175
2183
  if overlap >= limit_frames:
2176
2184
  limit_seconds = round(limit_frames / fps, 4) if fps else 0.0
2177
- message = (
2178
- f"Segment {i}: transition_in overlaps {overlap} frames ({spec.duration}s) but must overlap "
2179
- f"fewer frames than the shorter adjacent segment ({limit_frames} frames, {limit_seconds}s); "
2180
- "repair the plan or shorten the transition."
2181
- )
2182
- raise PlanValidationError(
2183
- message,
2184
- [
2185
- PlanError(
2186
- code=PlanErrorCode.TRANSITION_TOO_LONG,
2187
- location=f"segments[{i}]",
2188
- field="transition_in.duration",
2189
- value=spec.duration,
2190
- limit=limit_seconds,
2191
- predicted_duration=limit_seconds,
2192
- )
2193
- ],
2185
+ message, err = _transition_too_long_error(
2186
+ i,
2187
+ spec,
2188
+ overlap,
2189
+ limit_frames,
2190
+ limit_seconds,
2191
+ "repair the plan or shorten the transition.",
2194
2192
  )
2193
+ raise PlanValidationError(message, [err])
2195
2194
 
2196
2195
 
2197
2196
  def _plan_output_frames(plan: StreamingSegmentPlan) -> tuple[int, float]:
@@ -2210,12 +2209,6 @@ def _plan_output_frames(plan: StreamingSegmentPlan) -> tuple[int, float]:
2210
2209
  return frames, fps
2211
2210
 
2212
2211
 
2213
- def _plan_output_seconds(plan: StreamingSegmentPlan) -> float:
2214
- """A compiled segment plan's predicted post-op duration in seconds."""
2215
- frames, fps = _plan_output_frames(plan)
2216
- return frames / fps if fps else 0.0
2217
-
2218
-
2219
2212
  def _effect_frame_range(op: Effect, fps: float, total_frames: int) -> tuple[int, int]:
2220
2213
  """Resolve an effect's ``window`` to a ``(start_frame, end_frame)`` pair."""
2221
2214
  if op.window is None:
File without changes
File without changes
File without changes