better-ffmpeg-progress 5.4.2__tar.gz → 6.0.1__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 (25) hide show
  1. {better_ffmpeg_progress-5.4.2/better_ffmpeg_progress.egg-info → better_ffmpeg_progress-6.0.1}/PKG-INFO +51 -14
  2. better_ffmpeg_progress-5.4.2/PKG-INFO → better_ffmpeg_progress-6.0.1/README.md +40 -24
  3. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress/__init__.py +3 -0
  4. better_ffmpeg_progress-6.0.1/better_ffmpeg_progress/better_ffmpeg_progress.py +277 -0
  5. better_ffmpeg_progress-6.0.1/better_ffmpeg_progress/progress_bars.py +98 -0
  6. better_ffmpeg_progress-6.0.1/better_ffmpeg_progress/py.typed +0 -0
  7. better_ffmpeg_progress-6.0.1/better_ffmpeg_progress/terminate_process.py +44 -0
  8. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress/utils.py +95 -70
  9. better_ffmpeg_progress-5.4.2/README.md → better_ffmpeg_progress-6.0.1/better_ffmpeg_progress.egg-info/PKG-INFO +62 -6
  10. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress.egg-info/SOURCES.txt +5 -2
  11. better_ffmpeg_progress-6.0.1/better_ffmpeg_progress.egg-info/requires.txt +5 -0
  12. better_ffmpeg_progress-6.0.1/pyproject.toml +39 -0
  13. better_ffmpeg_progress-6.0.1/tests/test_ffmpeg_process.py +153 -0
  14. better_ffmpeg_progress-6.0.1/tests/test_utils.py +118 -0
  15. better_ffmpeg_progress-5.4.2/better_ffmpeg_progress/better_ffmpeg_progress.py +0 -239
  16. better_ffmpeg_progress-5.4.2/better_ffmpeg_progress/progress_bars.py +0 -179
  17. better_ffmpeg_progress-5.4.2/better_ffmpeg_progress/terminate_process.py +0 -227
  18. better_ffmpeg_progress-5.4.2/better_ffmpeg_progress.egg-info/requires.txt +0 -4
  19. better_ffmpeg_progress-5.4.2/setup.py +0 -24
  20. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/LICENSE +0 -0
  21. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress/enums.py +0 -0
  22. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress/exceptions.py +0 -0
  23. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
  24. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
  25. {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/setup.cfg +0 -0
@@ -1,21 +1,23 @@
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.1
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
- Requires-Dist: psutil
16
- Requires-Dist: requests
17
16
  Requires-Dist: rich
18
17
  Requires-Dist: tqdm
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest; extra == "test"
20
+ Dynamic: license-file
19
21
 
20
22
  <div align="center">
21
23
 
@@ -65,9 +67,8 @@ command = [
65
67
  ]
66
68
 
67
69
  try:
70
+ # Pass use_tqdm=True to use tqdm instead of Rich.
68
71
  process = FfmpegProcess(command)
69
- # Uncomment this line to use tqdm instead of Rich.
70
- # process.use_tqdm = True
71
72
 
72
73
  process.run()
73
74
  except FfmpegProcessError as e:
@@ -137,14 +138,50 @@ An instance of `FfmpegProcess` accepts the following optional arguments:
137
138
  * `ffmpeg_log_file` — Filepath or file-like object to which FFmpeg stderr will be written. Default: `<input filename>_ffmpeg_log.txt`.
138
139
  * `print_detected_duration` — Print the detected duration. Default: `False`.
139
140
  * `duration_override` — Specify input file duration in seconds to use for progress calculation, instead of trying to detect this automatically with FFprobe. Default: `None`.
141
+ * `use_tqdm` — Use tqdm instead of Rich for the progress bar. Default: `False`.
142
+ * `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
143
 
141
144
  The `run` method accepts the following optional argument:
142
145
  * `print_command` — Print the FFmpeg command being executed. Default: `False`.
143
146
 
144
147
  ## 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`:
148
+ 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
149
  ```python
147
- process = FfmpegProcess(command)
148
- process.use_tqdm = True
150
+ process = FfmpegProcess(command, use_tqdm=True)
149
151
  process.run()
150
152
  ```
153
+ ## Limitations
154
+ - Progress is calculated from the duration of the first input (the first `-i`).
155
+ - 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.
156
+
157
+ ## Development
158
+ Install the package in editable mode with the test dependencies, then run the tests (FFmpeg and FFprobe must be installed):
159
+ ```bash
160
+ pip install -e ".[test]"
161
+ ```
162
+ ```bash
163
+ pytest
164
+ ```
165
+ `test.py` is a standalone example that encodes a test pattern and shows the progress bar.
166
+
167
+ ## Publishing
168
+ Update `__version__` in `better_ffmpeg_progress/__init__.py` and add an entry to `CHANGELOG.md`.
169
+
170
+ One-time setup:
171
+ ```bash
172
+ python -m pip install --upgrade build twine pkginfo
173
+ ```
174
+
175
+ Then, for each release:
176
+ ```bash
177
+ rm -rf dist
178
+ ```
179
+ ```bash
180
+ python -m build
181
+ ```
182
+ ```bash
183
+ python -m twine check dist/*
184
+ ```
185
+ ```bash
186
+ python -m twine upload dist/*
187
+ ```
@@ -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
+ python -m 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
+ python -m twine check dist/*
163
+ ```
164
+ ```bash
165
+ python -m twine upload dist/*
166
+ ```
@@ -1,3 +1,5 @@
1
+ __version__ = "6.0.1"
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",
@@ -0,0 +1,277 @@
1
+ import contextlib
2
+ import math
3
+ import os
4
+ import re
5
+ import shlex
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import IO, Any, List, Optional, Union
9
+
10
+ from .enums import FfmpegLogLevel
11
+ from .exceptions import (
12
+ FfmpegCommandError,
13
+ FfmpegProcessError,
14
+ FfmpegProcessInterruptedError,
15
+ FfmpegProcessUserCancelledError,
16
+ )
17
+ from .progress_bars import show_rich_progress, show_tqdm_progress
18
+ from .terminate_process import terminate_ffmpeg_process
19
+ from .utils import (
20
+ build_failure_message,
21
+ get_expected_output_duration,
22
+ get_media_duration,
23
+ is_lavfi_input,
24
+ is_local_file_input,
25
+ is_stdin_input,
26
+ truncate_filename,
27
+ validate_ffmpeg_command,
28
+ )
29
+
30
+ _MAX_FILENAME_LENGTH = 60
31
+
32
+
33
+ def _safe_filename(name: str) -> str:
34
+ """Replaces characters that are not valid in filenames on every OS."""
35
+ return re.sub(r"[^\w.,-]", "_", name)
36
+
37
+
38
+ def _parse_log_level(ffmpeg_log_level: Optional[Union[FfmpegLogLevel, str]]) -> FfmpegLogLevel:
39
+ if ffmpeg_log_level is None:
40
+ return FfmpegLogLevel.VERBOSE
41
+ if isinstance(ffmpeg_log_level, FfmpegLogLevel):
42
+ return ffmpeg_log_level
43
+ if not isinstance(ffmpeg_log_level, str):
44
+ raise TypeError(
45
+ "ffmpeg_log_level must be an FfmpegLogLevel, a string or None, not "
46
+ f"{type(ffmpeg_log_level).__name__}"
47
+ )
48
+ try:
49
+ return FfmpegLogLevel(ffmpeg_log_level.lower())
50
+ except ValueError:
51
+ valid_levels = [level.value for level in FfmpegLogLevel]
52
+ raise FfmpegCommandError(
53
+ f"Invalid ffmpeg_log_level string: '{ffmpeg_log_level}'. "
54
+ f"Must be one of {valid_levels} (case-insensitive)."
55
+ ) from None
56
+
57
+
58
+ def _parse_duration_override(duration_override: Any) -> float:
59
+ if isinstance(duration_override, bool):
60
+ raise TypeError("duration_override must be a number of seconds, not bool")
61
+ try:
62
+ duration = float(duration_override)
63
+ except (TypeError, ValueError) as e:
64
+ raise TypeError("duration_override must be a number of seconds or None") from e
65
+ if not math.isfinite(duration) or duration <= 0:
66
+ raise ValueError("duration_override must be a finite number greater than 0")
67
+ return duration
68
+
69
+
70
+ def _default_log_file(command: List[str], input_flag_pos: int) -> Path:
71
+ """Returns the default log file path, which is named after the input."""
72
+ input_file = command[input_flag_pos + 1]
73
+
74
+ if is_lavfi_input(command, input_flag_pos):
75
+ # A filtergraph such as "testsrc2=size=1920x1080" can contain characters that are not
76
+ # valid in filenames (e.g. ":" on Windows), so name the log after the source instead.
77
+ source_name = _safe_filename(input_file.split("=", 1)[0])
78
+ return Path(f"lavfi_{source_name}_ffmpeg_log.txt")
79
+ if input_file == "-":
80
+ return Path("stdin_ffmpeg_log.txt")
81
+ if is_local_file_input(command, input_flag_pos):
82
+ return Path(f"{Path(input_file).name}_ffmpeg_log.txt")
83
+ # URLs and other protocols (e.g. "pipe:0") can contain characters that are not valid in
84
+ # filenames, such as ":" or "?".
85
+ name = Path(input_file).name or input_file
86
+ return Path(f"{_safe_filename(name)}_ffmpeg_log.txt")
87
+
88
+
89
+ class FfmpegProcess:
90
+ def __init__(
91
+ self,
92
+ command: List[str],
93
+ ffmpeg_log_level: Optional[Union[FfmpegLogLevel, str]] = None,
94
+ ffmpeg_log_file: Optional[Union[str, os.PathLike, IO[Any]]] = None,
95
+ print_detected_duration: bool = False,
96
+ duration_override: Optional[float] = None,
97
+ overwrite: Optional[bool] = None,
98
+ use_tqdm: bool = False,
99
+ ):
100
+ """
101
+ Create an FFmpeg process with progress reporting.
102
+
103
+ Args:
104
+ command:
105
+ FFmpeg command as a list of arguments.
106
+ ffmpeg_log_level:
107
+ FFmpeg log level enum or case-insensitive string.
108
+ ffmpeg_log_file:
109
+ File path or file object (with a file descriptor, e.g. one returned by open())
110
+ to which FFmpeg's stderr is written.
111
+ print_detected_duration:
112
+ Print the duration used for progress calculation.
113
+ duration_override:
114
+ Specify input file duration in seconds to use for progress calculation, instead
115
+ of trying to detect this automatically with FFprobe. This is useful for partial,
116
+ damaged, streamed, or synthetic inputs whose duration cannot be detected
117
+ correctly.
118
+ overwrite:
119
+ What to do if the output file already exists. True overwrites it, False raises
120
+ FfmpegCommandError, and None (the default) asks the user. Ignored if the command
121
+ contains -y or -n.
122
+ use_tqdm:
123
+ Use tqdm instead of Rich for the progress bar.
124
+ """
125
+ validate_ffmpeg_command(command)
126
+ log_level = _parse_log_level(ffmpeg_log_level)
127
+ if overwrite is not None and not isinstance(overwrite, bool):
128
+ raise TypeError("overwrite must be True, False or None")
129
+
130
+ input_flag_pos = command.index("-i")
131
+ input_file = command[input_flag_pos + 1]
132
+ self._input_filepath = Path(input_file)
133
+ # Assumes the last argument is the output.
134
+ self._output_filepath = Path(command[-1])
135
+
136
+ if ffmpeg_log_file is None:
137
+ self._ffmpeg_log_file = _default_log_file(command, input_flag_pos)
138
+ elif isinstance(ffmpeg_log_file, (str, os.PathLike)):
139
+ self._ffmpeg_log_file = Path(ffmpeg_log_file)
140
+ else:
141
+ self._ffmpeg_log_file = ffmpeg_log_file
142
+
143
+ if duration_override is not None:
144
+ input_duration_secs = _parse_duration_override(duration_override)
145
+ elif is_stdin_input(input_file):
146
+ # Running FFprobe on standard input would consume data that is meant for FFmpeg.
147
+ input_duration_secs = None
148
+ else:
149
+ input_duration_secs = get_media_duration(input_file)
150
+
151
+ # Account for trimming (-ss, -sseof, -t, -to) so the progress bar reflects the
152
+ # duration of the output rather than the whole input.
153
+ self._duration_secs = (
154
+ get_expected_output_duration(command, input_duration_secs)
155
+ if input_duration_secs is not None
156
+ else None
157
+ )
158
+
159
+ if duration_override is not None or print_detected_duration:
160
+ if input_duration_secs is None:
161
+ print("Could not detect duration. Progress bar may not show time remaining.")
162
+ else:
163
+ label = (
164
+ "Using duration override"
165
+ if duration_override is not None
166
+ else "Detected duration"
167
+ )
168
+ print(f"{label}: {input_duration_secs:.2f} seconds")
169
+ if self._duration_secs != input_duration_secs:
170
+ print(
171
+ "Expected output duration after trimming: "
172
+ f"{self._duration_secs:.2f} seconds"
173
+ )
174
+
175
+ self._ffmpeg_command = [
176
+ command[0],
177
+ "-hide_banner",
178
+ "-loglevel",
179
+ log_level.value,
180
+ "-progress",
181
+ "pipe:1",
182
+ "-nostats",
183
+ *command[1:],
184
+ ]
185
+
186
+ user_args = command[1:]
187
+ if "-y" not in user_args and self._output_filepath.exists():
188
+ if "-n" in user_args or overwrite is False:
189
+ raise FfmpegCommandError(
190
+ f"Output file {self._output_filepath} already exists and overwriting is "
191
+ "disabled."
192
+ )
193
+ if overwrite is None:
194
+ if is_stdin_input(input_file):
195
+ # Prompting would read from standard input, which carries FFmpeg's input.
196
+ raise FfmpegCommandError(
197
+ f"Output file {self._output_filepath} already exists. Pass "
198
+ "overwrite=True or add -y to the command to overwrite it."
199
+ )
200
+ self._ask_to_overwrite()
201
+ self._ffmpeg_command.insert(1, "-y")
202
+
203
+ self._process: Optional[subprocess.Popen] = None
204
+ self.use_tqdm: bool = use_tqdm
205
+
206
+ def _ask_to_overwrite(self) -> None:
207
+ """Raises an FfmpegProcessError unless the user agrees to overwrite the output file."""
208
+ try:
209
+ answer = input(
210
+ f"Output file {self._output_filepath} already exists. Overwrite? [y/N]: "
211
+ )
212
+ except KeyboardInterrupt as e:
213
+ raise FfmpegProcessInterruptedError(
214
+ "[KeyboardInterrupt] FFmpeg process cancelled during overwrite prompt."
215
+ ) from e
216
+ except EOFError as e:
217
+ raise FfmpegProcessInterruptedError(
218
+ "Input error (EOF) during overwrite prompt. FFmpeg process cancelled."
219
+ ) from e
220
+
221
+ if answer.strip().lower() != "y":
222
+ raise FfmpegProcessUserCancelledError(
223
+ "FFmpeg process cancelled. Output file exists and overwrite declined."
224
+ )
225
+
226
+ def run(self, print_command: bool = False) -> None:
227
+ if print_command:
228
+ print(f"Executing: {shlex.join(self._ffmpeg_command)}")
229
+
230
+ # Run FFmpeg in its own process group, so that pressing Ctrl+C does not send the signal
231
+ # to FFmpeg directly, and terminating the group does not terminate this Python process.
232
+ if os.name == "nt":
233
+ new_process_group = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
234
+ else:
235
+ new_process_group = {"start_new_session": True}
236
+
237
+ if isinstance(self._ffmpeg_log_file, Path):
238
+ log_file = open(self._ffmpeg_log_file, "w", encoding="utf-8")
239
+ else:
240
+ log_file = contextlib.nullcontext(self._ffmpeg_log_file)
241
+
242
+ try:
243
+ # FFmpeg keeps its own handle to the log file, so it can be closed here.
244
+ with log_file as stderr:
245
+ self._process = subprocess.Popen(
246
+ self._ffmpeg_command,
247
+ stdout=subprocess.PIPE,
248
+ stderr=stderr,
249
+ **new_process_group,
250
+ )
251
+ except Exception as e:
252
+ raise FfmpegProcessError(f"Error starting FFmpeg process: {e}") from e
253
+
254
+ filename = truncate_filename(self._input_filepath.name, _MAX_FILENAME_LENGTH)
255
+ try:
256
+ with self._process.stdout:
257
+ if not self._duration_secs:
258
+ print(f"Processing '{filename}'...")
259
+ # FFmpeg's progress output must be read even though it is not displayed:
260
+ # once the pipe's buffer is full, FFmpeg blocks and never exits.
261
+ for _ in self._process.stdout:
262
+ pass
263
+ elif self.use_tqdm:
264
+ show_tqdm_progress(self._process, filename, self._duration_secs)
265
+ else:
266
+ show_rich_progress(self._process, filename, self._duration_secs)
267
+ returncode = self._process.wait()
268
+ except KeyboardInterrupt as e:
269
+ terminate_ffmpeg_process(self._process)
270
+ raise FfmpegProcessInterruptedError(
271
+ "[KeyboardInterrupt] FFmpeg process cancelled."
272
+ ) from e
273
+
274
+ if returncode != 0:
275
+ raise FfmpegProcessError(build_failure_message(self._ffmpeg_log_file))
276
+ if not self._duration_secs:
277
+ print(f"✓ Processed '{filename}'")
@@ -0,0 +1,98 @@
1
+ import os
2
+ import subprocess
3
+ from typing import Optional
4
+
5
+ from rich.progress import (
6
+ BarColumn,
7
+ Progress,
8
+ TaskProgressColumn,
9
+ TextColumn,
10
+ TimeElapsedColumn,
11
+ TimeRemainingColumn,
12
+ )
13
+ from tqdm import tqdm
14
+
15
+ from .utils import estimate_final_size_mb, read_progress
16
+
17
+
18
+ def _format_size(current_size_mb: float, estimated_size_mb: Optional[float]) -> str:
19
+ """
20
+ Returns e.g. "(3.25 MB / ~14.33 MB)", "(3.25 MB)" while there is no estimate yet, or ""
21
+ if FFmpeg has not reported a size (e.g. at the start, or when the output is "-f null").
22
+ """
23
+ # Also hide sizes that would display as "0.00 MB", such as a container header of a few bytes.
24
+ if round(current_size_mb, 2) <= 0:
25
+ return ""
26
+ if estimated_size_mb is None:
27
+ return f"({current_size_mb:.2f} MB)"
28
+ return f"({current_size_mb:.2f} MB / ~{estimated_size_mb:.2f} MB)"
29
+
30
+
31
+ def _with_size(text: str, current_size_mb: float, estimated_size_mb: Optional[float]) -> str:
32
+ size = _format_size(current_size_mb, estimated_size_mb)
33
+ return f"{text} {size}" if size else text
34
+
35
+
36
+ def show_rich_progress(process: subprocess.Popen, filename: str, duration_secs: float) -> None:
37
+ """Shows a Rich progress bar until FFmpeg exits."""
38
+ description = f"Processing '{filename}'"
39
+ progress_bar = Progress(
40
+ TextColumn("[progress.description]{task.description}"),
41
+ BarColumn(),
42
+ TaskProgressColumn(),
43
+ TimeElapsedColumn(),
44
+ TimeRemainingColumn(compact=True),
45
+ refresh_per_second=2,
46
+ )
47
+
48
+ with progress_bar:
49
+ task_id = progress_bar.add_task(description, total=duration_secs)
50
+ size_mb = 0.0
51
+
52
+ for secs, size_mb in read_progress(process.stdout, duration_secs):
53
+ estimated_size_mb = estimate_final_size_mb(size_mb, secs, duration_secs)
54
+ progress_bar.update(
55
+ task_id,
56
+ completed=secs,
57
+ description=_with_size(description, size_mb, estimated_size_mb),
58
+ )
59
+
60
+ if process.wait() == 0:
61
+ # Remove the time columns, which are no longer useful.
62
+ progress_bar.columns = progress_bar.columns[:3]
63
+ progress_bar.update(
64
+ task_id,
65
+ completed=duration_secs,
66
+ description=_with_size(f"✓ Processed '{filename}'", size_mb, None),
67
+ )
68
+
69
+
70
+ def show_tqdm_progress(process: subprocess.Popen, filename: str, duration_secs: float) -> None:
71
+ """Shows a tqdm progress bar until FFmpeg exits."""
72
+ try:
73
+ terminal_width = os.get_terminal_size().columns
74
+ except OSError:
75
+ terminal_width = 80
76
+
77
+ progress_bar = tqdm(
78
+ mininterval=0.5,
79
+ total=duration_secs,
80
+ desc=f"Processing '{filename}'",
81
+ ncols=80,
82
+ dynamic_ncols=terminal_width < 80,
83
+ bar_format="{desc} {bar} {percentage:.1f}% [{elapsed}<{remaining}, {rate_fmt}{postfix}]",
84
+ )
85
+ size_mb = 0.0
86
+
87
+ with progress_bar:
88
+ for secs, size_mb in read_progress(process.stdout, duration_secs):
89
+ progress_bar.n = secs
90
+ # set_postfix_str refreshes the bar.
91
+ progress_bar.set_postfix_str(
92
+ _format_size(size_mb, estimate_final_size_mb(size_mb, secs, duration_secs))
93
+ )
94
+
95
+ if process.wait() == 0:
96
+ progress_bar.n = duration_secs
97
+ progress_bar.set_description(f"✓ Processed '{filename}'")
98
+ progress_bar.set_postfix_str(_format_size(size_mb, None))
@@ -0,0 +1,44 @@
1
+ import os
2
+ import signal
3
+ import subprocess
4
+
5
+ from .exceptions import FfmpegProcessError
6
+
7
+ _TIMEOUT_SECS = 2
8
+
9
+
10
+ def _signal_process_group(process: subprocess.Popen, sig: int) -> None:
11
+ # FFmpeg is started in its own process group (see FfmpegProcess.run), whose ID is FFmpeg's PID.
12
+ # Signalling the group also stops any processes that FFmpeg started.
13
+ try:
14
+ os.killpg(process.pid, sig)
15
+ except ProcessLookupError:
16
+ pass # The process has already exited.
17
+
18
+
19
+ def terminate_ffmpeg_process(process: subprocess.Popen) -> None:
20
+ """
21
+ Stops the FFmpeg process. Raises FfmpegProcessError if it is still running afterwards.
22
+ """
23
+ if process.poll() is not None:
24
+ return
25
+
26
+ print("[KeyboardInterrupt] Terminating the FFmpeg process...")
27
+
28
+ if os.name == "nt":
29
+ # On Windows, terminate() stops the process immediately.
30
+ process.terminate()
31
+ else:
32
+ # Ask FFmpeg to exit, and force it to if it has not done so in time.
33
+ _signal_process_group(process, signal.SIGTERM)
34
+ try:
35
+ process.wait(timeout=_TIMEOUT_SECS)
36
+ except subprocess.TimeoutExpired:
37
+ _signal_process_group(process, signal.SIGKILL)
38
+
39
+ try:
40
+ process.wait(timeout=_TIMEOUT_SECS)
41
+ except subprocess.TimeoutExpired:
42
+ raise FfmpegProcessError(
43
+ f"FFmpeg process (PID: {process.pid}) could not be terminated."
44
+ ) from None