videokit 0.1.0__py3-none-any.whl
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.
- videokit/__init__.py +20 -0
- videokit/_ffmpeg.py +61 -0
- videokit/_vscode.py +288 -0
- videokit/audio.py +156 -0
- videokit/exceptions.py +13 -0
- videokit/py.typed +0 -0
- videokit/render.py +51 -0
- videokit/timeline.py +280 -0
- videokit/video.py +654 -0
- videokit-0.1.0.dist-info/METADATA +463 -0
- videokit-0.1.0.dist-info/RECORD +14 -0
- videokit-0.1.0.dist-info/WHEEL +5 -0
- videokit-0.1.0.dist-info/licenses/LICENSE +21 -0
- videokit-0.1.0.dist-info/top_level.txt +1 -0
videokit/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""A beginner-friendly Python interface for basic video editing."""
|
|
2
|
+
|
|
3
|
+
from .audio import Audio, AudioClip
|
|
4
|
+
from .exceptions import FFmpegError, MediaError, VideoKitError
|
|
5
|
+
from .render import RenderSettings
|
|
6
|
+
from .timeline import Timeline
|
|
7
|
+
from .video import Clip, Video
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Audio",
|
|
11
|
+
"AudioClip",
|
|
12
|
+
"Clip",
|
|
13
|
+
"FFmpegError",
|
|
14
|
+
"MediaError",
|
|
15
|
+
"RenderSettings",
|
|
16
|
+
"Timeline",
|
|
17
|
+
"Video",
|
|
18
|
+
"VideoKitError",
|
|
19
|
+
]
|
|
20
|
+
__version__ = "0.1.0"
|
videokit/_ffmpeg.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Small, safe wrappers around the FFmpeg command-line tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .exceptions import FFmpegError, MediaError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _tool_path(name: str) -> str:
|
|
15
|
+
executable = shutil.which(name)
|
|
16
|
+
if executable is None:
|
|
17
|
+
raise FFmpegError(
|
|
18
|
+
f"{name} was not found. Install FFmpeg and make sure its bin folder "
|
|
19
|
+
"is on your PATH."
|
|
20
|
+
)
|
|
21
|
+
return executable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def probe(path: Path) -> dict[str, Any]:
|
|
25
|
+
command = [
|
|
26
|
+
_tool_path("ffprobe"),
|
|
27
|
+
"-v",
|
|
28
|
+
"error",
|
|
29
|
+
"-show_streams",
|
|
30
|
+
"-show_format",
|
|
31
|
+
"-of",
|
|
32
|
+
"json",
|
|
33
|
+
str(path),
|
|
34
|
+
]
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
command, capture_output=True, text=True, check=False
|
|
38
|
+
)
|
|
39
|
+
except OSError as error:
|
|
40
|
+
raise FFmpegError(f"Could not start ffprobe: {error}") from error
|
|
41
|
+
if result.returncode:
|
|
42
|
+
details = result.stderr.strip() or "ffprobe could not read this file."
|
|
43
|
+
raise MediaError(f"Could not read video '{path}': {details}")
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(result.stdout)
|
|
46
|
+
except json.JSONDecodeError as error:
|
|
47
|
+
raise FFmpegError("ffprobe returned invalid metadata.") from error
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_ffmpeg(command: list[str], output_path: Path) -> None:
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
command, capture_output=True, text=True, check=False
|
|
54
|
+
)
|
|
55
|
+
except OSError as error:
|
|
56
|
+
raise FFmpegError(f"Could not start FFmpeg: {error}") from error
|
|
57
|
+
if result.returncode:
|
|
58
|
+
details = result.stderr.strip() or "FFmpeg returned an unknown error."
|
|
59
|
+
raise FFmpegError(
|
|
60
|
+
f"Could not export '{output_path}': {details}"
|
|
61
|
+
)
|
videokit/_vscode.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Small JSON helper used by the optional VS Code extension."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import base64
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from . import Audio, AudioClip, Clip, Timeline, Video
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .exceptions import VideoKitError
|
|
18
|
+
from .render import RenderSettings
|
|
19
|
+
|
|
20
|
+
_CLASSES = (Video, Clip, Audio, AudioClip, Timeline, RenderSettings)
|
|
21
|
+
_CLASS_BY_NAME = {item.__name__: item for item in _CLASSES}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _annotation_name(annotation: Any) -> str | None:
|
|
25
|
+
if annotation is inspect.Signature.empty:
|
|
26
|
+
return None
|
|
27
|
+
value = getattr(annotation, "__name__", None)
|
|
28
|
+
if value:
|
|
29
|
+
return value
|
|
30
|
+
text = str(annotation).strip("'\"")
|
|
31
|
+
return text.split("|", 1)[0].strip().split(".")[-1]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parameters(callable_object: Any) -> list[dict[str, Any]]:
|
|
35
|
+
try:
|
|
36
|
+
signature = inspect.signature(callable_object)
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return []
|
|
39
|
+
parameters = []
|
|
40
|
+
for parameter in signature.parameters.values():
|
|
41
|
+
if parameter.name in {"self", "cls"}:
|
|
42
|
+
continue
|
|
43
|
+
default = None
|
|
44
|
+
has_default = parameter.default is not inspect.Parameter.empty
|
|
45
|
+
if has_default:
|
|
46
|
+
default = repr(parameter.default)
|
|
47
|
+
parameters.append(
|
|
48
|
+
{
|
|
49
|
+
"name": parameter.name,
|
|
50
|
+
"kind": parameter.kind.name,
|
|
51
|
+
"type": _annotation_name(parameter.annotation),
|
|
52
|
+
"has_default": has_default,
|
|
53
|
+
"default": default,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
return parameters
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def api_description() -> dict[str, Any]:
|
|
60
|
+
"""Describe the real public methods, docstrings, and signatures."""
|
|
61
|
+
classes: dict[str, Any] = {}
|
|
62
|
+
for cls in _CLASSES:
|
|
63
|
+
methods = {}
|
|
64
|
+
for name, member in inspect.getmembers(cls, predicate=inspect.isfunction):
|
|
65
|
+
if name.startswith("_") or member.__module__ != cls.__module__:
|
|
66
|
+
continue
|
|
67
|
+
signature = inspect.signature(member)
|
|
68
|
+
methods[name] = {
|
|
69
|
+
"parameters": _parameters(member),
|
|
70
|
+
"return_type": _annotation_name(signature.return_annotation),
|
|
71
|
+
"documentation": inspect.getdoc(member) or "",
|
|
72
|
+
}
|
|
73
|
+
attributes = {
|
|
74
|
+
name: _annotation_name(annotation)
|
|
75
|
+
for name, annotation in getattr(cls, "__annotations__", {}).items()
|
|
76
|
+
if not name.startswith("_")
|
|
77
|
+
}
|
|
78
|
+
for name, member in inspect.getmembers(cls, predicate=lambda item: isinstance(item, property)):
|
|
79
|
+
if not name.startswith("_"):
|
|
80
|
+
attributes[name] = _annotation_name(member.fget.__annotations__.get("return"))
|
|
81
|
+
classes[cls.__name__] = {
|
|
82
|
+
"documentation": inspect.getdoc(cls) or "",
|
|
83
|
+
"parameters": _parameters(cls),
|
|
84
|
+
"methods": methods,
|
|
85
|
+
"attributes": attributes,
|
|
86
|
+
}
|
|
87
|
+
return {"version": __version__, "classes": classes}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _expression_type(node: ast.AST, variables: dict[str, str], api: dict[str, Any]) -> str | None:
|
|
91
|
+
if isinstance(node, ast.Name):
|
|
92
|
+
return variables.get(node.id)
|
|
93
|
+
if isinstance(node, ast.Call):
|
|
94
|
+
if isinstance(node.func, ast.Name):
|
|
95
|
+
if node.func.id in _CLASS_BY_NAME:
|
|
96
|
+
return node.func.id
|
|
97
|
+
alias_type = variables.get(node.func.id)
|
|
98
|
+
if alias_type in _CLASS_BY_NAME:
|
|
99
|
+
return alias_type
|
|
100
|
+
if isinstance(node.func, ast.Attribute):
|
|
101
|
+
receiver = _expression_type(node.func.value, variables, api)
|
|
102
|
+
method = api["classes"].get(receiver or "", {}).get("methods", {}).get(
|
|
103
|
+
node.func.attr
|
|
104
|
+
)
|
|
105
|
+
result = method.get("return_type") if method else None
|
|
106
|
+
return result if result in _CLASS_BY_NAME else None
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _walk_statements(
|
|
111
|
+
statements: list[ast.stmt], variables: dict[str, str], api: dict[str, Any]
|
|
112
|
+
) -> None:
|
|
113
|
+
for statement in statements:
|
|
114
|
+
if isinstance(statement, ast.ImportFrom) and statement.module == "videokit":
|
|
115
|
+
for item in statement.names:
|
|
116
|
+
if item.name in _CLASS_BY_NAME:
|
|
117
|
+
variables[item.asname or item.name] = item.name
|
|
118
|
+
elif isinstance(statement, (ast.Assign, ast.AnnAssign)):
|
|
119
|
+
targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
|
|
120
|
+
value = _expression_type(statement.value, variables, api) if statement.value else None
|
|
121
|
+
for target in targets:
|
|
122
|
+
if isinstance(target, ast.Name):
|
|
123
|
+
if value:
|
|
124
|
+
variables[target.id] = value
|
|
125
|
+
else:
|
|
126
|
+
variables.pop(target.id, None)
|
|
127
|
+
elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
128
|
+
local = dict(variables)
|
|
129
|
+
_walk_statements(statement.body, local, api)
|
|
130
|
+
elif isinstance(statement, (ast.For, ast.AsyncFor, ast.While, ast.If, ast.With)):
|
|
131
|
+
_walk_statements(statement.body, variables, api)
|
|
132
|
+
_walk_statements(statement.orelse, variables, api)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def infer_variables(source: str) -> dict[str, str]:
|
|
136
|
+
"""Infer simple VideoKit variables in valid Python source using the AST."""
|
|
137
|
+
api = api_description()
|
|
138
|
+
try:
|
|
139
|
+
tree = ast.parse(source)
|
|
140
|
+
except SyntaxError:
|
|
141
|
+
return {}
|
|
142
|
+
variables: dict[str, str] = {}
|
|
143
|
+
_walk_statements(tree.body, variables, api)
|
|
144
|
+
return variables
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def infer_expression(expression: str, source: str = "") -> str | None:
|
|
148
|
+
"""Infer a simple VideoKit expression using names found in Python source."""
|
|
149
|
+
variables = infer_variables(source)
|
|
150
|
+
try:
|
|
151
|
+
node = ast.parse(expression, mode="eval").body
|
|
152
|
+
except SyntaxError:
|
|
153
|
+
return None
|
|
154
|
+
return _expression_type(node, variables, api_description())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _python_to_utf16_column(line: str, byte_column: int) -> int:
|
|
158
|
+
prefix = line.encode("utf-8")[:byte_column].decode("utf-8", errors="ignore")
|
|
159
|
+
return len(prefix.encode("utf-16-le")) // 2
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _video_string_target(
|
|
163
|
+
source: str, line_number: int, character: int
|
|
164
|
+
) -> tuple[str, dict[str, int]] | None:
|
|
165
|
+
try:
|
|
166
|
+
tree = ast.parse(source)
|
|
167
|
+
except SyntaxError:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
video_names = {"Video"}
|
|
171
|
+
for node in ast.walk(tree):
|
|
172
|
+
if isinstance(node, ast.ImportFrom) and node.module == "videokit":
|
|
173
|
+
for alias in node.names:
|
|
174
|
+
if alias.name == "Video":
|
|
175
|
+
video_names.add(alias.asname or alias.name)
|
|
176
|
+
|
|
177
|
+
lines = source.splitlines()
|
|
178
|
+
for node in ast.walk(tree):
|
|
179
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
|
|
180
|
+
continue
|
|
181
|
+
if node.func.id not in video_names or not node.args:
|
|
182
|
+
continue
|
|
183
|
+
value = node.args[0]
|
|
184
|
+
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
|
|
185
|
+
continue
|
|
186
|
+
start_line = getattr(value, "lineno", 0)
|
|
187
|
+
end_line = getattr(value, "end_lineno", start_line)
|
|
188
|
+
if not start_line <= line_number <= end_line:
|
|
189
|
+
continue
|
|
190
|
+
start_column = _python_to_utf16_column(
|
|
191
|
+
lines[start_line - 1], value.col_offset
|
|
192
|
+
)
|
|
193
|
+
end_column = _python_to_utf16_column(
|
|
194
|
+
lines[end_line - 1], value.end_col_offset
|
|
195
|
+
)
|
|
196
|
+
if start_line == end_line and start_column <= character <= end_column:
|
|
197
|
+
return value.value, {
|
|
198
|
+
"line": start_line - 1,
|
|
199
|
+
"start_column": start_column,
|
|
200
|
+
"end_column": end_column,
|
|
201
|
+
}
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def preview_at(
|
|
206
|
+
source: str,
|
|
207
|
+
source_path: str,
|
|
208
|
+
workspace_path: str,
|
|
209
|
+
line: int,
|
|
210
|
+
character: int,
|
|
211
|
+
) -> dict[str, Any] | None:
|
|
212
|
+
"""Return media details and a small frame for a hovered Video path literal."""
|
|
213
|
+
target = _video_string_target(source, line, character)
|
|
214
|
+
if target is None:
|
|
215
|
+
return None
|
|
216
|
+
relative_path, location = target
|
|
217
|
+
|
|
218
|
+
raw_path = Path(os.path.expandvars(relative_path)).expanduser()
|
|
219
|
+
if raw_path.is_absolute():
|
|
220
|
+
media_path = raw_path.resolve()
|
|
221
|
+
else:
|
|
222
|
+
candidates = []
|
|
223
|
+
if workspace_path:
|
|
224
|
+
candidates.append(Path(workspace_path) / raw_path)
|
|
225
|
+
if source_path:
|
|
226
|
+
candidates.append(Path(source_path).parent / raw_path)
|
|
227
|
+
media_path = next(
|
|
228
|
+
(candidate.resolve() for candidate in candidates if candidate.is_file()),
|
|
229
|
+
candidates[0].resolve() if candidates else raw_path.resolve(),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
video = Video(media_path)
|
|
234
|
+
with tempfile.TemporaryDirectory(prefix="videokit-preview-") as directory:
|
|
235
|
+
thumbnail_path = Path(directory) / "preview.jpg"
|
|
236
|
+
video.thumbnail(thumbnail_path, max_width=360, max_height=203)
|
|
237
|
+
thumbnail = base64.b64encode(thumbnail_path.read_bytes()).decode("ascii")
|
|
238
|
+
except (OSError, VideoKitError) as error:
|
|
239
|
+
return {**location, "path": str(media_path), "error": str(error)}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
**location,
|
|
243
|
+
"path": str(video.path),
|
|
244
|
+
"name": video.path.name,
|
|
245
|
+
"duration": video.duration,
|
|
246
|
+
"width": video.width,
|
|
247
|
+
"height": video.height,
|
|
248
|
+
"fps": video.fps,
|
|
249
|
+
"thumbnail": thumbnail,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main() -> None:
|
|
254
|
+
"""Read one JSON request from stdin and write one JSON response to stdout."""
|
|
255
|
+
try:
|
|
256
|
+
request = json.load(sys.stdin)
|
|
257
|
+
command = request.get("command")
|
|
258
|
+
if command == "api":
|
|
259
|
+
response: Any = api_description()
|
|
260
|
+
elif command == "infer":
|
|
261
|
+
response = infer_variables(request.get("source", ""))
|
|
262
|
+
elif command == "infer_expression":
|
|
263
|
+
response = infer_expression(
|
|
264
|
+
request.get("expression", ""), request.get("source", "")
|
|
265
|
+
)
|
|
266
|
+
elif command == "preview":
|
|
267
|
+
try:
|
|
268
|
+
response = preview_at(
|
|
269
|
+
request.get("source", ""),
|
|
270
|
+
request.get("source_path", ""),
|
|
271
|
+
request.get("workspace_path", ""),
|
|
272
|
+
int(request.get("line", "-1")),
|
|
273
|
+
int(request.get("character", "-1")),
|
|
274
|
+
)
|
|
275
|
+
except (OSError, ValueError, VideoKitError) as error:
|
|
276
|
+
response = {"error": str(error)}
|
|
277
|
+
else:
|
|
278
|
+
raise ValueError("Unknown request command.")
|
|
279
|
+
json.dump(response, sys.stdout)
|
|
280
|
+
sys.stdout.write("\n")
|
|
281
|
+
except (OSError, ValueError, TypeError, VideoKitError) as error:
|
|
282
|
+
json.dump({"error": str(error)}, sys.stdout)
|
|
283
|
+
sys.stdout.write("\n")
|
|
284
|
+
raise SystemExit(1) from error
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
main()
|
videokit/audio.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Audio metadata and non-destructive audio clips."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._ffmpeg import probe, run_ffmpeg
|
|
12
|
+
from .exceptions import MediaError
|
|
13
|
+
from .video import _atempo, _check_destination, _seconds
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class AudioClip:
|
|
18
|
+
"""A selected audio range with optional volume, speed, and fades."""
|
|
19
|
+
|
|
20
|
+
_source: Path
|
|
21
|
+
start: float
|
|
22
|
+
end: float
|
|
23
|
+
_speed: float = 1
|
|
24
|
+
_volume: float = 1
|
|
25
|
+
_filters: tuple[str, ...] = ()
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def duration(self) -> float:
|
|
29
|
+
"""Playback duration in seconds."""
|
|
30
|
+
return (self.end - self.start) / self._speed
|
|
31
|
+
|
|
32
|
+
def speed(self, factor: float) -> AudioClip:
|
|
33
|
+
"""Change playback speed by a positive multiplier without pitch correction."""
|
|
34
|
+
value = _seconds(factor, "factor")
|
|
35
|
+
if value <= 0 or value > 100:
|
|
36
|
+
raise ValueError("factor must be greater than 0 and no more than 100.")
|
|
37
|
+
return AudioClip(
|
|
38
|
+
self._source, self.start, self.end,
|
|
39
|
+
self._speed * value, self._volume, self._filters,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def volume(self, level: float) -> AudioClip:
|
|
43
|
+
"""Set volume from 0 (silent) to 4 (four times as loud)."""
|
|
44
|
+
value = _seconds(level, "level")
|
|
45
|
+
if not 0 <= value <= 4:
|
|
46
|
+
raise ValueError("level must be between 0 and 4.")
|
|
47
|
+
return AudioClip(
|
|
48
|
+
self._source, self.start, self.end, self._speed, value, self._filters
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def fade_in(self, duration: float) -> AudioClip:
|
|
52
|
+
"""Fade audio in from silence."""
|
|
53
|
+
seconds = _seconds(duration, "duration")
|
|
54
|
+
if seconds <= 0 or seconds > self.duration:
|
|
55
|
+
raise ValueError("duration must be positive and no longer than the clip.")
|
|
56
|
+
return AudioClip(
|
|
57
|
+
self._source, self.start, self.end, self._speed, self._volume,
|
|
58
|
+
self._filters + (f"afade=t=in:st=0:d={seconds:.9g}",),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def fade_out(self, duration: float) -> AudioClip:
|
|
62
|
+
"""Fade audio out at the end of the clip."""
|
|
63
|
+
seconds = _seconds(duration, "duration")
|
|
64
|
+
if seconds <= 0 or seconds > self.duration:
|
|
65
|
+
raise ValueError("duration must be positive and no longer than the clip.")
|
|
66
|
+
start = self.duration - seconds
|
|
67
|
+
return AudioClip(
|
|
68
|
+
self._source, self.start, self.end, self._speed, self._volume,
|
|
69
|
+
self._filters + (f"afade=t=out:st={start:.9g}:d={seconds:.9g}",),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def export(
|
|
73
|
+
self, output_path: str | Path, *, overwrite: bool = False
|
|
74
|
+
) -> Path:
|
|
75
|
+
"""Export this selection as AAC, MP3, or PCM audio based on its extension."""
|
|
76
|
+
destination = _check_destination(output_path, self._source, overwrite)
|
|
77
|
+
command = [
|
|
78
|
+
shutil.which("ffmpeg") or "ffmpeg",
|
|
79
|
+
"-hide_banner",
|
|
80
|
+
"-loglevel",
|
|
81
|
+
"error",
|
|
82
|
+
"-y" if overwrite else "-n",
|
|
83
|
+
"-ss",
|
|
84
|
+
f"{self.start:.9f}",
|
|
85
|
+
"-t",
|
|
86
|
+
f"{self.end - self.start:.9f}",
|
|
87
|
+
"-i",
|
|
88
|
+
str(self._source),
|
|
89
|
+
"-vn",
|
|
90
|
+
]
|
|
91
|
+
filters = [*_atempo(self._speed), f"volume={self._volume:.9g}"]
|
|
92
|
+
filters.extend(self._filters)
|
|
93
|
+
command.extend(["-af", ",".join(filters)])
|
|
94
|
+
suffix = destination.suffix.lower()
|
|
95
|
+
if suffix == ".wav":
|
|
96
|
+
command.extend(["-c:a", "pcm_s16le"])
|
|
97
|
+
elif suffix == ".mp3":
|
|
98
|
+
command.extend(["-c:a", "libmp3lame"])
|
|
99
|
+
else:
|
|
100
|
+
command.extend(["-c:a", "aac"])
|
|
101
|
+
command.append(str(destination))
|
|
102
|
+
run_ffmpeg(command, destination)
|
|
103
|
+
return destination
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Audio:
|
|
107
|
+
"""An audio file with basic metadata and time-range selection."""
|
|
108
|
+
|
|
109
|
+
path: Path
|
|
110
|
+
duration: float
|
|
111
|
+
codec: str | None
|
|
112
|
+
sample_rate: int | None
|
|
113
|
+
channels: int | None
|
|
114
|
+
|
|
115
|
+
def __init__(self, path: str | Path) -> None:
|
|
116
|
+
self.path = Path(path).expanduser().resolve()
|
|
117
|
+
if not self.path.is_file():
|
|
118
|
+
raise MediaError(f"Audio file does not exist: '{self.path}'.")
|
|
119
|
+
metadata = probe(self.path)
|
|
120
|
+
streams: list[dict[str, Any]] = metadata.get("streams", [])
|
|
121
|
+
stream = next(
|
|
122
|
+
(item for item in streams if item.get("codec_type") == "audio"), None
|
|
123
|
+
)
|
|
124
|
+
if stream is None:
|
|
125
|
+
raise MediaError(f"No audio stream was found in '{self.path}'.")
|
|
126
|
+
duration_value = stream.get("duration") or metadata.get("format", {}).get(
|
|
127
|
+
"duration"
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
self.duration = float(duration_value)
|
|
131
|
+
except (TypeError, ValueError) as error:
|
|
132
|
+
raise MediaError(f"Audio duration is unavailable for '{self.path}'.") from error
|
|
133
|
+
if not math.isfinite(self.duration) or self.duration <= 0:
|
|
134
|
+
raise MediaError(f"Audio duration is invalid for '{self.path}'.")
|
|
135
|
+
self.codec = stream.get("codec_name")
|
|
136
|
+
try:
|
|
137
|
+
self.sample_rate = (
|
|
138
|
+
int(stream["sample_rate"]) if stream.get("sample_rate") else None
|
|
139
|
+
)
|
|
140
|
+
self.channels = int(stream["channels"]) if stream.get("channels") else None
|
|
141
|
+
except (TypeError, ValueError) as error:
|
|
142
|
+
raise MediaError(f"Audio metadata is invalid for '{self.path}'.") from error
|
|
143
|
+
|
|
144
|
+
def cut(self, start: float, end: float) -> AudioClip:
|
|
145
|
+
"""Select audio from ``start`` up to (but not including) ``end`` seconds."""
|
|
146
|
+
first = _seconds(start, "start")
|
|
147
|
+
last = _seconds(end, "end")
|
|
148
|
+
if first < 0:
|
|
149
|
+
raise ValueError("start cannot be negative.")
|
|
150
|
+
if last <= first:
|
|
151
|
+
raise ValueError("end must be greater than start.")
|
|
152
|
+
if last > self.duration:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"end ({last:g}s) exceeds audio duration ({self.duration:g}s)."
|
|
155
|
+
)
|
|
156
|
+
return AudioClip(self.path, first, last)
|
videokit/exceptions.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exceptions raised by VideoKit."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class VideoKitError(Exception):
|
|
5
|
+
"""Base exception for VideoKit errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MediaError(VideoKitError):
|
|
9
|
+
"""Raised when a media file is missing, invalid, or unsupported."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FFmpegError(VideoKitError):
|
|
13
|
+
"""Raised when FFmpeg cannot complete a requested operation."""
|
videokit/py.typed
ADDED
|
File without changes
|
videokit/render.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Output-quality choices shared by final video renders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
Quality = Literal["draft", "balanced", "high"]
|
|
9
|
+
|
|
10
|
+
_QUALITY_SETTINGS: dict[Quality, tuple[str, int]] = {
|
|
11
|
+
"draft": ("veryfast", 28),
|
|
12
|
+
"balanced": ("medium", 22),
|
|
13
|
+
"high": ("slow", 18),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RenderSettings:
|
|
19
|
+
"""Simple, reusable quality settings for final video output.
|
|
20
|
+
|
|
21
|
+
``draft`` renders faster with lower image quality; ``balanced`` is the
|
|
22
|
+
everyday default; ``high`` prioritizes image quality at the cost of longer
|
|
23
|
+
render time and larger files.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
settings = RenderSettings(quality="high")
|
|
27
|
+
timeline.export("final.mp4", settings=settings)
|
|
28
|
+
timeline.render("quick-check.mp4", quality="draft")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
quality: Quality = "balanced"
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not isinstance(self.quality, str) or self.quality not in _QUALITY_SETTINGS:
|
|
35
|
+
choices = ", ".join(_QUALITY_SETTINGS)
|
|
36
|
+
raise ValueError(f"quality must be one of: {choices}.")
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def preset(self) -> str:
|
|
40
|
+
"""FFmpeg x264 speed preset selected for this quality level."""
|
|
41
|
+
return _QUALITY_SETTINGS[self.quality][0]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def crf(self) -> int:
|
|
45
|
+
"""FFmpeg constant-rate-factor value; lower means higher quality."""
|
|
46
|
+
return _QUALITY_SETTINGS[self.quality][1]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def audio_bitrate(self) -> str:
|
|
50
|
+
"""AAC bitrate used for final renders."""
|
|
51
|
+
return "192k"
|