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.
- {videopython-0.31.0 → videopython-0.31.1}/PKG-INFO +1 -1
- {videopython-0.31.0 → videopython-0.31.1}/pyproject.toml +1 -1
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/remux.py +11 -8
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/transforms.py +11 -15
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/video_analysis.py +4 -15
- videopython-0.31.1/src/videopython/base/_dimensions.py +41 -0
- videopython-0.31.1/src/videopython/base/_ffmpeg.py +152 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/audio/audio.py +16 -34
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/exceptions.py +18 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/streaming.py +33 -47
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/transforms.py +8 -7
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/video.py +63 -162
- {videopython-0.31.0 → videopython-0.31.1}/.gitignore +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/LICENSE +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/README.md +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/__init__.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/_device.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/__init__.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/audio/__init__.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/audio/analysis.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/description.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/effects.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/operation.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/scene.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/text/__init__.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/text/overlay.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/base/text/transcription.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/editing/video_edit.py +0 -0
- {videopython-0.31.0 → videopython-0.31.1}/src/videopython/py.typed +0 -0
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
raise RemuxError(
|
|
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 =
|
|
109
|
-
crop_w =
|
|
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 =
|
|
112
|
-
crop_h =
|
|
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 =
|
|
118
|
-
crop_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 =
|
|
145
|
-
out_w =
|
|
140
|
+
out_h = floor_to_even(h)
|
|
141
|
+
out_w = floor_to_even(int(out_h * target_ratio))
|
|
146
142
|
else:
|
|
147
|
-
out_w =
|
|
148
|
-
out_h =
|
|
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
|
-
|
|
1048
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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 =
|
|
124
|
-
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
|
|
254
|
-
# 2 * (cw // 2) pixels —
|
|
255
|
-
cw = (cw
|
|
256
|
-
ch = (ch
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
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
|
-
|
|
283
|
+
self._iter = self._iter_frames()
|
|
284
|
+
return self._iter
|
|
291
285
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 =
|
|
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
|
-
|
|
310
|
-
|
|
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.
|
|
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
|
-
|
|
364
|
+
with _ffmpeg.popen_decode(cmd, bufsize=10**8) as process:
|
|
401
365
|
raw_data, _ = process.communicate()
|
|
402
366
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
411
|
-
|
|
371
|
+
# Truncate to complete frames only
|
|
372
|
+
raw_data = raw_data[: actual_frames * frame_size]
|
|
412
373
|
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
833
|
-
|
|
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
|
-
|
|
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
|
|
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
|