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.
- {videopython-0.43.1 → videopython-0.44.1}/PKG-INFO +2 -2
- {videopython-0.43.1 → videopython-0.44.1}/README.md +1 -1
- {videopython-0.43.1 → videopython-0.44.1}/pyproject.toml +1 -1
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/__init__.py +5 -14
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/_optional.py +40 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/__init__.py +5 -14
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/dubber.py +1 -3
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/effects.py +3 -3
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/__init__.py +5 -14
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/_tts_backend.py +1 -1
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/transforms.py +6 -24
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/__init__.py +5 -14
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/analyzer.py +2 -3
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/audio/audio.py +2 -2
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/_ffmpeg.py +11 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/exceptions.py +1 -5
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/video.py +17 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/_ass.py +1 -12
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/audio_ops.py +31 -12
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/effects.py +47 -130
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/operation.py +22 -87
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/streaming.py +11 -222
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/transcription_overlay.py +11 -73
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/transforms.py +24 -259
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/video_edit.py +117 -273
- {videopython-0.43.1 → videopython-0.44.1}/.gitignore +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/LICENSE +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/_device.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/_predictor.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/_revisions.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/description.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/base/transcription.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.43.1 → videopython-0.44.1}/src/videopython/editing/_easing.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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``.
|
|
37
|
-
overridden
|
|
38
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
19
|
-
from videopython.
|
|
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
|
|
98
|
-
:meth:`predict_metadata
|
|
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),
|
|
135
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
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>)] [
|
|
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"
|
|
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`.
|
|
11
|
-
single :func:`build_music_bed_filter_complex` builder
|
|
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
|
-
|
|
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 ``
|
|
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.
|
|
151
|
-
mix
|
|
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,
|
|
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``
|