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.
Files changed (20) hide show
  1. {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/PKG-INFO +30 -7
  2. {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/README.md +30 -7
  3. better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/__init__.py +17 -0
  4. better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/better_ffmpeg_progress.py +200 -0
  5. better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/enums.py +13 -0
  6. better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/exceptions.py +14 -0
  7. better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/progress_bars.py +130 -0
  8. better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/terminate_process.py +249 -0
  9. better_ffmpeg_progress-4.0.0/better_ffmpeg_progress/utils.py +151 -0
  10. {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/PKG-INFO +30 -7
  11. {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/SOURCES.txt +4 -0
  12. {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/setup.py +1 -1
  13. better_ffmpeg_progress-3.2.5/better_ffmpeg_progress/__init__.py +0 -1
  14. better_ffmpeg_progress-3.2.5/better_ffmpeg_progress/better_ffmpeg_progress.py +0 -351
  15. better_ffmpeg_progress-3.2.5/better_ffmpeg_progress/utils.py +0 -10
  16. {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/LICENSE +0 -0
  17. {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
  18. {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/requires.txt +0 -0
  19. {better_ffmpeg_progress-3.2.5 → better_ffmpeg_progress-4.0.0}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
  20. {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.2.5
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
- Here's a simple example:
56
+ Example:
57
57
  ```py
58
- from better_ffmpeg_progress import FfmpegProcess
58
+ from better_ffmpeg_progress import FfmpegProcess, FfmpegProcessError
59
59
 
60
- process = FfmpegProcess(["ffmpeg", "-i", "input.mp4", "-c:v", "libx265", "output.mp4"])
61
- # return_code will be 0 if the process completed successfully, otherwise it will be 1
62
- return_code = process.run()
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
- Here's a simple example:
38
+ Example:
39
39
  ```py
40
- from better_ffmpeg_progress import FfmpegProcess
40
+ from better_ffmpeg_progress import FfmpegProcess, FfmpegProcessError
41
41
 
42
- process = FfmpegProcess(["ffmpeg", "-i", "input.mp4", "-c:v", "libx265", "output.mp4"])
43
- # return_code will be 0 if the process completed successfully, otherwise it will be 1
44
- return_code = process.run()
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,13 @@
1
+ from enum import Enum
2
+
3
+
4
+ class FfmpegLogLevel(Enum):
5
+ QUIET = "quiet"
6
+ PANIC = "panic"
7
+ FATAL = "fatal"
8
+ ERROR = "error"
9
+ WARNING = "warning"
10
+ INFO = "info"
11
+ VERBOSE = "verbose"
12
+ DEBUG = "debug"
13
+ TRACE = "trace"
@@ -0,0 +1,14 @@
1
+ class FfmpegProcessError(Exception):
2
+ pass
3
+
4
+
5
+ class FfmpegCommandError(FfmpegProcessError):
6
+ pass
7
+
8
+
9
+ class FfmpegProcessUserCancelledError(FfmpegProcessError):
10
+ pass
11
+
12
+
13
+ class FfmpegProcessInterruptedError(FfmpegProcessError):
14
+ pass
@@ -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.2.5
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
- Here's a simple example:
56
+ Example:
57
57
  ```py
58
- from better_ffmpeg_progress import FfmpegProcess
58
+ from better_ffmpeg_progress import FfmpegProcess, FfmpegProcessError
59
59
 
60
- process = FfmpegProcess(["ffmpeg", "-i", "input.mp4", "-c:v", "libx265", "output.mp4"])
61
- # return_code will be 0 if the process completed successfully, otherwise it will be 1
62
- return_code = process.run()
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="3.2.5",
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()
@@ -1,10 +0,0 @@
1
- import os
2
-
3
-
4
- def line():
5
- width, _ = os.get_terminal_size()
6
- print("-" * width)
7
-
8
-
9
- def print_with_prefix(prefix, message):
10
- print(f"{prefix}{message}")