ffmpeg-normalize 1.33.4__py3-none-any.whl → 1.34.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.
@@ -315,6 +315,44 @@ def create_parser() -> argparse.ArgumentParser:
315
315
  ),
316
316
  )
317
317
 
318
+ group_stream_selection = parser.add_argument_group("Audio Stream Selection")
319
+ group_stream_selection.add_argument(
320
+ "-as",
321
+ "--audio-streams",
322
+ type=str,
323
+ help=textwrap.dedent(
324
+ """\
325
+ Select specific audio streams to normalize by stream index (comma-separated).
326
+ Example: --audio-streams 0,2 will normalize only streams 0 and 2.
327
+
328
+ By default, all audio streams are normalized.
329
+ """
330
+ ),
331
+ )
332
+ group_stream_selection.add_argument(
333
+ "--audio-default-only",
334
+ action="store_true",
335
+ help=textwrap.dedent(
336
+ """\
337
+ Only normalize audio streams with the 'default' disposition flag.
338
+ This is useful for files with multiple audio tracks where only the main track
339
+ should be normalized (e.g., keeping commentary tracks unchanged).
340
+ """
341
+ ),
342
+ )
343
+ group_stream_selection.add_argument(
344
+ "--keep-other-audio",
345
+ action="store_true",
346
+ help=textwrap.dedent(
347
+ """\
348
+ Keep non-selected audio streams in the output file (copy without normalization).
349
+ Only applies when --audio-streams or --audio-default-only is used.
350
+
351
+ By default, only selected streams are included in the output.
352
+ """
353
+ ),
354
+ )
355
+
318
356
  group_acodec = parser.add_argument_group("Audio Encoding")
319
357
  group_acodec.add_argument(
320
358
  "-c:a",
@@ -553,6 +591,18 @@ def main() -> None:
553
591
  extra_input_options = _split_options(cli_args.extra_input_options)
554
592
  extra_output_options = _split_options(cli_args.extra_output_options)
555
593
 
594
+ # parse audio streams selection
595
+ audio_streams = None
596
+ if cli_args.audio_streams:
597
+ try:
598
+ audio_streams = [int(s.strip()) for s in cli_args.audio_streams.split(",")]
599
+ except ValueError:
600
+ error("Invalid audio stream indices. Must be comma-separated integers.")
601
+
602
+ # validate stream selection options
603
+ if cli_args.audio_default_only and cli_args.audio_streams:
604
+ error("Cannot use both --audio-default-only and --audio-streams together.")
605
+
556
606
  ffmpeg_normalize = FFmpegNormalize(
557
607
  normalization_type=cli_args.normalization_type,
558
608
  target_level=cli_args.target_level,
@@ -586,6 +636,9 @@ def main() -> None:
586
636
  dry_run=cli_args.dry_run,
587
637
  progress=cli_args.progress,
588
638
  replaygain=cli_args.replaygain,
639
+ audio_streams=audio_streams,
640
+ audio_default_only=cli_args.audio_default_only,
641
+ keep_other_audio=cli_args.keep_other_audio,
589
642
  )
590
643
 
591
644
  if cli_args.output and len(cli_args.input) > len(cli_args.output):
@@ -84,6 +84,9 @@ 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
+ audio_streams (list[int] | None, optional): List of audio stream indices to normalize. Defaults to None (all streams).
88
+ audio_default_only (bool, optional): Only normalize audio streams with default disposition. Defaults to False.
89
+ keep_other_audio (bool, optional): Keep non-selected audio streams in output (copy without normalization). Defaults to False.
87
90
 
88
91
  Raises:
89
92
  FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
@@ -124,6 +127,9 @@ class FFmpegNormalize:
124
127
  debug: bool = False,
125
128
  progress: bool = False,
126
129
  replaygain: bool = False,
130
+ audio_streams: list[int] | None = None,
131
+ audio_default_only: bool = False,
132
+ keep_other_audio: bool = False,
127
133
  ):
128
134
  self.ffmpeg_exe = get_ffmpeg_exe()
129
135
  self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
@@ -207,6 +213,11 @@ class FFmpegNormalize:
207
213
  self.progress = progress
208
214
  self.replaygain = replaygain
209
215
 
216
+ # Stream selection options
217
+ self.audio_streams = audio_streams
218
+ self.audio_default_only = audio_default_only
219
+ self.keep_other_audio = keep_other_audio
220
+
210
221
  if (
211
222
  self.audio_codec is None or "pcm" in self.audio_codec
212
223
  ) and self.output_format in PCM_INCOMPATIBLE_FORMATS:
@@ -221,6 +232,19 @@ class FFmpegNormalize:
221
232
  "ReplayGain only works for EBU normalization type for now."
222
233
  )
223
234
 
