ffmpeg-normalize 1.35.0__py3-none-any.whl → 1.36.1__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.
@@ -13,6 +13,7 @@ from typing import NoReturn
13
13
  from ._errors import FFmpegNormalizeError
14
14
  from ._ffmpeg_normalize import NORMALIZATION_TYPES, FFmpegNormalize
15
15
  from ._logger import setup_cli_logger
16
+ from ._presets import PresetManager
16
17
 
17
18
  # Import version from package
18
19
  import importlib.metadata
@@ -50,7 +51,7 @@ def create_parser() -> argparse.ArgumentParser:
50
51
 
51
52
  Author: Werner Robitza
52
53
  License: MIT
53
- Homepage / Issues: https://github.com/slhck/ffmpeg-normalize
54
+ Website / Issues: https://github.com/slhck/ffmpeg-normalize
54
55
  """
55
56
  ),
56
57
  )
@@ -90,7 +91,7 @@ def create_parser() -> argparse.ArgumentParser:
90
91
  name specified.
91
92
  """
92
93
  ),
93
- default="normalized",
94
+ default=FFmpegNormalize.DEFAULTS["output_folder"],
94
95
  )
95
96
 
96
97
  group_general = parser.add_argument_group("General Options")
@@ -124,6 +125,28 @@ def create_parser() -> argparse.ArgumentParser:
124
125
  version=f"%(prog)s v{__version__}",
125
126
  help="Print version and exit",
126
127
  )
128
+ group_general.add_argument(
129
+ "--preset",
130
+ type=str,
131
+ help=textwrap.dedent(
132
+ """\
133
+ Load options from a preset file.
134
+
135
+ Preset files are JSON files located in the presets directory.
136
+ The directory location depends on your OS:
137
+ - Linux/macOS: ~/.config/ffmpeg-normalize/presets/
138
+ - Windows: %%APPDATA%%/ffmpeg-normalize/presets/
139
+
140
+ Use --list-presets to see available presets.
141
+ CLI options specified on the command line take precedence over preset values.
142
+ """
143
+ ),
144
+ )
145
+ group_general.add_argument(
146
+ "--list-presets",
147
+ action="store_true",
148
+ help="List all available presets and exit",
149
+ )
127
150
 
128
151
  group_normalization = parser.add_argument_group("Normalization")
129
152
  group_normalization.add_argument(
@@ -144,15 +167,15 @@ def create_parser() -> argparse.ArgumentParser:
144
167
  Peak normalization brings the signal to the specified peak level.
145
168
  """
146
169
  ),
147
- default="ebu",
170
+ default=FFmpegNormalize.DEFAULTS["normalization_type"],
148
171
  )
149
172
  group_normalization.add_argument(
150
173
  "-t",
151
174
  "--target-level",
152
175
  type=float,
153
176
  help=textwrap.dedent(
154
- """\
155
- Normalization target level in dB/LUFS (default: -23).
177
+ f"""\
178
+ Normalization target level in dB/LUFS (default: {FFmpegNormalize.DEFAULTS["target_level"]}).
156
179
 
157
180
  For EBU normalization, it corresponds to Integrated Loudness Target
158
181
  in LUFS. The range is -70.0 - -5.0.
@@ -160,7 +183,7 @@ def create_parser() -> argparse.ArgumentParser:
160
183
  Otherwise, the range is -99 to 0.
161
184
  """
162
185
  ),
163
- default=-23.0,
186
+ default=FFmpegNormalize.DEFAULTS["target_level"],
164
187
  )
