videopython 0.31.0__tar.gz → 0.31.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.
Files changed (49) hide show
  1. {videopython-0.31.0 → videopython-0.31.1}/PKG-INFO +1 -1
  2. {videopython-0.31.0 → videopython-0.31.1}/pyproject.toml +1 -1
  3. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/remux.py +11 -8
  4. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/transforms.py +11 -15
  5. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/video_analysis.py +4 -15
  6. videopython-0.31.1/src/videopython/base/_dimensions.py +41 -0
  7. videopython-0.31.1/src/videopython/base/_ffmpeg.py +152 -0
  8. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/audio/audio.py +16 -34
  9. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/exceptions.py +18 -0
  10. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/streaming.py +33 -47
  11. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/transforms.py +8 -7
  12. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/video.py +63 -162
  13. {videopython-0.31.0 → videopython-0.31.1}/.gitignore +0 -0
  14. {videopython-0.31.0 → videopython-0.31.1}/LICENSE +0 -0
  15. {videopython-0.31.0 → videopython-0.31.1}/README.md +0 -0
  16. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/__init__.py +0 -0
  17. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/__init__.py +0 -0
  18. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/_device.py +0 -0
  19. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/__init__.py +0 -0
  20. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/dubber.py +0 -0
  21. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/models.py +0 -0
  22. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/pipeline.py +0 -0
  23. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/quality.py +0 -0
  24. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/timing.py +0 -0
  25. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/__init__.py +0 -0
  26. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/audio.py +0 -0
  27. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/image.py +0 -0
  28. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/qwen3.py +0 -0
  29. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/translation.py +0 -0
  30. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/video.py +0 -0
  31. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/__init__.py +0 -0
  32. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/audio.py +0 -0
  33. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/faces.py +0 -0
  34. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/image.py +0 -0
  35. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/separation.py +0 -0
  36. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/temporal.py +0 -0
  37. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/__init__.py +0 -0
  38. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/audio/__init__.py +0 -0
  39. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/audio/analysis.py +0 -0
  40. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/description.py +0 -0
  41. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/effects.py +0 -0
  42. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/operation.py +0 -0
  43. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/scene.py +0 -0
  44. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/text/__init__.py +0 -0
  45. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/text/overlay.py +0 -0
  46. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/text/transcription.py +0 -0
  47. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/editing/__init__.py +0 -0
  48. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/editing/video_edit.py +0 -0
  49. {videopython-0.31.0 → videopython-0.31.1}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.31.0
3
+ Version: 0.31.1
4
4
  Summary: Minimal video generation and processing library.
5
5
  Project-URL: Homepage, https://videopython.com
6
6
  Project-URL: Repository, https://github.com/bartwojtowicz/videopython/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.31.0"
3
+ version = "0.31.1"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -4,13 +4,15 @@ from __future__ import annotations
4
4
 
5
5
  import io
6
6
  import logging
7
- import subprocess
8
7
  import wave
9
8
  from pathlib import Path
10
9
  from typing import TYPE_CHECKING
11
10
 
12
11
  import numpy as np
13
12
 
13
+ from videopython.base import _ffmpeg
14
+ from videopython.base.exceptions import FFmpegRunError
15
+
14
16
  if TYPE_CHECKING:
15
17
  from videopython.base.audio import Audio
16
18
 
@@ -95,9 +97,10 @@ def replace_audio_stream(
95
97
  ]
96
98
 
97
99
  logger.info("replace_audio_stream: %s + %s -> %s", video_path, audio_path, output_path)
98
- result = subprocess.run(cmd, capture_output=True)
99
- if result.returncode != 0:
100
- raise RemuxError(f"ffmpeg failed (exit {result.returncode}): {result.stderr.decode(errors='replace')}")
100
+ try:
101
+ _ffmpeg.run(cmd)
102
+ except FFmpegRunError as e:
103
+ raise RemuxError(str(e)) from e
101
104
 
102
105
 
