better-ffmpeg-progress 5.4.2__tar.gz → 6.0.0__tar.gz

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.
Files changed (21) hide show
  1. {better_ffmpeg_progress-5.4.2/better_ffmpeg_progress.egg-info → better_ffmpeg_progress-6.0.0}/PKG-INFO +51 -13
  2. better_ffmpeg_progress-5.4.2/PKG-INFO → better_ffmpeg_progress-6.0.0/README.md +40 -24
  3. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/__init__.py +3 -0
  4. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/better_ffmpeg_progress.py +67 -25
  5. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/progress_bars.py +15 -2
  6. better_ffmpeg_progress-6.0.0/better_ffmpeg_progress/py.typed +0 -0
  7. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/terminate_process.py +54 -37
  8. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/utils.py +77 -21
  9. better_ffmpeg_progress-5.4.2/README.md → better_ffmpeg_progress-6.0.0/better_ffmpeg_progress.egg-info/PKG-INFO +63 -6
  10. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress.egg-info/SOURCES.txt +5 -2
  11. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress.egg-info/requires.txt +3 -1
  12. better_ffmpeg_progress-6.0.0/pyproject.toml +39 -0
  13. better_ffmpeg_progress-6.0.0/tests/test_ffmpeg_process.py +152 -0
  14. better_ffmpeg_progress-6.0.0/tests/test_utils.py +119 -0
  15. better_ffmpeg_progress-5.4.2/setup.py +0 -24
  16. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/LICENSE +0 -0
  17. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/enums.py +0 -0
  18. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/exceptions.py +0 -0
  19. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
  20. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
  21. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/setup.cfg +0 -0
@@ -1,21 +1,24 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: better-ffmpeg-progress
3
- Version: 5.4.2
3
+ Version: 6.0.0
4
4
  Summary: Run FFmpeg & see percentage progress + ETA.
5
- Home-page: https://github.com/CrypticSignal/better-ffmpeg-progress
6
- Author: GitHub.com/CrypticSignal
7
- Author-email: theaudiophile@outlook.com
5
+ Author-email: "GitHub.com/CrypticSignal" <theaudiophile@outlook.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/CrypticSignal/better-ffmpeg-progress
8
+ Project-URL: Changelog, https://github.com/CrypticSignal/better-ffmpeg-progress/blob/main/CHANGELOG.md
8
9
  Keywords: ffmpeg,progress
9
10
  Classifier: Programming Language :: Python :: 3
10
- Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.7
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.9
13
14
  Description-Content-Type: text/markdown
14
15
  License-File: LICENSE
15
16
  Requires-Dist: psutil
16
- Requires-Dist: requests
17
17
  Requires-Dist: rich
18
18
  Requires-Dist: tqdm
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == "test"
21
+ Dynamic: license-file
19
22
 
20
23
  <div align="center">
21
24
 
@@ -65,9 +68,8 @@ command = [
65
68
  ]
66
69
 
67
70
  try:
71
+ # Pass use_tqdm=True to use tqdm instead of Rich.
68
72
  process = FfmpegProcess(command)
69
- # Uncomment this line to use tqdm instead of Rich.
70
- # process.use_tqdm = True
71
73
 
72
74
  process.run()
73
75
  except FfmpegProcessError as e:
@@ -137,14 +139,50 @@ An instance of `FfmpegProcess` accepts the following optional arguments:
137
139
  * `ffmpeg_log_file` — Filepath or file-like object to which FFmpeg stderr will be written. Default: `<input filename>_ffmpeg_log.txt`.
138
140
  * `print_detected_duration` — Print the detected duration. Default: `False`.
139
141
  * `duration_override` — Specify input file duration in seconds to use for progress calculation, instead of trying to detect this automatically with FFprobe. Default: `None`.
142
+ * `use_tqdm` — Use tqdm instead of Rich for the progress bar. Default: `False`.
143
+ * `overwrite` — What to do if the output file already exists: `True` overwrites it, `False` raises `FfmpegCommandError`, and `None` asks whether to overwrite it. Ignored if the command contains `-y` or `-n`. Default: `None`.
140
144
 
141
145
  The `run` method accepts the following optional argument:
142
146
  * `print_command` — Print the FFmpeg command being executed. Default: `False`.
143
147
 
144
148
  ## Using tqdm
