videopython 0.55.1__tar.gz → 0.55.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 (92) hide show
  1. {videopython-0.55.1 → videopython-0.55.2}/PKG-INFO +1 -1
  2. {videopython-0.55.1 → videopython-0.55.2}/pyproject.toml +1 -1
  3. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/audio/audio.py +106 -65
  4. {videopython-0.55.1 → videopython-0.55.2}/.gitignore +0 -0
  5. {videopython-0.55.1 → videopython-0.55.2}/LICENSE +0 -0
  6. {videopython-0.55.1 → videopython-0.55.2}/README.md +0 -0
  7. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/__init__.py +0 -0
  8. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/__init__.py +0 -0
  9. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/_device.py +0 -0
  10. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/_ollama.py +0 -0
  11. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/_optional.py +0 -0
  12. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/_predictor.py +0 -0
  13. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/_revisions.py +0 -0
  14. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/auto_edit/__init__.py +0 -0
  15. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/auto_edit/backend.py +0 -0
  16. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/auto_edit/catalog.py +0 -0
  17. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/auto_edit/editor.py +0 -0
  18. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/auto_edit/local.py +0 -0
  19. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/auto_edit/models.py +0 -0
  20. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/auto_edit/resolve.py +0 -0
  21. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/__init__.py +0 -0
  22. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/_tts_backend.py +0 -0
  23. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/audio_ops.py +0 -0
  24. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/config.py +0 -0
  25. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/dubber.py +0 -0
  26. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/models.py +0 -0
  27. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/pipeline.py +0 -0
  28. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/quality.py +0 -0
  29. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/remux.py +0 -0
  30. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/separation.py +0 -0
  31. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/timing.py +0 -0
  32. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/translation.py +0 -0
  33. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  34. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/effects.py +0 -0
  35. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/errors.py +0 -0
  36. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/generation/__init__.py +0 -0
  37. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/generation/audio.py +0 -0
  38. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/generation/image.py +0 -0
  39. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/generation/video.py +0 -0
  40. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/keyframe.py +0 -0
  41. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/ops.py +0 -0
  42. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/transforms.py +0 -0
  43. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/understanding/__init__.py +0 -0
  44. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/understanding/_detector.py +0 -0
  45. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/understanding/audio.py +0 -0
  46. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/understanding/classification.py +0 -0
  47. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/understanding/faces.py +0 -0
  48. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/understanding/image.py +0 -0
  49. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/understanding/objects.py +0 -0
  50. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/understanding/temporal.py +0 -0
  51. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/video_analysis/__init__.py +0 -0
  52. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  53. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/video_analysis/detectors.py +0 -0
  54. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/video_analysis/models.py +0 -0
  55. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/video_analysis/sampling.py +0 -0
  56. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/ai/video_analysis/source_metadata.py +0 -0
  57. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/audio/__init__.py +0 -0
  58. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/audio/analysis.py +0 -0
  59. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/__init__.py +0 -0
  60. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/_dimensions.py +0 -0
  61. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/_ffmpeg.py +0 -0
  62. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/_video_io.py +0 -0
  63. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/description.py +0 -0
  64. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/draw_detections.py +0 -0
  65. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/exceptions.py +0 -0
  66. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  67. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  68. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  69. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  70. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  71. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  72. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  73. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  74. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  75. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  76. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/fonts/__init__.py +0 -0
  77. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/transcription.py +0 -0
  78. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/base/video.py +0 -0
  79. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/__init__.py +0 -0
  80. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/_ass.py +0 -0
  81. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/_easing.py +0 -0
  82. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/_schema.py +0 -0
  83. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/audio_ops.py +0 -0
  84. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/effects.py +0 -0
  85. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/operation.py +0 -0
  86. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/streaming.py +0 -0
  87. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/transcription_overlay.py +0 -0
  88. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/transforms.py +0 -0
  89. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/editing/video_edit.py +0 -0
  90. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/mcp/__init__.py +0 -0
  91. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/mcp/server.py +0 -0
  92. {videopython-0.55.1 → videopython-0.55.2}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: videopython
3
- Version: 0.55.1
3
+ Version: 0.55.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.55.1"
3
+ version = "0.55.2"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import io
4
4
  import subprocess
5
+ import tempfile
5
6
  import wave
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
@@ -16,6 +17,17 @@ from videopython.base.exceptions import AudioLoadError, FFmpegProbeError
16
17
  if TYPE_CHECKING:
17
18
  from videopython.audio.analysis import AudioLevels, AudioSegment, AudioSegmentType, SilentSegment
18
19
 
