better-ffmpeg-progress 3.2.5__tar.gz → 4.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-3.2.5 → better_ffmpeg_progress-4.0.0}/PKG-INFO +30 -7
- {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/README.md +30 -7
- better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/__init__.py +17 -0
- better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/better_ffmpeg_progress.py +200 -0
- better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/enums.py +13 -0
- better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/exceptions.py +14 -0
- better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/progress_bars.py +130 -0
- better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/terminate_process.py +249 -0
- better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/utils.py +151 -0
- {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/PKG-INFO +30 -7
- {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/SOURCES.txt +4 -0
- {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/setup.py +1 -1
- better_ffmpeg_progress-3.2.5/better_ffmpeg_progress/__init__.py +0 -1
- better_ffmpeg_progress-3.2.5/better_ffmpeg_progress/better_ffmpeg_progress.py +0 -351
- better_ffmpeg_progress-3.2.5/better_ffmpeg_progress/utils.py +0 -10
- {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/LICENSE +0 -0
- {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
- {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/requires.txt +0 -0
- {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
- {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: better-ffmpeg-progress
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: Run FFmpeg & see percentage progress + ETA.
|
|
5
5
|
Home-page: https://github.com/CrypticSignal/better-ffmpeg-progress
|
|
6
6
|
Author: GitHub.com/CrypticSignal
|
|
@@ -53,13 +53,37 @@ pip install better-ffmpeg-progress --upgrade
|
|
|
53
53
|
## Usage
|
|
54
54
|
Create an instance of the `FfmpegProcess` class and supply a list of arguments like you would to `subprocess.run()` or `subprocess.Popen()`.
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
Example:
|
|
57
57
|
```py
|
|
58
|
-
from better_ffmpeg_progress import FfmpegProcess
|
|
58
|
+
from better_ffmpeg_progress import FfmpegProcess, FfmpegProcessError
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
command = [
|
|
61
|
+
"ffmpeg",
|
|
62
|
+
"-i",
|
|
63
|
+
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
|
64
|
+
"-c:v",
|
|
65
|
+
"libx264",
|
|
66
|
+
"-preset",
|
|
67
|
+
"ultrafast",
|
|
68
|
+
"-c:a",
|
|
69
|
+
"copy",
|
|
70
|
+
"-f",
|
|
71
|
+
"null",
|
|
72
|
+
"-",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
process = FfmpegProcess(command)
|
|
77
|
+
# Uncomment the line below if you want to use tqdm instead of rich for the progress bar
|
|
78
|
+
# process.use_tqdm = True
|
|
79
|
+
|
|
80
|
+
# Run the FFmpeg command and show a progress bar
|
|
81
|
+
process.run()
|
|
82
|
+
# The FFmpeg process failed if the return code is not 0
|
|
83
|
+
if process.return_code != 0:
|
|
84
|
+
pass
|
|
85
|
+
except FfmpegProcessError as e:
|
|
86
|
+
print(f"An error occurred when running the better-ffmpeg-process package:\n{e}")
|
|
63
87
|
```
|
|
64
88
|
## Optional Arguments
|
|
65
89
|
An instance of the `FfmpegProcess` class takes the following **optional** arguments:
|
|
@@ -71,4 +95,3 @@ An instance of the `FfmpegProcess` class takes the following **optional** argume
|
|
|
71
95
|
|
|
72
96
|
The `run` method takes the following **optional** arguments:
|
|
73
97
|
- `print_command` - Print the FFmpeg command being executed. Default: `False`
|
|
74
|
-
- `use_tqdm` - Use [tqdm](https://github.com/tqdm/tqdm) instead of [Rich](https://github.com/Textualize/rich) for the progress bar. Default: `False`
|
|
@@ -35,13 +35,37 @@ pip install better-ffmpeg-progress --upgrade
|
|
|
35
35
|
## Usage
|
|
36
36
|
Create an instance of the `FfmpegProcess` class and supply a list of arguments like you would to `subprocess.run()` or `subprocess.Popen()`.
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Example:
|
|
39
39
|
```py
|
|
40
|
-
from better_ffmpeg_progress import FfmpegProcess
|
|
40
|
+
from better_ffmpeg_progress import FfmpegProcess, FfmpegProcessError
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
command = [
|
|
43
|
+
"ffmpeg",
|
|
44
|
+
"-i",
|
|
45
|
+
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
|
46
|
+
"-c:v",
|
|
47
|
+
"libx264",
|
|
48
|
+
"-preset",
|
|
49
|
+
"ultrafast",
|
|
50
|
+
"-c:a",
|
|
51
|
+
"copy",
|
|
52
|
+
"-f",
|
|
53
|
+
"null",
|
|
54
|
+
"-",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
process = FfmpegProcess(command)
|
|
59
|
+
# Uncomment the line below if you want to use tqdm instead of rich for the progress bar
|
|
60
|
+
# process.use_tqdm = True
|
|
61
|
+
|
|
62
|
+
# Run the FFmpeg command and show a progress bar
|
|
63
|
+
process.run()
|
|
64
|
+
# The FFmpeg process failed if the return code is not 0
|
|
65
|
+
if process.return_code != 0:
|
|
66
|
+
pass
|
|
67
|
+
except FfmpegProcessError as e:
|
|
68
|
+
print(f"An error occurred when running the better-ffmpeg-process package:\n{e}")
|
|
45
69
|
```
|
|
46
70
|
## Optional Arguments
|
|
47
71
|
An instance of the `FfmpegProcess` class takes the following **optional** arguments:
|
|
@@ -52,5 +76,4 @@ An instance of the `FfmpegProcess` class takes the following **optional** argume
|
|
|
52
76
|
- `print_stderr_new_line` - If better progress information cannot be shown, print FFmpeg stderr in a new line instead of replacing the current line in the terminal. Default: `False`
|
|
53
77
|
|
|
54
78
|
The `run` method takes the following **optional** arguments:
|
|
55
|
-
- `print_command` - Print the FFmpeg command being executed. Default: `False`
|
|
56
|
-
- `use_tqdm` - Use [tqdm](https://github.com/tqdm/tqdm) instead of [Rich](https://github.com/Textualize/rich) for the progress bar. Default: `False`
|
|
79
|
+
- `print_command` - Print the FFmpeg command being executed. Default: `False`
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .better_ffmpeg_progress import FfmpegProcess
|
|
2
|
+
from .enums import FfmpegLogLevel
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
FfmpegProcessError,
|
|
5
|
+
FfmpegCommandError,
|
|
6
|
+
FfmpegProcessUserCancelledError,
|
|
7
|
+
FfmpegProcessInterruptedError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"FfmpegProcess",
|
|
12
|
+
"FfmpegLogLevel",
|
|
13
|
+
"FfmpegProcessError",
|
|
14
|
+
"FfmpegCommandError",
|
|
15
|
+
"FfmpegProcessUserCancelledError",
|
|
16
|
+
"FfmpegProcessInterruptedError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import subprocess
|
|
4
|
+
from sys import exit
|
|
5
|
+
from typing import List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .enums import FfmpegLogLevel
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
FfmpegProcessError,
|
|
10
|
+
FfmpegCommandError,
|
|
11
|
+
FfmpegProcessUserCancelledError,
|
|
12
|
+
FfmpegProcessInterruptedError,
|
|
13
|
+
)
|
|
14
|
+
from .utils import (
|
|
15
|
+
validate_ffmpeg_command,
|
|
16
|
+
get_media_duration,
|
|
17
|
+
check_shell_needed_for_command,
|
|
18
|
+
)
|
|
19
|
+
from .terminate_process import terminate_ffmpeg_process
|
|
20
|
+
from .progress_bars import use_rich, use_tqdm
|
|
21
|
+
|
|
22
|
+
_FFMPEG_OVERWRITE_FLAG = "-y"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FfmpegProcess:
|
|
26
|
+
def _handle_overwrite_prompt(self) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Handles the logic for overwriting an existing output file.
|
|
29
|
+
Modifies self._ffmpeg_command to include -y if overwrite is confirmed.
|
|
30
|
+
Raises FfmpegProcessUserCancelledError or FfmpegProcessInterruptedError if not proceeding.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
answer = (
|
|
34
|
+
input(
|
|
35
|
+
f"Output file {self._output_filepath} already exists. Overwrite? [y/N]: "
|
|
36
|
+
)
|
|
37
|
+
.strip()
|
|
38
|
+
.lower()
|
|
39
|
+
)
|
|
40
|
+
if answer == "y":
|
|
41
|
+
# Insert -y
|
|
42
|
+
self._ffmpeg_command.insert(1, _FFMPEG_OVERWRITE_FLAG)
|
|
43
|
+
else:
|
|
44
|
+
raise FfmpegProcessUserCancelledError(
|
|
45
|
+
"FFmpeg process cancelled. Output file exists and overwrite declined."
|
|
46
|
+
)
|
|
47
|
+
except KeyboardInterrupt as e:
|
|
48
|
+
raise FfmpegProcessInterruptedError(
|
|
49
|
+
"[KeyboardInterrupt] FFmpeg process cancelled during overwrite prompt."
|
|
50
|
+
) from e
|
|
51
|
+
except EOFError as e:
|
|
52
|
+
raise FfmpegProcessInterruptedError(
|
|
53
|
+
"Input error (EOF) during overwrite prompt. FFmpeg process cancelled."
|
|
54
|
+
) from e
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
command: List[str],
|
|
59
|
+
ffmpeg_log_level: Optional[Union[FfmpegLogLevel, str]] = None,
|
|
60
|
+
ffmpeg_log_file: Optional[Union[str, Path]] = None,
|
|
61
|
+
print_detected_duration: bool = False,
|
|
62
|
+
):
|
|
63
|
+
# Raises FfmpegCommandError if the command is invalid
|
|
64
|
+
validate_ffmpeg_command(command)
|
|
65
|
+
|
|
66
|
+
ffmpeg_log_level_val: str
|
|
67
|
+
|
|
68
|
+
if ffmpeg_log_level is None:
|
|
69
|
+
ffmpeg_log_level_val = FfmpegLogLevel.VERBOSE.value
|
|
70
|
+
elif isinstance(ffmpeg_log_level, FfmpegLogLevel):
|
|
71
|
+
ffmpeg_log_level_val = ffmpeg_log_level.value
|
|
72
|
+
elif isinstance(ffmpeg_log_level, str):
|
|
73
|
+
try:
|
|
74
|
+
ffmpeg_log_level_val = FfmpegLogLevel(ffmpeg_log_level.lower()).value
|
|
75
|
+
except ValueError:
|
|
76
|
+
valid_levels = [e.value for e in FfmpegLogLevel]
|
|
77
|
+
raise FfmpegCommandError(
|
|
78
|
+
f"Invalid ffmpeg_log_level string: '{ffmpeg_log_level}'. "
|
|
79
|
+
f"Must be one of {valid_levels} (case-insensitive)."
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
raise TypeError(
|
|
83
|
+
f"ffmpeg_log_level must be an FfmpegLogLevel enum instance, a string, or None, "
|
|
84
|
+
f"not {type(ffmpeg_log_level).__name__}"
|
|
85
|
+
)
|
|
86
|
+
self._ffmpeg_log_level_val = ffmpeg_log_level_val
|
|
87
|
+
|
|
88
|
+
input_file_index = command.index("-i")
|
|
89
|
+
input_file_path_str = command[input_file_index + 1]
|
|
90
|
+
self._input_filepath = Path(input_file_path_str)
|
|
91
|
+
# Assumes last argument is output
|
|
92
|
+
self._output_filepath = Path(command[-1])
|
|
93
|
+
|
|
94
|
+
self._ffmpeg_log_file = Path(
|
|
95
|
+
ffmpeg_log_file or f"{self._input_filepath.name}_ffmpeg_log.txt"
|
|
96
|
+
)
|
|
97
|
+
self._print_detected_duration = print_detected_duration
|
|
98
|
+
self._duration_secs = get_media_duration(input_file_path_str)
|
|
99
|
+
|
|
100
|
+
if self._print_detected_duration and self._duration_secs is not None:
|
|
101
|
+
print(f"Detected duration: {self._duration_secs:.2f} seconds")
|
|
102
|
+
elif self._print_detected_duration and self._duration_secs is None:
|
|
103
|
+
print(
|
|
104
|
+
"Could not detect duration. Progress bar may not show time remaining."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self._ffmpeg_command = [
|
|
108
|
+
command[0],
|
|
109
|
+
"-hide_banner",
|
|
110
|
+
"-loglevel",
|
|
111
|
+
self._ffmpeg_log_level_val,
|
|
112
|
+
"-progress",
|
|
113
|
+
"pipe:1", # stdout
|
|
114
|
+
"-nostats",
|
|
115
|
+
]
|
|
116
|
+
self._ffmpeg_command.extend(command[1:])
|
|
117
|
+
|
|
118
|
+
is_overwrite_in_user_command = any(
|
|
119
|
+
arg == _FFMPEG_OVERWRITE_FLAG for arg in command[1:]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if self._output_filepath.exists() and not is_overwrite_in_user_command:
|
|
123
|
+
self._handle_overwrite_prompt()
|
|
124
|
+
|
|
125
|
+
self._process: Optional[subprocess.Popen] = None
|
|
126
|
+
self._return_code: Optional[int] = None
|
|
127
|
+
self.use_tqdm: bool = False
|
|
128
|
+
|
|
129
|
+
def run(
|
|
130
|
+
self,
|
|
131
|
+
print_command: bool = False,
|
|
132
|
+
) -> int:
|
|
133
|
+
if print_command:
|
|
134
|
+
cmd_str = (
|
|
135
|
+
" ".join(self._ffmpeg_command)
|
|
136
|
+
if isinstance(self._ffmpeg_command, list)
|
|
137
|
+
else self._ffmpeg_command
|
|
138
|
+
)
|
|
139
|
+
print(f"Executing: {cmd_str}")
|
|
140
|
+
|
|
141
|
+
self._shell_needed = check_shell_needed_for_command(
|
|
142
|
+
self._ffmpeg_command
|
|
143
|
+
if isinstance(self._ffmpeg_command, list)
|
|
144
|
+
else [self._ffmpeg_command]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
current_ffmpeg_command = self._ffmpeg_command
|
|
148
|
+
if self._shell_needed and isinstance(current_ffmpeg_command, list):
|
|
149
|
+
current_ffmpeg_command = " ".join(current_ffmpeg_command)
|
|
150
|
+
|
|
151
|
+
self._return_code = 1
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with open(self._ffmpeg_log_file, "w", encoding="utf-8") as f:
|
|
155
|
+
creationflags = 0
|
|
156
|
+
|
|
157
|
+
if os.name == "nt":
|
|
158
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
159
|
+
|
|
160
|
+
self._process = subprocess.Popen(
|
|
161
|
+
current_ffmpeg_command,
|
|
162
|
+
shell=self._shell_needed,
|
|
163
|
+
stdout=subprocess.PIPE,
|
|
164
|
+
stderr=f,
|
|
165
|
+
creationflags=creationflags,
|
|
166
|
+
)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
# self._return_code remains None as the process didn't start
|
|
169
|
+
raise FfmpegProcessError(f"Error starting FFmpeg process: {e}") from e
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
if self.use_tqdm:
|
|
173
|
+
self._return_code = use_tqdm(self, self._process)
|
|
174
|
+
else:
|
|
175
|
+
self._return_code = use_rich(self, self._process)
|
|
176
|
+
except KeyboardInterrupt:
|
|
177
|
+
self._terminate()
|
|
178
|
+
self._return_code = 1
|
|
179
|
+
finally:
|
|
180
|
+
if self._process:
|
|
181
|
+
self._return_code = self._process.returncode
|
|
182
|
+
|
|
183
|
+
if self._process.stdout:
|
|
184
|
+
self._process.stdout.close()
|
|
185
|
+
|
|
186
|
+
return self._return_code
|
|
187
|
+
|
|
188
|
+
def _terminate(self):
|
|
189
|
+
if self._process:
|
|
190
|
+
terminate_ffmpeg_process(self._process)
|
|
191
|
+
else:
|
|
192
|
+
exit()
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def return_code(self) -> Optional[int]:
|
|
196
|
+
"""
|
|
197
|
+
The return code of the FFmpeg process.
|
|
198
|
+
None if the process has not started or failed to start.
|
|
199
|
+
"""
|
|
200
|
+
return self._return_code
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from rich.progress import (
|
|
6
|
+
Progress,
|
|
7
|
+
TextColumn,
|
|
8
|
+
BarColumn,
|
|
9
|
+
TaskProgressColumn,
|
|
10
|
+
TimeRemainingColumn,
|
|
11
|
+
TimeElapsedColumn,
|
|
12
|
+
)
|
|
13
|
+
from tqdm import tqdm
|
|
14
|
+
|
|
15
|
+
from .utils import parse_ffmpeg_progress_line
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .better_ffmpeg_progress import FfmpegProcess
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def use_rich(
|
|
22
|
+
ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
|
|
23
|
+
) -> int:
|
|
24
|
+
task_id = None
|
|
25
|
+
progress_bar_instance = Progress(
|
|
26
|
+
TextColumn("[progress.description]{task.description}"),
|
|
27
|
+
BarColumn(),
|
|
28
|
+
TaskProgressColumn(),
|
|
29
|
+
TimeElapsedColumn(),
|
|
30
|
+
TimeRemainingColumn(compact=True),
|
|
31
|
+
refresh_per_second=2,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
duration_secs = ffmpeg_process_instance._duration_secs
|
|
35
|
+
input_filename = ffmpeg_process_instance._input_filepath.name
|
|
36
|
+
log_file = ffmpeg_process_instance._ffmpeg_log_file
|
|
37
|
+
|
|
38
|
+
with progress_bar_instance as progress_bar:
|
|
39
|
+
if duration_secs:
|
|
40
|
+
task_id = progress_bar.add_task(
|
|
41
|
+
f"Processing {input_filename}",
|
|
42
|
+
total=duration_secs,
|
|
43
|
+
)
|
|
44
|
+
update_progress = progress_bar.update
|
|
45
|
+
|
|
46
|
+
for line_bytes in process.stdout:
|
|
47
|
+
stripped_line_bytes = line_bytes.strip()
|
|
48
|
+
progress_val_secs = parse_ffmpeg_progress_line(
|
|
49
|
+
stripped_line_bytes, duration_secs
|
|
50
|
+
)
|
|
51
|
+
if progress_val_secs is not None and task_id is not None:
|
|
52
|
+
update_progress(task_id, completed=progress_val_secs)
|
|
53
|
+
else:
|
|
54
|
+
print(f"Processing {input_filename}...")
|
|
55
|
+
|
|
56
|
+
process.wait()
|
|
57
|
+
|
|
58
|
+
if task_id is not None:
|
|
59
|
+
if process.returncode == 0:
|
|
60
|
+
update_progress(task_id, completed=duration_secs)
|
|
61
|
+
progress_bar.columns = (
|
|
62
|
+
TextColumn("[progress.description]{task.description}"),
|
|
63
|
+
BarColumn(),
|
|
64
|
+
TaskProgressColumn(),
|
|
65
|
+
)
|
|
66
|
+
update_progress(
|
|
67
|
+
task_id,
|
|
68
|
+
description=f"✓ Processed {input_filename}",
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
progress_bar.update(
|
|
72
|
+
task_id,
|
|
73
|
+
description=f"FFmpeg process failed. Check {log_file} for details.",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if process.returncode != 0 and task_id is None:
|
|
77
|
+
print(f"FFmpeg process failed. Check {log_file} for details.")
|
|
78
|
+
|
|
79
|
+
return 1 if process.returncode != 0 else 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def use_tqdm(
|
|
83
|
+
ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
|
|
84
|
+
) -> int:
|
|
85
|
+
progress_bar = None
|
|
86
|
+
try:
|
|
87
|
+
width = os.get_terminal_size().columns
|
|
88
|
+
except OSError:
|
|
89
|
+
width = 80
|
|
90
|
+
|
|
91
|
+
duration_secs = ffmpeg_process_instance._duration_secs
|
|
92
|
+
input_filename = ffmpeg_process_instance._input_filepath.name
|
|
93
|
+
log_file = ffmpeg_process_instance._ffmpeg_log_file
|
|
94
|
+
|
|
95
|
+
if duration_secs:
|
|
96
|
+
progress_bar = tqdm(
|
|
97
|
+
mininterval=0.5,
|
|
98
|
+
total=duration_secs,
|
|
99
|
+
desc=f"Processing {input_filename}",
|
|
100
|
+
ncols=80,
|
|
101
|
+
dynamic_ncols=True if width < 80 else False,
|
|
102
|
+
bar_format="{desc} {bar} {percentage:.1f}% [{elapsed}<{remaining}, {rate_fmt}{postfix}]",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
for line_bytes in process.stdout:
|
|
106
|
+
stripped_line_bytes = line_bytes.strip()
|
|
107
|
+
progress_val_secs = parse_ffmpeg_progress_line(
|
|
108
|
+
stripped_line_bytes, duration_secs
|
|
109
|
+
)
|
|
110
|
+
if progress_val_secs is not None and progress_bar is not None:
|
|
111
|
+
progress_bar.n = progress_val_secs
|
|
112
|
+
progress_bar.refresh()
|
|
113
|
+
else:
|
|
114
|
+
print(f"Processing {input_filename}...")
|
|
115
|
+
|
|
116
|
+
process.wait()
|
|
117
|
+
|
|
118
|
+
if process.returncode == 0:
|
|
119
|
+
if progress_bar:
|
|
120
|
+
progress_bar.n = duration_secs
|
|
121
|
+
progress_bar.set_description(f"✓ Processed {input_filename}")
|
|
122
|
+
progress_bar.close()
|
|
123
|
+
else:
|
|
124
|
+
print(f"✓ Processed {input_filename}")
|
|
125
|
+
else:
|
|
126
|
+
if progress_bar:
|
|
127
|
+
progress_bar.close()
|
|
128
|
+
print(f"FFmpeg process failed. Check {log_file} for details.")
|
|
129
|
+
|
|
130
|
+
return 1 if process.returncode != 0 else 0
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import psutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from sys import exit
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
_TERMINATION_TIMEOUT = 2
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _terminate_children_processes(parent_proc: psutil.Process):
|
|
11
|
+
"""Helper to terminate child processes of the given parent process."""
|
|
12
|
+
try:
|
|
13
|
+
if not parent_proc.is_running():
|
|
14
|
+
return
|
|
15
|
+
children = parent_proc.children(recursive=True)
|
|
16
|
+
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
|
17
|
+
print(
|
|
18
|
+
f"Parent process {parent_proc.pid} not found or is a zombie while fetching children."
|
|
19
|
+
)
|
|
20
|
+
return
|
|
21
|
+
except psutil.AccessDenied:
|
|
22
|
+
print(
|
|
23
|
+
f"Access denied when trying to list children of {parent_proc.pid}. Skipping child termination."
|
|
24
|
+
)
|
|
25
|
+
return
|
|
26
|
+
except Exception as e:
|
|
27
|
+
print(
|
|
28
|
+
f"Error fetching children for process {parent_proc.pid}: {e}. Skipping child termination."
|
|
29
|
+
)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
if not children:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
print(
|
|
36
|
+
f"Attempting to terminate {len(children)} child process(es) of {parent_proc.pid}..."
|
|
37
|
+
)
|
|
38
|
+
for child in children:
|
|
39
|
+
try:
|
|
40
|
+
if child.is_running():
|
|
41
|
+
child.terminate()
|
|
42
|
+
except psutil.Error:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
_, alive = psutil.wait_procs(children, timeout=_TERMINATION_TIMEOUT)
|
|
46
|
+
|
|
47
|
+
if alive:
|
|
48
|
+
print(f"Force killing {len(alive)} remaining child process(es)...")
|
|
49
|
+
for child_to_kill in alive:
|
|
50
|
+
try:
|
|
51
|
+
if child_to_kill.is_running():
|
|
52
|
+
child_to_kill.kill()
|
|
53
|
+
except psutil.Error:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _terminate_parent_process_windows(proc: psutil.Process):
|
|
58
|
+
try:
|
|
59
|
+
# Same as .kill() on Windows
|
|
60
|
+
proc.terminate()
|
|
61
|
+
proc.wait(timeout=_TERMINATION_TIMEOUT)
|
|
62
|
+
except psutil.NoSuchProcess:
|
|
63
|
+
print(f"FFmpeg process {proc.pid} already exited.")
|
|
64
|
+
except psutil.TimeoutExpired:
|
|
65
|
+
print(
|
|
66
|
+
f"FFmpeg process {proc.pid} did not terminate within {_TERMINATION_TIMEOUT}s. Checking status..."
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
# Check the status after the timeout
|
|
70
|
+
if proc.is_running():
|
|
71
|
+
print(
|
|
72
|
+
f"FFmpeg process {proc.pid} still running after {_TERMINATION_TIMEOUT}s."
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
print(
|
|
76
|
+
f"FFmpeg process termination took longer than {_TERMINATION_TIMEOUT}s."
|
|
77
|
+
)
|
|
78
|
+
except psutil.NoSuchProcess:
|
|
79
|
+
print(
|
|
80
|
+
f"FFmpeg process not found after {_TERMINATION_TIMEOUT}s. It seems like it has terminated."
|
|
81
|
+
)
|
|
82
|
+
except psutil.Error as e:
|
|
83
|
+
print(
|
|
84
|
+
f"Error during status check for process {proc.pid} after timeout:\n{e}"
|
|
85
|
+
)
|
|
86
|
+
except psutil.AccessDenied:
|
|
87
|
+
print(f"Access denied when trying to terminate FFmpeg process {proc.pid}.")
|
|
88
|
+
except psutil.Error as e:
|
|
89
|
+
print(f"Unable to terminate FFmpeg process:\n{e}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _terminate_parent_process_unix(proc: psutil.Process, original_pid: int):
|
|
93
|
+
"""Helper to terminate the parent process and its group on Unix operating systems."""
|
|
94
|
+
from signal import SIGTERM, SIGKILL
|
|
95
|
+
|
|
96
|
+
terminated_by_pg = False
|
|
97
|
+
pgid_to_signal = 0
|
|
98
|
+
try:
|
|
99
|
+
pgid_to_signal = os.getpgid(original_pid)
|
|
100
|
+
print(f"Sending SIGTERM to process group {pgid_to_signal}...")
|
|
101
|
+
os.killpg(pgid_to_signal, SIGTERM)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
proc.wait(timeout=_TERMINATION_TIMEOUT)
|
|
105
|
+
|
|
106
|
+
if not proc.is_running():
|
|
107
|
+
terminated_by_pg = True
|
|
108
|
+
except psutil.TimeoutExpired:
|
|
109
|
+
print(
|
|
110
|
+
f"Process group {pgid_to_signal} did not exit after SIGTERM, sending SIGKILL..."
|
|
111
|
+
)
|
|
112
|
+
os.killpg(pgid_to_signal, SIGKILL)
|
|
113
|
+
try:
|
|
114
|
+
proc.wait(
|
|
115
|
+
timeout=_TERMINATION_TIMEOUT
|
|
116
|
+
) # Wait for SIGKILL on process group
|
|
117
|
+
if not proc.is_running():
|
|
118
|
+
terminated_by_pg = True
|
|
119
|
+
except psutil.TimeoutExpired:
|
|
120
|
+
print(f"Process group {pgid_to_signal} still running after SIGKILL.")
|
|
121
|
+
# Process died after SIGKILL
|
|
122
|
+
except psutil.NoSuchProcess:
|
|
123
|
+
terminated_by_pg = True
|
|
124
|
+
# Process died after SIGTERM
|
|
125
|
+
except psutil.NoSuchProcess:
|
|
126
|
+
terminated_by_pg = True
|
|
127
|
+
# os.getpgid(original_pid) failed
|
|
128
|
+
except ProcessLookupError:
|
|
129
|
+
print(
|
|
130
|
+
f"ProcessLookupError for PID {original_pid} when trying to get PGID. Fallback to individual process."
|
|
131
|
+
)
|
|
132
|
+
# os.killpg failed
|
|
133
|
+
except OSError as e:
|
|
134
|
+
target_desc = (
|
|
135
|
+
f"process group {pgid_to_signal}"
|
|
136
|
+
if pgid_to_signal != 0
|
|
137
|
+
else f"process {original_pid}"
|
|
138
|
+
)
|
|
139
|
+
print(
|
|
140
|
+
f"Error sending signal to {target_desc}: {e}. Fallback to individual process."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if terminated_by_pg:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Fallback: Terminate the main process directly if process group termination failed or was skipped
|
|
147
|
+
try:
|
|
148
|
+
if proc.is_running():
|
|
149
|
+
print(f"Attempting direct SIGTERM for main process {proc.pid}...")
|
|
150
|
+
try:
|
|
151
|
+
# Send SIGTERM
|
|
152
|
+
proc.terminate()
|
|
153
|
+
proc.wait(timeout=_TERMINATION_TIMEOUT)
|
|
154
|
+
# Successfully terminated with SIGTERM
|
|
155
|
+
if not proc.is_running():
|
|
156
|
+
return
|
|
157
|
+
except psutil.TimeoutExpired:
|
|
158
|
+
print(
|
|
159
|
+
f"SIGTERM for FFmpeg process (PID: {proc.pid}) timed out after {_TERMINATION_TIMEOUT}s."
|
|
160
|
+
)
|
|
161
|
+
except psutil.Error as e:
|
|
162
|
+
print(
|
|
163
|
+
f"Error during SIGTERM for FFmpeg process (PID: {proc.pid}):\n{e}"
|
|
164
|
+
)
|
|
165
|
+
if not proc.is_running():
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# If SIGTERM failed/timed out and the process is still running, try SIGKILL:
|
|
169
|
+
|
|
170
|
+
# Check if process is still running
|
|
171
|
+
if proc.is_running():
|
|
172
|
+
print(
|
|
173
|
+
f"FFmpeg process (PID: {proc.pid}) still running after direct SIGTERM, attempting SIGKILL..."
|
|
174
|
+
)
|
|
175
|
+
try:
|
|
176
|
+
# Send SIGKILL
|
|
177
|
+
proc.kill()
|
|
178
|
+
print(f"SIGKILL sent to FFmpeg process (PID: {proc.pid}).")
|
|
179
|
+
except psutil.Error as e:
|
|
180
|
+
print(
|
|
181
|
+
f"Error during SIGKILL for FFmpeg process (PID: {proc.pid}):\n{e}"
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
print(f"It seems like FFmpeg process {proc.pid} is no longer running.")
|
|
185
|
+
except psutil.NoSuchProcess:
|
|
186
|
+
print(f"It seems like the FFmpeg process (PID: {proc.pid}) has terminated.")
|
|
187
|
+
except psutil.Error as e:
|
|
188
|
+
print(f"Unable to check the status of FFmpeg process (PID: {proc.pid}):\n{e}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def terminate_ffmpeg_process(process: Optional[subprocess.Popen]):
|
|
192
|
+
"""
|
|
193
|
+
Terminates an FFmpeg process and its children.
|
|
194
|
+
'process' is the subprocess.Popen object for the FFmpeg process.
|
|
195
|
+
"""
|
|
196
|
+
if not process or process.poll() is not None:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
original_pid = process.pid
|
|
200
|
+
|
|
201
|
+
parent_ps_proc: Optional[psutil.Process] = None
|
|
202
|
+
try:
|
|
203
|
+
parent_ps_proc = psutil.Process(original_pid)
|
|
204
|
+
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
|
205
|
+
print(
|
|
206
|
+
f"FFmpeg process {original_pid} already gone or is a zombie when termination started."
|
|
207
|
+
)
|
|
208
|
+
return
|
|
209
|
+
except Exception as e:
|
|
210
|
+
print(f"Error accessing FFmpeg process {original_pid} with psutil: {e}")
|
|
211
|
+
if process.poll() is None:
|
|
212
|
+
try:
|
|
213
|
+
process.terminate()
|
|
214
|
+
process.wait(timeout=_TERMINATION_TIMEOUT)
|
|
215
|
+
if process.poll() is None:
|
|
216
|
+
process.kill()
|
|
217
|
+
process.wait(timeout=_TERMINATION_TIMEOUT)
|
|
218
|
+
except Exception as popen_term_err:
|
|
219
|
+
print(f"Error during fallback Popen termination: {popen_term_err}")
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
print("[better-ffmpeg-progress] Terminating the FFmpeg process...")
|
|
223
|
+
|
|
224
|
+
if parent_ps_proc:
|
|
225
|
+
_terminate_children_processes(parent_ps_proc)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
if parent_ps_proc and parent_ps_proc.is_running():
|
|
229
|
+
if os.name == "nt":
|
|
230
|
+
_terminate_parent_process_windows(parent_ps_proc)
|
|
231
|
+
else:
|
|
232
|
+
_terminate_parent_process_unix(parent_ps_proc, original_pid)
|
|
233
|
+
elif parent_ps_proc:
|
|
234
|
+
print(
|
|
235
|
+
f"Main FFmpeg process {parent_ps_proc.pid} exited, possibly during child termination or was already gone."
|
|
236
|
+
)
|
|
237
|
+
except psutil.NoSuchProcess:
|
|
238
|
+
print(
|
|
239
|
+
f"Main FFmpeg process {original_pid} became inaccessible during termination sequence."
|
|
240
|
+
)
|
|
241
|
+
except Exception as e:
|
|
242
|
+
print(
|
|
243
|
+
f"Unexpected error during main process termination for {original_pid}: {e}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if process.poll() is None:
|
|
247
|
+
exit("It seems like the FFmpeg process has not terminated.")
|
|
248
|
+
else:
|
|
249
|
+
print(f"FFmpeg process (PID: {original_pid}) terminated.")
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
import requests # Added for URL validation
|
|
6
|
+
|
|
7
|
+
from .exceptions import FfmpegCommandError
|
|
8
|
+
|
|
9
|
+
_FFMPEG_INPUT_FLAG = "-i"
|
|
10
|
+
_OUT_TIME_US_PREFIX = b"out_time_us="
|
|
11
|
+
_MULTIPLIER = 1e-6
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_ffmpeg_command(command: List[str]) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Validates the FFmpeg command.
|
|
17
|
+
Raises FfmpegCommandError if validation fails.
|
|
18
|
+
"""
|
|
19
|
+
if not command:
|
|
20
|
+
raise FfmpegCommandError("FFmpeg command list cannot be empty.")
|
|
21
|
+
|
|
22
|
+
ffmpeg_executable = command[0]
|
|
23
|
+
if not shutil.which(ffmpeg_executable):
|
|
24
|
+
err_msg = (
|
|
25
|
+
f"'{ffmpeg_executable}' not found. "
|
|
26
|
+
"Ensure it's in your system's PATH or provide the full path of the FFmpeg executable."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
raise FfmpegCommandError(err_msg)
|
|
30
|
+
|
|
31
|
+
if _FFMPEG_INPUT_FLAG not in command:
|
|
32
|
+
raise FfmpegCommandError(
|
|
33
|
+
f"FFmpeg command must include the input flag '{_FFMPEG_INPUT_FLAG}'."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
input_flag_pos = command.index(_FFMPEG_INPUT_FLAG)
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise FfmpegCommandError(
|
|
40
|
+
f"Internal error: Input flag '{_FFMPEG_INPUT_FLAG}' not found after check."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
input_file_idx = input_flag_pos + 1
|
|
44
|
+
if input_file_idx >= len(command):
|
|
45
|
+
raise FfmpegCommandError(
|
|
46
|
+
f"No input file specified after '{_FFMPEG_INPUT_FLAG}'."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
input_file = command[input_file_idx]
|
|
50
|
+
if (
|
|
51
|
+
input_file.startswith("-")
|
|
52
|
+
and len(input_file) == 2
|
|
53
|
+
and not input_file[1:].isdigit()
|
|
54
|
+
):
|
|
55
|
+
raise FfmpegCommandError(
|
|
56
|
+
f"Input file path '{input_file}' looks like an option flag. Please check your command."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Validate input file/URL
|
|
60
|
+
if input_file.startswith("http://") or input_file.startswith("https://"):
|
|
61
|
+
try:
|
|
62
|
+
response = requests.head(input_file, timeout=5, allow_redirects=True)
|
|
63
|
+
response.raise_for_status()
|
|
64
|
+
except requests.exceptions.RequestException as e:
|
|
65
|
+
raise FfmpegCommandError(
|
|
66
|
+
f"Input URL not accessible: {input_file}. Error: {e}"
|
|
67
|
+
)
|
|
68
|
+
elif not Path(input_file).exists():
|
|
69
|
+
raise FfmpegCommandError(f"Input file not found: {input_file}")
|
|
70
|
+
|
|
71
|
+
if len(command) <= input_file_idx + 1:
|
|
72
|
+
raise FfmpegCommandError(
|
|
73
|
+
"No output file specified. The command seems to end after the input file path."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
output_file_path_str = command[-1]
|
|
77
|
+
if output_file_path_str == input_file:
|
|
78
|
+
raise FfmpegCommandError(
|
|
79
|
+
"Output file path cannot be the same as the input file path in this command structure."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_media_duration(input_file: str) -> Optional[float]:
|
|
84
|
+
"""
|
|
85
|
+
Retrieves the duration of a media file or URL using ffprobe.
|
|
86
|
+
Returns duration in seconds, or None if it cannot be determined.
|
|
87
|
+
Accepts input_file as a string to correctly handle URLs.
|
|
88
|
+
"""
|
|
89
|
+
if not shutil.which("ffprobe"):
|
|
90
|
+
print("Warning: ffprobe not found in PATH. Cannot determine media duration.")
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
output = subprocess.check_output(
|
|
94
|
+
[
|
|
95
|
+
"ffprobe",
|
|
96
|
+
"-v",
|
|
97
|
+
"error",
|
|
98
|
+
"-show_entries",
|
|
99
|
+
"format=duration",
|
|
100
|
+
"-of",
|
|
101
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
102
|
+
"-i",
|
|
103
|
+
input_file,
|
|
104
|
+
],
|
|
105
|
+
text=True,
|
|
106
|
+
stderr=subprocess.DEVNULL,
|
|
107
|
+
)
|
|
108
|
+
return float(output)
|
|
109
|
+
except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
|
|
110
|
+
print(f"Warning: Could not determine duration for '{input_file}'.")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def check_shell_needed_for_command(command: List[str]) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Checks if any part of the command contains shell operators,
|
|
117
|
+
indicating that shell=True might be needed for subprocess.Popen.
|
|
118
|
+
This is a basic check and might not cover all edge cases.
|
|
119
|
+
"""
|
|
120
|
+
shell_operators = {"|", ">", "<", ">>", "&&", "||"} # Basic set
|
|
121
|
+
return any(op in item for item in command for op in shell_operators)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_ffmpeg_progress_line(
|
|
125
|
+
line: bytes, total_duration_secs: Optional[float]
|
|
126
|
+
) -> Optional[float]:
|
|
127
|
+
"""
|
|
128
|
+
Parses a progress line from FFmpeg's stdout.
|
|
129
|
+
Example line: b"out_time_us=12345678"
|
|
130
|
+
Returns the current progress time in seconds, or None if parsing fails or line is not a progress line.
|
|
131
|
+
Caps progress at total_duration_secs if provided.
|
|
132
|
+
Assumes `line` has already been stripped of leading/trailing whitespace.
|
|
133
|
+
"""
|
|
134
|
+
if line.startswith(_OUT_TIME_US_PREFIX):
|
|
135
|
+
try:
|
|
136
|
+
# Get the value after out_time_us=
|
|
137
|
+
value_str = line[len(_OUT_TIME_US_PREFIX) :]
|
|
138
|
+
|
|
139
|
+
if not value_str:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
current_time_us = int(value_str)
|
|
143
|
+
current_time_secs = current_time_us * _MULTIPLIER
|
|
144
|
+
|
|
145
|
+
if total_duration_secs is not None:
|
|
146
|
+
return min(current_time_secs, total_duration_secs)
|
|
147
|
+
return current_time_secs
|
|
148
|
+
except (ValueError, IndexError):
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: better-ffmpeg-progress
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: Run FFmpeg & see percentage progress + ETA.
|
|
5
5
|
Home-page: https://github.com/CrypticSignal/better-ffmpeg-progress
|
|
6
6
|
Author: GitHub.com/CrypticSignal
|
|
@@ -53,13 +53,37 @@ pip install better-ffmpeg-progress --upgrade
|
|
|
53
53
|
## Usage
|
|
54
54
|
Create an instance of the `FfmpegProcess` class and supply a list of arguments like you would to `subprocess.run()` or `subprocess.Popen()`.
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
Example:
|
|
57
57
|
```py
|
|
58
|
-
from better_ffmpeg_progress import FfmpegProcess
|
|
58
|
+
from better_ffmpeg_progress import FfmpegProcess, FfmpegProcessError
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
command = [
|
|
61
|
+
"ffmpeg",
|
|
62
|
+
"-i",
|
|
63
|
+
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
|
64
|
+
"-c:v",
|
|
65
|
+
"libx264",
|
|
66
|
+
"-preset",
|
|
67
|
+
"ultrafast",
|
|
68
|
+
"-c:a",
|
|
69
|
+
"copy",
|
|
70
|
+
"-f",
|
|
71
|
+
"null",
|
|
72
|
+
"-",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
process = FfmpegProcess(command)
|
|
77
|
+
# Uncomment the line below if you want to use tqdm instead of rich for the progress bar
|
|
78
|
+
# process.use_tqdm = True
|
|
79
|
+
|
|
80
|
+
# Run the FFmpeg command and show a progress bar
|
|
81
|
+
process.run()
|
|
82
|
+
# The FFmpeg process failed if the return code is not 0
|
|
83
|
+
if process.return_code != 0:
|
|
84
|
+
pass
|
|
85
|
+
except FfmpegProcessError as e:
|
|
86
|
+
print(f"An error occurred when running the better-ffmpeg-process package:\n{e}")
|
|
63
87
|
```
|
|
64
88
|
## Optional Arguments
|
|
65
89
|
An instance of the `FfmpegProcess` class takes the following **optional** arguments:
|
|
@@ -71,4 +95,3 @@ An instance of the `FfmpegProcess` class takes the following **optional** argume
|
|
|
71
95
|
|
|
72
96
|
The `run` method takes the following **optional** arguments:
|
|
73
97
|
- `print_command` - Print the FFmpeg command being executed. Default: `False`
|
|
74
|
-
- `use_tqdm` - Use [tqdm](https://github.com/tqdm/tqdm) instead of [Rich](https://github.com/Textualize/rich) for the progress bar. Default: `False`
|
|
@@ -3,6 +3,10 @@ README.md
|
|
|
3
3
|
setup.py
|
|
4
4
|
better_ffmpeg_progress/__init__.py
|
|
5
5
|
better_ffmpeg_progress/better_ffmpeg_progress.py
|
|
6
|
+
better_ffmpeg_progress/enums.py
|
|
7
|
+
better_ffmpeg_progress/exceptions.py
|
|
8
|
+
better_ffmpeg_progress/progress_bars.py
|
|
9
|
+
better_ffmpeg_progress/terminate_process.py
|
|
6
10
|
better_ffmpeg_progress/utils.py
|
|
7
11
|
better_ffmpeg_progress.egg-info/PKG-INFO
|
|
8
12
|
better_ffmpeg_progress.egg-info/SOURCES.txt
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as f:
|
|
|
5
5
|
|
|
6
6
|
setuptools.setup(
|
|
7
7
|
name="better-ffmpeg-progress",
|
|
8
|
-
version="
|
|
8
|
+
version="4.0.0",
|
|
9
9
|
author="GitHub.com/CrypticSignal",
|
|
10
10
|
author_email="theaudiophile@outlook.com",
|
|
11
11
|
description="Run FFmpeg & see percentage progress + ETA.",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
from .better_ffmpeg_progress import FfmpegProcess
|
|
@@ -1,351 +0,0 @@
|
|
|
1
|
-
from enum import Enum
|
|
2
|
-
import os
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
import psutil
|
|
5
|
-
import shutil
|
|
6
|
-
import subprocess
|
|
7
|
-
from typing import List, Optional, Union
|
|
8
|
-
|
|
9
|
-
from rich.progress import (
|
|
10
|
-
Progress,
|
|
11
|
-
TextColumn,
|
|
12
|
-
BarColumn,
|
|
13
|
-
TaskProgressColumn,
|
|
14
|
-
TimeRemainingColumn,
|
|
15
|
-
TimeElapsedColumn,
|
|
16
|
-
)
|
|
17
|
-
from tqdm import tqdm
|
|
18
|
-
|
|
19
|
-
OUT_TIME_PREFIX = b"out_time_"
|
|
20
|
-
# Convert microseconds to seconds
|
|
21
|
-
MULTIPLIER = 1e-6
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class FfmpegLogLevel(Enum):
|
|
25
|
-
QUIET = "quiet"
|
|
26
|
-
PANIC = "panic"
|
|
27
|
-
FATAL = "fatal"
|
|
28
|
-
ERROR = "error"
|
|
29
|
-
WARNING = "warning"
|
|
30
|
-
INFO = "info"
|
|
31
|
-
VERBOSE = "verbose"
|
|
32
|
-
DEBUG = "debug"
|
|
33
|
-
TRACE = "trace"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class FfmpegProcess:
|
|
37
|
-
@staticmethod
|
|
38
|
-
def _validate_command(command: List[str]) -> bool:
|
|
39
|
-
if not shutil.which("ffmpeg"):
|
|
40
|
-
print("Error: FFmpeg executable not found in PATH")
|
|
41
|
-
return False
|
|
42
|
-
|
|
43
|
-
if "-i" not in command:
|
|
44
|
-
print("Error: FFmpeg command must include '-i'")
|
|
45
|
-
return False
|
|
46
|
-
|
|
47
|
-
input_idx = command.index("-i") + 1
|
|
48
|
-
if input_idx >= len(command):
|
|
49
|
-
print("Error: No input file specified after -i")
|
|
50
|
-
return False
|
|
51
|
-
|
|
52
|
-
input_file = Path(command[input_idx])
|
|
53
|
-
if not input_file.exists():
|
|
54
|
-
print(f"Error: Input file not found: {input_file}")
|
|
55
|
-
return False
|
|
56
|
-
|
|
57
|
-
if input_idx + 1 >= len(command):
|
|
58
|
-
print("Error: No output file specified")
|
|
59
|
-
return False
|
|
60
|
-
|
|
61
|
-
return True
|
|
62
|
-
|
|
63
|
-
def _should_overwrite(self):
|
|
64
|
-
if self._output_filepath.exists() and "-y" not in self._ffmpeg_command:
|
|
65
|
-
try:
|
|
66
|
-
if (
|
|
67
|
-
input(
|
|
68
|
-
f"{self._output_filepath} already exists. Overwrite? [Y/N]: "
|
|
69
|
-
).lower()
|
|
70
|
-
!= "y"
|
|
71
|
-
):
|
|
72
|
-
print(
|
|
73
|
-
"FFmpeg process cancelled. Output file exists and overwrite declined."
|
|
74
|
-
)
|
|
75
|
-
return False
|
|
76
|
-
except EOFError:
|
|
77
|
-
print("Input error. FFmpeg process cancelled.")
|
|
78
|
-
return False
|
|
79
|
-
except KeyboardInterrupt:
|
|
80
|
-
print("[KeyboardInterrupt] FFmpeg process cancelled.")
|
|
81
|
-
return False
|
|
82
|
-
|
|
83
|
-
self._ffmpeg_command.insert(1, "-y")
|
|
84
|
-
return True
|
|
85
|
-
|
|
86
|
-
@staticmethod
|
|
87
|
-
def _get_duration(filepath: Path) -> Optional[float]:
|
|
88
|
-
try:
|
|
89
|
-
output = subprocess.check_output(
|
|
90
|
-
[
|
|
91
|
-
"ffprobe",
|
|
92
|
-
"-v",
|
|
93
|
-
"error",
|
|
94
|
-
"-show_entries",
|
|
95
|
-
"format=duration",
|
|
96
|
-
"-of",
|
|
97
|
-
"default=noprint_wrappers=1:nokey=1",
|
|
98
|
-
"-i",
|
|
99
|
-
str(filepath),
|
|
100
|
-
],
|
|
101
|
-
text=True,
|
|
102
|
-
)
|
|
103
|
-
return float(output)
|
|
104
|
-
except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
|
|
105
|
-
return None
|
|
106
|
-
|
|
107
|
-
@staticmethod
|
|
108
|
-
def _check_shell_needed(command):
|
|
109
|
-
shell_operators = {"|", ">", "<", ">>", "&&", "||"}
|
|
110
|
-
return any(op in item for item in command for op in shell_operators)
|
|
111
|
-
|
|
112
|
-
def __init__(
|
|
113
|
-
self,
|
|
114
|
-
command: List[str],
|
|
115
|
-
ffmpeg_log_level: Optional[Union[FfmpegLogLevel, str]] = None,
|
|
116
|
-
ffmpeg_log_file: Optional[Union[str, Path]] = None,
|
|
117
|
-
print_detected_duration: bool = False,
|
|
118
|
-
print_stderr_new_line: bool = False,
|
|
119
|
-
):
|
|
120
|
-
if not self._validate_command(command):
|
|
121
|
-
self._return_code = 1
|
|
122
|
-
return
|
|
123
|
-
|
|
124
|
-
if ffmpeg_log_level and ffmpeg_log_level not in FfmpegLogLevel:
|
|
125
|
-
print(
|
|
126
|
-
f"ffmpeg_log_level must be a lowercase variant of: {list(FfmpegLogLevel.__members__)}"
|
|
127
|
-
)
|
|
128
|
-
self._return_code = 1
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
input_idx = command.index("-i") + 1
|
|
132
|
-
self._input_filepath = Path(command[input_idx])
|
|
133
|
-
self._output_filepath = Path(command[-1])
|
|
134
|
-
self._ffmpeg_log_level = (
|
|
135
|
-
ffmpeg_log_level if ffmpeg_log_level else FfmpegLogLevel.VERBOSE.value
|
|
136
|
-
)
|
|
137
|
-
self._ffmpeg_log_file = Path(
|
|
138
|
-
ffmpeg_log_file or f"{self._input_filepath.name}_log.txt"
|
|
139
|
-
)
|
|
140
|
-
self._print_detected_duration = print_detected_duration
|
|
141
|
-
self._print_stderr_new_line = print_stderr_new_line
|
|
142
|
-
self._duration_secs = self._get_duration(self._input_filepath)
|
|
143
|
-
|
|
144
|
-
if self._print_detected_duration and self._duration_secs is not None:
|
|
145
|
-
print(f"Detected duration: {self._duration_secs} seconds")
|
|
146
|
-
|
|
147
|
-
self._ffmpeg_command = [
|
|
148
|
-
command[0],
|
|
149
|
-
"-hide_banner",
|
|
150
|
-
"-loglevel",
|
|
151
|
-
self._ffmpeg_log_level,
|
|
152
|
-
"-progress",
|
|
153
|
-
"pipe:1",
|
|
154
|
-
"-nostats",
|
|
155
|
-
]
|
|
156
|
-
self._ffmpeg_command.extend(command[1:])
|
|
157
|
-
|
|
158
|
-
if not self._should_overwrite():
|
|
159
|
-
self._return_code = 1
|
|
160
|
-
return
|
|
161
|
-
|
|
162
|
-
def _use_rich(self, process) -> int:
|
|
163
|
-
task_id = None
|
|
164
|
-
|
|
165
|
-
with Progress(
|
|
166
|
-
TextColumn("[progress.description]{task.description}"),
|
|
167
|
-
BarColumn(),
|
|
168
|
-
TaskProgressColumn(),
|
|
169
|
-
TimeElapsedColumn(),
|
|
170
|
-
TimeRemainingColumn(compact=True),
|
|
171
|
-
refresh_per_second=2,
|
|
172
|
-
) as progress_bar:
|
|
173
|
-
if self._duration_secs:
|
|
174
|
-
task_id = progress_bar.add_task(
|
|
175
|
-
f"Processing {self._input_filepath.name}",
|
|
176
|
-
total=self._duration_secs,
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
update_progress = progress_bar.update
|
|
180
|
-
duration = self._duration_secs
|
|
181
|
-
|
|
182
|
-
for line in process.stdout:
|
|
183
|
-
if line[:9] == OUT_TIME_PREFIX:
|
|
184
|
-
value = line[12:-1]
|
|
185
|
-
# Check if the first byte is a digit
|
|
186
|
-
try:
|
|
187
|
-
if 48 <= value[0] <= 57:
|
|
188
|
-
value_int = int(value) * MULTIPLIER
|
|
189
|
-
if value_int <= duration:
|
|
190
|
-
update_progress(task_id, completed=value_int)
|
|
191
|
-
except (IndexError, ValueError):
|
|
192
|
-
pass
|
|
193
|
-
|
|
194
|
-
process.wait()
|
|
195
|
-
|
|
196
|
-
if process.returncode == 0 and task_id is not None:
|
|
197
|
-
update_progress(task_id, completed=duration)
|
|
198
|
-
progress_bar.columns = (
|
|
199
|
-
TextColumn("[progress.description]{task.description}"),
|
|
200
|
-
BarColumn(),
|
|
201
|
-
TaskProgressColumn(),
|
|
202
|
-
)
|
|
203
|
-
update_progress(
|
|
204
|
-
task_id,
|
|
205
|
-
description=f"✓ Processed {self._input_filepath.name}",
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
if process.returncode != 0:
|
|
209
|
-
if task_id is not None:
|
|
210
|
-
progress_bar.update(
|
|
211
|
-
task_id,
|
|
212
|
-
description=f"FFmpeg process failed. Check out {self._ffmpeg_log_file} for details.",
|
|
213
|
-
)
|
|
214
|
-
return 1
|
|
215
|
-
|
|
216
|
-
return 0
|
|
217
|
-
|
|
218
|
-
def _use_tqdm(self, process) -> int:
|
|
219
|
-
progress_bar = None
|
|
220
|
-
|
|
221
|
-
try:
|
|
222
|
-
width = os.get_terminal_size().columns
|
|
223
|
-
except OSError:
|
|
224
|
-
width = 80
|
|
225
|
-
|
|
226
|
-
if self._duration_secs:
|
|
227
|
-
progress_bar = tqdm(
|
|
228
|
-
mininterval=0.5,
|
|
229
|
-
total=self._duration_secs,
|
|
230
|
-
desc=f"Processing {self._input_filepath.name}",
|
|
231
|
-
ncols=80,
|
|
232
|
-
dynamic_ncols=True if width < 80 else False,
|
|
233
|
-
bar_format="{desc} {bar} {percentage:.1f}% {elapsed} {remaining}",
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
duration = self._duration_secs
|
|
237
|
-
|
|
238
|
-
for line in process.stdout:
|
|
239
|
-
if line[:9] == OUT_TIME_PREFIX:
|
|
240
|
-
value = line[12:-1]
|
|
241
|
-
try:
|
|
242
|
-
# Check if the first byte is a digit
|
|
243
|
-
if 48 <= value[0] <= 57:
|
|
244
|
-
value_int = int(value) * MULTIPLIER
|
|
245
|
-
if value_int <= duration:
|
|
246
|
-
progress_bar.n = value_int
|
|
247
|
-
progress_bar.refresh()
|
|
248
|
-
except (IndexError, ValueError):
|
|
249
|
-
pass
|
|
250
|
-
|
|
251
|
-
process.wait()
|
|
252
|
-
|
|
253
|
-
if process.returncode == 0 and progress_bar is not None:
|
|
254
|
-
progress_bar.n = duration
|
|
255
|
-
progress_bar.set_description(f"✓ Processed {self._input_filepath.name}")
|
|
256
|
-
progress_bar.close()
|
|
257
|
-
|
|
258
|
-
if process.returncode != 0:
|
|
259
|
-
print(
|
|
260
|
-
f"FFmpeg process failed. Check out {self._ffmpeg_log_file} for details."
|
|
261
|
-
)
|
|
262
|
-
return 1
|
|
263
|
-
|
|
264
|
-
return 0
|
|
265
|
-
|
|
266
|
-
def run(
|
|
267
|
-
self,
|
|
268
|
-
print_command: bool = False,
|
|
269
|
-
use_tqdm: bool = False,
|
|
270
|
-
) -> int:
|
|
271
|
-
if hasattr(self, "_return_code") and self._return_code != 0:
|
|
272
|
-
return 1
|
|
273
|
-
|
|
274
|
-
if print_command:
|
|
275
|
-
print(f"Executing: {' '.join(self._ffmpeg_command)}")
|
|
276
|
-
|
|
277
|
-
self._shell_needed = self._check_shell_needed(self._ffmpeg_command)
|
|
278
|
-
|
|
279
|
-
if self._shell_needed:
|
|
280
|
-
self._ffmpeg_command = " ".join(self._ffmpeg_command)
|
|
281
|
-
|
|
282
|
-
try:
|
|
283
|
-
with open(self._ffmpeg_log_file, "w") as f:
|
|
284
|
-
self._process = subprocess.Popen(
|
|
285
|
-
self._ffmpeg_command,
|
|
286
|
-
shell=self._shell_needed,
|
|
287
|
-
stdout=subprocess.PIPE,
|
|
288
|
-
stderr=f,
|
|
289
|
-
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
|
290
|
-
if os.name == "nt"
|
|
291
|
-
else 0,
|
|
292
|
-
)
|
|
293
|
-
except Exception as e:
|
|
294
|
-
print(f"Error starting FFmpeg process: {e}")
|
|
295
|
-
return 1
|
|
296
|
-
|
|
297
|
-
try:
|
|
298
|
-
return (
|
|
299
|
-
self._use_tqdm(self._process)
|
|
300
|
-
if use_tqdm
|
|
301
|
-
else self._use_rich(self._process)
|
|
302
|
-
)
|
|
303
|
-
except KeyboardInterrupt:
|
|
304
|
-
print("\n[KeyboardInterrupt] Terminating FFmpeg process...")
|
|
305
|
-
self._terminate()
|
|
306
|
-
print("Done!")
|
|
307
|
-
return 1
|
|
308
|
-
finally:
|
|
309
|
-
if self._process and self._process.poll() is None:
|
|
310
|
-
self._terminate()
|
|
311
|
-
|
|
312
|
-
def _terminate(self):
|
|
313
|
-
if not self._process or self._process.poll() is not None:
|
|
314
|
-
return
|
|
315
|
-
|
|
316
|
-
try:
|
|
317
|
-
proc = psutil.Process(self._process.pid)
|
|
318
|
-
children = proc.children(recursive=True)
|
|
319
|
-
|
|
320
|
-
# Windows. Use CTRL_BREAK_EVENT to terminate entire process group
|
|
321
|
-
if os.name == "nt":
|
|
322
|
-
if self._shell_needed:
|
|
323
|
-
# If running under a shell, terminate shell first
|
|
324
|
-
proc.terminate()
|
|
325
|
-
else:
|
|
326
|
-
from signal import CTRL_BREAK_EVENT
|
|
327
|
-
|
|
328
|
-
# Send CTRL_BREAK_EVENT to entire process group
|
|
329
|
-
os.kill(self._process.pid, CTRL_BREAK_EVENT)
|
|
330
|
-
# Unix
|
|
331
|
-
else:
|
|
332
|
-
for child in children:
|
|
333
|
-
try:
|
|
334
|
-
child.terminate()
|
|
335
|
-
except psutil.NoSuchProcess:
|
|
336
|
-
pass
|
|
337
|
-
proc.terminate()
|
|
338
|
-
|
|
339
|
-
# Wait up to 1 second for processes to exit gracefully
|
|
340
|
-
gone, still_alive = psutil.wait_procs(children + [proc], timeout=1)
|
|
341
|
-
|
|
342
|
-
# Force kill any remaining processes
|
|
343
|
-
for p in still_alive:
|
|
344
|
-
try:
|
|
345
|
-
p.kill()
|
|
346
|
-
except psutil.NoSuchProcess:
|
|
347
|
-
pass
|
|
348
|
-
|
|
349
|
-
except (psutil.NoSuchProcess, ProcessLookupError, KeyboardInterrupt):
|
|
350
|
-
# As a last resort, force kill the main process
|
|
351
|
-
self._process.kill()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|