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.
- {better_ffmpeg_progress-5.4.2/better_ffmpeg_progress.egg-info → better_ffmpeg_progress-6.0.0}/PKG-INFO +51 -13
- better_ffmpeg_progress-5.4.2/PKG-INFO → better_ffmpeg_progress-6.0.0/README.md +40 -24
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/__init__.py +3 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/better_ffmpeg_progress.py +67 -25
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/progress_bars.py +15 -2
- better_ffmpeg_progress-6.0.0/better_ffmpeg_progress/py.typed +0 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/terminate_process.py +54 -37
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/utils.py +77 -21
- better_ffmpeg_progress-5.4.2/README.md → better_ffmpeg_progress-6.0.0/better_ffmpeg_progress.egg-info/PKG-INFO +63 -6
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress.egg-info/SOURCES.txt +5 -2
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress.egg-info/requires.txt +3 -1
- better_ffmpeg_progress-6.0.0/pyproject.toml +39 -0
- better_ffmpeg_progress-6.0.0/tests/test_ffmpeg_process.py +152 -0
- better_ffmpeg_progress-6.0.0/tests/test_utils.py +119 -0
- better_ffmpeg_progress-5.4.2/setup.py +0 -24
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/LICENSE +0 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/enums.py +0 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/exceptions.py +0 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
- {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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: better-ffmpeg-progress
|
|
3
|
-
Version:
|
|
3
|
+
Version: 6.0.0
|
|
4
4
|
Summary: Run FFmpeg & see percentage progress + ETA.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
```
|
{better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/__init__.py
RENAMED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
207
|
+
if overwrite is not None and not isinstance(overwrite, bool):
|
|
208
|
+
raise TypeError("overwrite must be True, False or None")
|
|
182
209
|
|
|
183
|
-
|
|
184
|
-
|
|
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 =
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
192
|
+
raise FfmpegProcessError(build_failure_message(log_file))
|
|
File without changes
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import os
|
|
2
|
-
import psutil
|
|
3
3
|
import subprocess
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
+
logger.info(f"FFmpeg process {proc.pid} already exited.")
|
|
62
69
|
except psutil.TimeoutExpired:
|
|
63
|
-
|
|
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
|
-
|
|
76
|
+
logger.warning(
|
|
77
|
+
f"FFmpeg process {proc.pid} still running after {_TERMINATION_TIMEOUT}s."
|
|
78
|
+
)
|
|
70
79
|
else:
|
|
71
|
-
|
|
80
|
+
logger.info(
|
|
81
|
+
f"FFmpeg process termination took longer than {_TERMINATION_TIMEOUT}s."
|
|
82
|
+
)
|
|
72
83
|
except psutil.NoSuchProcess:
|
|
73
|
-
|
|
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
|
-
|
|
88
|
+
logger.warning(f"Error during status check for process {proc.pid} after timeout:\n{e}")
|
|
78
89
|
except psutil.AccessDenied:
|
|
79
|
-
|
|
90
|
+
logger.warning(f"Access denied when trying to terminate FFmpeg process {proc.pid}.")
|
|
80
91
|
except psutil.Error as e:
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
+
logger.info(f"SIGKILL sent to FFmpeg process (PID: {proc.pid}).")
|
|
161
176
|
except psutil.Error as e:
|
|
162
|
-
|
|
177
|
+
logger.warning(
|
|
178
|
+
f"Error during SIGKILL for FFmpeg process (PID: {proc.pid}):\n{e}"
|
|
179
|
+
)
|
|
163
180
|
else:
|
|
164
|
-
|
|
181
|
+
logger.info(f"It seems like FFmpeg process {proc.pid} is no longer running.")
|
|
165
182
|
except psutil.NoSuchProcess:
|
|
166
|
-
|
|
183
|
+
logger.info(f"It seems like the FFmpeg process (PID: {proc.pid}) has terminated.")
|
|
167
184
|
except psutil.Error as e:
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
logger.warning(f"Unexpected error during main process termination for {original_pid}: {e}")
|
|
223
240
|
|
|
224
241
|
if process.poll() is None:
|
|
225
|
-
|
|
242
|
+
raise FfmpegProcessError(f"FFmpeg process (PID: {original_pid}) could not be terminated.")
|
|
226
243
|
else:
|
|
227
|
-
|
|
244
|
+
logger.info(f"FFmpeg process (PID: {original_pid}) terminated.")
|
{better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/utils.py
RENAMED
|
@@ -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
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
)
|
|
File without changes
|
{better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/enums.py
RENAMED
|
File without changes
|
{better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.0}/better_ffmpeg_progress/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|