20
+ # What `Audio.from_path` asks ffmpeg for. Piped WAV comes back as pcm_s16le whatever
21
+ # the source's bit depth, so requesting raw s16le loses no fidelity that the WAV path
22
+ # was preserving, and there is no header to parse back.
23
+ _PCM_FORMAT = "s16le"
24
+ _PCM_DTYPE = np.int16
25
+ _PCM_SAMPLE_WIDTH = 2
26
+
27
+ # Read size when draining ffmpeg's stdout: big enough that a multi-GB decode is not
28
+ # millions of round trips, small enough to be irrelevant for a short clip.
29
+ _DECODE_CHUNK_BYTES = 8 << 20
30
+
19
31
 
20
32
  def atempo_chain(speed: float) -> list[str]:
21
33
  """Build the ``atempo`` filter chain that time-stretches audio by ``speed``.
@@ -163,100 +175,131 @@ class Audio:
163
175
  return cls(data, metadata)
164
176
 
165
177
  @classmethod
166
- def from_path(cls, file_path: str | Path) -> Audio:
178
+ def from_path(
179
+ cls,
180
+ file_path: str | Path,
181
+ *,
182
+ sample_rate: int | None = None,
183
+ channels: int | None = None,
184
+ ) -> Audio:
167
185
  """
168
- Load audio from a file using ffmpeg
186
+ Load audio from a file using ffmpeg.
187
+
188
+ ``sample_rate`` and ``channels`` ask ffmpeg to convert *while decoding*
189
+ rather than loading the source in full and converting afterwards. For a
190
+ caller that only wants 16kHz mono -- speech recognition, diarization,
191
+ speaker embeddings -- that is the difference between holding the whole
192
+ source in memory and holding a twelfth of it: a 12-hour 48kHz stereo
193
+ recording is 16.5GB of float32 at source rate and 1.4GB at 16kHz mono.
194
+ Resampling uses soxr, the engine :meth:`resample` uses, so the result
195
+ tracks ``Audio.from_path(p).to_mono().resample(r)`` sample for sample at
196
+ an error RMS around one 16-bit LSB -- the two quantize at different points
197
+ in the chain, and neither is the more faithful for it.
169
198
 
170
199
  Args:
171
200
  file_path: Path to the audio file
201
+ sample_rate: Decode at this rate instead of the source's.
202
+ channels: Decode to this many channels instead of the source's.
203
+ ``1`` downmixes to mono.
172
204
 
173
205
  Returns:
174
206
  Audio: New Audio instance
175
207
 
176
208
  Raises:
177
209
  FileNotFoundError: If the file doesn't exist
210
+ ValueError: If ``sample_rate`` or ``channels`` is not positive
178
211
  AudioLoadError: If there's an error loading the audio
