ffmpeg-normalize 1.33.4__py3-none-any.whl → 1.35.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/__main__.py +72 -0
- ffmpeg_normalize/_ffmpeg_normalize.py +187 -17
- ffmpeg_normalize/_media_file.py +156 -30
- ffmpeg_normalize/_streams.py +49 -2
- {ffmpeg_normalize-1.33.4.dist-info → ffmpeg_normalize-1.35.0.dist-info}/METADATA +44 -8
- ffmpeg_normalize-1.35.0.dist-info/RECORD +14 -0
- {ffmpeg_normalize-1.33.4.dist-info → ffmpeg_normalize-1.35.0.dist-info}/WHEEL +1 -1
- ffmpeg_normalize-1.33.4.dist-info/RECORD +0 -14
- {ffmpeg_normalize-1.33.4.dist-info → ffmpeg_normalize-1.35.0.dist-info}/entry_points.txt +0 -0
- {ffmpeg_normalize-1.33.4.dist-info → ffmpeg_normalize-1.35.0.dist-info}/licenses/LICENSE.md +0 -0
ffmpeg_normalize/__main__.py
CHANGED
|
@@ -179,6 +179,24 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
179
179
|
"""
|
|
180
180
|
),
|
|
181
181
|
)
|
|
182
|
+
group_normalization.add_argument(
|
|
183
|
+
"--batch",
|
|
184
|
+
action="store_true",
|
|
185
|
+
help=textwrap.dedent(
|
|
186
|
+
"""\
|
|
187
|
+
Preserve relative loudness between files (album mode).
|
|
188
|
+
|
|
189
|
+
When operating on a group of unrelated files, you usually want all of them at the same
|
|
190
|
+
level. However, a group of music files all from the same album is generally meant to be
|
|
191
|
+
listened to at the relative volumes they were recorded at. In batch mode, all the specified
|
|
192
|
+
files are considered to be part of a single album and their relative volumes are preserved.
|
|
193
|
+
This is done by averaging the loudness of all the files, computing a single adjustment from
|
|
194
|
+
that, and applying a relative adjustment to all the files.
|
|
195
|
+
|
|
196
|
+
Batch mode works with all normalization types (EBU, RMS, peak).
|
|
197
|
+
"""
|
|
198
|
+
),
|
|
199
|
+
)
|
|
182
200
|
|
|
183
201
|
# group_normalization.add_argument(
|
|
184
202
|
# '--threshold',
|
|
@@ -315,6 +333,44 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
315
333
|
),
|
|
316
334
|
)
|
|
317
335
|
|
|
336
|
+
group_stream_selection = parser.add_argument_group("Audio Stream Selection")
|
|
337
|
+
group_stream_selection.add_argument(
|
|
338
|
+
"-as",
|
|
339
|
+
"--audio-streams",
|
|
340
|
+
type=str,
|
|
341
|
+
help=textwrap.dedent(
|
|
342
|
+
"""\
|
|
343
|
+
Select specific audio streams to normalize by stream index (comma-separated).
|
|
344
|
+
Example: --audio-streams 0,2 will normalize only streams 0 and 2.
|
|
345
|
+
|
|
346
|
+
By default, all audio streams are normalized.
|
|
347
|
+
"""
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
group_stream_selection.add_argument(
|
|
351
|
+
"--audio-default-only",
|
|
352
|
+
action="store_true",
|
|
353
|
+
help=textwrap.dedent(
|
|
354
|
+
"""\
|
|
355
|
+
Only normalize audio streams with the 'default' disposition flag.
|
|
356
|
+
This is useful for files with multiple audio tracks where only the main track
|
|
357
|
+
should be normalized (e.g., keeping commentary tracks unchanged).
|
|
358
|
+
"""
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
group_stream_selection.add_argument(
|
|
362
|
+
"--keep-other-audio",
|
|
363
|
+
action="store_true",
|
|
364
|
+
help=textwrap.dedent(
|
|
365
|
+
"""\
|
|
366
|
+
Keep non-selected audio streams in the output file (copy without normalization).
|
|
367
|
+
Only applies when --audio-streams or --audio-default-only is used.
|
|
368
|
+
|
|
369
|
+
By default, only selected streams are included in the output.
|
|
370
|
+
"""
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
|
|
318
374
|
group_acodec = parser.add_argument_group("Audio Encoding")
|
|
319
375
|
group_acodec.add_argument(
|
|
320
376
|
"-c:a",
|
|
@@ -553,6 +609,18 @@ def main() -> None:
|
|
|
553
609
|
extra_input_options = _split_options(cli_args.extra_input_options)
|
|
554
610
|
extra_output_options = _split_options(cli_args.extra_output_options)
|
|
555
611
|
|
|
612
|
+
# parse audio streams selection
|
|
613
|
+
audio_streams = None
|
|
614
|
+
if cli_args.audio_streams:
|
|
615
|
+
try:
|
|
616
|
+
audio_streams = [int(s.strip()) for s in cli_args.audio_streams.split(",")]
|
|
617
|
+
except ValueError:
|
|
618
|
+
error("Invalid audio stream indices. Must be comma-separated integers.")
|
|
619
|
+
|
|
620
|
+
# validate stream selection options
|
|
621
|
+
if cli_args.audio_default_only and cli_args.audio_streams:
|
|
622
|
+
error("Cannot use both --audio-default-only and --audio-streams together.")
|
|
623
|
+
|
|
556
624
|
ffmpeg_normalize = FFmpegNormalize(
|
|
557
625
|
normalization_type=cli_args.normalization_type,
|
|
558
626
|
target_level=cli_args.target_level,
|
|
@@ -586,6 +654,10 @@ def main() -> None:
|
|
|
586
654
|
dry_run=cli_args.dry_run,
|
|
587
655
|
progress=cli_args.progress,
|
|
588
656
|
replaygain=cli_args.replaygain,
|
|
657
|
+
batch=cli_args.batch,
|
|
658
|
+
audio_streams=audio_streams,
|
|
659
|
+
audio_default_only=cli_args.audio_default_only,
|
|
660
|
+
keep_other_audio=cli_args.keep_other_audio,
|
|
589
661
|
)
|
|
590
662
|
|
|
591
663
|
if cli_args.output and len(cli_args.input) > len(cli_args.output):
|
|
@@ -84,6 +84,10 @@ class FFmpegNormalize:
|
|
|
84
84
|
debug (bool, optional): Debug. Defaults to False.
|
|
85
85
|
progress (bool, optional): Progress. Defaults to False.
|
|
86
86
|
replaygain (bool, optional): Write ReplayGain tags without normalizing. Defaults to False.
|
|
87
|
+
batch (bool, optional): Preserve relative loudness between files (album mode). Defaults to False.
|
|
88
|
+
audio_streams (list[int] | None, optional): List of audio stream indices to normalize. Defaults to None (all streams).
|
|
89
|
+
audio_default_only (bool, optional): Only normalize audio streams with default disposition. Defaults to False.
|
|
90
|
+
keep_other_audio (bool, optional): Keep non-selected audio streams in output (copy without normalization). Defaults to False.
|
|
87
91
|
|
|
88
92
|
Raises:
|
|
89
93
|
FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
|
|
@@ -124,6 +128,10 @@ class FFmpegNormalize:
|
|
|
124
128
|
debug: bool = False,
|
|
125
129
|
progress: bool = False,
|
|
126
130
|
replaygain: bool = False,
|
|
131
|
+
batch: bool = False,
|
|
132
|
+
audio_streams: list[int] | None = None,
|
|
133
|
+
audio_default_only: bool = False,
|
|
134
|
+
keep_other_audio: bool = False,
|
|
127
135
|
):
|
|
128
136
|
self.ffmpeg_exe = get_ffmpeg_exe()
|
|
129
137
|
self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
|
|
@@ -206,6 +214,12 @@ class FFmpegNormalize:
|
|
|
206
214
|
self.debug = debug
|
|
207
215
|
self.progress = progress
|
|
208
216
|
self.replaygain = replaygain
|
|
217
|
+
self.batch = batch
|
|
218
|
+
|
|
219
|
+
# Stream selection options
|
|
220
|
+
self.audio_streams = audio_streams
|
|
221
|
+
self.audio_default_only = audio_default_only
|
|
222
|
+
self.keep_other_audio = keep_other_audio
|
|
209
223
|
|
|
210
224
|
if (
|
|
211
225
|
self.audio_codec is None or "pcm" in self.audio_codec
|
|
@@ -221,6 +235,19 @@ class FFmpegNormalize:
|
|
|
221
235
|
"ReplayGain only works for EBU normalization type for now."
|
|
222
236
|
)
|
|
223
237
|
|
|
238
|
+
# Validate stream selection options
|
|
239
|
+
if self.audio_streams is not None and self.audio_default_only:
|
|
240
|
+
raise FFmpegNormalizeError(
|
|
241
|
+
"Cannot use both audio_streams and audio_default_only together."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if self.keep_other_audio and self.keep_original_audio:
|
|
245
|
+
raise FFmpegNormalizeError(
|
|
246
|
+
"Cannot use both --keep-other-audio and --keep-original-audio together. "
|
|
247
|
+
"Use --keep-original-audio to keep all original streams alongside normalized ones, "
|
|
248
|
+
"or --keep-other-audio to keep only non-selected streams as passthrough."
|
|
249
|
+
)
|
|
250
|
+
|
|
224
251
|
self.stats: list[LoudnessStatisticsWithMetadata] = []
|
|
225
252
|
self.media_files: list[MediaFile] = []
|
|
226
253
|
self.file_count = 0
|
|
@@ -248,29 +275,172 @@ class FFmpegNormalize:
|
|
|
248
275
|
self.media_files.append(MediaFile(self, input_file, output_file))
|
|
249
276
|
self.file_count += 1
|
|
250
277
|
|
|
278
|
+
def _calculate_batch_reference(self) -> float | None:
|
|
279
|
+
"""
|
|
280
|
+
Calculate the batch reference loudness by averaging measurements across all files.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
float | None: The batch reference loudness value, or None if no measurements found.
|
|
284
|
+
|
|
285
|
+
Note:
|
|
286
|
+
TODO: Add option to specify different averaging methods (duration-weighted,
|
|
287
|
+
use quietest/loudest track, etc.)
|
|
288
|
+
"""
|
|
289
|
+
measurements: list[float] = []
|
|
290
|
+
|
|
291
|
+
for media_file in self.media_files:
|
|
292
|
+
# Access audio streams from the streams dict
|
|
293
|
+
audio_streams = media_file.streams.get("audio", {})
|
|
294
|
+
for stream in audio_streams.values():
|
|
295
|
+
if self.normalization_type == "ebu":
|
|
296
|
+
# Get EBU integrated loudness from first pass
|
|
297
|
+
ebu_stats = stream.loudness_statistics.get("ebu_pass1")
|
|
298
|
+
if ebu_stats and "input_i" in ebu_stats:
|
|
299
|
+
measurements.append(float(ebu_stats["input_i"]))
|
|
300
|
+
elif self.normalization_type == "rms":
|
|
301
|
+
# Get RMS mean value
|
|
302
|
+
mean = stream.loudness_statistics.get("mean")
|
|
303
|
+
if mean is not None:
|
|
304
|
+
measurements.append(float(mean))
|
|
305
|
+
elif self.normalization_type == "peak":
|
|
306
|
+
# Get peak max value
|
|
307
|
+
max_val = stream.loudness_statistics.get("max")
|
|
308
|
+
if max_val is not None:
|
|
309
|
+
measurements.append(float(max_val))
|
|
310
|
+
|
|
311
|
+
if not measurements:
|
|
312
|
+
_logger.warning(
|
|
313
|
+
"No loudness measurements found for batch reference calculation. "
|
|
314
|
+
"Batch mode will not be applied."
|
|
315
|
+
)
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
# Simple average of all measurements
|
|
319
|
+
batch_reference = sum(measurements) / len(measurements)
|
|
320
|
+
_logger.debug(f"Batch mode: Measurements for batch reference: {measurements}")
|
|
321
|
+
_logger.info(
|
|
322
|
+
f"Batch mode: Calculated reference loudness = {batch_reference:.2f} "
|
|
323
|
+
f"({self.normalization_type.upper()}, averaged from {len(measurements)} stream(s))"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return batch_reference
|
|
327
|
+
|
|
251
328
|
def run_normalization(self) -> None:
|
|
252
329
|
"""
|
|
253
|
-
Run the normalization procedures
|
|
330
|
+
Run the normalization procedures.
|
|
331
|
+
|
|
332
|
+
In batch mode, all files are analyzed first (first pass), then a batch reference
|
|
333
|
+
loudness is calculated, and finally all files are normalized (second pass) with
|
|
334
|
+
adjustments relative to the batch reference to preserve relative loudness.
|
|
335
|
+
|
|
336
|
+
In non-batch mode, each file is processed completely (both passes) before
|
|
337
|
+
moving to the next file.
|
|
254
338
|
"""
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
):
|
|
339
|
+
if self.batch:
|
|
340
|
+
# Batch mode: analyze all files first, then normalize with relative adjustments
|
|
258
341
|
_logger.info(
|
|
259
|
-
f"
|
|
342
|
+
f"Batch mode enabled: processing {self.file_count} file(s) while preserving relative loudness"
|
|
260
343
|
)
|
|
261
344
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
345
|
+
# Recommend RMS/Peak for album normalization instead of EBU
|
|
346
|
+
if self.normalization_type == "ebu":
|
|
347
|
+
_logger.warning(
|
|
348
|
+
"Using EBU R128 normalization with --batch. For true album normalization where "
|
|
349
|
+
"all tracks are shifted by the same amount, consider using --normalization-type rms "
|
|
350
|
+
"or --normalization-type peak instead. EBU normalization applies different processing "
|
|
351
|
+
"to each track based on its loudness characteristics, which may alter relative levels "
|
|
352
|
+
"slightly due to psychoacoustic adjustments."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Warn if using dynamic EBU mode with batch
|
|
356
|
+
if self.dynamic and self.normalization_type == "ebu":
|
|
357
|
+
_logger.warning(
|
|
358
|
+
"ffmpeg uses dynamic EBU normalization. This may change relative "
|
|
359
|
+
"loudness within a file. Use linear mode for true album normalization, or "
|
|
360
|
+
"switch to --normalization-type peak or --normalization-type rms instead. "
|
|
361
|
+
"To force linear mode, use --keep-lra-above-loudness-range-target or "
|
|
362
|
+
"--keep-loudness-range-target."
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Phase 1: Run first pass on all files to collect measurements
|
|
366
|
+
_logger.info("Phase 1: Analyzing all files...")
|
|
367
|
+
for index, media_file in enumerate(
|
|
368
|
+
tqdm(
|
|
369
|
+
self.media_files,
|
|
370
|
+
desc="Analysis",
|
|
371
|
+
disable=not self.progress,
|
|
372
|
+
position=0,
|
|
373
|
+
)
|
|
374
|
+
):
|
|
375
|
+
_logger.info(
|
|
376
|
+
f"Analyzing file {media_file} ({index + 1} of {self.file_count})"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
# Only run first pass if not in dynamic EBU mode
|
|
381
|
+
if not (self.dynamic and self.normalization_type == "ebu"):
|
|
382
|
+
media_file._first_pass()
|
|
383
|
+
else:
|
|
384
|
+
_logger.debug(
|
|
385
|
+
"Dynamic EBU mode: First pass skipped for this file."
|
|
386
|
+
)
|
|
387
|
+
except Exception as e:
|
|
388
|
+
if len(self.media_files) > 1:
|
|
389
|
+
_logger.error(
|
|
390
|
+
f"Error analyzing input file {media_file}, will "
|
|
391
|
+
f"continue batch-processing. Error was: {e}"
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
raise e
|
|
395
|
+
|
|
396
|
+
# Phase 2: Calculate batch reference loudness
|
|
397
|
+
batch_reference = self._calculate_batch_reference()
|
|
398
|
+
|
|
399
|
+
# Phase 3: Run second pass on all files with batch reference
|
|
400
|
+
_logger.info("Phase 2: Normalizing all files...")
|
|
401
|
+
for index, media_file in enumerate(
|
|
402
|
+
tqdm(
|
|
403
|
+
self.media_files,
|
|
404
|
+
desc="Normalization",
|
|
405
|
+
disable=not self.progress,
|
|
406
|
+
position=0,
|
|
407
|
+
)
|
|
408
|
+
):
|
|
409
|
+
_logger.info(
|
|
410
|
+
f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
media_file.run_normalization(batch_reference=batch_reference)
|
|
415
|
+
except Exception as e:
|
|
416
|
+
if len(self.media_files) > 1:
|
|
417
|
+
_logger.error(
|
|
418
|
+
f"Error processing input file {media_file}, will "
|
|
419
|
+
f"continue batch-processing. Error was: {e}"
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
raise e
|
|
423
|
+
else:
|
|
424
|
+
# Non-batch mode: process each file completely before moving to the next
|
|
425
|
+
for index, media_file in enumerate(
|
|
426
|
+
tqdm(
|
|
427
|
+
self.media_files, desc="File", disable=not self.progress, position=0
|
|
428
|
+
)
|
|
429
|
+
):
|
|
430
|
+
_logger.info(
|
|
431
|
+
f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
media_file.run_normalization()
|
|
436
|
+
except Exception as e:
|
|
437
|
+
if len(self.media_files) > 1:
|
|
438
|
+
_logger.error(
|
|
439
|
+
f"Error processing input file {media_file}, will "
|
|
440
|
+
f"continue batch-processing. Error was: {e}"
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
raise e
|
|
274
444
|
|
|
275
445
|
if self.print_stats:
|
|
276
446
|
json.dump(
|
ffmpeg_normalize/_media_file.py
CHANGED
|
@@ -86,6 +86,7 @@ class MediaFile:
|
|
|
86
86
|
self.output_ext = current_ext
|
|
87
87
|
self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
|
|
88
88
|
self.temp_file: Union[str, None] = None
|
|
89
|
+
self.batch_reference: float | None = None
|
|
89
90
|
|
|
90
91
|
self.parse_streams()
|
|
91
92
|
|
|
@@ -136,6 +137,18 @@ class MediaFile:
|
|
|
136
137
|
|
|
137
138
|
output_lines = [line.strip() for line in output.split("\n")]
|
|
138
139
|
|
|
140
|
+
# First pass: parse disposition flags for each stream
|
|
141
|
+
stream_dispositions: dict[int, bool] = {}
|
|
142
|
+
|
|
143
|
+
for line in output_lines:
|
|
144
|
+
if line.startswith("Stream"):
|
|
145
|
+
if stream_id_match := re.search(r"#0:([\d]+)", line):
|
|
146
|
+
stream_id = int(stream_id_match.group(1))
|
|
147
|
+
# Check if (default) appears on the Stream line
|
|
148
|
+
is_default = "(default)" in line
|
|
149
|
+
stream_dispositions[stream_id] = is_default
|
|
150
|
+
|
|
151
|
+
# Second pass: parse stream information
|
|
139
152
|
duration = None
|
|
140
153
|
for line in output_lines:
|
|
141
154
|
if "Duration" in line:
|
|
@@ -155,8 +168,12 @@ class MediaFile:
|
|
|
155
168
|
else:
|
|
156
169
|
continue
|
|
157
170
|
|
|
171
|
+
is_default = stream_dispositions.get(stream_id, False)
|
|
172
|
+
|
|
158
173
|
if "Audio" in line:
|
|
159
|
-
_logger.debug(
|
|
174
|
+
_logger.debug(
|
|
175
|
+
f"Found audio stream at index {stream_id} (default: {is_default})"
|
|
176
|
+
)
|
|
160
177
|
sample_rate_match = re.search(r"(\d+) Hz", line)
|
|
161
178
|
sample_rate = (
|
|
162
179
|
int(sample_rate_match.group(1)) if sample_rate_match else None
|
|
@@ -170,6 +187,7 @@ class MediaFile:
|
|
|
170
187
|
sample_rate,
|
|
171
188
|
bit_depth,
|
|
172
189
|
duration,
|
|
190
|
+
is_default,
|
|
173
191
|
)
|
|
174
192
|
|
|
175
193
|
elif "Video" in line:
|
|
@@ -201,21 +219,82 @@ class MediaFile:
|
|
|
201
219
|
self.streams["video"] = {}
|
|
202
220
|
self.streams["subtitle"] = {}
|
|
203
221
|
|
|
204
|
-
def
|
|
222
|
+
def _get_streams_to_normalize(self) -> list[AudioStream]:
|
|
223
|
+
"""
|
|
224
|
+
Determine which audio streams to normalize based on configuration.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
list[AudioStream]: List of audio streams to normalize
|
|
228
|
+
"""
|
|
229
|
+
all_audio_streams = list(self.streams["audio"].values())
|
|
230
|
+
|
|
231
|
+
if self.ffmpeg_normalize.audio_streams is not None:
|
|
232
|
+
# User specified specific stream indices
|
|
233
|
+
selected_streams = [
|
|
234
|
+
stream
|
|
235
|
+
for stream in all_audio_streams
|
|
236
|
+
if stream.stream_id in self.ffmpeg_normalize.audio_streams
|
|
237
|
+
]
|
|
238
|
+
if not selected_streams:
|
|
239
|
+
_logger.warning(
|
|
240
|
+
f"No audio streams found matching indices {self.ffmpeg_normalize.audio_streams}. "
|
|
241
|
+
f"Available streams: {[s.stream_id for s in all_audio_streams]}"
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
_logger.info(
|
|
245
|
+
f"Normalizing selected audio streams: {[s.stream_id for s in selected_streams]}"
|
|
246
|
+
)
|
|
247
|
+
return selected_streams
|
|
248
|
+
|
|
249
|
+
elif self.ffmpeg_normalize.audio_default_only:
|
|
250
|
+
# Only normalize streams with default disposition
|
|
251
|
+
default_streams = [
|
|
252
|
+
stream for stream in all_audio_streams if stream.is_default
|
|
253
|
+
]
|
|
254
|
+
if not default_streams:
|
|
255
|
+
_logger.warning(
|
|
256
|
+
"No audio streams with 'default' disposition found. "
|
|
257
|
+
f"Available streams: {[s.stream_id for s in all_audio_streams]}"
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
_logger.info(
|
|
261
|
+
f"Normalizing default audio streams: {[s.stream_id for s in default_streams]}"
|
|
262
|
+
)
|
|
263
|
+
return default_streams
|
|
264
|
+
|
|
265
|
+
else:
|
|
266
|
+
# Normalize all streams (default behavior)
|
|
267
|
+
return all_audio_streams
|
|
268
|
+
|
|
269
|
+
def run_normalization(self, batch_reference: float | None = None) -> None:
|
|
205
270
|
"""
|
|
206
271
|
Run the normalization process for this file.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
batch_reference (float | None, optional): Reference loudness for batch mode.
|
|
275
|
+
If provided, the first pass is skipped (assumed already done) and this
|
|
276
|
+
reference is used to calculate relative adjustments. Defaults to None.
|
|
207
277
|
"""
|
|
208
278
|
_logger.debug(f"Running normalization for {self.input_file}")
|
|
209
279
|
|
|
210
|
-
#
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
)
|
|
215
|
-
|
|
280
|
+
# Store batch reference for use in second pass
|
|
281
|
+
self.batch_reference = batch_reference
|
|
282
|
+
|
|
283
|
+
# run the first pass to get loudness stats, unless in dynamic EBU mode or batch mode
|
|
284
|
+
# (in batch mode, first pass is already done in FFmpegNormalize.run_normalization)
|
|
285
|
+
if batch_reference is None:
|
|
286
|
+
if not (
|
|
287
|
+
self.ffmpeg_normalize.dynamic
|
|
288
|
+
and self.ffmpeg_normalize.normalization_type == "ebu"
|
|
289
|
+
):
|
|
290
|
+
self._first_pass()
|
|
291
|
+
else:
|
|
292
|
+
_logger.debug(
|
|
293
|
+
"Dynamic EBU mode: First pass will not run, as it is not needed."
|
|
294
|
+
)
|
|
216
295
|
else:
|
|
217
296
|
_logger.debug(
|
|
218
|
-
"
|
|
297
|
+
f"Batch mode: Skipping first pass (already completed), using batch reference = {batch_reference:.2f}"
|
|
219
298
|
)
|
|
220
299
|
|
|
221
300
|
# for second pass, create a temp file
|
|
@@ -400,7 +479,9 @@ class MediaFile:
|
|
|
400
479
|
"""
|
|
401
480
|
_logger.debug(f"Parsing normalization info for {self.input_file}")
|
|
402
481
|
|
|
403
|
-
|
|
482
|
+
streams_to_normalize = self._get_streams_to_normalize()
|
|
483
|
+
|
|
484
|
+
for index, audio_stream in enumerate(streams_to_normalize):
|
|
404
485
|
if self.ffmpeg_normalize.normalization_type == "ebu":
|
|
405
486
|
fun = getattr(audio_stream, "parse_loudnorm_stats")
|
|
406
487
|
else:
|
|
@@ -410,7 +491,7 @@ class MediaFile:
|
|
|
410
491
|
with tqdm(
|
|
411
492
|
total=100,
|
|
412
493
|
position=1,
|
|
413
|
-
desc=f"Stream {index + 1}/{len(
|
|
494
|
+
desc=f"Stream {index + 1}/{len(streams_to_normalize)}",
|
|
414
495
|
bar_format=TQDM_BAR_FORMAT,
|
|
415
496
|
) as pbar:
|
|
416
497
|
for progress in fun():
|
|
@@ -429,7 +510,9 @@ class MediaFile:
|
|
|
429
510
|
filter_chains = []
|
|
430
511
|
output_labels = []
|
|
431
512
|
|
|
432
|
-
|
|
513
|
+
streams_to_normalize = self._get_streams_to_normalize()
|
|
514
|
+
|
|
515
|
+
for audio_stream in streams_to_normalize:
|
|
433
516
|
skip_normalization = False
|
|
434
517
|
if self.ffmpeg_normalize.lower_only:
|
|
435
518
|
if self.ffmpeg_normalize.normalization_type == "ebu":
|
|
@@ -461,9 +544,13 @@ class MediaFile:
|
|
|
461
544
|
normalization_filter = "acopy"
|
|
462
545
|
else:
|
|
463
546
|
if self.ffmpeg_normalize.normalization_type == "ebu":
|
|
464
|
-
normalization_filter = audio_stream.get_second_pass_opts_ebu(
|
|
547
|
+
normalization_filter = audio_stream.get_second_pass_opts_ebu(
|
|
548
|
+
batch_reference=self.batch_reference
|
|
549
|
+
)
|
|
465
550
|
else:
|
|
466
|
-
normalization_filter = audio_stream.get_second_pass_opts_peakrms(
|
|
551
|
+
normalization_filter = audio_stream.get_second_pass_opts_peakrms(
|
|
552
|
+
batch_reference=self.batch_reference
|
|
553
|
+
)
|
|
467
554
|
|
|
468
555
|
input_label = f"[0:{audio_stream.stream_id}]"
|
|
469
556
|
output_label = f"[norm{audio_stream.stream_id}]"
|
|
@@ -551,29 +638,66 @@ class MediaFile:
|
|
|
551
638
|
f"The chosen output extension {self.output_ext} does not support video/cover art. It will be disabled."
|
|
552
639
|
)
|
|
553
640
|
|
|
641
|
+
# Determine streams to normalize and passthrough
|
|
642
|
+
streams_to_normalize = self._get_streams_to_normalize()
|
|
643
|
+
all_audio_streams = list(self.streams["audio"].values())
|
|
644
|
+
|
|
645
|
+
# Determine which streams to passthrough
|
|
646
|
+
if self.ffmpeg_normalize.keep_other_audio and (
|
|
647
|
+
self.ffmpeg_normalize.audio_streams is not None
|
|
648
|
+
or self.ffmpeg_normalize.audio_default_only
|
|
649
|
+
):
|
|
650
|
+
streams_to_passthrough = [
|
|
651
|
+
s for s in all_audio_streams if s not in streams_to_normalize
|
|
652
|
+
]
|
|
653
|
+
else:
|
|
654
|
+
streams_to_passthrough = []
|
|
655
|
+
|
|
554
656
|
# ... and map the output of the normalization filters
|
|
555
657
|
for ol in output_labels:
|
|
556
658
|
cmd.extend(["-map", ol])
|
|
557
659
|
|
|
558
|
-
#
|
|
559
|
-
|
|
560
|
-
cmd.extend(["-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
660
|
+
# ... and map passthrough audio streams (copy without normalization)
|
|
661
|
+
for stream in streams_to_passthrough:
|
|
662
|
+
cmd.extend(["-map", f"0:{stream.stream_id}"])
|
|
663
|
+
|
|
664
|
+
# Track output audio stream index for codec assignment
|
|
665
|
+
output_audio_idx = 0
|
|
564
666
|
|
|
565
|
-
#
|
|
667
|
+
# set audio codec for normalized streams
|
|
668
|
+
for audio_stream in streams_to_normalize:
|
|
669
|
+
if self.ffmpeg_normalize.audio_codec:
|
|
670
|
+
codec = self.ffmpeg_normalize.audio_codec
|
|
671
|
+
else:
|
|
672
|
+
codec = audio_stream.get_pcm_codec()
|
|
673
|
+
cmd.extend([f"-c:a:{output_audio_idx}", codec])
|
|
674
|
+
output_audio_idx += 1
|
|
675
|
+
|
|
676
|
+
# set audio codec for passthrough streams (always copy)
|
|
677
|
+
for _ in streams_to_passthrough:
|
|
678
|
+
cmd.extend([f"-c:a:{output_audio_idx}", "copy"])
|
|
679
|
+
output_audio_idx += 1
|
|
680
|
+
|
|
681
|
+
# other audio options (if any) - only apply to normalized streams
|
|
566
682
|
if self.ffmpeg_normalize.audio_bitrate:
|
|
567
683
|
if self.ffmpeg_normalize.audio_codec == "libvorbis":
|
|
568
684
|
# libvorbis takes just a "-b" option, for some reason
|
|
569
685
|
# https://github.com/slhck/ffmpeg-normalize/issues/277
|
|
570
686
|
cmd.extend(["-b", str(self.ffmpeg_normalize.audio_bitrate)])
|
|
571
687
|
else:
|
|
572
|
-
|
|
688
|
+
# Only apply to normalized streams
|
|
689
|
+
for idx in range(len(streams_to_normalize)):
|
|
690
|
+
cmd.extend(
|
|
691
|
+
[f"-b:a:{idx}", str(self.ffmpeg_normalize.audio_bitrate)]
|
|
692
|
+
)
|
|
573
693
|
if self.ffmpeg_normalize.sample_rate:
|
|
574
|
-
|
|
694
|
+
# Only apply to normalized streams
|
|
695
|
+
for idx in range(len(streams_to_normalize)):
|
|
696
|
+
cmd.extend([f"-ar:a:{idx}", str(self.ffmpeg_normalize.sample_rate)])
|
|
575
697
|
if self.ffmpeg_normalize.audio_channels:
|
|
576
|
-
|
|
698
|
+
# Only apply to normalized streams
|
|
699
|
+
for idx in range(len(streams_to_normalize)):
|
|
700
|
+
cmd.extend([f"-ac:a:{idx}", str(self.ffmpeg_normalize.audio_channels)])
|
|
577
701
|
|
|
578
702
|
# ... and subtitles
|
|
579
703
|
if not self.ffmpeg_normalize.subtitle_disable:
|
|
@@ -583,10 +707,11 @@ class MediaFile:
|
|
|
583
707
|
cmd.extend(["-c:s", "copy"])
|
|
584
708
|
|
|
585
709
|
if self.ffmpeg_normalize.keep_original_audio:
|
|
586
|
-
|
|
710
|
+
# Map all original audio streams after normalized and passthrough streams
|
|
587
711
|
for index, _ in enumerate(self.streams["audio"].items()):
|
|
588
712
|
cmd.extend(["-map", f"0:a:{index}"])
|
|
589
|
-
cmd.extend([f"-c:a:{
|
|
713
|
+
cmd.extend([f"-c:a:{output_audio_idx}", "copy"])
|
|
714
|
+
output_audio_idx += 1
|
|
590
715
|
|
|
591
716
|
# extra options (if any)
|
|
592
717
|
if self.ffmpeg_normalize.extra_output_options:
|
|
@@ -645,13 +770,14 @@ class MediaFile:
|
|
|
645
770
|
ebu_pass_2_stats = list(
|
|
646
771
|
AudioStream.prune_and_parse_loudnorm_output(output).values()
|
|
647
772
|
)
|
|
648
|
-
# Only set second pass stats
|
|
649
|
-
|
|
650
|
-
|
|
773
|
+
# Only set second pass stats for streams that were actually normalized
|
|
774
|
+
streams_to_normalize = self._get_streams_to_normalize()
|
|
775
|
+
if len(ebu_pass_2_stats) == len(streams_to_normalize):
|
|
776
|
+
for idx, audio_stream in enumerate(streams_to_normalize):
|
|
651
777
|
audio_stream.set_second_pass_stats(ebu_pass_2_stats[idx])
|
|
652
778
|
else:
|
|
653
779
|
_logger.debug(
|
|
654
|
-
f"Expected {len(
|
|
780
|
+
f"Expected {len(streams_to_normalize)} EBU pass 2 statistics but got {len(ebu_pass_2_stats)}. "
|
|
655
781
|
"This can happen when normalization is skipped (e.g., with --lower-only)."
|
|
656
782
|
)
|
|
657
783
|
|
ffmpeg_normalize/_streams.py
CHANGED
|
@@ -99,6 +99,7 @@ class AudioStream(MediaStream):
|
|
|
99
99
|
sample_rate: int | None,
|
|
100
100
|
bit_depth: int | None,
|
|
101
101
|
duration: float | None,
|
|
102
|
+
is_default: bool = False,
|
|
102
103
|
):
|
|
103
104
|
"""
|
|
104
105
|
Create an AudioStream object.
|
|
@@ -110,6 +111,7 @@ class AudioStream(MediaStream):
|
|
|
110
111
|
sample_rate (int): sample rate in Hz
|
|
111
112
|
bit_depth (int): bit depth in bits
|
|
112
113
|
duration (float): duration in seconds
|
|
114
|
+
is_default (bool): Whether this stream has the default disposition flag
|
|
113
115
|
"""
|
|
114
116
|
super().__init__(ffmpeg_normalize, media_file, "audio", stream_id)
|
|
115
117
|
|
|
@@ -124,6 +126,7 @@ class AudioStream(MediaStream):
|
|
|
124
126
|
self.bit_depth = bit_depth
|
|
125
127
|
|
|
126
128
|
self.duration = duration
|
|
129
|
+
self.is_default = is_default
|
|
127
130
|
|
|
128
131
|
@staticmethod
|
|
129
132
|
def _constrain(
|
|
@@ -441,9 +444,14 @@ class AudioStream(MediaStream):
|
|
|
441
444
|
)
|
|
442
445
|
return result
|
|
443
446
|
|
|
444
|
-
def get_second_pass_opts_ebu(self) -> str:
|
|
447
|
+
def get_second_pass_opts_ebu(self, batch_reference: float | None = None) -> str:
|
|
445
448
|
"""
|
|
446
449
|
Return second pass loudnorm filter options string for ffmpeg
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
batch_reference (float | None, optional): Reference loudness for batch mode.
|
|
453
|
+
When provided, the target level is adjusted to preserve relative loudness.
|
|
454
|
+
Defaults to None.
|
|
447
455
|
"""
|
|
448
456
|
|
|
449
457
|
# In dynamic mode, we can do everything in one pass, and we do not have first pass stats
|
|
@@ -549,6 +557,19 @@ class AudioStream(MediaStream):
|
|
|
549
557
|
|
|
550
558
|
stats = self.loudness_statistics["ebu_pass1"]
|
|
551
559
|
|
|
560
|
+
# Adjust target level for batch mode to preserve relative loudness
|
|
561
|
+
if batch_reference is not None:
|
|
562
|
+
input_i = float(stats["input_i"])
|
|
563
|
+
# Formula: adjusted_target = target_level + (input_i - batch_reference)
|
|
564
|
+
# If track is quieter than average (input_i < batch_ref), offset is negative → quieter target
|
|
565
|
+
# If track is louder than average (input_i > batch_ref), offset is positive → louder target
|
|
566
|
+
adjusted_target = target_level + (input_i - batch_reference)
|
|
567
|
+
_logger.info(
|
|
568
|
+
f"Batch mode: Adjusting target from {target_level:.2f} to {adjusted_target:.2f} LUFS "
|
|
569
|
+
f"(input_i={input_i:.2f}, batch_ref={batch_reference:.2f}, offset={input_i - batch_reference:.2f})"
|
|
570
|
+
)
|
|
571
|
+
target_level = adjusted_target
|
|
572
|
+
|
|
552
573
|
opts = {
|
|
553
574
|
"i": target_level,
|
|
554
575
|
"lra": self.media_file.ffmpeg_normalize.loudness_range_target,
|
|
@@ -573,11 +594,16 @@ class AudioStream(MediaStream):
|
|
|
573
594
|
|
|
574
595
|
return "loudnorm=" + dict_to_filter_opts(opts)
|
|
575
596
|
|
|
576
|
-
def get_second_pass_opts_peakrms(self) -> str:
|
|
597
|
+
def get_second_pass_opts_peakrms(self, batch_reference: float | None = None) -> str:
|
|
577
598
|
"""
|
|
578
599
|
Set the adjustment gain based on chosen option and mean/max volume,
|
|
579
600
|
return the matching ffmpeg volume filter.
|
|
580
601
|
|
|
602
|
+
Args:
|
|
603
|
+
batch_reference (float | None, optional): Reference loudness for batch mode.
|
|
604
|
+
When provided, the target level is adjusted to preserve relative loudness.
|
|
605
|
+
Defaults to None.
|
|
606
|
+
|
|
581
607
|
Returns:
|
|
582
608
|
str: ffmpeg volume filter string
|
|
583
609
|
"""
|
|
@@ -592,6 +618,27 @@ class AudioStream(MediaStream):
|
|
|
592
618
|
normalization_type = self.media_file.ffmpeg_normalize.normalization_type
|
|
593
619
|
target_level = self.media_file.ffmpeg_normalize.target_level
|
|
594
620
|
|
|
621
|
+
# Adjust target level for batch mode to preserve relative loudness
|
|
622
|
+
if batch_reference is not None:
|
|
623
|
+
if normalization_type == "peak":
|
|
624
|
+
measured_level = float(self.loudness_statistics["max"])
|
|
625
|
+
elif normalization_type == "rms":
|
|
626
|
+
measured_level = float(self.loudness_statistics["mean"])
|
|
627
|
+
else:
|
|
628
|
+
raise FFmpegNormalizeError(
|
|
629
|
+
"Can only set adjustment for peak and RMS normalization"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Formula: adjusted_target = target_level + (measured_level - batch_reference)
|
|
633
|
+
# If track is quieter than average (measured < batch_ref), offset is negative → quieter target
|
|
634
|
+
# If track is louder than average (measured > batch_ref), offset is positive → louder target
|
|
635
|
+
adjusted_target = target_level + (measured_level - batch_reference)
|
|
636
|
+
_logger.info(
|
|
637
|
+
f"Batch mode: Adjusting target from {target_level:.2f} to {adjusted_target:.2f} dB "
|
|
638
|
+
f"(measured={measured_level:.2f}, batch_ref={batch_reference:.2f}, offset={measured_level - batch_reference:.2f})"
|
|
639
|
+
)
|
|
640
|
+
target_level = adjusted_target
|
|
641
|
+
|
|
595
642
|
if normalization_type == "peak":
|
|
596
643
|
adjustment = 0 + target_level - self.loudness_statistics["max"]
|
|
597
644
|
elif normalization_type == "rms":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ffmpeg-normalize
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.35.0
|
|
4
4
|
Summary: Normalize audio via ffmpeg
|
|
5
5
|
Keywords: ffmpeg,normalize,audio
|
|
6
6
|
Author: Werner Robitza
|
|
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
|
|
|
33
33
|
# ffmpeg-normalize
|
|
34
34
|
|
|
35
35
|
[](https://pypi.org/project/ffmpeg-normalize)
|
|
36
|
-

|
|
36
|
+
[](https://hub.docker.com/r/slhck/ffmpeg-normalize)
|
|
37
37
|

|
|
38
38
|
|
|
39
39
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
|
@@ -46,12 +46,48 @@ This program normalizes media files to a certain loudness level using the EBU R1
|
|
|
46
46
|
|
|
47
47
|
## ✨ Features
|
|
48
48
|
|
|
49
|
-
- EBU R128 loudness normalization
|
|
50
|
-
- RMS-based normalization
|
|
51
|
-
- Peak normalization
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
49
|
+
- EBU R128 loudness normalization — Two-pass by default, with an option for one-pass dynamic normalization
|
|
50
|
+
- RMS-based normalization — Adjust audio to a specific RMS level
|
|
51
|
+
- Peak normalization — Adjust audio to a specific peak level
|
|
52
|
+
- Selective audio stream normalization — Normalize specific audio streams or only default streams
|
|
53
|
+
- Video file support — Process video files while preserving video streams
|
|
54
|
+
- Docker support — Run via Docker container
|
|
55
|
+
- Python API — Use programmatically in your Python projects
|
|
56
|
+
- Shell completions — Available for bash, zsh, and fish
|
|
57
|
+
- Album Batch normalization – Process files jointy, preserving relative loudness
|
|
58
|
+
|
|
59
|
+
## 🆕 What's New
|
|
60
|
+
|
|
61
|
+
- Version 1.35.0 has **batch/album normalization** with `--batch`. It preserves relative loudness between files! Perfect for music albums where you want to shift all tracks by the same amount.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ffmpeg-normalize album/*.flac --batch -nt rms -t -20
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
shifts the entire album so the average RMS is -20 dB, preserving the original relative loudness as mastered.
|
|
70
|
+
|
|
71
|
+
- Version 1.34.0 brings **selective audio stream normalization**! You can now:
|
|
72
|
+
|
|
73
|
+
- Normalize specific audio streams with `-as/--audio-streams` (e.g., `-as 1,2` to normalize only streams 1 and 2)
|
|
74
|
+
- Normalize only default audio streams with `--audio-default-only` (useful for files with multiple language tracks)
|
|
75
|
+
- Keep other streams unchanged with `--keep-other-audio` (copy non-selected streams without normalization)
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
ffmpeg-normalize input.mkv -as 1 --keep-other-audio
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
normalizes stream 1 and copies all other audio streams unchanged.
|
|
84
|
+
|
|
85
|
+
Other recent additions:
|
|
86
|
+
|
|
87
|
+
- **Shell completions** (v1.31.0) — Tab completion for bash, zsh, and fish shells. See the [installation guide](https://slhck.info/ffmpeg-normalize/getting-started/installation/#shell-completions) for setup instructions.
|
|
88
|
+
- **`--lower-only` option** — Prevent audio from increasing in loudness, only lower it if needed (works with all normalization types).
|
|
89
|
+
|
|
90
|
+
See the [full changelog](https://github.com/slhck/ffmpeg-normalize/blob/master/CHANGELOG.md) for all updates.
|
|
55
91
|
|
|
56
92
|
## 🚀 Quick Start
|
|
57
93
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
ffmpeg_normalize/__init__.py,sha256=l0arjiMMBNbiH3IH67gT6SdZjPGAVLAdorUx38dNtvE,508
|
|
2
|
+
ffmpeg_normalize/__main__.py,sha256=ooM9MZRI4BfyF2G8eioY5KcumZ3jELRBkGNNchmF_ck,23788
|
|
3
|
+
ffmpeg_normalize/_cmd_utils.py,sha256=1JspVpguAPsq7DqvyvjUNzHhVv8J3X93xNOMwito_jY,5284
|
|
4
|
+
ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
|
|
5
|
+
ffmpeg_normalize/_ffmpeg_normalize.py,sha256=NvFhiiYHLJz8M78WGEIit4Qbe-XsjLlrXvzKuNMW7v8,19973
|
|
6
|
+
ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
|
|
7
|
+
ffmpeg_normalize/_media_file.py,sha256=awznS5C8ph6Mjy5dzwT-ubBpB3MXwbO94QFcn7mBejY,32197
|
|
8
|
+
ffmpeg_normalize/_streams.py,sha256=4Dnzuunhqz2qsOhlDv0dKML-lLjmPmUmM7M4dpn66Ow,24910
|
|
9
|
+
ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
ffmpeg_normalize-1.35.0.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
|
|
11
|
+
ffmpeg_normalize-1.35.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
12
|
+
ffmpeg_normalize-1.35.0.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
|
|
13
|
+
ffmpeg_normalize-1.35.0.dist-info/METADATA,sha256=Z4VJQhRECSX51YKaJFQkgY5CqaPxlgd6GIlyNYRFmSU,13387
|
|
14
|
+
ffmpeg_normalize-1.35.0.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
ffmpeg_normalize/__init__.py,sha256=l0arjiMMBNbiH3IH67gT6SdZjPGAVLAdorUx38dNtvE,508
|
|
2
|
-
ffmpeg_normalize/__main__.py,sha256=UJIaAel7DSWd0eJr0FlZupsaHvj4wwrQsWd7fmBWB8s,20965
|
|
3
|
-
ffmpeg_normalize/_cmd_utils.py,sha256=1JspVpguAPsq7DqvyvjUNzHhVv8J3X93xNOMwito_jY,5284
|
|
4
|
-
ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
|
|
5
|
-
ffmpeg_normalize/_ffmpeg_normalize.py,sha256=_ZK2P3kAM0mnxY3iCYH61T6jhMndTdO2Rqh03vJo7rY,11852
|
|
6
|
-
ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
|
|
7
|
-
ffmpeg_normalize/_media_file.py,sha256=eJt9uNXmJheSH4B0ZZARFPMOwN0BGGXOELhcDpWmeew,26860
|
|
8
|
-
ffmpeg_normalize/_streams.py,sha256=XPM539yS220cOrCz0aAiKgoIcStbBUvR4-E0J-7uyOg,22174
|
|
9
|
-
ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
ffmpeg_normalize-1.33.4.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
|
|
11
|
-
ffmpeg_normalize-1.33.4.dist-info/WHEEL,sha256=F3mArEuDT3LDFEqo9fCiUx6ISLN64aIhcGSiIwtu4r8,79
|
|
12
|
-
ffmpeg_normalize-1.33.4.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
|
|
13
|
-
ffmpeg_normalize-1.33.4.dist-info/METADATA,sha256=01HzoufSfqquEDkixSDTGiOWZq0FsU0ZmGZhM-kG_Mg,11374
|
|
14
|
-
ffmpeg_normalize-1.33.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|