ffmpeg-normalize 1.36.0__py3-none-any.whl → 1.37.0__py3-none-any.whl
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.
- ffmpeg_normalize/__init__.py +2 -0
- ffmpeg_normalize/__main__.py +12 -0
- ffmpeg_normalize/_cmd_utils.py +103 -1
- ffmpeg_normalize/_ffmpeg_normalize.py +23 -1
- {ffmpeg_normalize-1.36.0.dist-info → ffmpeg_normalize-1.37.0.dist-info}/METADATA +11 -9
- {ffmpeg_normalize-1.36.0.dist-info → ffmpeg_normalize-1.37.0.dist-info}/RECORD +9 -9
- {ffmpeg_normalize-1.36.0.dist-info → ffmpeg_normalize-1.37.0.dist-info}/WHEEL +0 -0
- {ffmpeg_normalize-1.36.0.dist-info → ffmpeg_normalize-1.37.0.dist-info}/entry_points.txt +0 -0
- {ffmpeg_normalize-1.36.0.dist-info → ffmpeg_normalize-1.37.0.dist-info}/licenses/LICENSE.md +0 -0
ffmpeg_normalize/__init__.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import importlib.metadata
|
|
2
2
|
|
|
3
|
+
from ._cmd_utils import ffmpeg_env
|
|
3
4
|
from ._errors import FFmpegNormalizeError
|
|
4
5
|
from ._ffmpeg_normalize import FFmpegNormalize
|
|
5
6
|
from ._media_file import MediaFile
|
|
@@ -17,5 +18,6 @@ __all__ = [
|
|
|
17
18
|
"VideoStream",
|
|
18
19
|
"SubtitleStream",
|
|
19
20
|
"MediaStream",
|
|
21
|
+
"ffmpeg_env",
|
|
20
22
|
"__version__",
|
|
21
23
|
]
|
ffmpeg_normalize/__main__.py
CHANGED
|
@@ -723,6 +723,18 @@ def main() -> None:
|
|
|
723
723
|
if not input_files:
|
|
724
724
|
error("No input files specified. Use positional arguments or --input-list.")
|
|
725
725
|
|
|
726
|
+
# Validate all input files upfront before processing
|
|
727
|
+
_logger.debug("Validating all input files before processing...")
|
|
728
|
+
validation_errors = FFmpegNormalize.validate_input_files(input_files)
|
|
729
|
+
if validation_errors:
|
|
730
|
+
_logger.error("Validation failed for the following files:")
|
|
731
|
+
for err in validation_errors:
|
|
732
|
+
_logger.error(f" - {err}")
|
|
733
|
+
error(
|
|
734
|
+
f"Validation failed for {len(validation_errors)} file(s). "
|
|
735
|
+
"Please fix the issues above and try again."
|
|
736
|
+
)
|
|
737
|
+
|
|
726
738
|
for index, input_file in enumerate(input_files):
|
|
727
739
|
if cli_args.output is not None and index < len(cli_args.output):
|
|
728
740
|
if cli_args.output_folder and cli_args.output_folder != "normalized":
|
ffmpeg_normalize/_cmd_utils.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextvars
|
|
3
4
|
import logging
|
|
4
5
|
import os
|
|
5
6
|
import re
|
|
6
7
|
import shlex
|
|
7
8
|
import subprocess
|
|
9
|
+
from contextlib import contextmanager
|
|
8
10
|
from shutil import which
|
|
9
11
|
from typing import Any, Iterator
|
|
10
12
|
|
|
@@ -14,6 +16,30 @@ from ._errors import FFmpegNormalizeError
|
|
|
14
16
|
|
|
15
17
|
_logger = logging.getLogger(__name__)
|
|
16
18
|
|
|
19
|
+
_ffmpeg_env_var: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar(
|
|
20
|
+
"ffmpeg_env", default=None
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def ffmpeg_env(env: dict[str, str] | None) -> Iterator[None]:
|
|
26
|
+
"""
|
|
27
|
+
Temporarily set the environment for subprocess.Popen.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
env: Environment dict to pass to subprocess.Popen.
|
|
31
|
+
"""
|
|
32
|
+
token = _ffmpeg_env_var.set(env)
|
|
33
|
+
try:
|
|
34
|
+
yield
|
|
35
|
+
finally:
|
|
36
|
+
_ffmpeg_env_var.reset(token)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_ffmpeg_env() -> dict[str, str] | None:
|
|
40
|
+
return _ffmpeg_env_var.get()
|
|
41
|
+
|
|
42
|
+
|
|
17
43
|
DUR_REGEX = re.compile(
|
|
18
44
|
r"Duration: (?P<hour>\d{2}):(?P<min>\d{2}):(?P<sec>\d{2})\.(?P<ms>\d{2})"
|
|
19
45
|
)
|
|
@@ -76,7 +102,9 @@ class CommandRunner:
|
|
|
76
102
|
# wrapper for 'ffmpeg-progress-yield'
|
|
77
103
|
_logger.debug(f"Running command: {shlex.join(cmd)}")
|
|
78
104
|
with FfmpegProgress(cmd, dry_run=self.dry) as ff:
|
|
79
|
-
yield from ff.run_command_with_progress(
|
|
105
|
+
yield from ff.run_command_with_progress(
|
|
106
|
+
popen_kwargs={"env": _get_ffmpeg_env()}
|
|
107
|
+
)
|
|
80
108
|
|
|
81
109
|
self.output = ff.stderr
|
|
82
110
|
|
|
@@ -107,6 +135,7 @@ class CommandRunner:
|
|
|
107
135
|
stdout=subprocess.PIPE,
|
|
108
136
|
stderr=subprocess.PIPE,
|
|
109
137
|
universal_newlines=False,
|
|
138
|
+
env=_get_ffmpeg_env(),
|
|
110
139
|
)
|
|
111
140
|
|
|
112
141
|
stdout_bytes, stderr_bytes = p.communicate()
|
|
@@ -190,3 +219,76 @@ def ffmpeg_has_loudnorm() -> bool:
|
|
|
190
219
|
"Please make sure you are running ffmpeg v4.2 or above."
|
|
191
220
|
)
|
|
192
221
|
return supports_loudnorm
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def validate_input_file(input_file: str) -> tuple[bool, str | None]:
|
|
225
|
+
"""
|
|
226
|
+
Validate that an input file exists, is readable, and contains audio streams.
|
|
227
|
+
|
|
228
|
+
This function performs a lightweight probe of the file using ffmpeg to check
|
|
229
|
+
if it can be read and contains at least one audio stream.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
input_file: Path to the input file to validate
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
tuple: (is_valid, error_message)
|
|
236
|
+
- is_valid: True if the file is valid, False otherwise
|
|
237
|
+
- error_message: None if valid, otherwise a descriptive error message
|
|
238
|
+
"""
|
|
239
|
+
# Check if file exists
|
|
240
|
+
if not os.path.exists(input_file):
|
|
241
|
+
return False, f"File does not exist: {input_file}"
|
|
242
|
+
|
|
243
|
+
# Check if it's actually a file (not a directory)
|
|
244
|
+
if not os.path.isfile(input_file):
|
|
245
|
+
return False, f"Path is not a file: {input_file}"
|
|
246
|
+
|
|
247
|
+
# Check if file is readable
|
|
248
|
+
if not os.access(input_file, os.R_OK):
|
|
249
|
+
return False, f"File is not readable (permission denied): {input_file}"
|
|
250
|
+
|
|
251
|
+
# Check if file has audio streams using ffmpeg probe
|
|
252
|
+
ffmpeg_exe = get_ffmpeg_exe()
|
|
253
|
+
cmd = [
|
|
254
|
+
ffmpeg_exe,
|
|
255
|
+
"-i",
|
|
256
|
+
input_file,
|
|
257
|
+
"-c",
|
|
258
|
+
"copy",
|
|
259
|
+
"-t",
|
|
260
|
+
"0",
|
|
261
|
+
"-map",
|
|
262
|
+
"0",
|
|
263
|
+
"-f",
|
|
264
|
+
"null",
|
|
265
|
+
os.devnull,
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
output = CommandRunner().run_command(cmd).get_output()
|
|
270
|
+
except RuntimeError as e:
|
|
271
|
+
error_str = str(e)
|
|
272
|
+
# Extract a cleaner error message from ffmpeg output
|
|
273
|
+
if "Invalid data found" in error_str:
|
|
274
|
+
return False, f"Invalid or corrupted media file: {input_file}"
|
|
275
|
+
if "No such file or directory" in error_str:
|
|
276
|
+
return False, f"File not found or cannot be opened: {input_file}"
|
|
277
|
+
if "Permission denied" in error_str:
|
|
278
|
+
return False, f"Permission denied when reading file: {input_file}"
|
|
279
|
+
if "does not contain any stream" in error_str:
|
|
280
|
+
return False, f"File contains no media streams: {input_file}"
|
|
281
|
+
# Generic error for other ffmpeg failures
|
|
282
|
+
return False, f"Cannot read media file: {input_file}"
|
|
283
|
+
|
|
284
|
+
# Check for audio streams in the output
|
|
285
|
+
has_audio = False
|
|
286
|
+
for line in output.split("\n"):
|
|
287
|
+
if line.strip().startswith("Stream") and "Audio" in line:
|
|
288
|
+
has_audio = True
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
if not has_audio:
|
|
292
|
+
return False, f"File does not contain any audio streams: {input_file}"
|
|
293
|
+
|
|
294
|
+
return True, None
|
|
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Literal
|
|
|
9
9
|
|
|
10
10
|
from tqdm import tqdm
|
|
11
11
|
|
|
12
|
-
from ._cmd_utils import ffmpeg_has_loudnorm, get_ffmpeg_exe
|
|
12
|
+
from ._cmd_utils import ffmpeg_has_loudnorm, get_ffmpeg_exe, validate_input_file
|
|
13
13
|
from ._errors import FFmpegNormalizeError
|
|
14
14
|
from ._media_file import MediaFile
|
|
15
15
|
|
|
@@ -317,6 +317,28 @@ class FFmpegNormalize:
|
|
|
317
317
|
self.media_files.append(MediaFile(self, input_file, output_file))
|
|
318
318
|
self.file_count += 1
|
|
319
319
|
|
|
320
|
+
@staticmethod
|
|
321
|
+
def validate_input_files(input_files: list[str]) -> list[str]:
|
|
322
|
+
"""
|
|
323
|
+
Validate all input files before processing.
|
|
324
|
+
|
|
325
|
+
This method checks that each input file exists, is readable, and contains
|
|
326
|
+
at least one audio stream. All files are validated upfront so that users
|
|
327
|
+
can fix all issues before rerunning the batch.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
input_files: List of input file paths to validate
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
list: List of error messages for invalid files. Empty if all files are valid.
|
|
334
|
+
"""
|
|
335
|
+
errors = []
|
|
336
|
+
for input_file in input_files:
|
|
337
|
+
is_valid, error_msg = validate_input_file(input_file)
|
|
338
|
+
if not is_valid and error_msg:
|
|
339
|
+
errors.append(error_msg)
|
|
340
|
+
return errors
|
|
341
|
+
|
|
320
342
|
def _calculate_batch_reference(self) -> float | None:
|
|
321
343
|
"""
|
|
322
344
|
Calculate the batch reference loudness by averaging measurements across all files.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ffmpeg-normalize
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.37.0
|
|
4
4
|
Summary: Normalize audio via ffmpeg
|
|
5
5
|
Keywords: ffmpeg,normalize,audio
|
|
6
6
|
Author: Werner Robitza
|
|
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|

|
|
38
38
|
|
|
39
39
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
|
40
|
-
[](#contributors-)
|
|
41
41
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
|
42
42
|
|
|
43
43
|
A utility for batch-normalizing audio using ffmpeg.
|
|
@@ -54,7 +54,14 @@ This program normalizes media files to a certain loudness level using the EBU R1
|
|
|
54
54
|
- Docker support — Run via Docker container
|
|
55
55
|
- Python API — Use programmatically in your Python projects
|
|
56
56
|
- Shell completions — Available for bash, zsh, and fish
|
|
57
|
-
- Album Batch normalization – Process files
|
|
57
|
+
- Album Batch normalization – Process files jointly, preserving relative loudness
|
|
58
|
+
|
|
59
|
+
## 🚀 Quick Start
|
|
60
|
+
|
|
61
|
+
1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html)
|
|
62
|
+
2. Run `pip3 install ffmpeg-normalize` and `ffmpeg-normalize /path/to/your/file.mp4`, alternatively install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) and run `uvx ffmpeg-normalize /path/to/your/file.mp4`
|
|
63
|
+
3. Done! 🎧 (the normalized file will be called `normalized/file.mkv`)
|
|
64
|
+
|
|
58
65
|
|
|
59
66
|
## 🆕 What's New
|
|
60
67
|
|
|
@@ -99,12 +106,6 @@ Other recent additions:
|
|
|
99
106
|
|
|
100
107
|
See the [full changelog](https://github.com/slhck/ffmpeg-normalize/blob/master/CHANGELOG.md) for all updates.
|
|
101
108
|
|
|
102
|
-
## 🚀 Quick Start
|
|
103
|
-
|
|
104
|
-
1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html)
|
|
105
|
-
2. Run `pip3 install ffmpeg-normalize` and `ffmpeg-normalize /path/to/your/file.mp4`, alternatively install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) and run `uvx ffmpeg-normalize /path/to/your/file.mp4`
|
|
106
|
-
3. Done! 🎧 (the normalized file will be called `normalized/file.mkv`)
|
|
107
|
-
|
|
108
109
|
## 📓 Documentation
|
|
109
110
|
|
|
110
111
|
Check out our [documentation](https://slhck.info/ffmpeg-normalize/) for more info!
|
|
@@ -147,6 +148,7 @@ The only reason this project exists in its current form is because [@benjaoming]
|
|
|
147
148
|
</tr>
|
|
148
149
|
<tr>
|
|
149
150
|
<td align="center" valign="top" width="14.28%"><a href="https://davidbern.com/"><img src="https://avatars.githubusercontent.com/u/371066?v=4?s=100" width="100px;" alt="David Bern"/><br /><sub><b>David Bern</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=odie5533" title="Code">💻</a></td>
|
|
151
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/randompersona1"><img src="https://avatars.githubusercontent.com/u/74961116?v=4?s=100" width="100px;" alt="randompersona1"/><br /><sub><b>randompersona1</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=randompersona1" title="Code">💻</a></td>
|
|
150
152
|
</tr>
|
|
151
153
|
</tbody>
|
|
152
154
|
<tfoot>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
ffmpeg_normalize/__init__.py,sha256=
|
|
2
|
-
ffmpeg_normalize/__main__.py,sha256=
|
|
3
|
-
ffmpeg_normalize/_cmd_utils.py,sha256=
|
|
1
|
+
ffmpeg_normalize/__init__.py,sha256=FXJzAZSSg7CTNyicBcY7cOZVnhBsMET80SLdwvzvsY8,561
|
|
2
|
+
ffmpeg_normalize/__main__.py,sha256=U77hAgn7_w2QErUEocf6tal-7v-lJR7GbUQW8L-FmC0,26357
|
|
3
|
+
ffmpeg_normalize/_cmd_utils.py,sha256=A4cFo7sUccV_2OL2yHjZmocvVOj7nf5fiD_6Yw9gSPk,8450
|
|
4
4
|
ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
|
|
5
|
-
ffmpeg_normalize/_ffmpeg_normalize.py,sha256=
|
|
5
|
+
ffmpeg_normalize/_ffmpeg_normalize.py,sha256=tcO_nJvsNiucsCJJcMESYsb8GbThcyhniIl21JmxNn0,22190
|
|
6
6
|
ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
|
|
7
7
|
ffmpeg_normalize/_media_file.py,sha256=awznS5C8ph6Mjy5dzwT-ubBpB3MXwbO94QFcn7mBejY,32197
|
|
8
8
|
ffmpeg_normalize/_presets.py,sha256=AffqFzAHvy0GxCuJl_qd3xknncPJUQx7OXbSfYAw2xg,8852
|
|
@@ -11,8 +11,8 @@ ffmpeg_normalize/data/presets/music.json,sha256=rKtIKAD7jlMvnGRsx6QAyPHvrGu-Ecq4
|
|
|
11
11
|
ffmpeg_normalize/data/presets/podcast.json,sha256=spMBZ_tbfU81NF6o7ZUMyQmQ7kPz3jJ1GKH-hcRmt4s,132
|
|
12
12
|
ffmpeg_normalize/data/presets/streaming-video.json,sha256=Uy4QR0kSla4vXEqctZj26-8f_eS4SQxQWQ9zI_x5QBw,132
|
|
13
13
|
ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
ffmpeg_normalize-1.
|
|
15
|
-
ffmpeg_normalize-1.
|
|
16
|
-
ffmpeg_normalize-1.
|
|
17
|
-
ffmpeg_normalize-1.
|
|
18
|
-
ffmpeg_normalize-1.
|
|
14
|
+
ffmpeg_normalize-1.37.0.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
|
|
15
|
+
ffmpeg_normalize-1.37.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
16
|
+
ffmpeg_normalize-1.37.0.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
|
|
17
|
+
ffmpeg_normalize-1.37.0.dist-info/METADATA,sha256=SlCoUapWTSJjvsLBcVgxKagK2nHdqfEtaMjbfo-ZT-o,14296
|
|
18
|
+
ffmpeg_normalize-1.37.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|