235
+ # Validate stream selection options
236
+ if self.audio_streams is not None and self.audio_default_only:
237
+ raise FFmpegNormalizeError(
238
+ "Cannot use both audio_streams and audio_default_only together."
239
+ )
240
+
241
+ if self.keep_other_audio and self.keep_original_audio:
242
+ raise FFmpegNormalizeError(
243
+ "Cannot use both --keep-other-audio and --keep-original-audio together. "
244
+ "Use --keep-original-audio to keep all original streams alongside normalized ones, "
245
+ "or --keep-other-audio to keep only non-selected streams as passthrough."
246
+ )
247
+
224
248
  self.stats: list[LoudnessStatisticsWithMetadata] = []
225
249
  self.media_files: list[MediaFile] = []
226
250
  self.file_count = 0
@@ -136,6 +136,18 @@ class MediaFile:
136
136
 
137
137
  output_lines = [line.strip() for line in output.split("\n")]
138
138
 
139
+ # First pass: parse disposition flags for each stream
140
+ stream_dispositions: dict[int, bool] = {}
141
+
142
+ for line in output_lines:
143
+ if line.startswith("Stream"):
144
+ if stream_id_match := re.search(r"#0:([\d]+)", line):
145
+ stream_id = int(stream_id_match.group(1))
146
+ # Check if (default) appears on the Stream line
147
+ is_default = "(default)" in line
148
+ stream_dispositions[stream_id] = is_default
149
+
150
+ # Second pass: parse stream information
139
151
  duration = None
140
152
  for line in output_lines:
141
153
  if "Duration" in line:
@@ -155,8 +167,12 @@ class MediaFile:
155
167
  else:
156
168
  continue
157
169
 
170
+ is_default = stream_dispositions.get(stream_id, False)
171
+
158
172
  if "Audio" in line:
159
- _logger.debug(f"Found audio stream at index {stream_id}")
173
+ _logger.debug(
174
+ f"Found audio stream at index {stream_id} (default: {is_default})"
175
+ )
160
176
  sample_rate_match = re.search(r"(\d+) Hz", line)
161
177
  sample_rate = (
162
178
  int(sample_rate_match.group(1)) if sample_rate_match else None
@@ -170,6 +186,7 @@ class MediaFile:
170
186
  sample_rate,
171
187
  bit_depth,
172
188
  duration,
189
+ is_default,
173
190
  )
174
191
 
175
192
  elif "Video" in line:
@@ -201,6 +218,53 @@ class MediaFile:
201
218
  self.streams["video"] = {}
202
219
  self.streams["subtitle"] = {}
203
220
 
221
+ def _get_streams_to_normalize(self) -> list[AudioStream]:
222
+ """
223
+ Determine which audio streams to normalize based on configuration.
224
+
225
+ Returns:
226
+ list[AudioStream]: List of audio streams to normalize
227
+ """
228
+ all_audio_streams = list(self.streams["audio"].values())
229
+
230
+ if self.ffmpeg_normalize.audio_streams is not None:
231
+ # User specified specific stream indices
232
+ selected_streams = [
233
+ stream
234
+ for stream in all_audio_streams
235
+ if stream.stream_id in self.ffmpeg_normalize.audio_streams
236
+ ]
237
+ if not selected_streams:
238
+ _logger.warning(
239
+ f"No audio streams found matching indices {self.ffmpeg_normalize.audio_streams}. "
240
+ f"Available streams: {[s.stream_id for s in all_audio_streams]}"
241
+ )
242
+ else:
243
+ _logger.info(
244
+ f"Normalizing selected audio streams: {[s.stream_id for s in selected_streams]}"
245
+ )
246
+ return selected_streams
247
+
248
+ elif self.ffmpeg_normalize.audio_default_only:
249
+ # Only normalize streams with default disposition
250
+ default_streams = [
251
+ stream for stream in all_audio_streams if stream.is_default
252
+ ]
253
+ if not default_streams:
254
+ _logger.warning(
255
+ "No audio streams with 'default' disposition found. "
256
+ f"Available streams: {[s.stream_id for s in all_audio_streams]}"
257
+ )
258
+ else:
259
+ _logger.info(
260
+ f"Normalizing default audio streams: {[s.stream_id for s in default_streams]}"
261
+ )
262
+ return default_streams
263
+
264
+ else:
265
+ # Normalize all streams (default behavior)
266
+ return all_audio_streams
267
+
204
268
  def run_normalization(self) -> None:
205
269
  """
206
270
  Run the normalization process for this file.
@@ -400,7 +464,9 @@ class MediaFile:
400
464
  """
401
465
  _logger.debug(f"Parsing normalization info for {self.input_file}")
402
466
 