165
188
  group_normalization.add_argument(
166
189
  "-p",
@@ -215,12 +238,12 @@ def create_parser() -> argparse.ArgumentParser:
215
238
  "--loudness-range-target",
216
239
  type=float,
217
240
  help=textwrap.dedent(
218
- """\
219
- EBU Loudness Range Target in LUFS (default: 7.0).
241
+ f"""\
242
+ EBU Loudness Range Target in LUFS (default: {FFmpegNormalize.DEFAULTS["loudness_range_target"]}).
220
243
  Range is 1.0 - 50.0.
221
244
  """
222
245
  ),
223
- default=7.0,
246
+ default=FFmpegNormalize.DEFAULTS["loudness_range_target"],
224
247
  )
225
248
 
226
249
  group_ebu.add_argument(
@@ -249,26 +272,26 @@ def create_parser() -> argparse.ArgumentParser:
249
272
  "--true-peak",
250
273
  type=float,
251
274
  help=textwrap.dedent(
252
- """\
253
- EBU Maximum True Peak in dBTP (default: -2.0).
275
+ f"""\
276
+ EBU Maximum True Peak in dBTP (default: {FFmpegNormalize.DEFAULTS["true_peak"]}).
254
277
  Range is -9.0 - +0.0.
255
278
  """
256
279
  ),
257
- default=-2.0,
280
+ default=FFmpegNormalize.DEFAULTS["true_peak"],
258
281
  )
259
282
 
260
283
  group_ebu.add_argument(
261
284
  "--offset",
262
285
  type=float,
263
286
  help=textwrap.dedent(
264
- """\
265
- EBU Offset Gain (default: 0.0).
287
+ f"""\
288
+ EBU Offset Gain (default: {FFmpegNormalize.DEFAULTS["offset"]}).
266
289
  The gain is applied before the true-peak limiter in the first pass only.
267
290
  The offset for the second pass will be automatically determined based on the first pass statistics.
268
291
  Range is -99.0 - +99.0.
269
292
  """
270
293
  ),
271
- default=0.0,
294
+ default=FFmpegNormalize.DEFAULTS["offset"],
272
295
  )
273
296
 
274
297
  group_ebu.add_argument(
@@ -471,7 +494,7 @@ def create_parser() -> argparse.ArgumentParser:
471
494
  Will attempt to copy video codec by default.
472
495
  """
473
496
  ),
474
- default="copy",
497
+ default=FFmpegNormalize.DEFAULTS["video_codec"],
475
498
  )
476
499
  group_vcodec.add_argument(
477
500
  "-sn",
@@ -565,7 +588,7 @@ def create_parser() -> argparse.ArgumentParser:
565
588
  specified. (Default: `mkv`)
566
589
  """
567
590
  ),
568
- default="mkv",
591
+ default=FFmpegNormalize.DEFAULTS["extension"],
569
592
  )
570
593
  return parser
571
594
 
@@ -581,6 +604,28 @@ def main() -> None:
581
604
  _logger.error(message)
582
605
  sys.exit(1)
583
606
 
607
+ # Handle --list-presets
608
+ preset_manager = PresetManager()
609
+ if cli_args.list_presets:
610
+ presets = preset_manager.get_available_presets()
611
+ if presets:
612
+ print("Available presets:")
613
+ for preset in presets:
614
+ print(f" - {preset}")
615
+ else:
616
+ print(f"No presets found in {preset_manager.presets_dir}")
617
+ sys.exit(0)
618
+
619
+ # Load and apply preset if specified
620
+ if cli_args.preset:
621
+ try:
622
+ preset_data = preset_manager.load_preset(cli_args.preset)
623
+ _logger.debug(f"Loaded preset '{cli_args.preset}': {preset_data}")
624
+ preset_manager.merge_preset_with_args(preset_data, cli_args)
625
+ _logger.info(f"Applied preset '{cli_args.preset}'")
626
+ except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
627
+ error(str(e))
628
+
584
629
  def _split_options(opts: str) -> list[str]:
585
630
  """
586
631
  Parse extra options (input or output) into a list.
@@ -678,6 +723,18 @@ def main() -> None:
678
723
  if not input_files:
679
724
  error("No input files specified. Use positional arguments or --input-list.")
680
725
 
726
+ # Validate all input files upfront before processing
727
+ _logger.debug("Validating all input files before processing...")
728
+ validation_errors = FFmpegNormalize.validate_input_files(input_files)
729
+ if validation_errors:
730
+ _logger.error("Validation failed for the following files:")
731
+ for err in validation_errors:
732
+ _logger.error(f" - {err}")
733
+ error(
734
+ f"Validation failed for {len(validation_errors)} file(s). "
735
+ "Please fix the issues above and try again."
736
+ )
737
+
681
738
  for index, input_file in enumerate(input_files):
682
739
  if cli_args.output is not None and index < len(cli_args.output):
683
740
  if cli_args.output_folder and cli_args.output_folder != "normalized":
@@ -190,3 +190,76 @@ def ffmpeg_has_loudnorm() -> bool:
190
190
  "Please make sure you are running ffmpeg v4.2 or above."
191
191
  )
