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.
@@ -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,2 @@
1
+ class FFmpegNormalizeError(Exception):
2
+ pass
@@ -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)