403
- for index, audio_stream in enumerate(self.streams["audio"].values()):
467
+ streams_to_normalize = self._get_streams_to_normalize()
468
+
469
+ for index, audio_stream in enumerate(streams_to_normalize):
404
470
  if self.ffmpeg_normalize.normalization_type == "ebu":
405
471
  fun = getattr(audio_stream, "parse_loudnorm_stats")
406
472
  else:
@@ -410,7 +476,7 @@ class MediaFile:
410
476
  with tqdm(
411
477
  total=100,
412
478
  position=1,
413
- desc=f"Stream {index + 1}/{len(self.streams['audio'].values())}",
479
+ desc=f"Stream {index + 1}/{len(streams_to_normalize)}",
414
480
  bar_format=TQDM_BAR_FORMAT,
415
481
  ) as pbar:
416
482
  for progress in fun():
@@ -429,7 +495,9 @@ class MediaFile:
429
495
  filter_chains = []
430
496
  output_labels = []
431
497
 
432
- for audio_stream in self.streams["audio"].values():
498
+ streams_to_normalize = self._get_streams_to_normalize()
499
+
500
+ for audio_stream in streams_to_normalize:
433
501
  skip_normalization = False
434
502
  if self.ffmpeg_normalize.lower_only:
435
503
  if self.ffmpeg_normalize.normalization_type == "ebu":
@@ -551,29 +619,66 @@ class MediaFile:
551
619
  f"The chosen output extension {self.output_ext} does not support video/cover art. It will be disabled."
552
620
  )
553
621
 
622
+ # Determine streams to normalize and passthrough
623
+ streams_to_normalize = self._get_streams_to_normalize()
624
+ all_audio_streams = list(self.streams["audio"].values())
625
+
626
+ # Determine which streams to passthrough
627
+ if self.ffmpeg_normalize.keep_other_audio and (
628
+ self.ffmpeg_normalize.audio_streams is not None
629
+ or self.ffmpeg_normalize.audio_default_only
630
+ ):
631
+ streams_to_passthrough = [
632
+ s for s in all_audio_streams if s not in streams_to_normalize
633
+ ]
634
+ else:
635
+ streams_to_passthrough = []
636
+
554
637
  # ... and map the output of the normalization filters
555
638
  for ol in output_labels:
556
639
  cmd.extend(["-map", ol])
557
640
 
558
- # set audio codec (never copy)
559
- if self.ffmpeg_normalize.audio_codec:
560
- cmd.extend(["-c:a", self.ffmpeg_normalize.audio_codec])
561
- else:
562
- for index, (_, audio_stream) in enumerate(self.streams["audio"].items()):
563
- cmd.extend([f"-c:a:{index}", audio_stream.get_pcm_codec()])
641
+ # ... and map passthrough audio streams (copy without normalization)
642
+ for stream in streams_to_passthrough:
643
+ cmd.extend(["-map", f"0:{stream.stream_id}"])
564
644
 
565
- # other audio options (if any)
645
+ # Track output audio stream index for codec assignment
646
+ output_audio_idx = 0
647
+
648
+ # set audio codec for normalized streams
649
+ for audio_stream in streams_to_normalize:
650
+ if self.ffmpeg_normalize.audio_codec:
651
+ codec = self.ffmpeg_normalize.audio_codec
652
+ else:
653
+ codec = audio_stream.get_pcm_codec()
654
+ cmd.extend([f"-c:a:{output_audio_idx}", codec])
655
+ output_audio_idx += 1
656
+
657
+ # set audio codec for passthrough streams (always copy)
658
+ for _ in streams_to_passthrough:
659
+ cmd.extend([f"-c:a:{output_audio_idx}", "copy"])
660
+ output_audio_idx += 1
661
+
662
+ # other audio options (if any) - only apply to normalized streams
566
663
  if self.ffmpeg_normalize.audio_bitrate:
567
664
  if self.ffmpeg_normalize.audio_codec == "libvorbis":
568
665
  # libvorbis takes just a "-b" option, for some reason
569
666
  # https://github.com/slhck/ffmpeg-normalize/issues/277
570
667
  cmd.extend(["-b", str(self.ffmpeg_normalize.audio_bitrate)])
571
668
  else:
572
- cmd.extend(["-b:a", str(self.ffmpeg_normalize.audio_bitrate)])
669
+ # Only apply to normalized streams
670
+ for idx in range(len(streams_to_normalize)):
671
+ cmd.extend(
672
+ [f"-b:a:{idx}", str(self.ffmpeg_normalize.audio_bitrate)]
673
+ )
573
674
  if self.ffmpeg_normalize.sample_rate:
574
- cmd.extend(["-ar", str(self.ffmpeg_normalize.sample_rate)])
675
+ # Only apply to normalized streams
676
+ for idx in range(len(streams_to_normalize)):
677
+ cmd.extend([f"-ar:a:{idx}", str(self.ffmpeg_normalize.sample_rate)])
575
678
  if self.ffmpeg_normalize.audio_channels:
576
- cmd.extend(["-ac", str(self.ffmpeg_normalize.audio_channels)])
679
+ # Only apply to normalized streams
680
+ for idx in range(len(streams_to_normalize)):
681
+ cmd.extend([f"-ac:a:{idx}", str(self.ffmpeg_normalize.audio_channels)])
577
682
 
578
683
  # ... and subtitles
579
684
  if not self.ffmpeg_normalize.subtitle_disable:
@@ -583,10 +688,11 @@ class MediaFile:
583
688
  cmd.extend(["-c:s", "copy"])
584
689
 
585
690
  if self.ffmpeg_normalize.keep_original_audio:
586
- highest_index = len(self.streams["audio"])
691
+ # Map all original audio streams after normalized and passthrough streams
587
692
  for index, _ in enumerate(self.streams["audio"].items()):
588
693
  cmd.extend(["-map", f"0:a:{index}"])
589
- cmd.extend([f"-c:a:{highest_index + index}", "copy"])
694
+ cmd.extend([f"-c:a:{output_audio_idx}", "copy"])
695
+ output_audio_idx += 1
590
696
 
591
697
  # extra options (if any)
592
698
  if self.ffmpeg_normalize.extra_output_options:
@@ -645,13 +751,14 @@ class MediaFile:
645
751
  ebu_pass_2_stats = list(
646
752
  AudioStream.prune_and_parse_loudnorm_output(output).values()
647
753
  )
648
- # Only set second pass stats if they exist (they might not if all streams were skipped with --lower-only)
649
- if len(ebu_pass_2_stats) == len(self.streams["audio"]):
650
- for idx, audio_stream in enumerate(self.streams["audio"].values()):
754
+ # Only set second pass stats for streams that were actually normalized
755
+ streams_to_normalize = self._get_streams_to_normalize()
756
+ if len(ebu_pass_2_stats) == len(streams_to_normalize):
757
+ for idx, audio_stream in enumerate(streams_to_normalize):
651
758
  audio_stream.set_second_pass_stats(ebu_pass_2_stats[idx])
652
759
  else:
653
760
  _logger.debug(
654
- f"Expected {len(self.streams['audio'])} EBU pass 2 statistics but got {len(ebu_pass_2_stats)}. "
761
+ f"Expected {len(streams_to_normalize)} EBU pass 2 statistics but got {len(ebu_pass_2_stats)}. "
655
762
  "This can happen when normalization is skipped (e.g., with --lower-only)."
656
763
  )
657
764
 
@@ -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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffmpeg-normalize
3
- Version: 1.33.4
3
+ Version: 1.34.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
  [![PyPI version](https://img.shields.io/pypi/v/ffmpeg-normalize.svg)](https://pypi.org/project/ffmpeg-normalize)
36
- ![Docker Image Version](https://img.shields.io/docker/v/slhck/ffmpeg-normalize?sort=semver&label=Docker%20image)
36
+ [![Docker Image Version](https://img.shields.io/docker/v/slhck/ffmpeg-normalize?sort=semver&label=Docker%20image)](https://hub.docker.com/r/slhck/ffmpeg-normalize)
37
37
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/slhck/ffmpeg-normalize/python-package.yml)
38
38
 
39
39
  <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
@@ -0,0 +1,14 @@
1
+ ffmpeg_normalize/__init__.py,sha256=l0arjiMMBNbiH3IH67gT6SdZjPGAVLAdorUx38dNtvE,508
2
+ ffmpeg_normalize/__main__.py,sha256=pn5OePgr7-P5ajO3HxTMe4yQ1NR7wru5mGMHDklkbQI,22901
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=ThIglofVOXxPMZcIrrYQ03HchlIOKmVSn41qILQNgg4,13193
6
+ ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
7
+ ffmpeg_normalize/_media_file.py,sha256=Dz5vBDOelD1-GnlKX6830UbnlwvARFo3O_O-8Zyqlmw,31214
8
+ ffmpeg_normalize/_streams.py,sha256=V5MnTjSnvQa6BNPSoFrUu0zg6mM-b9qaZE0ltGS2FV0,22329
9
+ ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ ffmpeg_normalize-1.34.0.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
11
+ ffmpeg_normalize-1.34.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
12
+ ffmpeg_normalize-1.34.0.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
13
+ ffmpeg_normalize-1.34.0.dist-info/METADATA,sha256=yoBY7Ig4p2ijd91p1NcT96URVZKZPaDWb0A0XyysIQY,11425
14
+ ffmpeg_normalize-1.34.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.16
2
+ Generator: uv 0.8.24
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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,,