192
192
  return supports_loudnorm
193
+
194
+
195
+ def validate_input_file(input_file: str) -> tuple[bool, str | None]:
196
+ """
197
+ Validate that an input file exists, is readable, and contains audio streams.
198
+
199
+ This function performs a lightweight probe of the file using ffmpeg to check
200
+ if it can be read and contains at least one audio stream.
201
+
202
+ Args:
203
+ input_file: Path to the input file to validate
204
+
205
+ Returns:
206
+ tuple: (is_valid, error_message)
207
+ - is_valid: True if the file is valid, False otherwise
208
+ - error_message: None if valid, otherwise a descriptive error message
209
+ """
210
+ # Check if file exists
211
+ if not os.path.exists(input_file):
212
+ return False, f"File does not exist: {input_file}"
213
+
214
+ # Check if it's actually a file (not a directory)
215
+ if not os.path.isfile(input_file):
216
+ return False, f"Path is not a file: {input_file}"
217
+
218
+ # Check if file is readable
219
+ if not os.access(input_file, os.R_OK):
220
+ return False, f"File is not readable (permission denied): {input_file}"
221
+
222
+ # Check if file has audio streams using ffmpeg probe
223
+ ffmpeg_exe = get_ffmpeg_exe()
224
+ cmd = [
225
+ ffmpeg_exe,
226
+ "-i",
227
+ input_file,
228
+ "-c",
229
+ "copy",
230
+ "-t",
231
+ "0",
232
+ "-map",
233
+ "0",
234
+ "-f",
235
+ "null",
236
+ os.devnull,
237
+ ]
238
+
239
+ try:
240
+ output = CommandRunner().run_command(cmd).get_output()
241
+ except RuntimeError as e:
242
+ error_str = str(e)
243
+ # Extract a cleaner error message from ffmpeg output
244
+ if "Invalid data found" in error_str:
245
+ return False, f"Invalid or corrupted media file: {input_file}"
246
+ if "No such file or directory" in error_str:
247
+ return False, f"File not found or cannot be opened: {input_file}"
248
+ if "Permission denied" in error_str:
249
+ return False, f"Permission denied when reading file: {input_file}"
250
+ if "does not contain any stream" in error_str:
251
+ return False, f"File contains no media streams: {input_file}"
252
+ # Generic error for other ffmpeg failures
253
+ return False, f"Cannot read media file: {input_file}"
254
+
255
+ # Check for audio streams in the output
256
+ has_audio = False
257
+ for line in output.split("\n"):
258
+ if line.strip().startswith("Stream") and "Audio" in line:
259
+ has_audio = True
260
+ break
261
+
262
+ if not has_audio:
263
+ return False, f"File does not contain any audio streams: {input_file}"
264
+
265
+ return True, None
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Literal
9
9
 
10
10
  from tqdm import tqdm
11
11
 
12
- from ._cmd_utils import ffmpeg_has_loudnorm, get_ffmpeg_exe
12
+ from ._cmd_utils import ffmpeg_has_loudnorm, get_ffmpeg_exe, validate_input_file
13
13
  from ._errors import FFmpegNormalizeError
14
14
  from ._media_file import MediaFile
15
15
 
@@ -93,6 +93,48 @@ class FFmpegNormalize:
93
93
  FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
