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.
- {better_ffmpeg_progress-5.4.2/better_ffmpeg_progress.egg-info → better_ffmpeg_progress-6.0.1}/PKG-INFO +51 -14
- better_ffmpeg_progress-5.4.2/PKG-INFO → better_ffmpeg_progress-6.0.1/README.md +40 -24
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress/__init__.py +3 -0
- better_ffmpeg_progress-6.0.1/better_ffmpeg_progress/better_ffmpeg_progress.py +277 -0
- better_ffmpeg_progress-6.0.1/better_ffmpeg_progress/progress_bars.py +98 -0
- better_ffmpeg_progress-6.0.1/better_ffmpeg_progress/py.typed +0 -0
- better_ffmpeg_progress-6.0.1/better_ffmpeg_progress/terminate_process.py +44 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress/utils.py +95 -70
- better_ffmpeg_progress-5.4.2/README.md → better_ffmpeg_progress-6.0.1/better_ffmpeg_progress.egg-info/PKG-INFO +62 -6
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress.egg-info/SOURCES.txt +5 -2
- better_ffmpeg_progress-6.0.1/better_ffmpeg_progress.egg-info/requires.txt +5 -0
- better_ffmpeg_progress-6.0.1/pyproject.toml +39 -0
- better_ffmpeg_progress-6.0.1/tests/test_ffmpeg_process.py +153 -0
- better_ffmpeg_progress-6.0.1/tests/test_utils.py +118 -0
- better_ffmpeg_progress-5.4.2/better_ffmpeg_progress/better_ffmpeg_progress.py +0 -239
- better_ffmpeg_progress-5.4.2/better_ffmpeg_progress/progress_bars.py +0 -179
- better_ffmpeg_progress-5.4.2/better_ffmpeg_progress/terminate_process.py +0 -227
- better_ffmpeg_progress-5.4.2/better_ffmpeg_progress.egg-info/requires.txt +0 -4
- better_ffmpeg_progress-5.4.2/setup.py +0 -24
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/LICENSE +0 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress/enums.py +0 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress/exceptions.py +0 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
- {better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
- {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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: better-ffmpeg-progress
|
|
3
|
-
Version:
|
|
3
|
+
Version: 6.0.1
|
|
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
|
-
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
|
|
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
|
|
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
|
+
```
|
{better_ffmpeg_progress-5.4.2 → better_ffmpeg_progress-6.0.1}/better_ffmpeg_progress/__init__.py
RENAMED
|
@@ -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))
|
|
File without changes
|
|
@@ -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
|