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.
Files changed (50) hide show
  1. {videopython-0.31.1 → videopython-0.31.2}/PKG-INFO +1 -1
  2. {videopython-0.31.1 → videopython-0.31.2}/pyproject.toml +1 -1
  3. videopython-0.31.2/src/videopython/base/_video_io.py +289 -0
  4. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/video.py +25 -269
  5. {videopython-0.31.1 → videopython-0.31.2}/.gitignore +0 -0
  6. {videopython-0.31.1 → videopython-0.31.2}/LICENSE +0 -0
  7. {videopython-0.31.1 → videopython-0.31.2}/README.md +0 -0
  8. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/__init__.py +0 -0
  9. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/__init__.py +0 -0
  10. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/_device.py +0 -0
  11. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/__init__.py +0 -0
  12. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/dubber.py +0 -0
  13. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/models.py +0 -0
  14. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/pipeline.py +0 -0
  15. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/quality.py +0 -0
  16. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/remux.py +0 -0
  17. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/dubbing/timing.py +0 -0
  18. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/__init__.py +0 -0
  19. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/audio.py +0 -0
  20. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/image.py +0 -0
  21. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/qwen3.py +0 -0
  22. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/translation.py +0 -0
  23. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/generation/video.py +0 -0
  24. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/transforms.py +0 -0
  25. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/__init__.py +0 -0
  26. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/audio.py +0 -0
  27. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/faces.py +0 -0
  28. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/image.py +0 -0
  29. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/separation.py +0 -0
  30. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/understanding/temporal.py +0 -0
  31. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/ai/video_analysis.py +0 -0
  32. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/__init__.py +0 -0
  33. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/_dimensions.py +0 -0
  34. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/_ffmpeg.py +0 -0
  35. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/audio/__init__.py +0 -0
  36. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/audio/analysis.py +0 -0
  37. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/audio/audio.py +0 -0
  38. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/description.py +0 -0
  39. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/effects.py +0 -0
  40. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/exceptions.py +0 -0
  41. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/operation.py +0 -0
  42. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/scene.py +0 -0
  43. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/streaming.py +0 -0
  44. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/text/__init__.py +0 -0
  45. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/text/overlay.py +0 -0
  46. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/text/transcription.py +0 -0
  47. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/base/transforms.py +0 -0
  48. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/editing/__init__.py +0 -0
  49. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/editing/video_edit.py +0 -0
  50. {videopython-0.31.1 → videopython-0.31.2}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.31.1
3
+ Version: 0.31.2
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.1"
3
+ version = "0.31.2"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -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, Literal, get_args
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._dimensions import require_even
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
- try:
431
- # Get video metadata using VideoMetadata.from_path
432
- metadata = VideoMetadata.from_path(path)
433
-
434
- out_width = width if width is not None else metadata.width
435
- out_height = height if height is not None else metadata.height
436
- out_fps = fps if fps is not None else metadata.fps
437
- total_duration = metadata.total_seconds
438
-
439
- # Validate time bounds
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
- if format.lower() not in get_args(ALLOWED_VIDEO_FORMATS):
663
- raise ValueError(
664
- f"Unsupported format: {format}. Allowed formats are: {', '.join(get_args(ALLOWED_VIDEO_FORMATS))}"
665
- )
666
-
667
- if preset not in get_args(ALLOWED_VIDEO_PRESETS):
668
- raise ValueError(
669
- f"Unsupported preset: {preset}. Allowed presets are: {', '.join(get_args(ALLOWED_VIDEO_PRESETS))}"
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