145
- By default, the [Rich](https://github.com/Textualize/rich) library is used to display a progress bar. [tqdm](https://github.com/tqdm/tqdm) can be used instead by setting `process.use_tqdm = True`:
149
+ By default, the [Rich](https://github.com/Textualize/rich) library is used to display a progress bar. [tqdm](https://github.com/tqdm/tqdm) can be used instead by passing `use_tqdm=True`:
146
150
  ```python
147
- process = FfmpegProcess(command)
148
- process.use_tqdm = True
151
+ process = FfmpegProcess(command, use_tqdm=True)
149
152
  process.run()
150
153
  ```
154
+ ## Limitations
155
+ - Progress is calculated from the duration of the first input (the first `-i`).
156
+ - The last argument of the command is treated as the output file, e.g. when checking whether it already exists. With multiple outputs, only the last one is checked.
157
+
158
+ ## Development
159
+ Install the package in editable mode with the test dependencies, then run the tests (FFmpeg and FFprobe must be installed):
160
+ ```bash
161
+ pip install -e ".[test]"
162
+ ```
163
+ ```bash
164
+ pytest
165
+ ```
166
+ `test.py` is a standalone example that encodes a test pattern and shows the progress bar.
167
+
168
+ ## Publishing
169
+ Update `__version__` in `better_ffmpeg_progress/__init__.py` and add an entry to `CHANGELOG.md`.
170
+
171
+ One-time setup:
172
+ ```bash
173
+ pip install --upgrade build twine pkginfo
174
+ ```
175
+
176
+ Then, for each release:
177
+ ```bash
178
+ rm -rf dist
179
+ ```
180
+ ```bash
181
+ python -m build
182
+ ```
183
+ ```bash
184
+ twine check dist/*
185
+ ```
186
+ ```bash
187
+ twine upload dist/*
188
+ ```
@@ -1,22 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: better-ffmpeg-progress
3
- Version: 5.4.2
4
- Summary: Run FFmpeg & see percentage progress + ETA.
5
- Home-page: https://github.com/CrypticSignal/better-ffmpeg-progress
6
- Author: GitHub.com/CrypticSignal
7
- Author-email: theaudiophile@outlook.com
8
- Keywords: ffmpeg,progress
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.7
13
- Description-Content-Type: text/markdown
14
- License-File: LICENSE
15
- Requires-Dist: psutil
16
- Requires-Dist: requests
17
- Requires-Dist: rich
18
- Requires-Dist: tqdm
19
-
20
1
  <div align="center">
21
2
 
22
3
  # Better FFmpeg Progress
@@ -65,9 +46,8 @@ command = [
65
46
  ]
66
47
 
67
48
  try:
49
+ # Pass use_tqdm=True to use tqdm instead of Rich.
68
50
  process = FfmpegProcess(command)
69
- # Uncomment this line to use tqdm instead of Rich.
70
- # process.use_tqdm = True
71
51
 
72
52
  process.run()
73
53
  except FfmpegProcessError as e:
@@ -137,14 +117,50 @@ An instance of `FfmpegProcess` accepts the following optional arguments:
137
117
  * `ffmpeg_log_file` — Filepath or file-like object to which FFmpeg stderr will be written. Default: `<input filename>_ffmpeg_log.txt`.
138
118
  * `print_detected_duration` — Print the detected duration. Default: `False`.
139
119
  * `duration_override` — Specify input file duration in seconds to use for progress calculation, instead of trying to detect this automatically with FFprobe. Default: `None`.
120
+ * `use_tqdm` — Use tqdm instead of Rich for the progress bar. Default: `False`.
121
+ * `overwrite` — What to do if the output file already exists: `True` overwrites it, `False` raises `FfmpegCommandError`, and `None` asks whether to overwrite it. Ignored if the command contains `-y` or `-n`. Default: `None`.
140
122
 
141
123
  The `run` method accepts the following optional argument:
142
124
  * `print_command` — Print the FFmpeg command being executed. Default: `False`.
143
125
 
144
126
  ## Using tqdm
145
- By default, the [Rich](https://github.com/Textualize/rich) library is used to display a progress bar. [tqdm](https://github.com/tqdm/tqdm) can be used instead by setting `process.use_tqdm = True`:
127
+ By default, the [Rich](https://github.com/Textualize/rich) library is used to display a progress bar. [tqdm](https://github.com/tqdm/tqdm) can be used instead by passing `use_tqdm=True`:
146
128
  ```python
147
- process = FfmpegProcess(command)
148
- process.use_tqdm = True
129
+ process = FfmpegProcess(command, use_tqdm=True)
149
130
  process.run()
150
131
  ```
132
+ ## Limitations
133
+ - Progress is calculated from the duration of the first input (the first `-i`).
134
+ - The last argument of the command is treated as the output file, e.g. when checking whether it already exists. With multiple outputs, only the last one is checked.
135
+
136
+ ## Development
137
+ Install the package in editable mode with the test dependencies, then run the tests (FFmpeg and FFprobe must be installed):
138
+ ```bash
139
+ pip install -e ".[test]"
140
+ ```
141
+ ```bash
142
+ pytest
143
+ ```
144
+ `test.py` is a standalone example that encodes a test pattern and shows the progress bar.
145
+
146
+ ## Publishing
147
+ Update `__version__` in `better_ffmpeg_progress/__init__.py` and add an entry to `CHANGELOG.md`.
148
+
149
+ One-time setup:
150
+ ```bash
151
+ pip install --upgrade build twine pkginfo
152
+ ```
153
+
154
+ Then, for each release:
155
+ ```bash
156
+ rm -rf dist
157
+ ```
158
+ ```bash
159
+ python -m build
160
+ ```
161
+ ```bash
162
+ twine check dist/*
163
+ ```
164
+ ```bash
165
+ twine upload dist/*
166
+ ```
@@ -1,3 +1,5 @@
1
+ __version__ = "6.0.0"
2
+
1
3
  from .better_ffmpeg_progress import FfmpegProcess
2
4
  from .enums import FfmpegLogLevel
3
5
  from .exceptions import (
@@ -8,6 +10,7 @@ from .exceptions import (
8
10
  )
9
11
 
10
12
  __all__ = [
13
+ "__version__",
11
14
  "FfmpegProcess",
12
15
  "FfmpegLogLevel",
13
16
  "FfmpegProcessError",
@@ -4,8 +4,7 @@ import re
4
4
  import shlex
5
5
  from pathlib import Path
6
6
  import subprocess
7
- from sys import exit
8
- from typing import List, Optional, Union
7
+ from typing import IO, Any, List, Optional, Union
9
8
 
10
9
  from .enums import FfmpegLogLevel
11
10
  from .exceptions import (
@@ -19,11 +18,19 @@ from .utils import (
19
18
  get_media_duration,
20
19
  get_expected_output_duration,
21
20
  is_lavfi_input,
21
+ is_local_file_input,
22
+ is_stdin_input,
22
23
  )
23
24
  from .terminate_process import terminate_ffmpeg_process
24
25
  from .progress_bars import use_rich, use_tqdm
25
26
 
26
27
  _FFMPEG_OVERWRITE_FLAG = "-y"
28
+ _FFMPEG_NO_OVERWRITE_FLAG = "-n"
29
+
30
+
31
+ def _safe_filename(name: str) -> str:
32
+ """Replaces characters that are not valid in filenames on every OS."""
33
+ return re.sub(r"[^\w.,-]", "_", name)
27
34
 
28
35
 
29
36
  class FfmpegProcess:
@@ -59,9 +66,11 @@ class FfmpegProcess:
59
66
  self,
60
67
  command: List[str],
61
68
  ffmpeg_log_level: Optional[Union[FfmpegLogLevel, str]] = None,
62
- ffmpeg_log_file: Optional[Union[str, os.PathLike]] = None,
69
+ ffmpeg_log_file: Optional[Union[str, os.PathLike, IO[Any]]] = None,
63
70
  print_detected_duration: bool = False,
64
71
  duration_override: Optional[float] = None,
72
+ overwrite: Optional[bool] = None,
73
+ use_tqdm: bool = False,
65
74
  ):
66
75
  """
67
76
  Create an FFmpeg process with progress reporting.
@@ -72,12 +81,19 @@ class FfmpegProcess:
72
81
  ffmpeg_log_level:
73
82
  FFmpeg log level enum or case-insensitive string.
74
83
  ffmpeg_log_file:
75
- File path or file-like object used for FFmpeg stderr.
84
+ File path or file object (with a file descriptor, e.g. one returned by open())
85
+ to which FFmpeg's stderr is written.
76
86
  print_detected_duration:
77
87
  Print the duration used for progress calculation.
78
88
  duration_override:
79
89
  Specify input file duration in seconds to use for progress calculation, instead of trying to detect this automatically with FFprobe. This is useful for partial, damaged, streamed, or
80
90
  synthetic inputs whose duration cannot be detected correctly.
91
+ overwrite:
92
+ What to do if the output file already exists. True overwrites it, False raises
93
+ FfmpegCommandError, and None (the default) asks the user. Ignored if the command
94
+ contains -y or -n.
95
+ use_tqdm:
96
+ Use tqdm instead of Rich for the progress bar.
81
97
  """
82
98
  validate_ffmpeg_command(command)
83
99
 
@@ -118,10 +134,17 @@ class FfmpegProcess:
118
134
  # A filtergraph such as "testsrc2=size=1920x1080" can contain characters that
119
135
  # are not valid in filenames (e.g. ":" on Windows), so name the log after the
120
136
  # source instead.
121
- source_name = re.sub(r"[^\w.,-]", "_", input_file_path_str.split("=", 1)[0])
137
+ source_name = _safe_filename(input_file_path_str.split("=", 1)[0])
122
138
  self._ffmpeg_log_file = Path(f"lavfi_{source_name}_ffmpeg_log.txt")
123
- else:
139
+ elif input_file_path_str == "-":
140
+ self._ffmpeg_log_file = Path("stdin_ffmpeg_log.txt")
141
+ elif is_local_file_input(command, input_file_index):
124
142
  self._ffmpeg_log_file = Path(f"{self._input_filepath.name}_ffmpeg_log.txt")
143
+ else:
144
+ # URLs and other protocols (e.g. "pipe:0") can contain characters that are not
145
+ # valid in filenames, such as ":" or "?".
146
+ name = self._input_filepath.name or input_file_path_str
147
+ self._ffmpeg_log_file = Path(f"{_safe_filename(name)}_ffmpeg_log.txt")
125
148
  elif isinstance(ffmpeg_log_file, (str, os.PathLike)):
126
149
  self._ffmpeg_log_file = Path(ffmpeg_log_file)
127
150
  else:
@@ -142,6 +165,9 @@ class FfmpegProcess:
142
165
  raise ValueError("duration_override must be a finite number greater than 0")
143
166
 
144
167
  self._duration_secs = duration_value
168
+ elif is_stdin_input(input_file_path_str):
169
+ # Running FFprobe on standard input would consume data that is meant for FFmpeg.
170
+ self._duration_secs = None
145
171
  else:
146
172
  self._duration_secs = get_media_duration(input_file_path_str)
147
173
 
@@ -178,13 +204,29 @@ class FfmpegProcess:
178
204
  ]
179
205
  self._ffmpeg_command.extend(command[1:])
180
206
 
181
- is_overwrite_in_user_command = any(arg == _FFMPEG_OVERWRITE_FLAG for arg in command[1:])
207
+ if overwrite is not None and not isinstance(overwrite, bool):
208
+ raise TypeError("overwrite must be True, False or None")
182
209
 
183
- if self._output_filepath.exists() and not is_overwrite_in_user_command:
184
- self._handle_overwrite_prompt()
210
+ user_args = command[1:]
211
+ if _FFMPEG_OVERWRITE_FLAG not in user_args and self._output_filepath.exists():
212
+ if _FFMPEG_NO_OVERWRITE_FLAG in user_args or overwrite is False:
213
+ raise FfmpegCommandError(
214
+ f"Output file {self._output_filepath} already exists and overwriting is "
215
+ "disabled."
216
+ )
217
+ elif overwrite:
218
+ self._ffmpeg_command.insert(1, _FFMPEG_OVERWRITE_FLAG)
219
+ elif is_stdin_input(input_file_path_str):
220
+ # Prompting would read from standard input, which carries FFmpeg's input.
221
+ raise FfmpegCommandError(
222
+ f"Output file {self._output_filepath} already exists. Pass overwrite=True "
223
+ "or add -y to the command to overwrite it."
224
+ )
225
+ else:
226
+ self._handle_overwrite_prompt()
185
227
 
186
228
  self._process: Optional[subprocess.Popen] = None
187
- self.use_tqdm: bool = False
229
+ self.use_tqdm: bool = use_tqdm
188
230
 
189
231
  def run(
190
232
  self,
@@ -193,12 +235,15 @@ class FfmpegProcess:
193
235
  if print_command:
194
236
  print(f"Executing: {shlex.join(self._ffmpeg_command)}")
195
237
 
196
- try:
197
- creationflags = 0
198
- # Windows
199
- if os.name == "nt":
200
- creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
238
+ # Run FFmpeg in its own process group, so that terminating the group on
239
+ # KeyboardInterrupt does not also terminate this Python process.
240
+ popen_kwargs = {}
241
+ if os.name == "nt":
242
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
243
+ else:
244
+ popen_kwargs["start_new_session"] = True
201
245
 
246
+ try:
202
247
  if isinstance(self._ffmpeg_log_file, Path):
203
248
  with open(
204
249
  self._ffmpeg_log_file,
@@ -209,14 +254,14 @@ class FfmpegProcess:
209
254
  self._ffmpeg_command,
210
255
  stdout=subprocess.PIPE,
211
256
  stderr=f,
212
- creationflags=creationflags,
257
+ **popen_kwargs,
213
258
  )
214
259
  else:
215
260
  self._process = subprocess.Popen(
216
261
  self._ffmpeg_command,
217
262
  stdout=subprocess.PIPE,
218
263
  stderr=self._ffmpeg_log_file,
219
- creationflags=creationflags,
264
+ **popen_kwargs,
220
265
  )
221
266
  except Exception as e:
222
267
  raise FfmpegProcessError(f"Error starting FFmpeg process: {e}") from e
@@ -226,14 +271,11 @@ class FfmpegProcess:
226
271
  use_tqdm(self, self._process)
227
272
  else:
228
273
  use_rich(self, self._process)
229
- except KeyboardInterrupt:
230
- self._terminate()
274
+ except KeyboardInterrupt as e:
275
+ terminate_ffmpeg_process(self._process)
276
+ raise FfmpegProcessInterruptedError(
277
+ "[KeyboardInterrupt] FFmpeg process cancelled."
278
+ ) from e
231
279
  finally:
232
280
  if self._process and self._process.stdout:
233
281
  self._process.stdout.close()
234
-
235
- def _terminate(self):
236
- if self._process:
237
- terminate_ffmpeg_process(self._process)
238
- else:
239
- exit()
@@ -13,6 +13,7 @@ from rich.progress import (
13
13
  from tqdm import tqdm
14
14
 
15
15
  from .utils import (
16
+ build_failure_message,
16
17
  estimate_final_size_mb,
17
18
  parse_ffmpeg_progress_line,
18
19
  parse_ffmpeg_total_size_line,
@@ -44,6 +45,16 @@ def _with_size(text: str, current_size_mb: float, estimated_size_mb: Optional[fl
44
45
  return f"{text} {size}" if size else text
45
46
 
46
47
 
48
+ def _drain(process: subprocess.Popen) -> None:
49
+ """
50
+ Reads FFmpeg's progress output until it ends. FFmpeg writes it to a pipe, so it must be read
51
+ even when it is not displayed: once the pipe's buffer is full, FFmpeg blocks and never exits.
52
+ """
53
+ if process.stdout is not None:
54
+ for _ in process.stdout:
55
+ pass
56
+
57
+
47
58
  def use_rich(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen) -> None:
48
59
  task_id = None
49
60
  progress_bar_instance = Progress(
@@ -94,6 +105,7 @@ def use_rich(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
94
105
  )
95
106
  else:
96
107
  print(f"Processing '{input_filename}'...")
108
+ _drain(process)
97
109
 
98
110
  process.wait()
99
111
 
@@ -115,7 +127,7 @@ def use_rich(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
115
127
  if task_id is not None:
116
128
  progress_bar.update(task_id)
117
129
 
118
- raise FfmpegProcessError(f"FFmpeg process failed. Check '{log_file}' for details.")
130
+ raise FfmpegProcessError(build_failure_message(log_file))
119
131
 
120
132
 
121
133
  def use_tqdm(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen) -> None:
@@ -161,6 +173,7 @@ def use_tqdm(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
161
173
  progress_bar.set_postfix_str(_format_size(current_size_mb, estimated_size_mb))
162
174
  else:
163
175
  print(f"Processing '{input_filename}'...")
176
+ _drain(process)
164
177
 
165
178
  process.wait()
166
179
 
@@ -176,4 +189,4 @@ def use_tqdm(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
176
189
  if progress_bar:
177
190
  progress_bar.close()
178
191
 
179
- raise FfmpegProcessError(f"FFmpeg process failed. Check '{log_file}' for details.")
192
+ raise FfmpegProcessError(build_failure_message(log_file))
@@ -1,11 +1,16 @@
1
+ import logging
1
2
  import os
2
- import psutil
3
3
  import subprocess
4
- from sys import exit
4
+
5
+ import psutil
5
6
  from typing import Optional
6
7
 
8
+ from .exceptions import FfmpegProcessError
9
+
7
10
  _TERMINATION_TIMEOUT = 2
8
11
 
12
+ logger = logging.getLogger(__name__)
13
+
9
14
 
10
15
  def _terminate_children_processes(parent_proc: psutil.Process):
11
16
  """Helper to terminate child processes of the given parent process."""
@@ -14,17 +19,17 @@ def _terminate_children_processes(parent_proc: psutil.Process):
14
19
  return
15
20
  children = parent_proc.children(recursive=True)
16
21
  except (psutil.NoSuchProcess, psutil.ZombieProcess):
17
- print(
22
+ logger.info(
18
23
  f"Parent process {parent_proc.pid} not found or is a zombie while fetching children."
19
24
  )
20
25
  return
21
26
  except psutil.AccessDenied:
22
- print(
27
+ logger.warning(
23
28
  f"Access denied when trying to list children of {parent_proc.pid}. Skipping child termination."
24
29
  )
25
30
  return
26
31
  except Exception as e:
27
- print(
32
+ logger.warning(
28
33
  f"Error fetching children for process {parent_proc.pid}: {e}. Skipping child termination."
29
34
  )
30
35
  return
@@ -32,7 +37,9 @@ def _terminate_children_processes(parent_proc: psutil.Process):
32
37
  if not children:
33
38
  return
34
39
 
35
- print(f"Attempting to terminate {len(children)} child process(es) of {parent_proc.pid}...")
40
+ logger.info(
41
+ f"Attempting to terminate {len(children)} child process(es) of {parent_proc.pid}..."
42
+ )
36
43
  for child in children:
37
44
  try:
38
45
  if child.is_running():
@@ -43,7 +50,7 @@ def _terminate_children_processes(parent_proc: psutil.Process):
43
50
  _, alive = psutil.wait_procs(children, timeout=_TERMINATION_TIMEOUT)
44
51
 
45
52
  if alive:
46
- print(f"Force killing {len(alive)} remaining child process(es)...")
53
+ logger.warning(f"Force killing {len(alive)} remaining child process(es)...")
47
54
  for child_to_kill in alive:
48
55
  try:
49
56
  if child_to_kill.is_running():
@@ -58,27 +65,31 @@ def _terminate_parent_process_windows(proc: psutil.Process):
58
65
  proc.terminate()
59
66
  proc.wait(timeout=_TERMINATION_TIMEOUT)
60
67
  except psutil.NoSuchProcess:
61
- print(f"FFmpeg process {proc.pid} already exited.")
68
+ logger.info(f"FFmpeg process {proc.pid} already exited.")
62
69
  except psutil.TimeoutExpired:
63
- print(
70
+ logger.warning(
64
71
  f"FFmpeg process {proc.pid} did not terminate within {_TERMINATION_TIMEOUT}s. Checking status..."
65
72
  )
66
73
  try:
67
74
  # Check the status after the timeout
68
75
  if proc.is_running():
69
- print(f"FFmpeg process {proc.pid} still running after {_TERMINATION_TIMEOUT}s.")
76
+ logger.warning(
77
+ f"FFmpeg process {proc.pid} still running after {_TERMINATION_TIMEOUT}s."
78
+ )
70
79
  else:
71
- print(f"FFmpeg process termination took longer than {_TERMINATION_TIMEOUT}s.")
80
+ logger.info(
81
+ f"FFmpeg process termination took longer than {_TERMINATION_TIMEOUT}s."
82
+ )
72
83
  except psutil.NoSuchProcess:
73
- print(
84
+ logger.info(
74
85
  f"FFmpeg process not found after {_TERMINATION_TIMEOUT}s. It seems like it has terminated."
75
86
  )
76
87
  except psutil.Error as e:
77
- print(f"Error during status check for process {proc.pid} after timeout:\n{e}")
88
+ logger.warning(f"Error during status check for process {proc.pid} after timeout:\n{e}")
78
89
  except psutil.AccessDenied:
79
- print(f"Access denied when trying to terminate FFmpeg process {proc.pid}.")
90
+ logger.warning(f"Access denied when trying to terminate FFmpeg process {proc.pid}.")
80
91
  except psutil.Error as e:
81
- print(f"Unable to terminate FFmpeg process:\n{e}")
92
+ logger.warning(f"Unable to terminate FFmpeg process:\n{e}")
82
93
 
83
94
 
84
95
  def _terminate_parent_process_unix(proc: psutil.Process, original_pid: int):
@@ -89,7 +100,7 @@ def _terminate_parent_process_unix(proc: psutil.Process, original_pid: int):
89
100
  pgid_to_signal = 0
90
101
  try:
91
102
  pgid_to_signal = os.getpgid(original_pid)
92
- print(f"Sending SIGTERM to process group {pgid_to_signal}...")
103
+ logger.info(f"Sending SIGTERM to process group {pgid_to_signal}...")
93
104
  os.killpg(pgid_to_signal, SIGTERM)
94
105
 
95
106
  try:
@@ -98,14 +109,16 @@ def _terminate_parent_process_unix(proc: psutil.Process, original_pid: int):
98
109
  if not proc.is_running():
99
110
  terminated_by_pg = True
100
111
  except psutil.TimeoutExpired:
101
- print(f"Process group {pgid_to_signal} did not exit after SIGTERM, sending SIGKILL...")
112
+ logger.warning(
113
+ f"Process group {pgid_to_signal} did not exit after SIGTERM, sending SIGKILL..."
114
+ )
102
115
  os.killpg(pgid_to_signal, SIGKILL)
103
116
  try:
104
117
  proc.wait(timeout=_TERMINATION_TIMEOUT) # Wait for SIGKILL on process group
105
118
  if not proc.is_running():
106
119
  terminated_by_pg = True
107
120
  except psutil.TimeoutExpired:
108
- print(f"Process group {pgid_to_signal} still running after SIGKILL.")
121
+ logger.warning(f"Process group {pgid_to_signal} still running after SIGKILL.")
109
122
  # Process died after SIGKILL
110
123
  except psutil.NoSuchProcess:
111
124
  terminated_by_pg = True
@@ -114,7 +127,7 @@ def _terminate_parent_process_unix(proc: psutil.Process, original_pid: int):
114
127
  terminated_by_pg = True
115
128
  # os.getpgid(original_pid) failed
116
129
  except ProcessLookupError:
117
- print(
130
+ logger.warning(
118
131
  f"ProcessLookupError for PID {original_pid} when trying to get PGID. Fallback to individual process."
119
132
  )
120
133
  # os.killpg failed
@@ -122,7 +135,9 @@ def _terminate_parent_process_unix(proc: psutil.Process, original_pid: int):
122
135
  target_desc = (
123
136
  f"process group {pgid_to_signal}" if pgid_to_signal != 0 else f"process {original_pid}"
124
137
  )
125
- print(f"Error sending signal to {target_desc}: {e}. Fallback to individual process.")
138
+ logger.warning(
139
+ f"Error sending signal to {target_desc}: {e}. Fallback to individual process."
140
+ )
126
141
 
127
142
  if terminated_by_pg:
128
143
  return
@@ -130,7 +145,7 @@ def _terminate_parent_process_unix(proc: psutil.Process, original_pid: int):
130
145
  # Fallback: Terminate the main process directly if process group termination failed or was skipped
131
146
  try:
132
147
  if proc.is_running():
133
- print(f"Attempting direct SIGTERM for main process {proc.pid}...")
148
+ logger.info(f"Attempting direct SIGTERM for main process {proc.pid}...")
134
149
  try:
135
150
  # Send SIGTERM
136
151
  proc.terminate()
@@ -139,11 +154,11 @@ def _terminate_parent_process_unix(proc: psutil.Process, original_pid: int):
139
154
  if not proc.is_running():
140
155
  return
141
156
  except psutil.TimeoutExpired:
142
- print(
157
+ logger.warning(
143
158
  f"SIGTERM for FFmpeg process (PID: {proc.pid}) timed out after {_TERMINATION_TIMEOUT}s."
144
159
  )
145
160
  except psutil.Error as e:
146
- print(f"Error during SIGTERM for FFmpeg process (PID: {proc.pid}):\n{e}")
161
+ logger.warning(f"Error during SIGTERM for FFmpeg process (PID: {proc.pid}):\n{e}")
147
162
  if not proc.is_running():
148
163
  return
149
164
 
@@ -151,21 +166,23 @@ def _terminate_parent_process_unix(proc: psutil.Process, original_pid: int):
151
166
 
152
167
  # Check if process is still running
153
168
  if proc.is_running():
154
- print(
169
+ logger.warning(
155
170
  f"FFmpeg process (PID: {proc.pid}) still running after direct SIGTERM, attempting SIGKILL..."
156
171
  )
157
172
  try:
158
173
  # Send SIGKILL
159
174
  proc.kill()
160
- print(f"SIGKILL sent to FFmpeg process (PID: {proc.pid}).")
175
+ logger.info(f"SIGKILL sent to FFmpeg process (PID: {proc.pid}).")
161
176
  except psutil.Error as e:
162
- print(f"Error during SIGKILL for FFmpeg process (PID: {proc.pid}):\n{e}")
177
+ logger.warning(
178
+ f"Error during SIGKILL for FFmpeg process (PID: {proc.pid}):\n{e}"
179
+ )
163
180
  else:
164
- print(f"It seems like FFmpeg process {proc.pid} is no longer running.")
181
+ logger.info(f"It seems like FFmpeg process {proc.pid} is no longer running.")
165
182
  except psutil.NoSuchProcess:
166
- print(f"It seems like the FFmpeg process (PID: {proc.pid}) has terminated.")
183
+ logger.info(f"It seems like the FFmpeg process (PID: {proc.pid}) has terminated.")
167
184
  except psutil.Error as e:
168
- print(f"Unable to check the status of FFmpeg process (PID: {proc.pid}):\n{e}")
185
+ logger.warning(f"Unable to check the status of FFmpeg process (PID: {proc.pid}):\n{e}")
169
186
 
170
187
 
171
188
  def terminate_ffmpeg_process(process: Optional[subprocess.Popen]):
@@ -184,12 +201,12 @@ def terminate_ffmpeg_process(process: Optional[subprocess.Popen]):
184
201
  try:
185
202
  parent_ps_proc = psutil.Process(original_pid)
186
203
  except (psutil.NoSuchProcess, psutil.ZombieProcess):
187
- print(
204
+ logger.info(
188
205
  f"FFmpeg process {original_pid} already gone or is a zombie when termination started."
189
206
  )
190
207
  return
191
208
  except Exception as e:
192
- print(f"Error accessing FFmpeg process {original_pid} with psutil: {e}")
209
+ logger.warning(f"Error accessing FFmpeg process {original_pid} with psutil: {e}")
193
210
  if process.poll() is None:
194
211
  try:
195
212
  process.terminate()
@@ -198,7 +215,7 @@ def terminate_ffmpeg_process(process: Optional[subprocess.Popen]):
198
215
  process.kill()
199
216
  process.wait(timeout=_TERMINATION_TIMEOUT)
200
217
  except Exception as popen_term_err:
201
- print(f"Error during fallback Popen termination: {popen_term_err}")
218
+ logger.warning(f"Error during fallback Popen termination: {popen_term_err}")
202
219
  return
203
220
 
204
221
  if parent_ps_proc:
@@ -211,17 +228,17 @@ def terminate_ffmpeg_process(process: Optional[subprocess.Popen]):
211
228
  else:
212
229
  _terminate_parent_process_unix(parent_ps_proc, original_pid)
213
230
  elif parent_ps_proc:
214
- print(
231
+ logger.info(
215
232
  f"Main FFmpeg process {parent_ps_proc.pid} exited, possibly during child termination or was already gone."
216
233
  )
217
234
  except psutil.NoSuchProcess:
218
- print(
235
+ logger.info(
219
236
  f"Main FFmpeg process {original_pid} became inaccessible during termination sequence."
220
237
  )
221
238
  except Exception as e:
222
- print(f"Unexpected error during main process termination for {original_pid}: {e}")
239
+ logger.warning(f"Unexpected error during main process termination for {original_pid}: {e}")
223
240
 
224
241
  if process.poll() is None:
225
- exit("It seems like the FFmpeg process has not terminated.")
242
+ raise FfmpegProcessError(f"FFmpeg process (PID: {original_pid}) could not be terminated.")
226
243
  else:
227
- print(f"FFmpeg process (PID: {original_pid}) terminated.")
244
+ logger.info(f"FFmpeg process (PID: {original_pid}) terminated.")
@@ -2,9 +2,9 @@ import os
2
2
  import re
3
3
  import shutil
4
4
  import subprocess
5
+ from collections import deque
5
6
  from pathlib import Path
6
7
  from typing import Dict, List, Optional
7
- import requests # Added for URL validation
8
8
 
9
9
  from .exceptions import FfmpegCommandError
10
10
 
@@ -15,12 +15,56 @@ _BYTES_PER_MB = 1024 * 1024
15
15
  _MULTIPLIER = 1e-6
16
16
 
17
17
 
18
+ # A protocol prefix such as "pipe:", "rtsp://" or "concat:". At least two characters are required
19
+ # before the colon, so that a Windows drive letter (e.g. "C:\\video.mp4") is not matched.
20
+ _PROTOCOL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]+:")
21
+ # A printf-style pattern used for image sequences, e.g. "img%03d.png".
22
+ _SEQUENCE_PATTERN_RE = re.compile(r"%0?\d*d")
23
+
24
+
25
+ def get_input_format(command: List[str], input_flag_pos: int) -> Optional[str]:
26
+ """
27
+ Returns the value of the "-f" option given for the first input (e.g. "lavfi" for
28
+ "-f lavfi -i testsrc2"), or None if no input format is specified.
29
+ """
30
+ input_format = None
31
+ args = command[1:input_flag_pos]
32
+ for i, arg in enumerate(args[:-1]):
33
+ if arg == "-f":
34
+ input_format = args[i + 1]
35
+ return input_format
36
+
37
+
18
38
  def is_lavfi_input(command: List[str], input_flag_pos: int) -> bool:
19
39
  """
20
- Returns True if the input at input_flag_pos is preceded by "-f lavfi", meaning it is a
21
- filtergraph (e.g. "testsrc2=duration=30") generated by FFmpeg rather than a file or URL.
40
+ Returns True if the first input's format is "lavfi", meaning it is a filtergraph
41
+ (e.g. "testsrc2=duration=30") generated by FFmpeg rather than a file or URL.
22
42
  """
23
- return command[max(input_flag_pos - 2, 0) : input_flag_pos] == ["-f", "lavfi"]
43
+ return get_input_format(command, input_flag_pos) == "lavfi"
44
+
45
+
46
+ def is_stdin_input(input_file: str) -> bool:
47
+ """Returns True if the input is read from standard input or a file descriptor."""
48
+ return input_file == "-" or input_file.startswith("pipe:")
49
+
50
+
51
+ def is_protocol_input(input_file: str) -> bool:
52
+ """Returns True if the input uses a protocol, e.g. "https://...", "rtsp://..." or "pipe:0"."""
53
+ return bool(_PROTOCOL_RE.match(input_file))
54
+
55
+
56
+ def is_local_file_input(command: List[str], input_flag_pos: int) -> bool:
57
+ """
58
+ Returns True if the first input is expected to be a path to an existing local file.
59
+ Inputs read from standard input, inputs with a protocol (e.g. a URL), and inputs with an
60
+ explicit format (e.g. "-f lavfi", "-f v4l2" or "-f concat") are not.
61
+ """
62
+ input_file = command[input_flag_pos + 1]
63
+ return not (
64
+ is_stdin_input(input_file)
65
+ or is_protocol_input(input_file)
66
+ or get_input_format(command, input_flag_pos) is not None
67
+ )
24
68
 
25
69
 
26
70
  def validate_ffmpeg_command(command: List[str]) -> None:
@@ -45,12 +89,7 @@ def validate_ffmpeg_command(command: List[str]) -> None:
45
89
  f"FFmpeg command must include the input flag '{_FFMPEG_INPUT_FLAG}'."
46
90
  )
47
91
 
48
- try:
49
- input_flag_pos = command.index(_FFMPEG_INPUT_FLAG)
50
- except ValueError:
51
- raise FfmpegCommandError(
52
- f"Internal error: Input flag '{_FFMPEG_INPUT_FLAG}' not found after check."
53
- )
92
+ input_flag_pos = command.index(_FFMPEG_INPUT_FLAG)
54
93
 
55
94
  input_file_idx = input_flag_pos + 1
56
95
  if input_file_idx >= len(command):
@@ -62,16 +101,13 @@ def validate_ffmpeg_command(command: List[str]) -> None:
62
101
  f"Input file path '{input_file}' looks like an option flag. Please check your command."
63
102
  )
64
103
 
65
- # Validate input file/URL. lavfi inputs are generated by FFmpeg, so there is nothing to check.
66
- if is_lavfi_input(command, input_flag_pos):
67
- pass
68
- elif input_file.startswith("http://") or input_file.startswith("https://"):
69
- try:
70
- response = requests.head(input_file, timeout=5, allow_redirects=True)
71
- response.raise_for_status()
72
- except requests.exceptions.RequestException as e:
73
- raise FfmpegCommandError(f"Input URL not accessible: {input_file}. Error: {e}")
74
- elif not Path(input_file).exists():
104
+ # Validate the input. Only local file paths can be checked: inputs such as standard input,
105
+ # network streams, devices and lavfi filtergraphs are left for FFmpeg to validate.
106
+ if (
107
+ is_local_file_input(command, input_flag_pos)
108
+ and not Path(input_file).exists()
109
+ and not _SEQUENCE_PATTERN_RE.search(input_file)
110
+ ):
75
111
  raise FfmpegCommandError(f"Input file not found: {input_file}")
76
112
 
77
113
  if len(command) <= input_file_idx + 1:
@@ -80,7 +116,7 @@ def validate_ffmpeg_command(command: List[str]) -> None:
80
116
  )
81
117
 
82
118
  output_file_path_str = command[-1]
83
- if output_file_path_str == input_file:
119
+ if output_file_path_str == input_file and is_local_file_input(command, input_flag_pos):
84
120
  raise FfmpegCommandError(
85
121
  "Output file path cannot be the same as the input file path in this command structure."
86
122
  )
@@ -282,3 +318,23 @@ def estimate_final_size_mb(
282
318
  if current_time_secs < min_secs or current_size_mb <= 0:
283
319
  return None
284
320
  return current_size_mb / current_time_secs * total_duration_secs
321
+
322
+
323
+ def build_failure_message(log_file: object, max_lines: int = 5) -> str:
324
+ """
325
+ Returns the error message for a failed FFmpeg process, including the last lines of the
326
+ FFmpeg log when it was written to a file path.
327
+ """
328
+ message = f"FFmpeg process failed. Check '{log_file}' for details."
329
+ if not isinstance(log_file, Path):
330
+ return message
331
+
332
+ try:
333
+ with open(log_file, encoding="utf-8", errors="replace") as f:
334
+ tail = [line.rstrip() for line in deque(f, maxlen=max_lines) if line.strip()]
335
+ except OSError:
336
+ return message
337
+
338
+ if not tail:
339
+ return message
340
+ return message + " Last lines of the log:\n" + "\n".join(tail)
@@ -1,3 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: better-ffmpeg-progress
3
+ Version: 6.0.0
4
+ Summary: Run FFmpeg & see percentage progress + ETA.
5
+ Author-email: "GitHub.com/CrypticSignal" <theaudiophile@outlook.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/CrypticSignal/better-ffmpeg-progress
8
+ Project-URL: Changelog, https://github.com/CrypticSignal/better-ffmpeg-progress/blob/main/CHANGELOG.md
9
+ Keywords: ffmpeg,progress
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: psutil
17
+ Requires-Dist: rich
18
+ Requires-Dist: tqdm
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == "test"
21
+ Dynamic: license-file
22
+
1
23
  <div align="center">
2
24
 
3
25
  # Better FFmpeg Progress
@@ -46,9 +68,8 @@ command = [
46
68
  ]
47
69
 
48
70
  try:
71
+ # Pass use_tqdm=True to use tqdm instead of Rich.
49
72
  process = FfmpegProcess(command)
50
- # Uncomment this line to use tqdm instead of Rich.
51
- # process.use_tqdm = True
52
73
 
53
74
  process.run()
54
75
  except FfmpegProcessError as e:
@@ -118,14 +139,50 @@ An instance of `FfmpegProcess` accepts the following optional arguments:
118
139
  * `ffmpeg_log_file` — Filepath or file-like object to which FFmpeg stderr will be written. Default: `<input filename>_ffmpeg_log.txt`.
119
140
  * `print_detected_duration` — Print the detected duration. Default: `False`.
120
141
  * `duration_override` — Specify input file duration in seconds to use for progress calculation, instead of trying to detect this automatically with FFprobe. Default: `None`.
142
+ * `use_tqdm` — Use tqdm instead of Rich for the progress bar. Default: `False`.
143
+ * `overwrite` — What to do if the output file already exists: `True` overwrites it, `False` raises `FfmpegCommandError`, and `None` asks whether to overwrite it. Ignored if the command contains `-y` or `-n`. Default: `None`.
121
144
 
122
145
  The `run` method accepts the following optional argument:
123
146
  * `print_command` — Print the FFmpeg command being executed. Default: `False`.
124
147
 
125
148
  ## Using tqdm
126
- By default, the [Rich](https://github.com/Textualize/rich) library is used to display a progress bar. [tqdm](https://github.com/tqdm/tqdm) can be used instead by setting `process.use_tqdm = True`:
149
+ By default, the [Rich](https://github.com/Textualize/rich) library is used to display a progress bar. [tqdm](https://github.com/tqdm/tqdm) can be used instead by passing `use_tqdm=True`:
127
150
  ```python
128
- process = FfmpegProcess(command)
129
- process.use_tqdm = True
151
+ process = FfmpegProcess(command, use_tqdm=True)
130
152
  process.run()
131
- ```
153
+ ```
154
+ ## Limitations
155
+ - Progress is calculated from the duration of the first input (the first `-i`).
156
+ - The last argument of the command is treated as the output file, e.g. when checking whether it already exists. With multiple outputs, only the last one is checked.
157
+
158
+ ## Development
159
+ Install the package in editable mode with the test dependencies, then run the tests (FFmpeg and FFprobe must be installed):
160
+ ```bash
161
+ pip install -e ".[test]"
162
+ ```
163
+ ```bash
164
+ pytest
165
+ ```
166
+ `test.py` is a standalone example that encodes a test pattern and shows the progress bar.
167
+
168
+ ## Publishing
169
+ Update `__version__` in `better_ffmpeg_progress/__init__.py` and add an entry to `CHANGELOG.md`.
170
+
171
+ One-time setup:
172
+ ```bash
173
+ pip install --upgrade build twine pkginfo
174
+ ```
175
+
176
+ Then, for each release:
177
+ ```bash
178
+ rm -rf dist
179
+ ```
180
+ ```bash
181
+ python -m build
182
+ ```
183
+ ```bash
184
+ twine check dist/*
185
+ ```
186
+ ```bash
187
+ twine upload dist/*
188
+ ```
@@ -1,15 +1,18 @@
1
1
  LICENSE
2
2
  README.md
3
- setup.py
3
+ pyproject.toml
4
4
  better_ffmpeg_progress/__init__.py
5
5
  better_ffmpeg_progress/better_ffmpeg_progress.py
6
6
  better_ffmpeg_progress/enums.py
7
7
  better_ffmpeg_progress/exceptions.py
8
8
  better_ffmpeg_progress/progress_bars.py
9
+ better_ffmpeg_progress/py.typed
9
10
  better_ffmpeg_progress/terminate_process.py
10
11
  better_ffmpeg_progress/utils.py
11
12
  better_ffmpeg_progress.egg-info/PKG-INFO
12
13
  better_ffmpeg_progress.egg-info/SOURCES.txt
13
14
  better_ffmpeg_progress.egg-info/dependency_links.txt
14
15
  better_ffmpeg_progress.egg-info/requires.txt
15
- better_ffmpeg_progress.egg-info/top_level.txt
16
+ better_ffmpeg_progress.egg-info/top_level.txt
17
+ tests/test_ffmpeg_process.py
18
+ tests/test_utils.py
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "better-ffmpeg-progress"
7
+ dynamic = ["version"]
8
+ description = "Run FFmpeg & see percentage progress + ETA."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "GitHub.com/CrypticSignal", email = "theaudiophile@outlook.com" }]
13
+ requires-python = ">=3.9"
14
+ dependencies = ["psutil", "rich", "tqdm"]
15
+ keywords = ["ffmpeg", "progress"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/CrypticSignal/better-ffmpeg-progress"
24
+ Changelog = "https://github.com/CrypticSignal/better-ffmpeg-progress/blob/main/CHANGELOG.md"
25
+
26
+ [project.optional-dependencies]
27
+ test = ["pytest"]
28
+
29
+ [tool.setuptools]
30
+ packages = ["better_ffmpeg_progress"]
31
+
32
+ [tool.setuptools.dynamic]
33
+ version = { attr = "better_ffmpeg_progress.__version__" }
34
+
35
+ [tool.setuptools.package-data]
36
+ better_ffmpeg_progress = ["py.typed"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,152 @@
1
+ import os
2
+ import shutil
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from better_ffmpeg_progress import (
9
+ FfmpegCommandError,
10
+ FfmpegProcess,
11
+ FfmpegProcessError,
12
+ )
13
+
14
+ pytestmark = pytest.mark.skipif(
15
+ shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
16
+ reason="FFmpeg and FFprobe are required",
17
+ )
18
+
19
+
20
+ @pytest.fixture
21
+ def input_file(tmp_path: Path) -> Path:
22
+ path = tmp_path / "input.mkv"
23
+ FfmpegProcess(
24
+ [
25
+ "ffmpeg",
26
+ "-f",
27
+ "lavfi",
28
+ "-i",
29
+ "testsrc2=duration=2:size=64x64:rate=10",
30
+ "-c:v",
31
+ "ffv1",
32
+ str(path),
33
+ ],
34
+ ffmpeg_log_file=os.devnull,
35
+ duration_override=2,
36
+ ).run()
37
+ return path
38
+
39
+
40
+ @pytest.mark.parametrize("use_tqdm", [False, True])
41
+ def test_run_succeeds(input_file: Path, tmp_path: Path, use_tqdm: bool):
42
+ output_file = tmp_path / "output.mkv"
43
+ log_file = tmp_path / "log.txt"
44
+ process = FfmpegProcess(
45
+ ["ffmpeg", "-i", str(input_file), "-c:v", "ffv1", str(output_file)],
46
+ ffmpeg_log_file=log_file,
47
+ use_tqdm=use_tqdm,
48
+ )
49
+ assert process._duration_secs == pytest.approx(2, abs=0.2)
50
+ process.run()
51
+ assert output_file.stat().st_size > 0
52
+ assert log_file.exists()
53
+
54
+
55
+ def test_run_without_duration_does_not_hang(tmp_path: Path):
56
+ # Without a duration, the progress output must still be read, or FFmpeg blocks once the
57
+ # pipe's buffer is full. -stats_period makes FFmpeg write enough output to fill it.
58
+ process = FfmpegProcess(
59
+ [
60
+ "ffmpeg",
61
+ "-stats_period",
62
+ "0.001",
63
+ "-f",
64
+ "lavfi",
65
+ "-i",
66
+ "testsrc2=duration=20:size=32x32:rate=1000",
67
+ "-f",
68
+ "null",
69
+ "-",
70
+ ],
71
+ ffmpeg_log_file=tmp_path / "log.txt",
72
+ )
73
+ assert process._duration_secs is None
74
+ process.run()
75
+
76
+
77
+ def test_failure_includes_log_tail(input_file: Path, tmp_path: Path):
78
+ process = FfmpegProcess(
79
+ ["ffmpeg", "-i", str(input_file), "-c:v", "no_such_encoder", str(tmp_path / "o.mkv")],
80
+ ffmpeg_log_file=tmp_path / "log.txt",
81
+ )
82
+ with pytest.raises(FfmpegProcessError, match="no_such_encoder"):
83
+ process.run()
84
+
85
+
86
+ def test_missing_input_file(tmp_path: Path):
87
+ with pytest.raises(FfmpegCommandError, match="Input file not found"):
88
+ FfmpegProcess(["ffmpeg", "-i", str(tmp_path / "missing.mp4"), "out.mp4"])
89
+
90
+
91
+ @pytest.mark.parametrize(
92
+ "input_args",
93
+ [
94
+ ["-i", "-"],
95
+ ["-i", "pipe:0"],
96
+ ["-f", "lavfi", "-i", "testsrc2"],
97
+ ["-i", "rtsp://127.0.0.1:1/stream"],
98
+ ],
99
+ )
100
+ def test_non_file_inputs_are_accepted(input_args, monkeypatch, tmp_path: Path):
101
+ monkeypatch.chdir(tmp_path)
102
+ process = FfmpegProcess(["ffmpeg"] + input_args + ["-f", "null", "-"], duration_override=1)
103
+ assert ":" not in process._ffmpeg_log_file.name
104
+
105
+
106
+ def test_image_sequence_input_is_accepted(tmp_path: Path):
107
+ FfmpegProcess(
108
+ ["ffmpeg", "-i", str(tmp_path / "img%03d.png"), "-f", "null", "-"],
109
+ ffmpeg_log_file=os.devnull,
110
+ duration_override=1,
111
+ )
112
+
113
+
114
+ def test_overwrite(input_file: Path, tmp_path: Path):
115
+ output_file = tmp_path / "output.mkv"
116
+ output_file.touch()
117
+ command = ["ffmpeg", "-i", str(input_file), str(output_file)]
118
+
119
+ with pytest.raises(FfmpegCommandError, match="already exists"):
120
+ FfmpegProcess(command, overwrite=False)
121
+
122
+ with pytest.raises(FfmpegCommandError, match="already exists"):
123
+ FfmpegProcess(command[:-1] + ["-n", str(output_file)])
124
+
125
+ process = FfmpegProcess(command, overwrite=True)
126
+ assert "-y" in process._ffmpeg_command
127
+
128
+ # -y in the command takes priority over overwrite=False.
129
+ FfmpegProcess(command[:-1] + ["-y", str(output_file)], overwrite=False)
130
+
131
+
132
+ @pytest.mark.skipif(sys.platform == "win32", reason="Uses POSIX signals")
133
+ def test_keyboard_interrupt_raises_and_does_not_kill_python(tmp_path: Path):
134
+ import signal
135
+ import threading
136
+
137
+ from better_ffmpeg_progress import FfmpegProcessInterruptedError
138
+
139
+ process = FfmpegProcess(
140
+ ["ffmpeg", "-re", "-f", "lavfi", "-i", "testsrc2=duration=30", "-f", "null", "-"],
141
+ ffmpeg_log_file=tmp_path / "log.txt",
142
+ duration_override=30,
143
+ )
144
+ timer = threading.Timer(1, lambda: os.kill(os.getpid(), signal.SIGINT))
145
+ timer.start()
146
+ try:
147
+ with pytest.raises(FfmpegProcessInterruptedError):
148
+ process.run()
149
+ finally:
150
+ timer.cancel()
151
+ assert process._process is not None
152
+ assert process._process.poll() is not None
@@ -0,0 +1,119 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from better_ffmpeg_progress.utils import (
6
+ build_failure_message,
7
+ estimate_final_size_mb,
8
+ get_expected_output_duration,
9
+ get_input_format,
10
+ is_lavfi_input,
11
+ is_local_file_input,
12
+ parse_ffmpeg_progress_line,
13
+ parse_ffmpeg_time,
14
+ parse_ffmpeg_total_size_line,
15
+ truncate_filename,
16
+ )
17
+
18
+
19
+ @pytest.mark.parametrize(
20
+ "value, expected",
21
+ [
22
+ ("90", 90.0),
23
+ ("1.5", 1.5),
24
+ (".5", 0.5),
25
+ ("500ms", 0.5),
26
+ ("250000us", 0.25),
27
+ ("2s", 2.0),
28
+ ("01:30", 90.0),
29
+ ("00:01:30.5", 90.5),
30
+ ("-5", -5.0),
31
+ ("-00:00:10", -10.0),
32
+ (" 10 ", 10.0),
33
+ ("abc", None),
34
+ ("1:2:3:4", None),
35
+ ],
36
+ )
37
+ def test_parse_ffmpeg_time(value, expected):
38
+ assert parse_ffmpeg_time(value) == expected
39
+
40
+
41
+ @pytest.mark.parametrize(
42
+ "args, expected",
43
+ [
44
+ ([], 100.0),
45
+ (["-ss", "10", "-i", "in.mp4", "out.mp4"], 90.0),
46
+ (["-i", "in.mp4", "-ss", "10", "out.mp4"], 90.0),
47
+ (["-i", "in.mp4", "-t", "30", "out.mp4"], 30.0),
48
+ (["-i", "in.mp4", "-to", "40", "out.mp4"], 40.0),
49
+ (["-ss", "10", "-to", "40", "-i", "in.mp4", "out.mp4"], 30.0),
50
+ (["-ss", "10", "-t", "20", "-to", "80", "-i", "in.mp4", "out.mp4"], 20.0),
51
+ (["-sseof", "-15", "-i", "in.mp4", "out.mp4"], 15.0),
52
+ (["-ss", "10", "-i", "in.mp4", "-t", "200", "out.mp4"], 90.0),
53
+ (["-ss", "10", "-copyts", "-i", "in.mp4", "out.mp4"], 100.0),
54
+ # A trim that leaves nothing falls back to the input duration.
55
+ (["-ss", "200", "-i", "in.mp4", "out.mp4"], 100.0),
56
+ ],
57
+ )
58
+ def test_get_expected_output_duration(args, expected):
59
+ command = ["ffmpeg"] + (args or ["-i", "in.mp4", "out.mp4"])
60
+ assert get_expected_output_duration(command, 100.0) == pytest.approx(expected)
61
+
62
+
63
+ def test_parse_ffmpeg_progress_line():
64
+ assert parse_ffmpeg_progress_line(b"out_time_us=1500000", None) == pytest.approx(1.5)
65
+ assert parse_ffmpeg_progress_line(b"out_time_us=1500000", 1.0) == 1.0
66
+ assert parse_ffmpeg_progress_line(b"out_time_us=N/A", 10.0) is None
67
+ assert parse_ffmpeg_progress_line(b"out_time_us=", 10.0) is None
68
+ assert parse_ffmpeg_progress_line(b"frame=10", 10.0) is None
69
+
70
+
71
+ def test_parse_ffmpeg_total_size_line():
72
+ assert parse_ffmpeg_total_size_line(b"total_size=1048576") == 1.0
73
+ assert parse_ffmpeg_total_size_line(b"total_size=N/A") is None
74
+ assert parse_ffmpeg_total_size_line(b"out_time_us=1") is None
75
+
76
+
77
+ def test_truncate_filename():
78
+ assert truncate_filename("short.mp4", 60) == "short.mp4"
79
+ truncated = truncate_filename("a" * 100 + ".mp4", 20)
80
+ assert truncated == "a" * 13 + "....mp4"
81
+ assert len(truncated) == 20
82
+ assert len(truncate_filename("a" * 10 + "." + "b" * 30, 20)) == 20
83
+
84
+
85
+ def test_estimate_final_size_mb():
86
+ assert estimate_final_size_mb(10.0, 0.5, 100.0) is None
87
+ assert estimate_final_size_mb(0.0, 50.0, 100.0) is None
88
+ assert estimate_final_size_mb(10.0, 50.0, 100.0) == pytest.approx(20.0)
89
+
90
+
91
+ @pytest.mark.parametrize(
92
+ "args, expected_format, expected_local",
93
+ [
94
+ (["-i", "in.mp4"], None, True),
95
+ (["-i", "C:\\videos\\in.mp4"], None, True),
96
+ (["-f", "lavfi", "-i", "testsrc2"], "lavfi", False),
97
+ (["-f", "image2", "-pattern_type", "glob", "-i", "*.png"], "image2", False),
98
+ (["-i", "-"], None, False),
99
+ (["-i", "pipe:0"], None, False),
100
+ (["-i", "https://example.com/in.mp4"], None, False),
101
+ (["-i", "rtsp://example.com/stream"], None, False),
102
+ ],
103
+ )
104
+ def test_input_classification(args, expected_format, expected_local):
105
+ command = ["ffmpeg"] + args + ["out.mp4"]
106
+ input_flag_pos = command.index("-i")
107
+ assert get_input_format(command, input_flag_pos) == expected_format
108
+ assert is_lavfi_input(command, input_flag_pos) == (expected_format == "lavfi")
109
+ assert is_local_file_input(command, input_flag_pos) == expected_local
110
+
111
+
112
+ def test_build_failure_message(tmp_path: Path):
113
+ log_file = tmp_path / "log.txt"
114
+ log_file.write_text("".join(f"line {i}\n" for i in range(10)), encoding="utf-8")
115
+ message = build_failure_message(log_file, max_lines=3)
116
+ assert message.endswith("line 7\nline 8\nline 9")
117
+
118
+ assert build_failure_message(tmp_path / "missing.txt").endswith("for details.")
119
+ assert build_failure_message(object()).endswith("for details.")
@@ -1,24 +0,0 @@
1
- import setuptools
2
-
3
- with open("README.md", "r", encoding="utf-8") as f:
4
- long_description = f.read()
5
-
6
- setuptools.setup(
7
- name="better-ffmpeg-progress",
8
- version="5.4.2",
9
- author="GitHub.com/CrypticSignal",
10
- author_email="theaudiophile@outlook.com",
11
- description="Run FFmpeg & see percentage progress + ETA.",
12
- long_description=long_description,
13
- long_description_content_type="text/markdown",
14
- url="https://github.com/CrypticSignal/better-ffmpeg-progress",
15
- packages=["better_ffmpeg_progress"],
16
- install_requires=["psutil", "requests", "rich", "tqdm"],
17
- python_requires=">=3.7",
18
- keywords=["ffmpeg", "progress"],
19
- classifiers=[
20
- "Programming Language :: Python :: 3",
21
- "License :: OSI Approved :: MIT License",
22
- "Operating System :: OS Independent",
23
- ],
24
- )