ffmpeg-normalize 1.32.5__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 +18 -0
- ffmpeg_normalize/__main__.py +640 -0
- ffmpeg_normalize/_cmd_utils.py +192 -0
- ffmpeg_normalize/_errors.py +2 -0
- ffmpeg_normalize/_ffmpeg_normalize.py +285 -0
- ffmpeg_normalize/_logger.py +72 -0
- ffmpeg_normalize/_media_file.py +665 -0
- ffmpeg_normalize/_streams.py +594 -0
- ffmpeg_normalize/_version.py +1 -0
- ffmpeg_normalize/py.typed +0 -0
- ffmpeg_normalize-1.32.5.dist-info/METADATA +1467 -0
- ffmpeg_normalize-1.32.5.dist-info/RECORD +16 -0
- ffmpeg_normalize-1.32.5.dist-info/WHEEL +5 -0
- ffmpeg_normalize-1.32.5.dist-info/entry_points.txt +2 -0
- ffmpeg_normalize-1.32.5.dist-info/licenses/LICENSE +21 -0
- ffmpeg_normalize-1.32.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from shutil import which
|
|
9
|
+
from typing import Any, Iterator
|
|
10
|
+
|
|
11
|
+
from ffmpeg_progress_yield import FfmpegProgress
|
|
12
|
+
|
|
13
|
+
from ._errors import FFmpegNormalizeError
|
|
14
|
+
|
|
15
|
+
_logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
DUR_REGEX = re.compile(
|
|
18
|
+
r"Duration: (?P<hour>\d{2}):(?P<min>\d{2}):(?P<sec>\d{2})\.(?P<ms>\d{2})"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommandRunner:
|
|
23
|
+
"""
|
|
24
|
+
Wrapper for running ffmpeg commands
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, dry: bool = False):
|
|
28
|
+
"""Create a CommandRunner object
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cmd: Command to run as a list of strings
|
|
32
|
+
dry: Dry run mode. Defaults to False.
|
|
33
|
+
"""
|
|
34
|
+
self.dry = dry
|
|
35
|
+
self.output: str | None = None
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def prune_ffmpeg_progress_from_output(output: str) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Prune ffmpeg progress lines from output
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
output (str): Output from ffmpeg
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
str: Output with progress lines removed
|
|
47
|
+
"""
|
|
48
|
+
return "\n".join(
|
|
49
|
+
[
|
|
50
|
+
line
|
|
51
|
+
for line in output.splitlines()
|
|
52
|
+
if not any(
|
|
53
|
+
key in line
|
|
54
|
+
for key in [
|
|
55
|
+
"bitrate=",
|
|
56
|
+
"total_size=",
|
|
57
|
+
"out_time_us=",
|
|
58
|
+
"out_time_ms=",
|
|
59
|
+
"out_time=",
|
|
60
|
+
"dup_frames=",
|
|
61
|
+
"drop_frames=",
|
|
62
|
+
"speed=",
|
|
63
|
+
"progress=",
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def run_ffmpeg_command(self, cmd: list[str]) -> Iterator[float]:
|
|
70
|
+
"""
|
|
71
|
+
Run an ffmpeg command
|
|
72
|
+
|
|
73
|
+
Yields:
|
|
74
|
+
float: Progress percentage
|
|
75
|
+
"""
|
|
76
|
+
# wrapper for 'ffmpeg-progress-yield'
|
|
77
|
+
_logger.debug(f"Running command: {shlex.join(cmd)}")
|
|
78
|
+
with FfmpegProgress(cmd, dry_run=self.dry) as ff:
|
|
79
|
+
yield from ff.run_command_with_progress()
|
|
80
|
+
|
|
81
|
+
self.output = ff.stderr
|
|
82
|
+
|
|
83
|
+
if _logger.getEffectiveLevel() == logging.DEBUG and self.output is not None:
|
|
84
|
+
_logger.debug(
|
|
85
|
+
f"ffmpeg output: {CommandRunner.prune_ffmpeg_progress_from_output(self.output)}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def run_command(self, cmd: list[str]) -> CommandRunner:
|
|
89
|
+
"""
|
|
90
|
+
Run a command with subprocess
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
CommandRunner: itself
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
RuntimeError: If command returns non-zero exit code
|
|
97
|
+
"""
|
|
98
|
+
_logger.debug(f"Running command: {shlex.join(cmd)}")
|
|
99
|
+
|
|
100
|
+
if self.dry:
|
|
101
|
+
_logger.debug("Dry mode specified, not actually running command")
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
p = subprocess.Popen(
|
|
105
|
+
cmd,
|
|
106
|
+
stdin=subprocess.PIPE, # Apply stdin isolation by creating separate pipe.
|
|
107
|
+
stdout=subprocess.PIPE,
|
|
108
|
+
stderr=subprocess.PIPE,
|
|
109
|
+
universal_newlines=False,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
stdout_bytes, stderr_bytes = p.communicate()
|
|
113
|
+
|
|
114
|
+
stdout = stdout_bytes.decode("utf8", errors="replace")
|
|
115
|
+
stderr = stderr_bytes.decode("utf8", errors="replace")
|
|
116
|
+
|
|
117
|
+
if p.returncode != 0:
|
|
118
|
+
raise RuntimeError(f"Error running command {shlex.join(cmd)}: {stderr}")
|
|
119
|
+
|
|
120
|
+
self.output = stdout + stderr
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def get_output(self) -> str:
|
|
124
|
+
if self.output is None:
|
|
125
|
+
raise FFmpegNormalizeError("Command has not been run yet")
|
|
126
|
+
return self.output
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def dict_to_filter_opts(opts: dict[str, Any]) -> str:
|
|
130
|
+
"""
|
|
131
|
+
Convert a dictionary to a ffmpeg filter option string
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
opts (dict[str, Any]): Dictionary of options
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
str: Filter option string
|
|
138
|
+
"""
|
|
139
|
+
filter_opts = []
|
|
140
|
+
for k, v in opts.items():
|
|
141
|
+
filter_opts.append(f"{k}={v}")
|
|
142
|
+
return ":".join(filter_opts)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_ffmpeg_exe() -> str:
|
|
146
|
+
"""
|
|
147
|
+
Return path to ffmpeg executable
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
str: Path to ffmpeg executable
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
FFmpegNormalizeError: If ffmpeg is not found
|
|
154
|
+
"""
|
|
155
|
+
if ff_path := os.getenv("FFMPEG_PATH"):
|
|
156
|
+
if os.sep in ff_path:
|
|
157
|
+
if not os.path.isfile(ff_path):
|
|
158
|
+
raise FFmpegNormalizeError(f"No file exists at {ff_path}")
|
|
159
|
+
|
|
160
|
+
return ff_path
|
|
161
|
+
|
|
162
|
+
ff_exe = which(ff_path)
|
|
163
|
+
if not ff_exe:
|
|
164
|
+
raise FFmpegNormalizeError(f"Could not find '{ff_path}' in your $PATH.")
|
|
165
|
+
|
|
166
|
+
return ff_exe
|
|
167
|
+
|
|
168
|
+
ff_path = which("ffmpeg")
|
|
169
|
+
if not ff_path:
|
|
170
|
+
raise FFmpegNormalizeError(
|
|
171
|
+
"Could not find ffmpeg in your $PATH or $FFMPEG_PATH. "
|
|
172
|
+
"Please install ffmpeg from http://ffmpeg.org"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return ff_path
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def ffmpeg_has_loudnorm() -> bool:
|
|
179
|
+
"""
|
|
180
|
+
Run feature detection on ffmpeg to see if it supports the loudnorm filter.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
bool: True if loudnorm is supported, False otherwise
|
|
184
|
+
"""
|
|
185
|
+
output = CommandRunner().run_command([get_ffmpeg_exe(), "-filters"]).get_output()
|
|
186
|
+
supports_loudnorm = "loudnorm" in output
|
|
187
|
+
if not supports_loudnorm:
|
|
188
|
+
_logger.error(
|
|
189
|
+
"Your ffmpeg does not support the 'loudnorm' filter. "
|
|
190
|
+
"Please make sure you are running ffmpeg v4.2 or above."
|
|
191
|
+
)
|
|
192
|
+
return supports_loudnorm
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from itertools import chain
|
|
8
|
+
from typing import TYPE_CHECKING, Literal
|
|
9
|
+
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
|
|
12
|
+
from ._cmd_utils import ffmpeg_has_loudnorm, get_ffmpeg_exe
|
|
13
|
+
from ._errors import FFmpegNormalizeError
|
|
14
|
+
from ._media_file import MediaFile
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ._streams import LoudnessStatisticsWithMetadata
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
NORMALIZATION_TYPES = ("ebu", "rms", "peak")
|
|
22
|
+
PCM_INCOMPATIBLE_FORMATS = {"flac", "mp3", "mp4", "ogg", "oga", "opus", "webm"}
|
|
23
|
+
PCM_INCOMPATIBLE_EXTS = {"flac", "mp3", "mp4", "m4a", "ogg", "oga", "opus", "webm"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_range(number: object, min_r: float, max_r: float, name: str = "") -> float:
|
|
27
|
+
"""
|
|
28
|
+
Checks if "number" is an int or float and is between min_r (inclusive)
|
|
29
|
+
and max_r (inclusive).
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
number (object): Number to check
|
|
33
|
+
min_r (float): Minimum range
|
|
34
|
+
max_r (float): Maximum range
|
|
35
|
+
name (str): Name of object being checked
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
float: within given range
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
FFmpegNormalizeError: If number is wrong type or not within range
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(number, (float, int)):
|
|
44
|
+
raise FFmpegNormalizeError(f"{name} must be an int or float")
|
|
45
|
+
if number < min_r or number > max_r:
|
|
46
|
+
raise FFmpegNormalizeError(f"{name} must be within [{min_r},{max_r}]")
|
|
47
|
+
return number
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FFmpegNormalize:
|
|
51
|
+
"""
|
|
52
|
+
ffmpeg-normalize class.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
normalization_type (str, optional): Normalization type. Defaults to "ebu".
|
|
56
|
+
target_level (float, optional): Target level. Defaults to -23.0.
|
|
57
|
+
print_stats (bool, optional): Print loudnorm stats. Defaults to False.
|
|
58
|
+
loudness_range_target (float, optional): Loudness range target. Defaults to 7.0.
|
|
59
|
+
keep_loudness_range_target (bool, optional): Keep loudness range target. Defaults to False.
|
|
60
|
+
keep_lra_above_loudness_range_target (bool, optional): Keep input loudness range above loudness range target. Defaults to False.
|
|
61
|
+
true_peak (float, optional): True peak. Defaults to -2.0.
|
|
62
|
+
offset (float, optional): Offset. Defaults to 0.0.
|
|
63
|
+
lower_only (bool, optional): Whether the audio should not increase in loudness. Defaults to False.
|
|
64
|
+
auto_lower_loudness_target (bool, optional): Automatically lower EBU Integrated Loudness Target.
|
|
65
|
+
dual_mono (bool, optional): Dual mono. Defaults to False.
|
|
66
|
+
dynamic (bool, optional): Use dynamic EBU R128 normalization. This is a one-pass algorithm and skips the initial media scan. Defaults to False.
|
|
67
|
+
audio_codec (str, optional): Audio codec. Defaults to "pcm_s16le".
|
|
68
|
+
audio_bitrate (float, optional): Audio bitrate. Defaults to None.
|
|
69
|
+
sample_rate (int, optional): Sample rate. Defaults to None.
|
|
70
|
+
audio_channels (int | None, optional): Audio channels. Defaults to None.
|
|
71
|
+
keep_original_audio (bool, optional): Keep original audio. Defaults to False.
|
|
72
|
+
pre_filter (str, optional): Pre filter. Defaults to None.
|
|
73
|
+
post_filter (str, optional): Post filter. Defaults to None.
|
|
74
|
+
video_codec (str, optional): Video codec. Defaults to "copy".
|
|
75
|
+
video_disable (bool, optional): Disable video. Defaults to False.
|
|
76
|
+
subtitle_disable (bool, optional): Disable subtitles. Defaults to False.
|
|
77
|
+
metadata_disable (bool, optional): Disable metadata. Defaults to False.
|
|
78
|
+
chapters_disable (bool, optional): Disable chapters. Defaults to False.
|
|
79
|
+
extra_input_options (list, optional): Extra input options. Defaults to None.
|
|
80
|
+
extra_output_options (list, optional): Extra output options. Defaults to None.
|
|
81
|
+
output_format (str, optional): Output format. Defaults to None.
|
|
82
|
+
extension (str, optional): Output file extension to use for output files that were not explicitly specified. Defaults to "mkv".
|
|
83
|
+
dry_run (bool, optional): Dry run. Defaults to False.
|
|
84
|
+
debug (bool, optional): Debug. Defaults to False.
|
|
85
|
+
progress (bool, optional): Progress. Defaults to False.
|
|
86
|
+
replaygain (bool, optional): Write ReplayGain tags without normalizing. Defaults to False.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
normalization_type: Literal["ebu", "rms", "peak"] = "ebu",
|
|
95
|
+
target_level: float = -23.0,
|
|
96
|
+
print_stats: bool = False,
|
|
97
|
+
# threshold=0.5,
|
|
98
|
+
loudness_range_target: float = 7.0,
|
|
99
|
+
keep_loudness_range_target: bool = False,
|
|
100
|
+
keep_lra_above_loudness_range_target: bool = False,
|
|
101
|
+
true_peak: float = -2.0,
|
|
102
|
+
offset: float = 0.0,
|
|
103
|
+
lower_only: bool = False,
|
|
104
|
+
auto_lower_loudness_target: bool = False,
|
|
105
|
+
dual_mono: bool = False,
|
|
106
|
+
dynamic: bool = False,
|
|
107
|
+
audio_codec: str = "pcm_s16le",
|
|
108
|
+
audio_bitrate: float | None = None,
|
|
109
|
+
sample_rate: float | int | None = None,
|
|
110
|
+
audio_channels: int | None = None,
|
|
111
|
+
keep_original_audio: bool = False,
|
|
112
|
+
pre_filter: str | None = None,
|
|
113
|
+
post_filter: str | None = None,
|
|
114
|
+
video_codec: str = "copy",
|
|
115
|
+
video_disable: bool = False,
|
|
116
|
+
subtitle_disable: bool = False,
|
|
117
|
+
metadata_disable: bool = False,
|
|
118
|
+
chapters_disable: bool = False,
|
|
119
|
+
extra_input_options: list[str] | None = None,
|
|
120
|
+
extra_output_options: list[str] | None = None,
|
|
121
|
+
output_format: str | None = None,
|
|
122
|
+
extension: str = "mkv",
|
|
123
|
+
dry_run: bool = False,
|
|
124
|
+
debug: bool = False,
|
|
125
|
+
progress: bool = False,
|
|
126
|
+
replaygain: bool = False,
|
|
127
|
+
):
|
|
128
|
+
self.ffmpeg_exe = get_ffmpeg_exe()
|
|
129
|
+
self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
|
|
130
|
+
|
|
131
|
+
if normalization_type not in NORMALIZATION_TYPES:
|
|
132
|
+
raise FFmpegNormalizeError(
|
|
133
|
+
"Normalization type must be: 'ebu', 'rms', or 'peak'"
|
|
134
|
+
)
|
|
135
|
+
self.normalization_type = normalization_type
|
|
136
|
+
|
|
137
|
+
if not self.has_loudnorm_capabilities and self.normalization_type == "ebu":
|
|
138
|
+
raise FFmpegNormalizeError(
|
|
139
|
+
"Your ffmpeg does not support the 'loudnorm' EBU R128 filter. "
|
|
140
|
+
"Please install ffmpeg v4.2 or above, or choose another normalization type."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if self.normalization_type == "ebu":
|
|
144
|
+
self.target_level = check_range(target_level, -70, -5, name="target_level")
|
|
145
|
+
else:
|
|
146
|
+
self.target_level = check_range(target_level, -99, 0, name="target_level")
|
|
147
|
+
|
|
148
|
+
self.print_stats = print_stats
|
|
149
|
+
|
|
150
|
+
# self.threshold = float(threshold)
|
|
151
|
+
|
|
152
|
+
self.loudness_range_target = check_range(
|
|
153
|
+
loudness_range_target, 1, 50, name="loudness_range_target"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
self.keep_loudness_range_target = keep_loudness_range_target
|
|
157
|
+
|
|
158
|
+
if self.keep_loudness_range_target and loudness_range_target != 7.0:
|
|
159
|
+
_logger.warning(
|
|
160
|
+
"Setting --keep-loudness-range-target will override your set loudness range target value! "
|
|
161
|
+
"Remove --keep-loudness-range-target or remove the --lrt/--loudness-range-target option."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
self.keep_lra_above_loudness_range_target = keep_lra_above_loudness_range_target
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
self.keep_loudness_range_target
|
|
168
|
+
and self.keep_lra_above_loudness_range_target
|
|
169
|
+
):
|
|
170
|
+
raise FFmpegNormalizeError(
|
|
171
|
+
"Options --keep-loudness-range-target and --keep-lra-above-loudness-range-target are mutually exclusive! "
|
|
172
|
+
"Please choose just one of the two options."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
self.true_peak = check_range(true_peak, -9, 0, name="true_peak")
|
|
176
|
+
self.offset = check_range(offset, -99, 99, name="offset")
|
|
177
|
+
self.lower_only = lower_only
|
|
178
|
+
self.auto_lower_loudness_target = auto_lower_loudness_target
|
|
179
|
+
|
|
180
|
+
# Ensure library user is passing correct types
|
|
181
|
+
assert isinstance(dual_mono, bool), "dual_mono must be bool"
|
|
182
|
+
assert isinstance(dynamic, bool), "dynamic must be bool"
|
|
183
|
+
|
|
184
|
+
self.dual_mono = dual_mono
|
|
185
|
+
self.dynamic = dynamic
|
|
186
|
+
self.sample_rate = None if sample_rate is None else int(sample_rate)
|
|
187
|
+
self.audio_channels = None if audio_channels is None else int(audio_channels)
|
|
188
|
+
|
|
189
|
+
self.audio_codec = audio_codec
|
|
190
|
+
self.audio_bitrate = audio_bitrate
|
|
191
|
+
self.keep_original_audio = keep_original_audio
|
|
192
|
+
self.video_codec = video_codec
|
|
193
|
+
self.video_disable = video_disable
|
|
194
|
+
self.subtitle_disable = subtitle_disable
|
|
195
|
+
self.metadata_disable = metadata_disable
|
|
196
|
+
self.chapters_disable = chapters_disable
|
|
197
|
+
|
|
198
|
+
self.extra_input_options = extra_input_options
|
|
199
|
+
self.extra_output_options = extra_output_options
|
|
200
|
+
self.pre_filter = pre_filter
|
|
201
|
+
self.post_filter = post_filter
|
|
202
|
+
|
|
203
|
+
self.output_format = output_format
|
|
204
|
+
self.extension = extension
|
|
205
|
+
self.dry_run = dry_run
|
|
206
|
+
self.debug = debug
|
|
207
|
+
self.progress = progress
|
|
208
|
+
self.replaygain = replaygain
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
self.audio_codec is None or "pcm" in self.audio_codec
|
|
212
|
+
) and self.output_format in PCM_INCOMPATIBLE_FORMATS:
|
|
213
|
+
raise FFmpegNormalizeError(
|
|
214
|
+
f"Output format {self.output_format} does not support PCM audio. "
|
|
215
|
+
"Please choose a suitable audio codec with the -c:a option."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# replaygain only works for EBU for now
|
|
219
|
+
if self.replaygain and self.normalization_type != "ebu":
|
|
220
|
+
raise FFmpegNormalizeError(
|
|
221
|
+
"ReplayGain only works for EBU normalization type for now."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
self.stats: list[LoudnessStatisticsWithMetadata] = []
|
|
225
|
+
self.media_files: list[MediaFile] = []
|
|
226
|
+
self.file_count = 0
|
|
227
|
+
|
|
228
|
+
def add_media_file(self, input_file: str, output_file: str) -> None:
|
|
229
|
+
"""
|
|
230
|
+
Add a media file to normalize
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
input_file (str): Path to input file
|
|
234
|
+
output_file (str): Path to output file
|
|
235
|
+
"""
|
|
236
|
+
if not os.path.exists(input_file):
|
|
237
|
+
raise FFmpegNormalizeError(f"file {input_file} does not exist")
|
|
238
|
+
|
|
239
|
+
ext = os.path.splitext(output_file)[1][1:]
|
|
240
|
+
if (
|
|
241
|
+
self.audio_codec is None or "pcm" in self.audio_codec
|
|
242
|
+
) and ext in PCM_INCOMPATIBLE_EXTS:
|
|
243
|
+
raise FFmpegNormalizeError(
|
|
244
|
+
f"Output extension {ext} does not support PCM audio. "
|
|
245
|
+
"Please choose a suitable audio codec with the -c:a option."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
self.media_files.append(MediaFile(self, input_file, output_file))
|
|
249
|
+
self.file_count += 1
|
|
250
|
+
|
|
251
|
+
def run_normalization(self) -> None:
|
|
252
|
+
"""
|
|
253
|
+
Run the normalization procedures
|
|
254
|
+
"""
|
|
255
|
+
for index, media_file in enumerate(
|
|
256
|
+
tqdm(self.media_files, desc="File", disable=not self.progress, position=0)
|
|
257
|
+
):
|
|
258
|
+
_logger.info(
|
|
259
|
+
f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
media_file.run_normalization()
|
|
264
|
+
except Exception as e:
|
|
265
|
+
if len(self.media_files) > 1:
|
|
266
|
+
# simply warn and do not die
|
|
267
|
+
_logger.error(
|
|
268
|
+
f"Error processing input file {media_file}, will "
|
|
269
|
+
f"continue batch-processing. Error was: {e}"
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
# raise the error so the program will exit
|
|
273
|
+
raise e
|
|
274
|
+
|
|
275
|
+
if self.print_stats:
|
|
276
|
+
json.dump(
|
|
277
|
+
list(
|
|
278
|
+
chain.from_iterable(
|
|
279
|
+
media_file.get_stats() for media_file in self.media_files
|
|
280
|
+
)
|
|
281
|
+
),
|
|
282
|
+
sys.stdout,
|
|
283
|
+
indent=4,
|
|
284
|
+
)
|
|
285
|
+
print()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import colorlog
|
|
8
|
+
from tqdm import tqdm
|
|
9
|
+
|
|
10
|
+
from ffmpeg_normalize import __module_name__ as LOGGER_NAME
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# https://stackoverflow.com/questions/38543506/
|
|
14
|
+
class TqdmLoggingHandler(logging.StreamHandler):
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
super().__init__(sys.stderr)
|
|
17
|
+
|
|
18
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
19
|
+
try:
|
|
20
|
+
msg = self.format(record)
|
|
21
|
+
set_mp_lock()
|
|
22
|
+
tqdm.write(msg, file=sys.stderr)
|
|
23
|
+
self.flush()
|
|
24
|
+
except (KeyboardInterrupt, SystemExit):
|
|
25
|
+
raise
|
|
26
|
+
except Exception:
|
|
27
|
+
self.handleError(record)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_mp_lock() -> None:
|
|
31
|
+
try:
|
|
32
|
+
from multiprocessing import Lock
|
|
33
|
+
|
|
34
|
+
tqdm.set_lock(Lock())
|
|
35
|
+
except (ImportError, OSError):
|
|
36
|
+
# Some python environments do not support multiprocessing
|
|
37
|
+
# See: https://github.com/slhck/ffmpeg-normalize/issues/156
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def setup_cli_logger(arguments: argparse.Namespace) -> None:
|
|
42
|
+
"""Configurs the CLI logger.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
arguments (argparse.Namespace): The CLI arguments.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
logger = colorlog.getLogger(LOGGER_NAME)
|
|
49
|
+
|
|
50
|
+
handler = TqdmLoggingHandler()
|
|
51
|
+
handler.setFormatter(
|
|
52
|
+
colorlog.ColoredFormatter(
|
|
53
|
+
"%(log_color)s%(levelname)s: %(message)s",
|
|
54
|
+
log_colors={
|
|
55
|
+
"DEBUG": "cyan",
|
|
56
|
+
"INFO": "green",
|
|
57
|
+
"WARNING": "yellow",
|
|
58
|
+
"ERROR": "red",
|
|
59
|
+
"CRITICAL": "red,bg_white",
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
logger.addHandler(handler)
|
|
64
|
+
|
|
65
|
+
logger.setLevel(logging.WARNING)
|
|
66
|
+
|
|
67
|
+
if arguments.quiet:
|
|
68
|
+
logger.setLevel(logging.ERROR)
|
|
69
|
+
elif arguments.debug:
|
|
70
|
+
logger.setLevel(logging.DEBUG)
|
|
71
|
+
elif arguments.verbose:
|
|
72
|
+
logger.setLevel(logging.INFO)
|