94
94
  """
95
95
 
96
+ # Default parameter values - single source of truth for all defaults
97
+ # Note: output_folder is a CLI-level option and not passed to FFmpegNormalize.__init__
98
+ DEFAULTS = {
99
+ "normalization_type": "ebu",
100
+ "target_level": -23.0,
101
+ "print_stats": False,
102
+ "loudness_range_target": 7.0,
103
+ "keep_loudness_range_target": False,
104
+ "keep_lra_above_loudness_range_target": False,
105
+ "true_peak": -2.0,
106
+ "offset": 0.0,
107
+ "lower_only": False,
108
+ "auto_lower_loudness_target": False,
109
+ "dual_mono": False,
110
+ "dynamic": False,
111
+ "audio_codec": "pcm_s16le",
112
+ "audio_bitrate": None,
113
+ "sample_rate": None,
114
+ "audio_channels": None,
115
+ "keep_original_audio": False,
116
+ "pre_filter": None,
117
+ "post_filter": None,
118
+ "video_codec": "copy",
119
+ "video_disable": False,
120
+ "subtitle_disable": False,
121
+ "metadata_disable": False,
122
+ "chapters_disable": False,
123
+ "extra_input_options": None,
124
+ "extra_output_options": None,
125
+ "output_format": None,
126
+ "output_folder": "normalized",
127
+ "extension": "mkv",
128
+ "dry_run": False,
129
+ "debug": False,
130
+ "progress": False,
131
+ "replaygain": False,
132
+ "batch": False,
133
+ "audio_streams": None,
134
+ "audio_default_only": False,
135
+ "keep_other_audio": False,
136
+ }
137
+
96
138
  def __init__(
97
139
  self,
98
140
  normalization_type: Literal["ebu", "rms", "peak"] = "ebu",
@@ -275,6 +317,28 @@ class FFmpegNormalize:
275
317
  self.media_files.append(MediaFile(self, input_file, output_file))
276
318
  self.file_count += 1
277
319
 
320
+ @staticmethod
321
+ def validate_input_files(input_files: list[str]) -> list[str]:
322
+ """
323
+ Validate all input files before processing.
324
+
325
+ This method checks that each input file exists, is readable, and contains
326
+ at least one audio stream. All files are validated upfront so that users
327
+ can fix all issues before rerunning the batch.
328
+
329
+ Args:
330
+ input_files: List of input file paths to validate
331
+
332
+ Returns:
333
+ list: List of error messages for invalid files. Empty if all files are valid.
334
+ """
335
+ errors = []
336
+ for input_file in input_files:
337
+ is_valid, error_msg = validate_input_file(input_file)
338
+ if not is_valid and error_msg:
339
+ errors.append(error_msg)
340
+ return errors
341
+
278
342
  def _calculate_batch_reference(self) -> float | None:
279
343
  """
280
344
  Calculate the batch reference loudness by averaging measurements across all files.
