videopython 0.44.0__tar.gz → 0.45.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {videopython-0.44.0 → videopython-0.45.0}/PKG-INFO +1 -1
- {videopython-0.44.0 → videopython-0.45.0}/pyproject.toml +1 -1
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/__init__.py +5 -14
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/_optional.py +40 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/__init__.py +5 -14
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/generation/__init__.py +5 -14
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/generation/_tts_backend.py +1 -1
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/transforms.py +1 -1
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/understanding/__init__.py +5 -14
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/_ffmpeg.py +11 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/exceptions.py +2 -6
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/video.py +17 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/_ass.py +1 -12
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/audio_ops.py +25 -4
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/effects.py +8 -10
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/streaming.py +10 -10
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/transcription_overlay.py +2 -1
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/transforms.py +3 -29
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/video_edit.py +98 -105
- {videopython-0.44.0 → videopython-0.45.0}/.gitignore +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/LICENSE +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/README.md +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/_predictor.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/_revisions.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/effects.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/audio/audio.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/description.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/base/transcription.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/editing/operation.py +0 -0
- {videopython-0.44.0 → videopython-0.45.0}/src/videopython/py.typed +0 -0
|
@@ -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)
|
|
@@ -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._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
|
-
|
|
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)
|
|
@@ -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}'"
|
|
@@ -96,7 +96,7 @@ class PlanErrorCode(str, Enum):
|
|
|
96
96
|
TRANSITION_TOO_LONG = "transition_too_long"
|
|
97
97
|
MUSIC_BED_DUCK_MULTISEGMENT = "music_bed_duck_multisegment"
|
|
98
98
|
# Streaming: unstreamable op at its plan position (always reported).
|
|
99
|
-
|
|
99
|
+
STREAMING_UNSUPPORTED = "streaming_unsupported"
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
@dataclass
|
|
@@ -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]]:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
f"
|
|
571
|
-
f"
|
|
572
|
-
f"
|
|
573
|
-
|
|
574
|
-
return
|
|
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):
|
|
@@ -36,8 +36,8 @@ class StreamingClass(str, Enum):
|
|
|
36
36
|
|
|
37
37
|
``FILTER`` and ``FRAME_EFFECT`` stream in O(1) memory w.r.t. video
|
|
38
38
|
length. ``UNSTREAMABLE`` means the op (or its plan position) has no
|
|
39
|
-
streaming strategy;
|
|
40
|
-
|
|
39
|
+
streaming strategy; such plans are rejected with structured
|
|
40
|
+
``STREAMING_UNSUPPORTED`` errors.
|
|
41
41
|
"""
|
|
42
42
|
|
|
43
43
|
FILTER = "filter"
|
|
@@ -85,19 +85,19 @@ class StreamabilityReport:
|
|
|
85
85
|
return all(e.streams for e in self.entries)
|
|
86
86
|
|
|
87
87
|
@property
|
|
88
|
-
def
|
|
88
|
+
def unstreamable(self) -> tuple[OpStreamability, ...]:
|
|
89
89
|
"""The unstreamable ops, in plan order."""
|
|
90
90
|
return tuple(e for e in self.entries if not e.streams)
|
|
91
91
|
|
|
92
92
|
def errors(self) -> list[PlanError]:
|
|
93
|
-
"""The
|
|
93
|
+
"""The unstreamable ops as structured ``STREAMING_UNSUPPORTED`` plan errors.
|
|
94
94
|
|
|
95
95
|
The same shape :meth:`VideoEdit.check` returns, so an LLM refine loop
|
|
96
96
|
can treat "would not stream" exactly like any other plan violation.
|
|
97
97
|
"""
|
|
98
98
|
return [
|
|
99
|
-
PlanError(code=PlanErrorCode.
|
|
100
|
-
for e in self.
|
|
99
|
+
PlanError(code=PlanErrorCode.STREAMING_UNSUPPORTED, location=e.location, op=e.op, detail=e.reason)
|
|
100
|
+
for e in self.unstreamable
|
|
101
101
|
]
|
|
102
102
|
|
|
103
103
|
|
|
@@ -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
|
|
119
|
-
|
|
120
|
-
``to_ffmpeg_filter`` override. Purely structural:
|
|
121
|
-
runtime context.
|
|
118
|
+
compiles to ``None`` -- is caught at runtime by the ``STREAMING_UNSUPPORTED``
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
#
|
|
898
|
-
#
|
|
899
|
-
#
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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(
|
|
906
|
-
prop["anyOf"] = [
|
|
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":
|
|
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":
|
|
960
|
+
"music_bed": _optional_model_field(MusicBed, cls, "music_bed"),
|
|
946
961
|
},
|
|
947
962
|
"required": ["segments"],
|
|
948
963
|
"additionalProperties": False,
|
|
@@ -1021,7 +1036,7 @@ class VideoEdit(BaseModel):
|
|
|
1021
1036
|
:meth:`validate`: a clampable ``window.stop`` overrun is not reported.
|
|
1022
1037
|
|
|
1023
1038
|
Streaming is the only engine, so ops that cannot stream at their
|
|
1024
|
-
plan position are real plan errors: one ``
|
|
1039
|
+
plan position are real plan errors: one ``STREAMING_UNSUPPORTED`` per
|
|
1025
1040
|
offending op is appended after the validity errors, in plan order,
|
|
1026
1041
|
with the actionable cause in :attr:`PlanError.detail`. See
|
|
1027
1042
|
:meth:`streamability` for the full per-op report including the ops
|
|
@@ -1041,7 +1056,7 @@ class VideoEdit(BaseModel):
|
|
|
1041
1056
|
admitted. ``report.streamable`` answers "will :meth:`run_to_file`
|
|
1042
1057
|
stream this plan in O(1) memory, or is an op unstreamable at its
|
|
1043
1058
|
plan position?"; each entry carries the op's memory class and, for
|
|
1044
|
-
|
|
1059
|
+
unstreamable ops, the reason.
|
|
1045
1060
|
"""
|
|
1046
1061
|
return analyze_streamability(
|
|
1047
1062
|
[list(seg.operations) for seg in self.segments],
|
|
@@ -1588,7 +1603,7 @@ class VideoEdit(BaseModel):
|
|
|
1588
1603
|
Memory usage is O(1) w.r.t. video length (video; segment audio is
|
|
1589
1604
|
in-memory). Streaming is the only engine: a plan with an unstreamable
|
|
1590
1605
|
shape raises :class:`PlanValidationError` carrying one
|
|
1591
|
-
``
|
|
1606
|
+
``STREAMING_UNSUPPORTED`` :class:`PlanError` per offending op -- before
|
|
1592
1607
|
any decode. Gate plans early with :meth:`check` or
|
|
1593
1608
|
:meth:`streamability`, which report the same errors without running
|
|
1594
1609
|
anything.
|
|
@@ -1819,16 +1834,16 @@ class VideoEdit(BaseModel):
|
|
|
1819
1834
|
:class:`PlanValidationError` with the streamability report's
|
|
1820
1835
|
structured errors; a builder/report drift (e.g. a third-party
|
|
1821
1836
|
transform whose ``streamable`` flag does not match its
|
|
1822
|
-
``to_ffmpeg_filter``) raises with a generic ``
|
|
1837
|
+
``to_ffmpeg_filter``) raises with a generic ``STREAMING_UNSUPPORTED``.
|
|
1823
1838
|
On raise, any compile-time temp files already created are deleted;
|
|
1824
1839
|
on success the caller owns ``plan.owned_temp_files``.
|
|
1825
1840
|
"""
|
|
1826
1841
|
self._assert_post_ops_supported(context)
|
|
1827
1842
|
report = self.streamability()
|
|
1828
1843
|
if not report.streamable:
|
|
1829
|
-
causes = "; ".join(f"{e.location} '{e.op}': {e.reason}" for e in report.
|
|
1844
|
+
causes = "; ".join(f"{e.location} '{e.op}': {e.reason}" for e in report.unstreamable)
|
|
1830
1845
|
message = (
|
|
1831
|
-
f"Plan cannot stream: {len(report.
|
|
1846
|
+
f"Plan cannot stream: {len(report.unstreamable)} op(s) have no streaming "
|
|
1832
1847
|
f"strategy at their plan position -- {causes}"
|
|
1833
1848
|
)
|
|
1834
1849
|
raise PlanValidationError(message, report.errors())
|
|
@@ -1836,7 +1851,7 @@ class VideoEdit(BaseModel):
|
|
|
1836
1851
|
def drift(detail: str) -> PlanValidationError:
|
|
1837
1852
|
return PlanValidationError(
|
|
1838
1853
|
f"plan stopped streaming despite a clean streamability report ({detail})",
|
|
1839
|
-
[PlanError(code=PlanErrorCode.
|
|
1854
|
+
[PlanError(code=PlanErrorCode.STREAMING_UNSUPPORTED, detail=detail)],
|
|
1840
1855
|
)
|
|
1841
1856
|
|
|
1842
1857
|
target_fps, target_w, target_h = self._matching_targets_from_disk()
|
|
@@ -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 =
|
|
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 =
|
|
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):
|
|
@@ -2072,7 +2090,7 @@ class VideoEdit(BaseModel):
|
|
|
2072
2090
|
# flag-False transform is classified UNSTREAMABLE even if it has
|
|
2073
2091
|
# a working to_ffmpeg_filter. The remaining drift class (flag
|
|
2074
2092
|
# True, filter compiles to None) is caught by the
|
|
2075
|
-
#
|
|
2093
|
+
# STREAMING_UNSUPPORTED raise in _compile_streaming_plans.
|
|
2076
2094
|
if not op.streamable:
|
|
2077
2095
|
abandon()
|
|
2078
2096
|
return None
|
|
@@ -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 =
|
|
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
|
|
2107
|
-
#
|
|
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
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|