103
106
  def replace_audio_stream_from_audio(
@@ -175,7 +178,7 @@ def replace_audio_stream_from_audio(
175
178
  len(wav_bytes),
176
179
  output_path,
177
180
  )
178
- process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
179
- _, stderr = process.communicate(wav_bytes)
180
- if process.returncode != 0:
181
- raise RemuxError(f"ffmpeg failed (exit {process.returncode}): {stderr.decode(errors='replace')}")
181
+ try:
182
+ _ffmpeg.run(cmd, stdin=wav_bytes)
183
+ except FFmpegRunError as e:
184
+ raise RemuxError(str(e)) from e
@@ -11,17 +11,13 @@ from pydantic import Field
11
11
  from tqdm import tqdm
12
12
 
13
13
  from videopython.ai.understanding.faces import FaceTracker
14
+ from videopython.base._dimensions import floor_to_even
14
15
  from videopython.base.operation import OpCategory, Operation
15
16
  from videopython.base.video import Video
16
17
 
17
18
  logger = logging.getLogger(__name__)
18
19
 
19
20
 
20
- def _make_even(value: int) -> int:
21
- """Round down to nearest even number for H.264 compatibility."""
22
- return value - (value % 2)
23
-
24
-
25
21
  __all__ = [
26
22
  "FaceTrackingCrop",
27
23
  ]
@@ -105,17 +101,17 @@ class FaceTrackingCrop(Operation):
105
101
  frame_ratio = frame_w / frame_h
106
102
 
107
103
  if target_ratio < frame_ratio:
108
- crop_h = _make_even(frame_h)
109
- crop_w = _make_even(int(crop_h * target_ratio))
104
+ crop_h = floor_to_even(frame_h)
105
+ crop_w = floor_to_even(int(crop_h * target_ratio))
110
106
  else:
111
- crop_w = _make_even(frame_w)
112
- crop_h = _make_even(int(crop_w / target_ratio))
107
+ crop_w = floor_to_even(frame_w)
108
+ crop_h = floor_to_even(int(crop_w / target_ratio))
113
109
 
114
110
  min_face_dim = max(face_w * frame_w, face_h * frame_h)
115
111
  min_crop_dim = min_face_dim * (1 + 2 * self.padding)
116
112
  if crop_w < min_crop_dim * target_ratio:
117
- crop_w = _make_even(min(int(min_crop_dim * target_ratio), frame_w))
118
- crop_h = _make_even(min(int(crop_w / target_ratio), frame_h))
113
+ crop_w = floor_to_even(min(int(min_crop_dim * target_ratio), frame_w))
114
+ crop_h = floor_to_even(min(int(crop_w / target_ratio), frame_h))
119
115
 
120
116
  if center_position is None:
121
117
  center_position = self._apply_framing_offset(face_cx, face_cy, face_h)
@@ -141,11 +137,11 @@ class FaceTrackingCrop(Operation):
141
137
  h, w = video.frame_shape[:2]
142
138
  target_ratio = self.target_aspect[0] / self.target_aspect[1]
143
139
  if target_ratio < w / h:
144
- out_h = _make_even(h)
145
- out_w = _make_even(int(out_h * target_ratio))
140
+ out_h = floor_to_even(h)
141
+ out_w = floor_to_even(int(out_h * target_ratio))
146
142
  else:
147
- out_w = _make_even(w)
148
- out_h = _make_even(int(out_w / target_ratio))
143
+ out_w = floor_to_even(w)
144
+ out_h = floor_to_even(int(out_w / target_ratio))
149
145
 
150
146
  default_x = (w - out_w) // 2
151
147
  default_y = (h - out_h) // 2
@@ -5,7 +5,6 @@ import json
5
5
  import logging
6
6
  import math
7
7
  import re
8
- import subprocess
9
8
  import time
10
9
  from collections.abc import Callable, Iterator
11
10
  from concurrent.futures import ThreadPoolExecutor
@@ -26,6 +25,7 @@ from videopython.ai.understanding import (
26
25
  SemanticSceneDetector,
27
26
  )
28
27
  from videopython.ai.understanding.faces import FaceTracker
28
+ from videopython.base import _ffmpeg
29
29
  from videopython.base.audio import Audio
30
30
  from videopython.base.description import (
31
31
  AudioClassification,
@@ -34,6 +34,7 @@ from videopython.base.description import (
34
34
  SceneBoundary,
35
35
  SceneDescription,
36
36
  )
37
+ from videopython.base.exceptions import FFmpegProbeError
37
38
  from videopython.base.text.transcription import Transcription
38
39
  from videopython.base.video import Video, VideoMetadata, extract_frames_at_times
39
40
 
@@ -1032,21 +1033,9 @@ class VideoAnalyzer:
1032
1033
  if path is None:
1033
1034
  return {}
1034
1035
 
1035
- cmd = [
1036
- "ffprobe",
1037
- "-v",
1038
- "error",
1039
- "-show_entries",
1040
- "format_tags:stream_tags",
1041
- "-of",
1042
- "json",
1043
- str(path),
1044
- ]
1045
-
1046
1036
  try:
1047
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
1048
- payload = json.loads(result.stdout)
1049
- except (subprocess.CalledProcessError, json.JSONDecodeError, OSError):
1037
+ payload = _ffmpeg.probe(path, extra_args=["-show_entries", "format_tags:stream_tags"])
1038
+ except (FFmpegProbeError, OSError):
1050
1039
  return {}
1051
1040
 
1052
1041
  tags: dict[str, str] = {}
@@ -0,0 +1,41 @@
1
+ """Pure helpers for video dimension math.
2
+
3
+ Centralises the libx264+yuv420p even-dimension constraint and the
4
+ two "round to even" calculations that previously lived (with subtly
5
+ different semantics) in ``base/video.py``, ``ai/transforms.py``, and
6
+ ``base/transforms.py``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ def round_to_even(value: int | float) -> int:
13
+ """Round a dimension to the nearest even integer (minimum 2).
14
+
15
+ Use this when computing a target dimension from a ratio or scale
16
+ factor and either direction (up or down) is acceptable.
17
+ """
18
+ return max(2, int(round(float(value) / 2.0) * 2))
19
+
20
+
21
+ def floor_to_even(value: int | float) -> int:
22
+ """Round a dimension down to the next even integer (minimum 2).
23
+
24
+ Use this when the result must not exceed the source region — e.g.
25
+ cropping, where rounding up would read past the frame edge.
26
+ """
27
+ v = int(value)
28
+ return max(2, v - (v % 2))
29
+
30
+
31
+ def require_even(width: int, height: int) -> None:
32
+ """Guard for libx264+yuv420p output, which rejects odd dimensions.
33
+
34
+ Raises:
35
+ ValueError: If either dimension is odd.
36
+ """
37
+ if width % 2 != 0 or height % 2 != 0:
38
+ raise ValueError(
39
+ "libx264 with yuv420p requires even frame dimensions. "
40
+ f"Got {width}x{height}. Resize, crop, or pad to even width and height before saving."
41
+ )
@@ -0,0 +1,152 @@
1
+ """Internal wrappers for ffmpeg / ffprobe subprocess calls.
2
+
3
+ Centralises subprocess invocation patterns so that every call site shares
4
+ the same flag boilerplate, JSON parsing, and failure translation. Public
5
+ modules should keep raising their own domain exceptions (VideoLoadError,
6
+ AudioLoadError, etc.) and call into the helpers here, mapping
7
+ ``FFmpegError`` to whichever public exception they document.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import subprocess
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Iterator, Sequence
17
+
18
+ from videopython.base.exceptions import FFmpegProbeError, FFmpegRunError
19
+
20
+
21
+ def run(cmd: Sequence[str], *, stdin: bytes | None = None) -> bytes:
22
+ """Run a blocking ffmpeg/ffprobe command and return stdout.
23
+
24
+ Centralises non-zero exit handling so callers can map a single
25
+ ``FFmpegRunError`` to their own domain exception.
26
+
27
+ Args:
28
+ cmd: Full argv, starting with ``"ffmpeg"`` or ``"ffprobe"``.
29
+ stdin: Optional bytes to feed to the process's stdin (used by
30
+ the stdin-piped remux variant).
31
+
32
+ Returns:
33
+ Process stdout bytes (usually empty for muxing/concat commands).
34
+
35
+ Raises:
36
+ FFmpegRunError: On non-zero exit or missing binary.
37
+ """
38
+ try:
39
+ result = subprocess.run(cmd, capture_output=True, input=stdin)
40
+ except FileNotFoundError as e:
41
+ raise FFmpegRunError(f"binary not found on PATH: {cmd[0]}") from e
42
+ if result.returncode != 0:
43
+ raise FFmpegRunError(f"ffmpeg failed (exit {result.returncode}): {result.stderr.decode(errors='replace')}")
44
+ return result.stdout
45
+
46
+
47
+ def probe(path: str | Path, *, extra_args: Sequence[str] | None = None) -> dict:
48
+ """Run ffprobe and return the parsed JSON payload.
49
+
50
+ Args:
51
+ path: Path to the media file.
52
+ extra_args: Optional extra ffprobe flags inserted before ``-print_format``.
53
+ Defaults to ``("-show_streams", "-show_format")`` when omitted,
54
+ which mirrors the historical "everything" probe used by Audio.
55
+
56
+ Returns:
57
+ The decoded ffprobe JSON payload.
58
+
59
+ Raises:
60
+ FFmpegProbeError: On non-zero exit, JSON decode failure, or missing
61
+ ffprobe binary.
62
+ """
63
+ args = list(extra_args) if extra_args is not None else ["-show_streams", "-show_format"]
64
+ cmd = ["ffprobe", "-v", "error", *args, "-print_format", "json", str(path)]
65
+
66
+ try:
67
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
68
+ except subprocess.CalledProcessError as e:
69
+ raise FFmpegProbeError(f"ffprobe error: {e.stderr}") from e
70
+ except FileNotFoundError as e:
71
+ raise FFmpegProbeError("ffprobe binary not found on PATH") from e
72
+
73
+ try:
74
+ return json.loads(result.stdout)
75
+ except json.JSONDecodeError as e:
76
+ raise FFmpegProbeError(f"Error parsing ffprobe output: {e}") from e
77
+
78
+
79
+ def _terminate(proc: subprocess.Popen, *, timeout: float = 5) -> None:
80
+ """Terminate a still-running process, escalating to kill after ``timeout``."""
81
+ if proc.poll() is None:
82
+ proc.terminate()
83
+ try:
84
+ proc.wait(timeout=timeout)
85
+ except subprocess.TimeoutExpired:
86
+ proc.kill()
87
+ proc.wait()
88
+
89
+
90
+ @contextmanager
91
+ def popen_decode(cmd: Sequence[str], *, bufsize: int = -1) -> Iterator[subprocess.Popen]:
92
+ """Context manager wrapping an ffmpeg decode process.
93
+
94
+ Yields a Popen with ``stdout=PIPE`` and ``stderr=DEVNULL``. Callers
95
+ read raw bytes from ``proc.stdout``. On exit, the process is
96
+ terminated (with kill fallback) and stdout is closed.
97
+
98
+ Args:
99
+ cmd: Full ffmpeg argv. The output target is typically ``pipe:1``.
100
+ bufsize: Forwarded to ``subprocess.Popen``. Use a large value
101
+ (e.g. ``10**8``) for batched reads or a frame-sized value
102
+ for streaming reads.
103
+ """
104
+ proc = subprocess.Popen(
105
+ list(cmd),
106
+ stdout=subprocess.PIPE,
107
+ stderr=subprocess.DEVNULL,
108
+ bufsize=bufsize,
109
+ )
110
+ try:
111
+ yield proc
112
+ finally:
113
+ _terminate(proc)
114
+ if proc.stdout is not None and not proc.stdout.closed:
115
+ proc.stdout.close()
116
+
117
+
118
+ @contextmanager
119
+ def popen_encode(cmd: Sequence[str]) -> Iterator[subprocess.Popen]:
120
+ """Context manager wrapping an ffmpeg encode process via stdin pipe.
121
+
122
+ Yields a Popen with ``stdin=PIPE``, ``stdout=DEVNULL``, and
123
+ ``stderr=PIPE``. Callers write raw frames to ``proc.stdin``.
124
+
125
+ On clean exit, stdin and stderr are drained via ``communicate()``
126
+ and ``FFmpegRunError`` is raised if ffmpeg returns non-zero. On
127
+ exception exit, the process is killed and the caller's exception
128
+ propagates unmodified.
129
+ """
130
+ proc = subprocess.Popen(
131
+ list(cmd),
132
+ stdin=subprocess.PIPE,
133
+ stdout=subprocess.DEVNULL,
134
+ stderr=subprocess.PIPE,
135
+ )
136
+ try:
137
+ yield proc
138
+ except BaseException:
139
+ if proc.poll() is None:
140
+ proc.kill()
141
+ proc.wait()
142
+ for pipe in (proc.stdin, proc.stderr):
143
+ if pipe is not None and not pipe.closed:
144
+ try:
145
+ pipe.close()
146
+ except Exception:
147
+ pass
148
+ raise
149
+
150
+ _, stderr = proc.communicate()
151
+ if proc.returncode != 0:
152
+ raise FFmpegRunError(f"ffmpeg failed (exit {proc.returncode}): {stderr.decode(errors='replace')}")
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
- import json
5
4
  import subprocess
6
5
  import wave
7
6
  from dataclasses import dataclass
@@ -10,7 +9,8 @@ from typing import TYPE_CHECKING
10
9
 
11
10
  import numpy as np
12
11
 
13
- from videopython.base.exceptions import AudioLoadError
12
+ from videopython.base import _ffmpeg
13
+ from videopython.base.exceptions import AudioLoadError, FFmpegProbeError
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  from videopython.base.audio.analysis import AudioLevels, AudioSegment, AudioSegmentType, SilentSegment
@@ -71,39 +71,21 @@ class Audio:
71
71
  @staticmethod
72
72
  def _get_ffmpeg_info(file_path: Path) -> dict:
73
73
  """Get audio metadata using ffprobe"""
74
- cmd = [
75
- "ffprobe",
76
- "-v",
77
- "quiet",
78
- "-print_format",
79
- "json",
80
- "-show_format",
81
- "-show_streams",
82
- str(file_path),
83
- ]
84
-
85
74
  try:
86
- output = subprocess.check_output(cmd)
87
- info = json.loads(output.decode())
88
-
89
- # Find the audio stream
90
- audio_stream = None
91
- for stream in info["streams"]:
92
- if stream["codec_type"] == "audio":
93
- audio_stream = stream
94
- break
95
-
96
- if audio_stream is None:
97
- raise AudioLoadError("No audio stream found")
98
-
99
- return {
100
- "sample_rate": int(audio_stream["sample_rate"]),
101
- "channels": int(audio_stream["channels"]),
102
- "duration": float(info["format"]["duration"]),
103
- "bit_depth": int(audio_stream.get("bits_per_sample", 16)),
104
- }
105
- except subprocess.CalledProcessError as e:
106
- raise AudioLoadError(f"Error getting audio info: {e}")
75
+ info = _ffmpeg.probe(file_path)
76
+ except FFmpegProbeError as e:
77
+ raise AudioLoadError(f"Error getting audio info: {e}") from e
78
+
79
+ audio_stream = next((s for s in info["streams"] if s["codec_type"] == "audio"), None)
80
+ if audio_stream is None:
81
+ raise AudioLoadError("No audio stream found")
82
+
83
+ return {
84
+ "sample_rate": int(audio_stream["sample_rate"]),
85
+ "channels": int(audio_stream["channels"]),
86
+ "duration": float(info["format"]["duration"]),
87
+ "bit_depth": int(audio_stream.get("bits_per_sample", 16)),
88
+ }
107
89
 
108
90
  @classmethod
109
91
  def create_silent(
@@ -7,6 +7,24 @@ class VideoPythonError(Exception):
7
7
  pass
8
8
 
9
9
 
10
+ class FFmpegError(VideoPythonError):
11
+ """Base exception for ffmpeg/ffprobe subprocess failures."""
12
+
13
+ pass
14
+
15
+
16
+ class FFmpegProbeError(FFmpegError):
17
+ """Raised when an ffprobe invocation or its JSON output fails."""
18
+
19
+ pass
20
+
21
+
22
+ class FFmpegRunError(FFmpegError):
23
+ """Raised when a blocking ffmpeg run returns a non-zero exit code."""
24
+
25
+ pass
26
+
27
+
10
28
  class VideoError(VideoPythonError):
11
29
  """Base exception for video-related errors."""
12
30
 
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import logging
11
11
  import subprocess
12
12
  import tempfile
13
+ from contextlib import ExitStack
13
14
  from dataclasses import dataclass, field
14
15
  from pathlib import Path
15
16
  from typing import get_args
@@ -17,6 +18,8 @@ from typing import get_args
17
18
  import numpy as np
18
19
  from tqdm import tqdm
19
20
 
21
+ from videopython.base import _ffmpeg
22
+ from videopython.base._dimensions import require_even
20
23
  from videopython.base.audio import Audio
21
24
  from videopython.base.effects import Effect
22
25
  from videopython.base.video import ALLOWED_VIDEO_FORMATS, ALLOWED_VIDEO_PRESETS, FrameIterator
@@ -72,6 +75,7 @@ class FrameEncoder:
72
75
  self._format = format
73
76
  self._preset = preset
74
77
  self._crf = crf
78
+ self._stack: ExitStack | None = None
75
79
  self._process: subprocess.Popen | None = None
76
80
 
77
81
  def _build_command(self) -> list[str]:
@@ -121,18 +125,9 @@ class FrameEncoder:
121
125
  return cmd
122
126
 
123
127
  def __enter__(self) -> FrameEncoder:
124
- if self._width % 2 != 0 or self._height % 2 != 0:
125
- raise ValueError(
126
- f"libx264 with yuv420p requires even dimensions, got {self._width}x{self._height}. "
127
- "Resize or crop to even dimensions before encoding."
128
- )
129
- cmd = self._build_command()
130
- self._process = subprocess.Popen(
131
- cmd,
132
- stdin=subprocess.PIPE,
133
- stdout=subprocess.DEVNULL,
134
- stderr=subprocess.PIPE,
135
- )
128
+ require_even(self._width, self._height)
129
+ self._stack = ExitStack()
130
+ self._process = self._stack.enter_context(_ffmpeg.popen_encode(self._build_command()))
136
131
  return self
137
132
 
138
133
  def write_frame(self, frame: np.ndarray) -> None:
@@ -141,21 +136,13 @@ class FrameEncoder:
141
136
  raise RuntimeError("FrameEncoder not started -- use as context manager")
142
137
  self._process.stdin.write(frame.tobytes())
143
138
 
144
- def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
145
- if self._process is None:
146
- return
147
- try:
148
- if self._process.stdin and not self._process.stdin.closed:
149
- self._process.stdin.close()
150
- stderr = self._process.stderr.read() if self._process.stderr else b""
151
- returncode = self._process.wait(timeout=30)
152
- if returncode != 0 and exc_type is None:
153
- raise RuntimeError(f"FFmpeg encoder failed (code {returncode}): {stderr.decode(errors='ignore')}")
154
- except subprocess.TimeoutExpired:
155
- self._process.kill()
156
- self._process.wait()
157
- finally:
158
- self._process = None
139
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool | None: # type: ignore[no-untyped-def]
140
+ stack = self._stack
141
+ self._stack = None
142
+ self._process = None
143
+ if stack is None:
144
+ return None
145
+ return stack.__exit__(exc_type, exc_val, exc_tb)
159
146
 
160
147
 
161
148
  def stream_segment(
@@ -238,7 +225,7 @@ def stream_segment(
238
225
  frame_count += 1
239
226
 
240
227
  return output_path
241
- except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError):
228
+ except Exception:
242
229
  if output_path.exists():
243
230
  output_path.unlink()
244
231
  raise
@@ -259,25 +246,24 @@ def concat_files(segment_files: list[Path], output_path: Path) -> Path:
259
246
  list_path = Path(f.name)
260
247
 
261
248
  try:
262
- cmd = [
263
- "ffmpeg",
264
- "-y",
265
- "-hide_banner",
266
- "-loglevel",
267
- "error",
268
- "-f",
269
- "concat",
270
- "-safe",
271
- "0",
272
- "-i",
273
- str(list_path),
274
- "-c",
275
- "copy",
276
- str(output_path),
277
- ]
278
- result = subprocess.run(cmd, capture_output=True)
279
- if result.returncode != 0:
280
- raise RuntimeError(f"FFmpeg concat failed: {result.stderr.decode(errors='ignore')}")
249
+ _ffmpeg.run(
250
+ [
251
+ "ffmpeg",
252
+ "-y",
253
+ "-hide_banner",
254
+ "-loglevel",
255
+ "error",
256
+ "-f",
257
+ "concat",
258
+ "-safe",
259
+ "0",
260
+ "-i",
261
+ str(list_path),
262
+ "-c",
263
+ "copy",
264
+ str(output_path),
265
+ ]
266
+ )
281
267
  return output_path
282
268
  finally:
283
269
  list_path.unlink(missing_ok=True)
@@ -16,8 +16,9 @@ import numpy as np
16
16
  from pydantic import Field, model_validator
17
17
  from tqdm import tqdm
18
18
 
19
+ from videopython.base._dimensions import floor_to_even, round_to_even
19
20
  from videopython.base.operation import FilterCtx, OpCategory, Operation
20
- from videopython.base.video import Video, _round_dimension_to_even
21
+ from videopython.base.video import Video
21
22
 
22
23
  if TYPE_CHECKING:
23
24
  from videopython.base.text.transcription import Transcription
@@ -120,8 +121,8 @@ class Resize(Operation):
120
121
  new_h = self.height
121
122
  new_w = round(src_w * (self.height / src_h))
122
123
  if self.round_to_even:
123
- new_w = _round_dimension_to_even(new_w)
124
- new_h = _round_dimension_to_even(new_h)
124
+ new_w = round_to_even(new_w)
125
+ new_h = round_to_even(new_h)
125
126
  return new_w, new_h
126
127
 
127
128
  def apply(self, video: Video) -> Video:
@@ -250,10 +251,10 @@ class Crop(Operation):
250
251
  if cw > meta.width or ch > meta.height:
251
252
  raise ValueError(f"Crop {cw}x{ch} exceeds source {meta.width}x{meta.height}")
252
253
  if self.mode == CropMode.CENTER:
253
- # Mirror apply()'s `mid - cw//2 : mid + cw//2` slice, which produces
254
- # 2 * (cw // 2) pixels — i.e. odd targets get rounded down to even.
255
- cw = (cw // 2) * 2
256
- ch = (ch // 2) * 2
254
+ # Mirror apply()'s `mid - cw//2 : mid + cw//2` slice, which
255
+ # produces 2 * (cw // 2) pixels — odd targets round down.
256
+ cw = floor_to_even(cw)
257
+ ch = floor_to_even(ch)
257
258
  return meta.with_dimensions(cw, ch)
258
259
 
259
260
  def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
- import subprocess
5
3
  import tempfile
6
4
  import uuid
7
5
  import warnings
@@ -12,8 +10,16 @@ from typing import Generator, Literal, get_args
12
10
 
13
11
  import numpy as np
14
12
 
13
+ from videopython.base import _ffmpeg
14
+ from videopython.base._dimensions import require_even
15
15
  from videopython.base.audio import Audio
16
- from videopython.base.exceptions import AudioLoadError, VideoLoadError, VideoMetadataError
16
+ from videopython.base.exceptions import (
17
+ AudioLoadError,
18
+ FFmpegProbeError,
19
+ FFmpegRunError,
20
+ VideoLoadError,
21
+ VideoMetadataError,
22
+ )
17
23
 
18
24
  __all__ = [
19
25
  "Video",
@@ -34,11 +40,6 @@ FRAME_BUFFER_MULTIPLIER = 1.1 # 10% buffer for frame rate estimation errors
34
40
  FRAME_BUFFER_PADDING = 10 # Additional fixed frame padding
35
41
 
36
42
 
37
- def _round_dimension_to_even(value: int | float) -> int:
38
- """Round a dimension to the nearest even integer (minimum 2)."""
39
- return max(2, int(round(float(value) / 2.0) * 2))
40
-
41
-
42
43
  @dataclass
43
44
  class VideoMetadata:
44
45
  """Class to store video metadata."""
@@ -66,28 +67,20 @@ class VideoMetadata:
66
67
  @staticmethod
67
68
  def _run_ffprobe(video_path: str | Path) -> dict:
68
69
  """Run ffprobe and return parsed JSON output."""
69
- cmd = [
70
- "ffprobe",
71
- "-v",
72
- "error",
73
- "-select_streams",
74
- "v:0",
75
- "-show_entries",
76
- "stream=width,height,r_frame_rate,nb_frames",
77
- "-show_entries",
78
- "format=duration",
79
- "-print_format",
80
- "json",
81
- str(video_path),
82
- ]
83
-
84
70
  try:
85
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
86
- return json.loads(result.stdout)
87
- except subprocess.CalledProcessError as e:
88
- raise VideoMetadataError(f"FFprobe error: {e.stderr}")
89
- except json.JSONDecodeError as e:
90
- raise VideoMetadataError(f"Error parsing FFprobe output: {e}")
71
+ return _ffmpeg.probe(
72
+ video_path,
73
+ extra_args=[
74
+ "-select_streams",
75
+ "v:0",
76
+ "-show_entries",
77
+ "stream=width,height,r_frame_rate,nb_frames",
78
+ "-show_entries",
79
+ "format=duration",
80
+ ],
81
+ )
82
+ except FFmpegProbeError as e:
83
+ raise VideoMetadataError(str(e)) from e
91
84
 
92
85
  @classmethod
93
86
  def from_path(cls, video_path: str | Path) -> VideoMetadata:
@@ -238,7 +231,7 @@ class FrameIterator:
238
231
  self.metadata = VideoMetadata.from_path(path)
239
232
  self.start_second = start_second if start_second is not None else 0.0
240
233
  self.end_second = end_second
241
- self._process: subprocess.Popen | None = None
234
+ self._iter: Generator[tuple[int, np.ndarray], None, None] | None = None
242
235
 
243
236
  # Build -vf filter chain
244
237
  self._vf_filters = list(vf_filters) if vf_filters else []
@@ -287,52 +280,30 @@ class FrameIterator:
287
280
  Frame indices are absolute indices in the original video,
288
281
  accounting for any start_second offset.
289
282
  """
290
- cmd = self._build_ffmpeg_command()
283
+ self._iter = self._iter_frames()
284
+ return self._iter
291
285
 
292
- self._process = subprocess.Popen(
293
- cmd,
294
- stdout=subprocess.PIPE,
295
- stderr=subprocess.PIPE,
296
- bufsize=self._frame_size * 2,
297
- )
298
-
299
- # Calculate starting frame index based on start_second
300
- start_frame = int(self.start_second * self.output_fps)
301
- frame_idx = start_frame
302
-
303
- try:
286
+ def _iter_frames(self) -> Generator[tuple[int, np.ndarray], None, None]:
287
+ cmd = self._build_ffmpeg_command()
288
+ with _ffmpeg.popen_decode(cmd, bufsize=self._frame_size * 2) as proc:
289
+ frame_idx = int(self.start_second * self.output_fps)
304
290
  while True:
305
- raw_frame = self._process.stdout.read(self._frame_size) # type: ignore
291
+ raw_frame = proc.stdout.read(self._frame_size) # type: ignore[union-attr]
306
292
  if len(raw_frame) != self._frame_size:
307
293
  break
308
-
309
- frame = np.frombuffer(raw_frame, dtype=np.uint8).copy()
310
- frame = frame.reshape(self.output_height, self.output_width, 3)
311
-
294
+ frame = (
295
+ np.frombuffer(raw_frame, dtype=np.uint8).copy().reshape(self.output_height, self.output_width, 3)
296
+ )
312
297
  yield frame_idx, frame
313
298
  frame_idx += 1
314
- finally:
315
- self._cleanup()
316
-
317
- def _cleanup(self) -> None:
318
- """Clean up ffmpeg process."""
319
- if self._process is not None:
320
- if self._process.poll() is None:
321
- self._process.terminate()
322
- try:
323
- self._process.wait(timeout=5)
324
- except subprocess.TimeoutExpired:
325
- self._process.kill()
326
- self._process.wait()
327
- if self._process.stdout:
328
- self._process.stdout.close()
329
- self._process = None
330
299
 
331
300
  def __enter__(self) -> "FrameIterator":
332
301
  return self
333
302
 
334
303
  def __exit__(self, *args: object) -> None:
335
- self._cleanup()
304
+ if self._iter is not None:
305
+ self._iter.close()
306
+ self._iter = None
336
307
 
337
308
 
338
309
  def extract_frames_at_indices(
@@ -388,40 +359,28 @@ def extract_frames_at_indices(
388
359
  "pipe:1",
389
360
  ]
390
361
 
391
- process = subprocess.Popen(
392
- cmd,
393
- stdout=subprocess.PIPE,
394
- stderr=subprocess.DEVNULL,
395
- bufsize=10**8,
396
- )
397
-
398
362
  frame_size = metadata.width * metadata.height * 3
399
363
 
400
- try:
364
+ with _ffmpeg.popen_decode(cmd, bufsize=10**8) as process:
401
365
  raw_data, _ = process.communicate()
402
366
 
403
- actual_frames = len(raw_data) // frame_size
404
- if actual_frames == 0:
405
- return np.empty((0, metadata.height, metadata.width, 3), dtype=np.uint8)
406
-
407
- # Truncate to complete frames only
408
- raw_data = raw_data[: actual_frames * frame_size]
367
+ actual_frames = len(raw_data) // frame_size
368
+ if actual_frames == 0:
369
+ return np.empty((0, metadata.height, metadata.width, 3), dtype=np.uint8)
409
370
 
410
- frames = np.frombuffer(raw_data, dtype=np.uint8).copy()
411
- frames = frames.reshape(-1, metadata.height, metadata.width, 3)
371
+ # Truncate to complete frames only
372
+ raw_data = raw_data[: actual_frames * frame_size]
412
373
 
413
- # Reorder to match original frame_indices order if needed
414
- if unique_sorted_indices != frame_indices:
415
- index_map = {idx: i for i, idx in enumerate(unique_sorted_indices)}
416
- reorder = [index_map[idx] for idx in frame_indices if idx in index_map]
417
- frames = frames[reorder]
374
+ frames = np.frombuffer(raw_data, dtype=np.uint8).copy()
375
+ frames = frames.reshape(-1, metadata.height, metadata.width, 3)
418
376
 
419
- return frames
377
+ # Reorder to match original frame_indices order if needed
378
+ if unique_sorted_indices != frame_indices:
379
+ index_map = {idx: i for i, idx in enumerate(unique_sorted_indices)}
380
+ reorder = [index_map[idx] for idx in frame_indices if idx in index_map]
381
+ frames = frames[reorder]
420
382
 
421
- finally:
422
- if process.poll() is None:
423
- process.terminate()
424
- process.wait()
383
+ return frames
425
384
 
426
385
 
427
386
  def extract_frames_at_times(
@@ -546,14 +505,6 @@ class Video:
546
505
  ]
547
506
  )
548
507
 
549
- # Start FFmpeg process with stderr redirected to avoid deadlock
550
- process = subprocess.Popen(
551
- ffmpeg_cmd,
552
- stdout=subprocess.PIPE,
553
- stderr=subprocess.DEVNULL, # Redirect stderr to avoid deadlock
554
- bufsize=10**8, # Use large buffer for efficient I/O
555
- )
556
-
557
508
  # Calculate frame size in bytes
558
509
  frame_size = out_width * out_height * 3 # 3 bytes per pixel for RGB
559
510
 
@@ -574,59 +525,35 @@ class Video:
574
525
  frames = np.empty((estimated_frames, out_height, out_width, 3), dtype=np.uint8)
575
526
  frames_read = 0
576
527
 
577
- try:
528
+ with _ffmpeg.popen_decode(ffmpeg_cmd, bufsize=10**8) as process:
578
529
  while frames_read < estimated_frames:
579
- # Calculate remaining frames to read
580
530
  remaining_frames = estimated_frames - frames_read
581
531
  batch_size = min(read_batch_size, remaining_frames)
582
532
 
583
- # Read batch of data
584
- batch_data = process.stdout.read(frame_size * batch_size) # type: ignore
585
-
533
+ batch_data = process.stdout.read(frame_size * batch_size) # type: ignore[union-attr]
586
534
  if not batch_data:
587
535
  break
588
536
 
589
- # Convert to numpy array
590
537
  batch_frames = np.frombuffer(batch_data, dtype=np.uint8)
591
-
592
- # Calculate how many complete frames we got
593
538
  complete_frames = len(batch_frames) // (out_height * out_width * 3)
594
-
595
539
  if complete_frames == 0:
596
540
  break
597
541
 
598
- # Only keep complete frames
599
542
  complete_data = batch_frames[: complete_frames * out_height * out_width * 3]
600
543
  batch_frames_array = complete_data.reshape(complete_frames, out_height, out_width, 3)
601
544
 
602
- # Check if we have room in pre-allocated array
603
545
  if frames_read + complete_frames > estimated_frames:
604
- # Need to expand array - this should be rare with our buffer
546
+ # Pre-allocation undershoot rare with the buffer.
605
547
  new_size = max(estimated_frames * 2, frames_read + complete_frames + 100)
606
548
  new_frames = np.empty((new_size, out_height, out_width, 3), dtype=np.uint8)
607
549
  new_frames[:frames_read] = frames[:frames_read]
608
550
  frames = new_frames
609
551
  estimated_frames = new_size
610
552
 
611
- # Store batch in pre-allocated array
612
553
  end_idx = frames_read + complete_frames
613
554
  frames[frames_read:end_idx] = batch_frames_array
614
555
  frames_read += complete_frames
615
556
 
616
- finally:
617
- # Ensure process is properly terminated
618
- if process.poll() is None:
619
- process.terminate()
620
- try:
621
- process.wait(timeout=5)
622
- except subprocess.TimeoutExpired:
623
- process.kill()
624
- process.wait()
625
-
626
- # Clean up pipes
627
- if process.stdout:
628
- process.stdout.close()
629
-
630
557
  # Check if FFmpeg had an error (non-zero return code)
631
558
  if process.returncode not in (0, None) and frames_read == 0:
632
559
  raise ValueError(f"FFmpeg failed to process video (return code: {process.returncode})")
@@ -645,7 +572,7 @@ class Video:
645
572
  audio_start = start_second if start_second is not None else 0
646
573
  audio_end = end_second if end_second is not None else audio.metadata.duration_seconds
647
574
  audio = audio.slice(start_seconds=audio_start, end_seconds=audio_end)
648
- except (AudioLoadError, FileNotFoundError, subprocess.CalledProcessError):
575
+ except (AudioLoadError, FileNotFoundError):
649
576
  warnings.warn(f"No audio found for `{path}`, adding silent track.")
650
577
  # Create silent audio based on actual frames read
651
578
  segment_duration = frames_read / out_fps
@@ -655,8 +582,8 @@ class Video:
655
582
 
656
583
  except VideoMetadataError:
657
584
  raise
658
- except subprocess.CalledProcessError as e:
659
- raise VideoLoadError(f"FFmpeg failed: {e}")
585
+ except FFmpegRunError as e:
586
+ raise VideoLoadError(f"FFmpeg failed: {e}") from e
660
587
  except (OSError, IOError) as e:
661
588
  raise VideoLoadError(f"I/O error: {e}")
662
589
 
@@ -743,12 +670,7 @@ class Video:
743
670
  )
744
671
 
745
672
  frame_height, frame_width = self.frame_shape[:2]
746
- if frame_width % 2 != 0 or frame_height % 2 != 0:
747
- raise ValueError(
748
- "Current save pipeline uses libx264 with yuv420p, which requires even frame dimensions. "
749
- f"Got {frame_width}x{frame_height}. "
750
- "Resize, crop, or pad to an even width and height before saving."
751
- )
673
+ require_even(frame_width, frame_height)
752
674
 
753
675
  if filename is None:
754
676
  filename = Path(f"{uuid.uuid4()}.{format}")
@@ -808,43 +730,22 @@ class Video:
808
730
  str(filename),
809
731
  ]
810
732
 
811
- process = subprocess.Popen(
812
- ffmpeg_command,
813
- stdin=subprocess.PIPE,
814
- stdout=subprocess.DEVNULL,
815
- stderr=subprocess.PIPE,
816
- )
817
-
818
- try:
819
- if process.stdin is None:
820
- raise RuntimeError("Failed to open FFmpeg stdin pipe for video data")
821
-
733
+ with _ffmpeg.popen_encode(ffmpeg_command) as process:
822
734
  frames = self.frames
823
735
  if frames.dtype != np.uint8 or not frames.flags["C_CONTIGUOUS"]:
824
736
  frames = np.ascontiguousarray(frames, dtype=np.uint8)
825
737
 
826
738
  buffer = memoryview(frames)
827
739
  try:
828
- process.stdin.write(buffer)
829
- process.stdin.close()
740
+ process.stdin.write(buffer) # type: ignore[union-attr]
830
741
  except BrokenPipeError as e:
742
+ # ffmpeg has already died; surface its stderr for diagnostics.
831
743
  stderr = process.stderr.read() if process.stderr is not None else b""
832
- returncode = process.wait()
833
- raise RuntimeError(
834
- f"FFmpeg terminated while receiving video data (code {returncode}): "
835
- f"{stderr.decode(errors='ignore')}"
744
+ raise FFmpegRunError(
745
+ f"ffmpeg terminated while receiving video data: {stderr.decode(errors='replace')}"
836
746
  ) from e
837
747
 
838
- stderr = process.stderr.read() if process.stderr is not None else b""
839
- returncode = process.wait()
840
-
841
- if returncode != 0:
842
- raise RuntimeError(f"FFmpeg failed with code {returncode}: {stderr.decode(errors='ignore')}")
843
-
844
- return filename
845
- finally:
846
- if process.poll() is None:
847
- process.kill()
748
+ return filename
848
749
 
849
750
  def add_audio(self, audio: Audio, overlay: bool = True) -> Video:
850
751
  """Add audio to video, returning a new Video instance.
File without changes
File without changes
File without changes