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.
- {videopython-0.44.0 → videopython-0.44.1}/PKG-INFO +1 -1
- {videopython-0.44.0 → videopython-0.44.1}/pyproject.toml +1 -1
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/__init__.py +5 -14
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/_optional.py +40 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/__init__.py +5 -14
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/__init__.py +5 -14
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/_tts_backend.py +1 -1
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/transforms.py +1 -1
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/__init__.py +5 -14
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/_ffmpeg.py +11 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/exceptions.py +1 -5
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/video.py +17 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/_ass.py +1 -12
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/audio_ops.py +25 -4
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/effects.py +8 -10
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/streaming.py +4 -4
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/transcription_overlay.py +2 -1
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/transforms.py +3 -29
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/video_edit.py +90 -97
- {videopython-0.44.0 → videopython-0.44.1}/.gitignore +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/LICENSE +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/README.md +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/_device.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/_predictor.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/_revisions.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/effects.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/audio/audio.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/description.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/base/transcription.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/src/videopython/editing/operation.py +0 -0
- {videopython-0.44.0 → videopython-0.44.1}/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}'"
|
|
@@ -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):
|
|
@@ -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_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
|
|
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,
|
|
@@ -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):
|
|
@@ -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
|