@@ -0,0 +1,256 @@
1
+ """Preset management for ffmpeg-normalize."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ._ffmpeg_normalize import FFmpegNormalize
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+
17
+ def get_config_dir() -> Path:
18
+ """Get the platform-specific config directory for ffmpeg-normalize.
19
+
20
+ Returns:
21
+ Path: Configuration directory for presets
22
+
23
+ On Linux/macOS:
24
+ - XDG_CONFIG_HOME/ffmpeg-normalize (if XDG_CONFIG_HOME is set)
25
+ - ~/.config/ffmpeg-normalize (otherwise)
26
+
27
+ On Windows:
28
+ - %APPDATA%/ffmpeg-normalize
29
+ """
30
+ if os.name == "nt": # Windows
31
+ appdata = os.getenv("APPDATA")
32
+ if appdata:
33
+ config_dir = Path(appdata) / "ffmpeg-normalize"
34
+ else:
35
+ config_dir = Path.home() / "AppData" / "Roaming" / "ffmpeg-normalize"
36
+ else: # Linux/macOS
37
+ xdg_config = os.getenv("XDG_CONFIG_HOME")
38
+ if xdg_config:
39
+ config_dir = Path(xdg_config) / "ffmpeg-normalize"
40
+ else:
41
+ config_dir = Path.home() / ".config" / "ffmpeg-normalize"
42
+
43
+ return config_dir
44
+
45
+
46
+ def get_presets_dir() -> Path:
47
+ """Get the presets directory.
48
+
49
+ Returns:
50
+ Path: Directory containing preset files
51
+ """
52
+ return get_config_dir() / "presets"
53
+
54
+
55
+ def get_default_presets_dir() -> Path:
56
+ """Get the package default presets directory.
57
+
58
+ Returns:
59
+ Path: Directory containing default preset files bundled with the package
60
+ """
61
+ # Get the directory where this module is located
62
+ module_dir = Path(__file__).parent
63
+ return module_dir / "data" / "presets"
64
+
65
+
66
+ class PresetManager:
67
+ """Manages loading and merging of presets with CLI arguments."""
68
+
69
+ def __init__(self) -> None:
70
+ """Initialize the preset manager."""
71
+ self.presets_dir = get_presets_dir()
72
+ self.default_presets_dir = get_default_presets_dir()
73
+
74
+ def get_available_presets(self) -> list[str]:
75
+ """Get list of available preset names.
76
+
77
+ Includes both user-installed presets and default presets from the package.
78
+ User presets take precedence if they have the same name.
79
+
80
+ Returns:
81
+ list[str]: List of available preset names (without .json extension)
82
+ """
83
+ presets = set()
84
+
85
+ # Get presets from user config directory
86
+ if self.presets_dir.exists():
87
+ for file in self.presets_dir.glob("*.json"):
88
+ presets.add(file.stem)
89
+
90
+ # Get default presets from package
91
+ if self.default_presets_dir.exists():
92
+ for file in self.default_presets_dir.glob("*.json"):
93
+ presets.add(file.stem)
94
+
95
+ return sorted(presets)
96
+
97
+ def load_preset(self, preset_name: str) -> dict[str, Any]:
98
+ """Load a preset file by name.
99
+
100
+ Checks user config directory first, then falls back to package defaults.
101
+
102
+ Args:
103
+ preset_name: Name of the preset (without .json extension)
104
+
105
+ Returns:
106
+ dict[str, Any]: Preset configuration as a dictionary
107
+
108
+ Raises:
109
+ FileNotFoundError: If preset file doesn't exist
110
+ json.JSONDecodeError: If preset file is invalid JSON
111
+ """
112
+ # Try user config directory first
113
+ preset_path = self.presets_dir / f"{preset_name}.json"
114
+
115
+ # Fall back to package default presets
116
+ if not preset_path.exists():
117
+ preset_path = self.default_presets_dir / f"{preset_name}.json"
118
+
119
+ if not preset_path.exists():
120
+ raise FileNotFoundError(
121
+ f"Preset '{preset_name}' not found. "
122
+ f"Available presets: {', '.join(self.get_available_presets()) or 'none'}"
123
+ )
124
+
125
+ try:
126
+ with open(preset_path, "r") as f:
127
+ preset_data = json.load(f)
128
+ except json.JSONDecodeError as e:
129
+ raise json.JSONDecodeError(
130
+ f"Invalid JSON in preset '{preset_name}': {e.msg}",
131
+ e.doc,
132
+ e.pos,
133
+ )
134
+
135
+ if not isinstance(preset_data, dict):
136
+ raise ValueError(
137
+ f"Preset must be a JSON object, got {type(preset_data).__name__}"
138
+ )
139
+
140
+ return preset_data
141
+
142
+ def merge_preset_with_args(
143
+ self, preset_data: dict[str, Any], cli_args: Any
144
+ ) -> None:
145
+ """Merge preset data with CLI arguments, giving precedence to CLI args.
146
+
147
+ Args:
148
+ preset_data: Dictionary of preset configuration
149
+ cli_args: Parsed CLI arguments (argparse Namespace object)
150
+
151
+ The CLI arguments take precedence over preset values. This function modifies
152
+ cli_args in place by setting attributes that were not explicitly provided
153
+ on the command line.
154
+ """
155
+ for key, value in preset_data.items():
156
+ # Convert hyphens to underscores to match argparse attribute names
157
+ attr_name = key.replace("-", "_")
158
+
159
+ # Check if this attribute exists in cli_args
160
+ if not hasattr(cli_args, attr_name):
161
+ _logger.warning(
162
+ f"Preset option '{key}' is not a valid ffmpeg-normalize option. Skipping."
163
+ )
164
+ continue
165
+
166
+ # Get the current value
167
+ current_value = getattr(cli_args, attr_name)
168
+
169
+ # Check if this was explicitly set by the user
170
+ # For most types, we can infer this by checking against the defaults:
171
+ # - None values were not explicitly set
172
+ # - Empty lists were not explicitly set
173
+ # - False boolean flags were not explicitly set
174
+ # - Default numeric values (specific to each option) need special handling
175
+
176
+ should_apply = False
177
+
178
+ if isinstance(value, bool):
179
+ # For boolean flags, apply preset only if currently False (not set)
180
+ should_apply = not current_value
181
+ elif isinstance(current_value, list):
182
+ # For lists (like output), apply preset only if empty
183
+ should_apply = not current_value
184
+ elif current_value is None:
185
+ # For None values, always apply preset (not explicitly set)
186
+ should_apply = True
187
+ else:
188
+ # For other values (numbers, strings), check if they match known defaults
189
+ # This is conservative: only override if the value is a known default
190
+ if (
191
+ attr_name in FFmpegNormalize.DEFAULTS
192
+ and current_value == FFmpegNormalize.DEFAULTS[attr_name]
193
+ ):
194
+ should_apply = True
195
+
196
+ if should_apply:
197
+ setattr(cli_args, attr_name, value)
198
+ _logger.debug(f"Applied preset option '{key}' = {value}")
199
+
200
+ def validate_preset(self, preset_name: str) -> tuple[bool, str]:
201
+ """Validate that a preset file exists and is valid.
202
+
203
+ Args:
204
+ preset_name: Name of the preset to validate
205
+
206
+ Returns:
207
+ tuple[bool, str]: (is_valid, message)
208
+ """
209
+ try:
210
+ self.load_preset(preset_name)
211
+ return True, f"Preset '{preset_name}' is valid"
212
+ except FileNotFoundError as e:
213
+ return False, str(e)
214
+ except (json.JSONDecodeError, ValueError) as e:
215
+ return False, f"Error loading preset '{preset_name}': {e}"
216
+
217
+ def install_default_presets(self, force: bool = False) -> tuple[bool, str]:
218
+ """Install default presets to user config directory.
219
+
220
+ Args:
221
+ force: If True, overwrite existing presets
222
+
223
+ Returns:
224
+ tuple[bool, str]: (success, message)
225
+ """
226
+ try:
227
+ # Create presets directory if it doesn't exist
228
+ self.presets_dir.mkdir(parents=True, exist_ok=True)
229
+
230
+ if not self.default_presets_dir.exists():
231
+ return (
232
+ False,
233
+ f"Default presets directory not found at {self.default_presets_dir}",
234
+ )
235
+
236
+ # Copy all default presets
237
+ installed = []
238
+ skipped = []
239
+
240
+ for preset_file in self.default_presets_dir.glob("*.json"):
241
+ dest_file = self.presets_dir / preset_file.name
242
+
243
+ if dest_file.exists() and not force:
244
+ skipped.append(preset_file.name)
245
+ else:
246
+ shutil.copy2(preset_file, dest_file)
247
+ installed.append(preset_file.name)
248
+
249
+ message = f"Installed {len(installed)} preset(s) to {self.presets_dir}"
250
+ if skipped:
251
+ message += f" ({len(skipped)} skipped, use --force to overwrite)"
252
+
253
+ return True, message
254
+
255
+ except Exception as e:
256
+ return False, f"Error installing default presets: {e}"
@@ -0,0 +1,6 @@
1
+ {
2
+ "normalization-type": "rms",
3
+ "target-level": -20.0,
4
+ "batch": true,
5
+ "progress": true
6
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "normalization-type": "ebu",
3
+ "target-level": -16.0,
4
+ "loudness-range-target": 7.0,
5
+ "true-peak": -2.0,
6
+ "progress": true
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "normalization-type": "ebu",
3
+ "target-level": -14.0,
4
+ "loudness-range-target": 7.0,
5
+ "true-peak": -2.0,
6
+ "progress": true
7
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffmpeg-normalize
3
- Version: 1.35.0
3
+ Version: 1.36.1
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -54,10 +54,27 @@ This program normalizes media files to a certain loudness level using the EBU R1
54
54
  - Docker support — Run via Docker container
55
55
  - Python API — Use programmatically in your Python projects
56
56
  - Shell completions — Available for bash, zsh, and fish
57
- - Album Batch normalization – Process files jointy, preserving relative loudness
57
+ - Album Batch normalization – Process files jointly, preserving relative loudness
58
+
59
+ ## 🚀 Quick Start
60
+
61
+ 1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html)
62
+ 2. Run `pip3 install ffmpeg-normalize` and `ffmpeg-normalize /path/to/your/file.mp4`, alternatively install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) and run `uvx ffmpeg-normalize /path/to/your/file.mp4`
63
+ 3. Done! 🎧 (the normalized file will be called `normalized/file.mkv`)
64
+
58
65
 
59
66
  ## 🆕 What's New
60
67
 
68
+ - Version 1.36.0 introduces **presets** with `--preset`! Save and reuse your favorite normalization configurations for different use cases. Comes with three built-in presets: `podcast` (AES standard), `music` (RMS-based batch normalization), and `streaming-video` (video content). Create custom presets too!
69
+
70
+ Example:
71
+
72
+ ```bash
73
+ ffmpeg-normalize input.mp3 --preset podcast
74
+ ```
75
+
76
+ applies the podcast preset (EBU R128, -16 LUFS) to your file. Learn more in the [presets guide](https://slhck.info/ffmpeg-normalize/usage/presets/).
77
+
61
78
  - 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
79
 
63
80
  Example:
@@ -89,12 +106,6 @@ Other recent additions:
89
106
 
90
107
  See the [full changelog](https://github.com/slhck/ffmpeg-normalize/blob/master/CHANGELOG.md) for all updates.
91
108
 
92
- ## 🚀 Quick Start
93
-
94
- 1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html)
95
- 2. Run `pip3 install ffmpeg-normalize` and `ffmpeg-normalize /path/to/your/file.mp4`, alternatively install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) and run `uvx ffmpeg-normalize /path/to/your/file.mp4`
96
- 3. Done! 🎧 (the normalized file will be called `normalized/file.mkv`)
97
-
98
109
  ## 📓 Documentation
99
110
 
100
111
  Check out our [documentation](https://slhck.info/ffmpeg-normalize/) for more info!
@@ -0,0 +1,18 @@
1
+ ffmpeg_normalize/__init__.py,sha256=l0arjiMMBNbiH3IH67gT6SdZjPGAVLAdorUx38dNtvE,508
2
+ ffmpeg_normalize/__main__.py,sha256=U77hAgn7_w2QErUEocf6tal-7v-lJR7GbUQW8L-FmC0,26357
3
+ ffmpeg_normalize/_cmd_utils.py,sha256=nJwnV606CXZmuM8YMGZqTsgRwupSkJZn4iwXHjB8n60,7748
4
+ ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
5
+ ffmpeg_normalize/_ffmpeg_normalize.py,sha256=tcO_nJvsNiucsCJJcMESYsb8GbThcyhniIl21JmxNn0,22190
6
+ ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
7
+ ffmpeg_normalize/_media_file.py,sha256=awznS5C8ph6Mjy5dzwT-ubBpB3MXwbO94QFcn7mBejY,32197
8
+ ffmpeg_normalize/_presets.py,sha256=AffqFzAHvy0GxCuJl_qd3xknncPJUQx7OXbSfYAw2xg,8852
9
+ ffmpeg_normalize/_streams.py,sha256=4Dnzuunhqz2qsOhlDv0dKML-lLjmPmUmM7M4dpn66Ow,24910
10
+ ffmpeg_normalize/data/presets/music.json,sha256=rKtIKAD7jlMvnGRsx6QAyPHvrGu-Ecq4M5OcZgwNphA,96
11
+ ffmpeg_normalize/data/presets/podcast.json,sha256=spMBZ_tbfU81NF6o7ZUMyQmQ7kPz3jJ1GKH-hcRmt4s,132
12
+ ffmpeg_normalize/data/presets/streaming-video.json,sha256=Uy4QR0kSla4vXEqctZj26-8f_eS4SQxQWQ9zI_x5QBw,132
13
+ ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ ffmpeg_normalize-1.36.1.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
15
+ ffmpeg_normalize-1.36.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
16
+ ffmpeg_normalize-1.36.1.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
17
+ ffmpeg_normalize-1.36.1.dist-info/METADATA,sha256=6oOuG-z2hUURUJ0fKQAd_Oi0-3r7bHrNcZq_JSgeeUg,13935
18
+ ffmpeg_normalize-1.36.1.dist-info/RECORD,,
@@ -1,14 +0,0 @@
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,,