videopython 0.31.1__tar.gz → 0.31.2__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.1 → videopython-0.31.2}/PKG-INFO +1 -1
- {videopython-0.31.1 → videopython-0.31.2}/pyproject.toml +1 -1
- videopython-0.31.2/src/videopython/base/_video_io.py +289 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/video.py +25 -269
- {videopython-0.31.1 → videopython-0.31.2}/.gitignore +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/LICENSE +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/README.md +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/__init__.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/_device.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/transforms.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/video_analysis.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/__init__.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/audio/__init__.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/audio/analysis.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/audio/audio.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/description.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/effects.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/exceptions.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/operation.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/scene.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/streaming.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/text/__init__.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/text/overlay.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/text/transcription.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/transforms.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/editing/video_edit.py +0 -0
- {videopython-0.31.1 → videopython-0.31.2}/src/videopython/py.typed +0 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Internal ffmpeg decode/encode helpers for ``Video``.
|
|
2
|
+
|
|
3
|
+
Holds the subprocess-heavy bodies of ``Video.from_path`` (decode an
|
|
4
|
+
ffmpeg pipe into a frame array) and ``Video.save`` (stream a frame
|
|
5
|
+
array to an ffmpeg encode). Keeping these out of ``base/video.py``
|
|
6
|
+
lets the data class stay focused on the in-memory frame/audio
|
|
7
|
+
container.
|
|
8
|
+
|
|
9
|
+
Public callers should keep using ``Video.from_path`` and
|
|
10
|
+
``Video.save``; this module is internal scaffolding.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tempfile
|
|
16
|
+
import uuid
|
|
17
|
+
import warnings
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal, get_args
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from videopython.base import _ffmpeg
|
|
24
|
+
from videopython.base._dimensions import require_even
|
|
25
|
+
from videopython.base.audio import Audio
|
|
26
|
+
from videopython.base.exceptions import (
|
|
27
|
+
AudioLoadError,
|
|
28
|
+
FFmpegRunError,
|
|
29
|
+
VideoLoadError,
|
|
30
|
+
VideoMetadataError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ALLOWED_VIDEO_FORMATS = Literal["mp4", "avi", "mov", "mkv", "webm"]
|
|
34
|
+
ALLOWED_VIDEO_PRESETS = Literal[
|
|
35
|
+
"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Pre-allocation safety margin for the decode frame array.
|
|
39
|
+
FRAME_BUFFER_MULTIPLIER = 1.1
|
|
40
|
+
FRAME_BUFFER_PADDING = 10
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decode_video(
|
|
44
|
+
path: str,
|
|
45
|
+
*,
|
|
46
|
+
read_batch_size: int = 100,
|
|
47
|
+
start_second: float | None = None,
|
|
48
|
+
end_second: float | None = None,
|
|
49
|
+
fps: float | None = None,
|
|
50
|
+
width: int | None = None,
|
|
51
|
+
height: int | None = None,
|
|
52
|
+
) -> tuple[np.ndarray, float, Audio]:
|
|
53
|
+
"""Decode a video file into an RGB frame array plus its audio track.
|
|
54
|
+
|
|
55
|
+
Returns ``(frames, fps, audio)`` ready to feed straight into the
|
|
56
|
+
``Video`` constructor. Silent audio is substituted when the source
|
|
57
|
+
has no usable audio stream.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
FileNotFoundError: If ``path`` does not exist (via VideoMetadata).
|
|
61
|
+
VideoLoadError: On ffmpeg failure or unreadable I/O.
|
|
62
|
+
VideoMetadataError: When ffprobe cannot describe the source.
|
|
63
|
+
"""
|
|
64
|
+
from videopython.base.video import VideoMetadata
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
metadata = VideoMetadata.from_path(path)
|
|
68
|
+
|
|
69
|
+
out_width = width if width is not None else metadata.width
|
|
70
|
+
out_height = height if height is not None else metadata.height
|
|
71
|
+
out_fps = fps if fps is not None else metadata.fps
|
|
72
|
+
total_duration = metadata.total_seconds
|
|
73
|
+
|
|
74
|
+
if start_second is not None and start_second < 0:
|
|
75
|
+
raise ValueError("start_second must be non-negative")
|
|
76
|
+
if end_second is not None and end_second > total_duration:
|
|
77
|
+
raise ValueError(f"end_second ({end_second}) exceeds video duration ({total_duration})")
|
|
78
|
+
if start_second is not None and end_second is not None and start_second >= end_second:
|
|
79
|
+
raise ValueError("start_second must be less than end_second")
|
|
80
|
+
|
|
81
|
+
if start_second is not None and end_second is not None:
|
|
82
|
+
segment_duration = end_second - start_second
|
|
83
|
+
elif end_second is not None:
|
|
84
|
+
segment_duration = end_second
|
|
85
|
+
elif start_second is not None:
|
|
86
|
+
segment_duration = total_duration - start_second
|
|
87
|
+
else:
|
|
88
|
+
segment_duration = total_duration
|
|
89
|
+
|
|
90
|
+
estimated_bytes = int(segment_duration * out_fps) * out_height * out_width * 3
|
|
91
|
+
estimated_gb = estimated_bytes / (1024**3)
|
|
92
|
+
if estimated_gb > 10:
|
|
93
|
+
warnings.warn(
|
|
94
|
+
f"Loading this video will use ~{estimated_gb:.1f}GB of RAM. "
|
|
95
|
+
f"For large videos, consider using FrameIterator for memory-efficient streaming.",
|
|
96
|
+
ResourceWarning,
|
|
97
|
+
stacklevel=2,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
ffmpeg_cmd = ["ffmpeg"]
|
|
101
|
+
|
|
102
|
+
if start_second is not None:
|
|
103
|
+
ffmpeg_cmd.extend(["-ss", str(start_second)])
|
|
104
|
+
|
|
105
|
+
ffmpeg_cmd.extend(["-i", path])
|
|
106
|
+
|
|
107
|
+
if end_second is not None and start_second is not None:
|
|
108
|
+
duration = end_second - start_second
|
|
109
|
+
ffmpeg_cmd.extend(["-t", str(duration)])
|
|
110
|
+
elif end_second is not None:
|
|
111
|
+
ffmpeg_cmd.extend(["-t", str(end_second)])
|
|
112
|
+
|
|
113
|
+
vf_filters: list[str] = []
|
|
114
|
+
if width is not None or height is not None:
|
|
115
|
+
vf_filters.append(f"scale={out_width}:{out_height}")
|
|
116
|
+
if fps is not None and fps != metadata.fps:
|
|
117
|
+
vf_filters.append(f"fps={out_fps}")
|
|
118
|
+
if vf_filters:
|
|
119
|
+
ffmpeg_cmd.extend(["-vf", ",".join(vf_filters)])
|
|
120
|
+
|
|
121
|
+
ffmpeg_cmd.extend(
|
|
122
|
+
[
|
|
123
|
+
"-f",
|
|
124
|
+
"rawvideo",
|
|
125
|
+
"-pix_fmt",
|
|
126
|
+
"rgb24",
|
|
127
|
+
"-vcodec",
|
|
128
|
+
"rawvideo",
|
|
129
|
+
"-avoid_negative_ts",
|
|
130
|
+
"make_zero",
|
|
131
|
+
"-y",
|
|
132
|
+
"pipe:1",
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
frame_size = out_width * out_height * 3
|
|
137
|
+
estimated_frames = int(segment_duration * out_fps * FRAME_BUFFER_MULTIPLIER) + FRAME_BUFFER_PADDING
|
|
138
|
+
|
|
139
|
+
frames = np.empty((estimated_frames, out_height, out_width, 3), dtype=np.uint8)
|
|
140
|
+
frames_read = 0
|
|
141
|
+
|
|
142
|
+
with _ffmpeg.popen_decode(ffmpeg_cmd, bufsize=10**8) as process:
|
|
143
|
+
while frames_read < estimated_frames:
|
|
144
|
+
remaining_frames = estimated_frames - frames_read
|
|
145
|
+
batch_size = min(read_batch_size, remaining_frames)
|
|
146
|
+
|
|
147
|
+
batch_data = process.stdout.read(frame_size * batch_size) # type: ignore[union-attr]
|
|
148
|
+
if not batch_data:
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
batch_frames = np.frombuffer(batch_data, dtype=np.uint8)
|
|
152
|
+
complete_frames = len(batch_frames) // (out_height * out_width * 3)
|
|
153
|
+
if complete_frames == 0:
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
complete_data = batch_frames[: complete_frames * out_height * out_width * 3]
|
|
157
|
+
batch_frames_array = complete_data.reshape(complete_frames, out_height, out_width, 3)
|
|
158
|
+
|
|
159
|
+
if frames_read + complete_frames > estimated_frames:
|
|
160
|
+
new_size = max(estimated_frames * 2, frames_read + complete_frames + 100)
|
|
161
|
+
new_frames = np.empty((new_size, out_height, out_width, 3), dtype=np.uint8)
|
|
162
|
+
new_frames[:frames_read] = frames[:frames_read]
|
|
163
|
+
frames = new_frames
|
|
164
|
+
estimated_frames = new_size
|
|
165
|
+
|
|
166
|
+
end_idx = frames_read + complete_frames
|
|
167
|
+
frames[frames_read:end_idx] = batch_frames_array
|
|
168
|
+
frames_read += complete_frames
|
|
169
|
+
|
|
170
|
+
if process.returncode not in (0, None) and frames_read == 0:
|
|
171
|
+
raise ValueError(f"FFmpeg failed to process video (return code: {process.returncode})")
|
|
172
|
+
|
|
173
|
+
if frames_read == 0:
|
|
174
|
+
raise ValueError("No frames were read from the video")
|
|
175
|
+
|
|
176
|
+
frames = frames[:frames_read] # type: ignore
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
audio = Audio.from_path(path)
|
|
180
|
+
if start_second is not None or end_second is not None:
|
|
181
|
+
audio_start = start_second if start_second is not None else 0
|
|
182
|
+
audio_end = end_second if end_second is not None else audio.metadata.duration_seconds
|
|
183
|
+
audio = audio.slice(start_seconds=audio_start, end_seconds=audio_end)
|
|
184
|
+
except (AudioLoadError, FileNotFoundError):
|
|
185
|
+
warnings.warn(f"No audio found for `{path}`, adding silent track.")
|
|
186
|
+
segment_duration = frames_read / out_fps
|
|
187
|
+
audio = Audio.create_silent(duration_seconds=round(segment_duration, 2), stereo=True, sample_rate=44100)
|
|
188
|
+
|
|
189
|
+
return frames, out_fps, audio
|
|
190
|
+
|
|
191
|
+
except VideoMetadataError:
|
|
192
|
+
raise
|
|
193
|
+
except FFmpegRunError as e:
|
|
194
|
+
raise VideoLoadError(f"FFmpeg failed: {e}") from e
|
|
195
|
+
except (OSError, IOError) as e:
|
|
196
|
+
raise VideoLoadError(f"I/O error: {e}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def encode_video(
|
|
200
|
+
frames: np.ndarray,
|
|
201
|
+
fps: float,
|
|
202
|
+
audio: Audio,
|
|
203
|
+
*,
|
|
204
|
+
filename: str | Path | None = None,
|
|
205
|
+
format: ALLOWED_VIDEO_FORMATS = "mp4",
|
|
206
|
+
preset: ALLOWED_VIDEO_PRESETS = "medium",
|
|
207
|
+
crf: int = 23,
|
|
208
|
+
) -> Path:
|
|
209
|
+
"""Encode an RGB frame array + audio track to disk via ffmpeg.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
ValueError: If ``format`` or ``preset`` is not in the allowed set.
|
|
213
|
+
FFmpegRunError: If ffmpeg fails to encode.
|
|
214
|
+
"""
|
|
215
|
+
allowed_formats = get_args(ALLOWED_VIDEO_FORMATS)
|
|
216
|
+
if format.lower() not in allowed_formats:
|
|
217
|
+
raise ValueError(f"Unsupported format: {format}. Allowed formats are: {', '.join(allowed_formats)}")
|
|
218
|
+
|
|
219
|
+
allowed_presets = get_args(ALLOWED_VIDEO_PRESETS)
|
|
220
|
+
if preset not in allowed_presets:
|
|
221
|
+
raise ValueError(f"Unsupported preset: {preset}. Allowed presets are: {', '.join(allowed_presets)}")
|
|
222
|
+
|
|
223
|
+
frame_height, frame_width = frames.shape[1:3]
|
|
224
|
+
require_even(frame_width, frame_height)
|
|
225
|
+
|
|
226
|
+
if filename is None:
|
|
227
|
+
filename = Path(f"{uuid.uuid4()}.{format}")
|
|
228
|
+
else:
|
|
229
|
+
filename = Path(filename).with_suffix(f".{format}")
|
|
230
|
+
filename.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
|
|
232
|
+
with tempfile.NamedTemporaryFile(suffix=".wav") as temp_audio:
|
|
233
|
+
audio.save(temp_audio.name, format="wav")
|
|
234
|
+
|
|
235
|
+
duration = len(frames) / fps
|
|
236
|
+
|
|
237
|
+
ffmpeg_command = [
|
|
238
|
+
"ffmpeg",
|
|
239
|
+
"-y",
|
|
240
|
+
"-hide_banner",
|
|
241
|
+
"-loglevel",
|
|
242
|
+
"error",
|
|
243
|
+
"-f",
|
|
244
|
+
"rawvideo",
|
|
245
|
+
"-pixel_format",
|
|
246
|
+
"rgb24",
|
|
247
|
+
"-video_size",
|
|
248
|
+
f"{frame_width}x{frame_height}",
|
|
249
|
+
"-framerate",
|
|
250
|
+
str(fps),
|
|
251
|
+
"-i",
|
|
252
|
+
"pipe:0",
|
|
253
|
+
"-i",
|
|
254
|
+
temp_audio.name,
|
|
255
|
+
"-c:v",
|
|
256
|
+
"libx264",
|
|
257
|
+
"-preset",
|
|
258
|
+
preset,
|
|
259
|
+
"-crf",
|
|
260
|
+
str(crf),
|
|
261
|
+
"-c:a",
|
|
262
|
+
"aac",
|
|
263
|
+
"-b:a",
|
|
264
|
+
"192k",
|
|
265
|
+
"-pix_fmt",
|
|
266
|
+
"yuv420p",
|
|
267
|
+
"-movflags",
|
|
268
|
+
"+faststart",
|
|
269
|
+
"-t",
|
|
270
|
+
str(duration),
|
|
271
|
+
"-vsync",
|
|
272
|
+
"cfr",
|
|
273
|
+
str(filename),
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
with _ffmpeg.popen_encode(ffmpeg_command) as process:
|
|
277
|
+
if frames.dtype != np.uint8 or not frames.flags["C_CONTIGUOUS"]:
|
|
278
|
+
frames = np.ascontiguousarray(frames, dtype=np.uint8)
|
|
279
|
+
|
|
280
|
+
buffer = memoryview(frames)
|
|
281
|
+
try:
|
|
282
|
+
process.stdin.write(buffer) # type: ignore[union-attr]
|
|
283
|
+
except BrokenPipeError as e:
|
|
284
|
+
stderr = process.stderr.read() if process.stderr is not None else b""
|
|
285
|
+
raise FFmpegRunError(
|
|
286
|
+
f"ffmpeg terminated while receiving video data: {stderr.decode(errors='replace')}"
|
|
287
|
+
) from e
|
|
288
|
+
|
|
289
|
+
return filename
|
|
@@ -1,25 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import tempfile
|
|
4
|
-
import uuid
|
|
5
|
-
import warnings
|
|
6
3
|
from dataclasses import dataclass
|
|
7
4
|
from fractions import Fraction
|
|
8
5
|
from pathlib import Path
|
|
9
|
-
from typing import Generator
|
|
6
|
+
from typing import Generator
|
|
10
7
|
|
|
11
8
|
import numpy as np
|
|
12
9
|
|
|
13
|
-
from videopython.base import _ffmpeg
|
|
14
|
-
from videopython.base.
|
|
10
|
+
from videopython.base import _ffmpeg, _video_io
|
|
11
|
+
from videopython.base._video_io import ALLOWED_VIDEO_FORMATS, ALLOWED_VIDEO_PRESETS
|
|
15
12
|
from videopython.base.audio import Audio
|
|
16
|
-
from videopython.base.exceptions import
|
|
17
|
-
AudioLoadError,
|
|
18
|
-
FFmpegProbeError,
|
|
19
|
-
FFmpegRunError,
|
|
20
|
-
VideoLoadError,
|
|
21
|
-
VideoMetadataError,
|
|
22
|
-
)
|
|
13
|
+
from videopython.base.exceptions import FFmpegProbeError, VideoMetadataError
|
|
23
14
|
|
|
24
15
|
__all__ = [
|
|
25
16
|
"Video",
|
|
@@ -27,18 +18,10 @@ __all__ = [
|
|
|
27
18
|
"FrameIterator",
|
|
28
19
|
"extract_frames_at_indices",
|
|
29
20
|
"extract_frames_at_times",
|
|
21
|
+
"ALLOWED_VIDEO_FORMATS",
|
|
22
|
+
"ALLOWED_VIDEO_PRESETS",
|
|
30
23
|
]
|
|
31
24
|
|
|
32
|
-
ALLOWED_VIDEO_FORMATS = Literal["mp4", "avi", "mov", "mkv", "webm"]
|
|
33
|
-
ALLOWED_VIDEO_PRESETS = Literal[
|
|
34
|
-
"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
# Frame buffer constants for video loading
|
|
38
|
-
# Used to pre-allocate frame array with safety margin for frame rate variations
|
|
39
|
-
FRAME_BUFFER_MULTIPLIER = 1.1 # 10% buffer for frame rate estimation errors
|
|
40
|
-
FRAME_BUFFER_PADDING = 10 # Additional fixed frame padding
|
|
41
|
-
|
|
42
25
|
|
|
43
26
|
@dataclass
|
|
44
27
|
class VideoMetadata:
|
|
@@ -427,165 +410,16 @@ class Video:
|
|
|
427
410
|
width: int | None = None,
|
|
428
411
|
height: int | None = None,
|
|
429
412
|
) -> Video:
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if start_second is not None and start_second < 0:
|
|
441
|
-
raise ValueError("start_second must be non-negative")
|
|
442
|
-
if end_second is not None and end_second > total_duration:
|
|
443
|
-
raise ValueError(f"end_second ({end_second}) exceeds video duration ({total_duration})")
|
|
444
|
-
if start_second is not None and end_second is not None and start_second >= end_second:
|
|
445
|
-
raise ValueError("start_second must be less than end_second")
|
|
446
|
-
|
|
447
|
-
# Estimate memory usage and warn for large videos
|
|
448
|
-
segment_duration = total_duration
|
|
449
|
-
if start_second is not None and end_second is not None:
|
|
450
|
-
segment_duration = end_second - start_second
|
|
451
|
-
elif end_second is not None:
|
|
452
|
-
segment_duration = end_second
|
|
453
|
-
elif start_second is not None:
|
|
454
|
-
segment_duration = total_duration - start_second
|
|
455
|
-
|
|
456
|
-
estimated_frames = int(segment_duration * out_fps)
|
|
457
|
-
estimated_bytes = estimated_frames * out_height * out_width * 3
|
|
458
|
-
estimated_gb = estimated_bytes / (1024**3)
|
|
459
|
-
if estimated_gb > 10:
|
|
460
|
-
warnings.warn(
|
|
461
|
-
f"Loading this video will use ~{estimated_gb:.1f}GB of RAM. "
|
|
462
|
-
f"For large videos, consider using FrameIterator for memory-efficient streaming.",
|
|
463
|
-
ResourceWarning,
|
|
464
|
-
stacklevel=2,
|
|
465
|
-
)
|
|
466
|
-
|
|
467
|
-
# Build FFmpeg command with improved segment handling
|
|
468
|
-
ffmpeg_cmd = ["ffmpeg"]
|
|
469
|
-
|
|
470
|
-
# Add seek option BEFORE input for more efficient seeking
|
|
471
|
-
if start_second is not None:
|
|
472
|
-
ffmpeg_cmd.extend(["-ss", str(start_second)])
|
|
473
|
-
|
|
474
|
-
ffmpeg_cmd.extend(["-i", path])
|
|
475
|
-
|
|
476
|
-
# Add duration AFTER input for more precise timing
|
|
477
|
-
if end_second is not None and start_second is not None:
|
|
478
|
-
duration = end_second - start_second
|
|
479
|
-
ffmpeg_cmd.extend(["-t", str(duration)])
|
|
480
|
-
elif end_second is not None:
|
|
481
|
-
ffmpeg_cmd.extend(["-t", str(end_second)])
|
|
482
|
-
|
|
483
|
-
# Apply video filters for resize and fps resampling
|
|
484
|
-
vf_filters: list[str] = []
|
|
485
|
-
if width is not None or height is not None:
|
|
486
|
-
vf_filters.append(f"scale={out_width}:{out_height}")
|
|
487
|
-
if fps is not None and fps != metadata.fps:
|
|
488
|
-
vf_filters.append(f"fps={out_fps}")
|
|
489
|
-
if vf_filters:
|
|
490
|
-
ffmpeg_cmd.extend(["-vf", ",".join(vf_filters)])
|
|
491
|
-
|
|
492
|
-
# Output format settings - removed problematic -vsync 0
|
|
493
|
-
ffmpeg_cmd.extend(
|
|
494
|
-
[
|
|
495
|
-
"-f",
|
|
496
|
-
"rawvideo",
|
|
497
|
-
"-pix_fmt",
|
|
498
|
-
"rgb24",
|
|
499
|
-
"-vcodec",
|
|
500
|
-
"rawvideo",
|
|
501
|
-
"-avoid_negative_ts",
|
|
502
|
-
"make_zero", # Handle timing issues
|
|
503
|
-
"-y",
|
|
504
|
-
"pipe:1",
|
|
505
|
-
]
|
|
506
|
-
)
|
|
507
|
-
|
|
508
|
-
# Calculate frame size in bytes
|
|
509
|
-
frame_size = out_width * out_height * 3 # 3 bytes per pixel for RGB
|
|
510
|
-
|
|
511
|
-
# Estimate frame count for pre-allocation
|
|
512
|
-
if start_second is not None and end_second is not None:
|
|
513
|
-
estimated_duration = end_second - start_second
|
|
514
|
-
elif end_second is not None:
|
|
515
|
-
estimated_duration = end_second
|
|
516
|
-
elif start_second is not None:
|
|
517
|
-
estimated_duration = total_duration - start_second
|
|
518
|
-
else:
|
|
519
|
-
estimated_duration = total_duration
|
|
520
|
-
|
|
521
|
-
# Add buffer to handle frame rate variations and rounding
|
|
522
|
-
estimated_frames = int(estimated_duration * out_fps * FRAME_BUFFER_MULTIPLIER) + FRAME_BUFFER_PADDING
|
|
523
|
-
|
|
524
|
-
# Pre-allocate numpy array
|
|
525
|
-
frames = np.empty((estimated_frames, out_height, out_width, 3), dtype=np.uint8)
|
|
526
|
-
frames_read = 0
|
|
527
|
-
|
|
528
|
-
with _ffmpeg.popen_decode(ffmpeg_cmd, bufsize=10**8) as process:
|
|
529
|
-
while frames_read < estimated_frames:
|
|
530
|
-
remaining_frames = estimated_frames - frames_read
|
|
531
|
-
batch_size = min(read_batch_size, remaining_frames)
|
|
532
|
-
|
|
533
|
-
batch_data = process.stdout.read(frame_size * batch_size) # type: ignore[union-attr]
|
|
534
|
-
if not batch_data:
|
|
535
|
-
break
|
|
536
|
-
|
|
537
|
-
batch_frames = np.frombuffer(batch_data, dtype=np.uint8)
|
|
538
|
-
complete_frames = len(batch_frames) // (out_height * out_width * 3)
|
|
539
|
-
if complete_frames == 0:
|
|
540
|
-
break
|
|
541
|
-
|
|
542
|
-
complete_data = batch_frames[: complete_frames * out_height * out_width * 3]
|
|
543
|
-
batch_frames_array = complete_data.reshape(complete_frames, out_height, out_width, 3)
|
|
544
|
-
|
|
545
|
-
if frames_read + complete_frames > estimated_frames:
|
|
546
|
-
# Pre-allocation undershoot — rare with the buffer.
|
|
547
|
-
new_size = max(estimated_frames * 2, frames_read + complete_frames + 100)
|
|
548
|
-
new_frames = np.empty((new_size, out_height, out_width, 3), dtype=np.uint8)
|
|
549
|
-
new_frames[:frames_read] = frames[:frames_read]
|
|
550
|
-
frames = new_frames
|
|
551
|
-
estimated_frames = new_size
|
|
552
|
-
|
|
553
|
-
end_idx = frames_read + complete_frames
|
|
554
|
-
frames[frames_read:end_idx] = batch_frames_array
|
|
555
|
-
frames_read += complete_frames
|
|
556
|
-
|
|
557
|
-
# Check if FFmpeg had an error (non-zero return code)
|
|
558
|
-
if process.returncode not in (0, None) and frames_read == 0:
|
|
559
|
-
raise ValueError(f"FFmpeg failed to process video (return code: {process.returncode})")
|
|
560
|
-
|
|
561
|
-
if frames_read == 0:
|
|
562
|
-
raise ValueError("No frames were read from the video")
|
|
563
|
-
|
|
564
|
-
# Trim the pre-allocated array to actual frames read
|
|
565
|
-
frames = frames[:frames_read] # type: ignore
|
|
566
|
-
|
|
567
|
-
# Load audio for the specified segment
|
|
568
|
-
try:
|
|
569
|
-
audio = Audio.from_path(path)
|
|
570
|
-
# Slice audio to match the video segment
|
|
571
|
-
if start_second is not None or end_second is not None:
|
|
572
|
-
audio_start = start_second if start_second is not None else 0
|
|
573
|
-
audio_end = end_second if end_second is not None else audio.metadata.duration_seconds
|
|
574
|
-
audio = audio.slice(start_seconds=audio_start, end_seconds=audio_end)
|
|
575
|
-
except (AudioLoadError, FileNotFoundError):
|
|
576
|
-
warnings.warn(f"No audio found for `{path}`, adding silent track.")
|
|
577
|
-
# Create silent audio based on actual frames read
|
|
578
|
-
segment_duration = frames_read / out_fps
|
|
579
|
-
audio = Audio.create_silent(duration_seconds=round(segment_duration, 2), stereo=True, sample_rate=44100)
|
|
580
|
-
|
|
581
|
-
return cls(frames=frames, fps=out_fps, audio=audio)
|
|
582
|
-
|
|
583
|
-
except VideoMetadataError:
|
|
584
|
-
raise
|
|
585
|
-
except FFmpegRunError as e:
|
|
586
|
-
raise VideoLoadError(f"FFmpeg failed: {e}") from e
|
|
587
|
-
except (OSError, IOError) as e:
|
|
588
|
-
raise VideoLoadError(f"I/O error: {e}")
|
|
413
|
+
frames, out_fps, audio = _video_io.decode_video(
|
|
414
|
+
path,
|
|
415
|
+
read_batch_size=read_batch_size,
|
|
416
|
+
start_second=start_second,
|
|
417
|
+
end_second=end_second,
|
|
418
|
+
fps=fps,
|
|
419
|
+
width=width,
|
|
420
|
+
height=height,
|
|
421
|
+
)
|
|
422
|
+
return cls(frames=frames, fps=out_fps, audio=audio)
|
|
589
423
|
|
|
590
424
|
@classmethod
|
|
591
425
|
def from_frames(cls, frames: np.ndarray, fps: float) -> Video:
|
|
@@ -659,93 +493,15 @@ class Video:
|
|
|
659
493
|
if not self.is_loaded():
|
|
660
494
|
raise RuntimeError("Video is not loaded, cannot save!")
|
|
661
495
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
frame_height, frame_width = self.frame_shape[:2]
|
|
673
|
-
require_even(frame_width, frame_height)
|
|
674
|
-
|
|
675
|
-
if filename is None:
|
|
676
|
-
filename = Path(f"{uuid.uuid4()}.{format}")
|
|
677
|
-
else:
|
|
678
|
-
filename = Path(filename).with_suffix(f".{format}")
|
|
679
|
-
filename.parent.mkdir(parents=True, exist_ok=True)
|
|
680
|
-
|
|
681
|
-
# Save audio to temporary WAV file
|
|
682
|
-
with tempfile.NamedTemporaryFile(suffix=".wav") as temp_audio:
|
|
683
|
-
self.audio.save(temp_audio.name, format="wav")
|
|
684
|
-
|
|
685
|
-
# Calculate exact duration
|
|
686
|
-
duration = len(self.frames) / self.fps
|
|
687
|
-
|
|
688
|
-
# Construct FFmpeg command (stream raw video via stdin)
|
|
689
|
-
ffmpeg_command = [
|
|
690
|
-
"ffmpeg",
|
|
691
|
-
"-y",
|
|
692
|
-
"-hide_banner",
|
|
693
|
-
"-loglevel",
|
|
694
|
-
"error",
|
|
695
|
-
# Raw video input settings
|
|
696
|
-
"-f",
|
|
697
|
-
"rawvideo",
|
|
698
|
-
"-pixel_format",
|
|
699
|
-
"rgb24",
|
|
700
|
-
"-video_size",
|
|
701
|
-
f"{self.frame_shape[1]}x{self.frame_shape[0]}",
|
|
702
|
-
"-framerate",
|
|
703
|
-
str(self.fps),
|
|
704
|
-
"-i",
|
|
705
|
-
"pipe:0",
|
|
706
|
-
# Audio input
|
|
707
|
-
"-i",
|
|
708
|
-
temp_audio.name,
|
|
709
|
-
# Video encoding settings
|
|
710
|
-
"-c:v",
|
|
711
|
-
"libx264",
|
|
712
|
-
"-preset",
|
|
713
|
-
preset,
|
|
714
|
-
"-crf",
|
|
715
|
-
str(crf),
|
|
716
|
-
# Audio settings
|
|
717
|
-
"-c:a",
|
|
718
|
-
"aac",
|
|
719
|
-
"-b:a",
|
|
720
|
-
"192k",
|
|
721
|
-
# Output settings
|
|
722
|
-
"-pix_fmt",
|
|
723
|
-
"yuv420p",
|
|
724
|
-
"-movflags",
|
|
725
|
-
"+faststart", # Enable fast start for web playback
|
|
726
|
-
"-t",
|
|
727
|
-
str(duration),
|
|
728
|
-
"-vsync",
|
|
729
|
-
"cfr",
|
|
730
|
-
str(filename),
|
|
731
|
-
]
|
|
732
|
-
|
|
733
|
-
with _ffmpeg.popen_encode(ffmpeg_command) as process:
|
|
734
|
-
frames = self.frames
|
|
735
|
-
if frames.dtype != np.uint8 or not frames.flags["C_CONTIGUOUS"]:
|
|
736
|
-
frames = np.ascontiguousarray(frames, dtype=np.uint8)
|
|
737
|
-
|
|
738
|
-
buffer = memoryview(frames)
|
|
739
|
-
try:
|
|
740
|
-
process.stdin.write(buffer) # type: ignore[union-attr]
|
|
741
|
-
except BrokenPipeError as e:
|
|
742
|
-
# ffmpeg has already died; surface its stderr for diagnostics.
|
|
743
|
-
stderr = process.stderr.read() if process.stderr is not None else b""
|
|
744
|
-
raise FFmpegRunError(
|
|
745
|
-
f"ffmpeg terminated while receiving video data: {stderr.decode(errors='replace')}"
|
|
746
|
-
) from e
|
|
747
|
-
|
|
748
|
-
return filename
|
|
496
|
+
return _video_io.encode_video(
|
|
497
|
+
self.frames,
|
|
498
|
+
self.fps,
|
|
499
|
+
self.audio,
|
|
500
|
+
filename=filename,
|
|
501
|
+
format=format,
|
|
502
|
+
preset=preset,
|
|
503
|
+
crf=crf,
|
|
504
|
+
)
|
|
749
505
|
|
|
750
506
|
def add_audio(self, audio: Audio, overlay: bool = True) -> Video:
|
|
751
507
|
"""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
|
|
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
|