179
212
  """
180
213
  file_path = Path(file_path)
181
214
  if not file_path.exists():
182
215
  raise FileNotFoundError(f"File not found: {file_path}")
216
+ if sample_rate is not None and sample_rate <= 0:
217
+ raise ValueError("Sample rate must be positive")
218
+ if channels is not None and channels <= 0:
219
+ raise ValueError("Channel count must be positive")
183
220
 
184
- # Get audio info
185
221
  info = cls._get_ffmpeg_info(file_path)
186
-
187
- # Convert to WAV using ffmpeg
222
+ target_rate = info["sample_rate"] if sample_rate is None else sample_rate
223
+ target_channels = info["channels"] if channels is None else channels
224
+
225
+ # Raw PCM rather than a WAV round-trip. Piped WAV comes back as pcm_s16le
226
+ # whatever the source's bit depth -- `-bits_per_raw_sample` is a hint the
227
+ # WAV muxer does not act on -- and its header carries a placeholder length
228
+ # because a pipe is not seekable. So parsing it back told us only what we
229
+ # had already asked for, and cost two more full copies of the audio on the
230
+ # way: one for `BytesIO`, one for `readframes`.
188
231
  cmd = [
189
232
  "ffmpeg",
233
+ "-v",
234
+ "error",
190
235
  "-i",
191
236
  str(file_path),
192
237
  "-f",
193
- "wav",
238
+ _PCM_FORMAT,
194
239
  "-ar",
195
- str(info["sample_rate"]), # sample rate
240
+ str(target_rate),
196
241
  "-ac",
197
- str(info["channels"]), # channels
198
- "-bits_per_raw_sample",
199
- str(info["bit_depth"]),
242
+ str(target_channels),
243
+ # soxr rather than ffmpeg's default resampler, so that decoding at a
244
+ # rate and resampling to it afterwards agree.
245
+ "-af",
246
+ "aresample=resampler=soxr",
200
247
  "-", # Output to stdout
201
248
  ]
202
249
 
203
250
  try:
204
- process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
205
- wav_data, stderr = process.communicate()
251
+ # stderr to a file, not a pipe: stdout is drained to completion before
252
+ # stderr is read, and a full stderr pipe would deadlock ffmpeg partway
253
+ # through the audio.
254
+ with tempfile.TemporaryFile() as errors:
255
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=errors)
256
+ assert process.stdout is not None
257
+ # A bytearray rather than `communicate()`, which accumulates chunks
258
+ # in a list and joins them at the end -- holding the whole of a long
259
+ # decode twice at the moment it completes.
260
+ raw = bytearray()
261
+ try:
262
+ while chunk := process.stdout.read(_DECODE_CHUNK_BYTES):
263
+ raw += chunk
264
+ finally:
265
+ process.stdout.close()
266
+ if process.wait() != 0:
267
+ errors.seek(0)
268
+ raise AudioLoadError(f"FFmpeg error: {errors.read().decode(errors='replace')}")
269
+ except subprocess.CalledProcessError as e:
270
+ raise AudioLoadError(f"Error running ffmpeg: {e}")
206
271
 
207
- if process.returncode != 0:
208
- raise AudioLoadError(f"FFmpeg error: {stderr.decode()}")
209
-
210
- # Read WAV data
211
- with io.BytesIO(wav_data) as wav_io:
212
- with wave.open(wav_io, "rb") as wav_file:
213
- # Get WAV metadata
214
- sample_width = wav_file.getsampwidth()
215
- channels = wav_file.getnchannels()
216
- sample_rate = wav_file.getframerate()
217
- n_frames = wav_file.getnframes()
218
-
219
- # Read raw audio data
220
- raw_data = wav_file.readframes(n_frames)
221
-
222
- # Convert bytes to numpy array based on sample width
223
- dtype_map = {1: np.int8, 2: np.int16, 4: np.int32}
224
- dtype = dtype_map.get(sample_width)
225
- if dtype is None:
226
- raise AudioLoadError(f"Unsupported sample width: {sample_width}")
227
-
228
- # Explicitly annotated: numpy>=2.5 shape-types ndarray, so the
229
- # 1-D frombuffer result cannot be rebound to a 2-D view below.
230
- data: np.ndarray[Any, np.dtype[np.float32]]
231
- data = np.frombuffer(raw_data, dtype=dtype).astype(np.float32)
232
-
233
- # Reshape to (frames, channels) if stereo
234
- if channels == 2:
235
- data = data.reshape(-1, 2)
236
-
237
- # Normalize to float between -1 and 1
238
- max_value = float(np.iinfo(dtype).max) # type: ignore
239
- data = data / max_value
240
-
241
- # Ensure normalization is within bounds due to floating point precision
242
- data = np.clip(data, -1.0, 1.0)
243
-
244
- # Calculate frame count from actual data length
245
- # For stereo, len(data) is already correct after reshape
246
- frame_count = len(data)
247
-
248
- metadata = AudioMetadata(
249
- sample_rate=sample_rate,
250
- channels=channels,
251
- sample_width=sample_width,
252
- duration_seconds=info["duration"],
253
- frame_count=frame_count,
254
- )
272
+ # A truncated final frame would otherwise make `frombuffer` raise on a file
273
+ # that is entirely usable up to that point.
274
+ frame_bytes = _PCM_SAMPLE_WIDTH * target_channels
275
+ usable = len(raw) - (len(raw) % frame_bytes)
255
276
 
256
- return cls(data, metadata)
277
+ # Explicitly annotated: numpy>=2.5 shape-types ndarray, so the
278
+ # 1-D frombuffer result cannot be rebound to a 2-D view below.
279
+ data: np.ndarray[Any, np.dtype[np.float32]]
280
+ data = np.frombuffer(memoryview(raw)[:usable], dtype=_PCM_DTYPE).astype(np.float32)
257
281
 
258
- except subprocess.CalledProcessError as e:
259
- raise AudioLoadError(f"Error running ffmpeg: {e}")
282
+ # Reshape to (frames, channels) if stereo
283
+ if target_channels == 2:
284
+ data = data.reshape(-1, 2)
285
+
286
+ # Normalize to float between -1 and 1, and clamp for floating-point
287
+ # precision. Both in place: at these sizes a copy per step is most of what
288
+ # makes decoding a long file expensive.
289
+ data /= float(np.iinfo(_PCM_DTYPE).max)
290
+ np.clip(data, -1.0, 1.0, out=data)
291
+
292
+ # Calculate frame count from actual data length
293
+ # For stereo, len(data) is already correct after reshape
294
+ metadata = AudioMetadata(
295
+ sample_rate=target_rate,
296
+ channels=target_channels,
297
+ sample_width=_PCM_SAMPLE_WIDTH,
298
+ duration_seconds=info["duration"],
299
+ frame_count=len(data),
300
+ )
301
+
302
+ return cls(data, metadata)
260
303
 
261
304
  @classmethod
262
305
  def from_file(cls, file_path: str | Path) -> Audio:
@@ -653,8 +696,6 @@ class Audio:
653
696
  filter_str = ",".join(filters) if filters else "anull"
654
697
 
655
698
  # Save current audio to temp WAV, process with ffmpeg, read back
656
- import tempfile
657
-
658
699
  with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as input_file:
659
700
  input_path = input_file.name
660
701
 
File without changes
File without changes
File without changes