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 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"