better-ffmpeg-progress 3.2.2__tar.gz → 3.2.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-ffmpeg-progress
3
- Version: 3.2.2
3
+ Version: 3.2.3
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
@@ -12,7 +12,6 @@ Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.7
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
- Requires-Dist: ffmpeg-python
16
15
  Requires-Dist: psutil
17
16
  Requires-Dist: rich
18
17
  Requires-Dist: tqdm
@@ -0,0 +1,350 @@
1
+ from enum import Enum
2
+ import os
3
+ from pathlib import Path
4
+ import psutil
5
+ import shutil
6
+ from signal import CTRL_BREAK_EVENT
7
+ import subprocess
8
+ from typing import List, Optional, Union
9
+
10
+ from rich.progress import (
11
+ Progress,
12
+ TextColumn,
13
+ BarColumn,
14
+ TaskProgressColumn,
15
+ TimeRemainingColumn,
16
+ TimeElapsedColumn,
17
+ )
18
+ from tqdm import tqdm
19
+
20
+ OUT_TIME_PREFIX = b"out_time_"
21
+ # Convert microseconds to seconds
22
+ MULTIPLIER = 1e-6
23
+
24
+
25
+ class FfmpegLogLevel(Enum):
26
+ QUIET = "quiet"
27
+ PANIC = "panic"
28
+ FATAL = "fatal"
29
+ ERROR = "error"
30
+ WARNING = "warning"
31
+ INFO = "info"
32
+ VERBOSE = "verbose"
33
+ DEBUG = "debug"
34
+ TRACE = "trace"
35
+
36
+
37
+ class FfmpegProcess:
38
+ @staticmethod
39
+ def _validate_command(command: List[str]) -> bool:
40
+ if not shutil.which("ffmpeg"):
41
+ print("Error: FFmpeg executable not found in PATH")
42
+ return False
43
+
44
+ if "-i" not in command:
45
+ print("Error: FFmpeg command must include '-i'")
46
+ return False
47
+
48
+ input_idx = command.index("-i") + 1
49
+ if input_idx >= len(command):
50
+ print("Error: No input file specified after -i")
51
+ return False
52
+
53
+ input_file = Path(command[input_idx])
54
+ if not input_file.exists():
55
+ print(f"Error: Input file not found: {input_file}")
56
+ return False
57
+
58
+ if input_idx + 1 >= len(command):
59
+ print("Error: No output file specified")
60
+ return False
61
+
62
+ return True
63
+
64
+ def _should_overwrite(self):
65
+ if self._output_filepath.exists() and "-y" not in self._ffmpeg_command:
66
+ try:
67
+ if (
68
+ input(
69
+ f"{self._output_filepath} already exists. Overwrite? [Y/N]: "
70
+ ).lower()
71
+ != "y"
72
+ ):
73
+ print(
74
+ "FFmpeg process cancelled. Output file exists and overwrite declined."
75
+ )
76
+ return False
77
+ except EOFError:
78
+ print("Input error. FFmpeg process cancelled.")
79
+ return False
80
+ except KeyboardInterrupt:
81
+ print("[KeyboardInterrupt] FFmpeg process cancelled.")
82
+ return False
83
+
84
+ self._ffmpeg_command.insert(1, "-y")
85
+ return True
86
+
87
+ @staticmethod
88
+ def _get_duration(filepath: Path) -> Optional[float]:
89
+ try:
90
+ output = subprocess.check_output(
91
+ [
92
+ "ffprobe",
93
+ "-v",
94
+ "error",
95
+ "-show_entries",
96
+ "format=duration",
97
+ "-of",
98
+ "default=noprint_wrappers=1:nokey=1",
99
+ "-i",
100
+ str(filepath),
101
+ ],
102
+ text=True,
103
+ )
104
+ return float(output)
105
+ except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
106
+ return None
107
+
108
+ @staticmethod
109
+ def _check_shell_needed(command):
110
+ shell_operators = {"|", ">", "<", ">>", "&&", "||"}
111
+ return any(op in item for item in command for op in shell_operators)
112
+
113
+ def __init__(
114
+ self,
115
+ command: List[str],
116
+ ffmpeg_log_level: Optional[Union[FfmpegLogLevel, str]] = None,
117
+ ffmpeg_log_file: Optional[Union[str, Path]] = None,
118
+ print_detected_duration: bool = False,
119
+ print_stderr_new_line: bool = False,
120
+ ):
121
+ if not self._validate_command(command):
122
+ self._return_code = 1
123
+ return
124
+
125
+ if ffmpeg_log_level and ffmpeg_log_level not in FfmpegLogLevel:
126
+ print(
127
+ f"ffmpeg_log_level must be a lowercase variant of: {list(FfmpegLogLevel.__members__)}"
128
+ )
129
+ self._return_code = 1
130
+ return
131
+
132
+ input_idx = command.index("-i") + 1
133
+ self._input_filepath = Path(command[input_idx])
134
+ self._output_filepath = Path(command[-1])
135
+ self._ffmpeg_log_level = (
136
+ ffmpeg_log_level.value if ffmpeg_log_level else FfmpegLogLevel.VERBOSE.value
137
+ )
138
+ self._ffmpeg_log_file = Path(
139
+ ffmpeg_log_file or f"{self._input_filepath.name}_log.txt"
140
+ )
141
+ self._print_detected_duration = print_detected_duration
142
+ self._print_stderr_new_line = print_stderr_new_line
143
+ self._duration_secs = self._get_duration(self._input_filepath)
144
+
145
+ if self._print_detected_duration and self._duration_secs is not None:
146
+ print(f"Detected duration: {self._duration_secs} seconds")
147
+
148
+ self._ffmpeg_command = [
149
+ command[0],
150
+ "-hide_banner",
151
+ "-loglevel",
152
+ self._ffmpeg_log_level,
153
+ "-progress",
154
+ "pipe:1",
155
+ "-nostats",
156
+ ]
157
+ self._ffmpeg_command.extend(command[1:])
158
+
159
+ if not self._should_overwrite():
160
+ self._return_code = 1
161
+ return
162
+
163
+ def _use_rich(self, process) -> int:
164
+ task_id = None
165
+
166
+ with Progress(
167
+ TextColumn("[progress.description]{task.description}"),
168
+ BarColumn(),
169
+ TaskProgressColumn(),
170
+ TimeElapsedColumn(),
171
+ TimeRemainingColumn(compact=True),
172
+ refresh_per_second=2,
173
+ ) as progress_bar:
174
+ if self._duration_secs:
175
+ task_id = progress_bar.add_task(
176
+ f"Processing {self._input_filepath.name}",
177
+ total=self._duration_secs,
178
+ )
179
+
180
+ update_progress = progress_bar.update
181
+ duration = self._duration_secs
182
+
183
+ for line in process.stdout:
184
+ if line[:9] == OUT_TIME_PREFIX:
185
+ value = line[12:-1]
186
+ # Check if the first byte is a digit
187
+ try:
188
+ if 48 <= value[0] <= 57:
189
+ value_int = int(value) * MULTIPLIER
190
+ if value_int <= duration:
191
+ update_progress(task_id, completed=value_int)
192
+ except (IndexError, ValueError):
193
+ pass
194
+
195
+ process.wait()
196
+
197
+ if process.returncode == 0 and task_id is not None:
198
+ update_progress(task_id, completed=duration)
199
+ progress_bar.columns = (
200
+ TextColumn("[progress.description]{task.description}"),
201
+ BarColumn(),
202
+ TaskProgressColumn(),
203
+ )
204
+ update_progress(
205
+ task_id,
206
+ description=f"✓ Processed {self._input_filepath.name}",
207
+ )
208
+
209
+ if process.returncode != 0:
210
+ if task_id is not None:
211
+ progress_bar.update(
212
+ task_id,
213
+ description=f"FFmpeg process failed. Check out {self._ffmpeg_log_file} for details.",
214
+ )
215
+ return 1
216
+
217
+ return 0
218
+
219
+ def _use_tqdm(self, process) -> int:
220
+ progress_bar = None
221
+
222
+ try:
223
+ width = os.get_terminal_size().columns
224
+ except OSError:
225
+ width = 80
226
+
227
+ if self._duration_secs:
228
+ progress_bar = tqdm(
229
+ mininterval=0.5,
230
+ total=self._duration_secs,
231
+ desc=f"Processing {self._input_filepath.name}",
232
+ ncols=80,
233
+ dynamic_ncols=True if width < 80 else False,
234
+ bar_format="{desc} {bar} {percentage:.1f}% {elapsed} {remaining}",
235
+ )
236
+
237
+ duration = self._duration_secs
238
+
239
+ for line in process.stdout:
240
+ if line[:9] == OUT_TIME_PREFIX:
241
+ value = line[12:-1]
242
+ try:
243
+ # Check if the first byte is a digit
244
+ if 48 <= value[0] <= 57:
245
+ value_int = int(value) * MULTIPLIER
246
+ if value_int <= duration:
247
+ progress_bar.n = value_int
248
+ progress_bar.refresh()
249
+ except (IndexError, ValueError):
250
+ pass
251
+
252
+ process.wait()
253
+
254
+ if process.returncode == 0 and progress_bar is not None:
255
+ progress_bar.n = duration
256
+ progress_bar.set_description(f"✓ Processed {self._input_filepath.name}")
257
+ progress_bar.close()
258
+
259
+ if process.returncode != 0:
260
+ print(
261
+ f"FFmpeg process failed. Check out {self._ffmpeg_log_file} for details."
262
+ )
263
+ return 1
264
+
265
+ return 0
266
+
267
+ def run(
268
+ self,
269
+ print_command: bool = False,
270
+ use_tqdm: bool = False,
271
+ ) -> int:
272
+ if hasattr(self, "_return_code") and self._return_code != 0:
273
+ return 1
274
+
275
+ if print_command:
276
+ print(f"Executing: {' '.join(self._ffmpeg_command)}")
277
+
278
+ self._shell_needed = self._check_shell_needed(self._ffmpeg_command)
279
+
280
+ if self._shell_needed:
281
+ self._ffmpeg_command = " ".join(self._ffmpeg_command)
282
+
283
+ try:
284
+ with open(self._ffmpeg_log_file, "w") as f:
285
+ self._process = subprocess.Popen(
286
+ self._ffmpeg_command,
287
+ shell=self._shell_needed,
288
+ stdout=subprocess.PIPE,
289
+ stderr=f,
290
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
291
+ if os.name == "nt"
292
+ else 0,
293
+ )
294
+ except Exception as e:
295
+ print(f"Error starting FFmpeg process: {e}")
296
+ return 1
297
+
298
+ try:
299
+ return (
300
+ self._use_tqdm(self._process)
301
+ if use_tqdm
302
+ else self._use_rich(self._process)
303
+ )
304
+ except KeyboardInterrupt:
305
+ print("\n[KeyboardInterrupt] Terminating FFmpeg process...")
306
+ self._terminate()
307
+ print("Done!")
308
+ return 1
309
+ finally:
310
+ if self._process and self._process.poll() is None:
311
+ self._terminate()
312
+
313
+ def _terminate(self):
314
+ if not self._process or self._process.poll() is not None:
315
+ return
316
+
317
+ try:
318
+ proc = psutil.Process(self._process.pid)
319
+ children = proc.children(recursive=True)
320
+
321
+ # Windows. Use CTRL_BREAK_EVENT to terminate entire process group
322
+ if os.name == "nt":
323
+ if self._shell_needed:
324
+ # If running under a shell, terminate shell first
325
+ proc.terminate()
326
+ else:
327
+ # Send CTRL_BREAK_EVENT to entire process group
328
+ os.kill(self._process.pid, CTRL_BREAK_EVENT)
329
+ # Unix
330
+ else:
331
+ for child in children:
332
+ try:
333
+ child.terminate()
334
+ except psutil.NoSuchProcess:
335
+ pass
336
+ proc.terminate()
337
+
338
+ # Wait up to 1 second for processes to exit gracefully
339
+ gone, still_alive = psutil.wait_procs(children + [proc], timeout=1)
340
+
341
+ # Force kill any remaining processes
342
+ for p in still_alive:
343
+ try:
344
+ p.kill()
345
+ except psutil.NoSuchProcess:
346
+ pass
347
+
348
+ except (psutil.NoSuchProcess, ProcessLookupError, KeyboardInterrupt):
349
+ # As a last resort, force kill the main process
350
+ self._process.kill()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-ffmpeg-progress
3
- Version: 3.2.2
3
+ Version: 3.2.3
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
@@ -12,7 +12,6 @@ Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.7
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
- Requires-Dist: ffmpeg-python
16
15
  Requires-Dist: psutil
17
16
  Requires-Dist: rich
18
17
  Requires-Dist: tqdm
@@ -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.2",
8
+ version="3.2.3",
9
9
  author="GitHub.com/CrypticSignal",
10
10
  author_email="theaudiophile@outlook.com",
11
11
  description="Run FFmpeg & see percentage progress + ETA.",
@@ -13,7 +13,7 @@ setuptools.setup(
13
13
  long_description_content_type="text/markdown",
14
14
  url="https://github.com/CrypticSignal/better-ffmpeg-progress",
15
15
  packages=["better_ffmpeg_progress"],
16
- install_requires=["ffmpeg-python", "psutil", "rich", "tqdm"],
16
+ install_requires=["psutil", "rich", "tqdm"],
17
17
  python_requires=">=3.7",
18
18
  keywords=["ffmpeg", "progress"],
19
19
  classifiers=[
@@ -1,271 +0,0 @@
1
- from enum import Enum
2
- import os
3
- from pathlib import Path
4
- import psutil
5
- from queue import Queue
6
- import shutil
7
- import subprocess
8
- from typing import List, Optional, Union
9
-
10
- from ffmpeg import probe
11
- from rich.progress import (
12
- Progress,
13
- TextColumn,
14
- BarColumn,
15
- TaskProgressColumn,
16
- TimeRemainingColumn,
17
- TimeElapsedColumn,
18
- SpinnerColumn,
19
- )
20
- from tqdm import tqdm
21
-
22
-
23
- class FfmpegLogLevel(Enum):
24
- QUIET = "quiet"
25
- PANIC = "panic"
26
- FATAL = "fatal"
27
- ERROR = "error"
28
- WARNING = "warning"
29
- INFO = "info"
30
- VERBOSE = "verbose"
31
- DEBUG = "debug"
32
- TRACE = "trace"
33
-
34
-
35
- class FfmpegProcess:
36
- @classmethod
37
- def _validate_command(cls, command: List[str]) -> bool:
38
- if not shutil.which("ffmpeg"):
39
- print("Error: FFmpeg executable not found in PATH")
40
- return False
41
-
42
- if "-i" not in command:
43
- print("Error: FFmpeg command must include '-i'")
44
- return False
45
-
46
- input_idx = command.index("-i") + 1
47
- if input_idx >= len(command):
48
- print("Error: No input file specified after -i")
49
- return False
50
-
51
- input_file = Path(command[input_idx])
52
- if not input_file.exists():
53
- print(f"Error: Input file not found: {input_file}")
54
- return False
55
-
56
- if input_idx + 1 >= len(command):
57
- print("Error: No output file specified")
58
- return False
59
-
60
- return True
61
-
62
- def __init__(
63
- self,
64
- command: List[str],
65
- ffmpeg_log_level: Optional[FfmpegLogLevel] = None,
66
- ffmpeg_log_file: Optional[Union[str, Path]] = None,
67
- print_detected_duration: bool = False,
68
- print_stderr_new_line: bool = False,
69
- ):
70
- if not self._validate_command(command):
71
- self.return_code = 1
72
- return
73
-
74
- if ffmpeg_log_level and ffmpeg_log_level not in FfmpegLogLevel:
75
- print(
76
- f"ffmpeg_log_level must be a lowercase variant of: {list(FfmpegLogLevel.__members__)}"
77
- )
78
- self.return_code = 1
79
- return
80
-
81
- input_idx = command.index("-i") + 1
82
- self._input_filepath = Path(command[input_idx])
83
- self._output_filepath = Path(command[-1])
84
- self._ffmpeg_log_level = (
85
- ffmpeg_log_level if ffmpeg_log_level else FfmpegLogLevel.VERBOSE.value
86
- )
87
- self._ffmpeg_log_file = Path(
88
- ffmpeg_log_file
89
- if ffmpeg_log_file
90
- else f"{self._input_filepath.name}_log.txt"
91
- )
92
- self._print_detected_duration = print_detected_duration
93
- self._print_stderr_new_line = print_stderr_new_line
94
-
95
- try:
96
- probe_data = probe(self._input_filepath)
97
- self._duration_secs = float(probe_data["format"]["duration"])
98
- if self._print_detected_duration:
99
- print(f"Detected duration: {self._duration_secs} seconds")
100
- except Exception:
101
- self._duration_secs = None
102
-
103
- self._ffmpeg_command = [
104
- command[0],
105
- "-hide_banner",
106
- "-loglevel",
107
- self._ffmpeg_log_level,
108
- "-progress",
109
- "pipe:1",
110
- "-nostats",
111
- *command[1:],
112
- ]
113
-
114
- self.return_code = 0
115
-
116
- def _use_rich(self, process) -> int:
117
- with Progress(
118
- SpinnerColumn(),
119
- TextColumn("[progress.description]{task.description}"),
120
- BarColumn(),
121
- TaskProgressColumn(),
122
- TimeElapsedColumn(),
123
- TimeRemainingColumn(compact=True),
124
- refresh_per_second=10,
125
- ) as progress_bar:
126
- task_id = None
127
-
128
- if self._duration_secs:
129
- task_id = progress_bar.add_task(
130
- f"Processing {self._input_filepath.name}",
131
- total=self._duration_secs,
132
- )
133
-
134
- if task_id is not None:
135
- while process.poll() is None:
136
- stdout = process.stdout.readline()
137
-
138
- if not stdout.startswith("out_time_ms"):
139
- continue
140
-
141
- try:
142
- value = int(stdout.split("=")[1]) / 1_000_000
143
- except ValueError:
144
- continue
145
- else:
146
- if value <= self._duration_secs:
147
- progress_bar.update(task_id, completed=value)
148
-
149
- if process.returncode == 0:
150
- progress_bar.update(task_id, completed=self._duration_secs)
151
- progress_bar.columns = (
152
- TextColumn("[progress.description]{task.description}"),
153
- BarColumn(),
154
- TaskProgressColumn(),
155
- )
156
- progress_bar.update(
157
- task_id,
158
- description=f"✓ Processed {self._input_filepath.name}",
159
- )
160
-
161
- if process.returncode != 0:
162
- progress_bar.update(
163
- task_id,
164
- description=f"The FFmpeg process did not complete successfully. Check out {self._ffmpeg_log_file} for details.",
165
- )
166
- return 1
167
-
168
- return 0
169
-
170
- def _use_tqdm(self, process) -> int:
171
- progress_bar = None
172
- width, _ = os.get_terminal_size()
173
-
174
- if self._duration_secs:
175
- progress_bar = tqdm(
176
- total=self._duration_secs,
177
- desc=f"Processing {self._input_filepath.name}",
178
- ncols=80,
179
- dynamic_ncols=True if width < 80 else False,
180
- bar_format="{desc} {bar} {percentage:.1f}% {elapsed} {remaining}",
181
- )
182
-
183
- if progress_bar is not None:
184
- while process.poll() is None:
185
- stdout = process.stdout.readline()
186
-
187
- if not stdout.startswith("out_time_ms"):
188
- continue
189
-
190
- try:
191
- value = int(stdout.split("=")[1]) / 1_000_000
192
- except ValueError:
193
- continue
194
- else:
195
- if value <= self._duration_secs:
196
- progress_bar.n = value
197
- progress_bar.refresh()
198
-
199
- if process.returncode == 0:
200
- progress_bar.n = self._duration_secs
201
- progress_bar.set_description(f"✓ Processed {self._input_filepath.name}")
202
-
203
- progress_bar.close()
204
-
205
- if process.returncode != 0:
206
- print(
207
- f"The FFmpeg process did not complete successfully. Check out {self._ffmpeg_log_file} for details."
208
- )
209
- return 1
210
-
211
- return 0
212
-
213
- def run(
214
- self,
215
- print_command: bool = False,
216
- use_tqdm: bool = False,
217
- ) -> int:
218
- if hasattr(self, "return_code") and self.return_code != 0:
219
- return 1
220
-
221
- if self._output_filepath.exists() and "-y" not in self._ffmpeg_command:
222
- if (
223
- input(
224
- f"{self._output_filepath} already exists. Overwrite? [Y/N]: "
225
- ).lower()
226
- != "y"
227
- ):
228
- print(
229
- "FFmpeg process cancelled. Output file exists and overwrite declined."
230
- )
231
- return 1
232
- self._ffmpeg_command.insert(1, "-y")
233
-
234
- if print_command:
235
- print(f"Executing: {' '.join(self._ffmpeg_command)}")
236
-
237
- def _contains_shell_operators(command: List[str]) -> bool:
238
- shell_operators = {"|", ">", "<", ">>", "&&", "||"}
239
- return any(op in command for op in shell_operators)
240
-
241
- # If command contains shell operators, turn the list into a string.
242
- if _contains_shell_operators(self._ffmpeg_command):
243
- self._ffmpeg_command = " ".join(self._ffmpeg_command)
244
-
245
- try:
246
- with open(self._ffmpeg_log_file, "w") as f:
247
- process = subprocess.Popen(
248
- self._ffmpeg_command,
249
- shell=isinstance(self._ffmpeg_command, str),
250
- stdout=subprocess.PIPE,
251
- stderr=f,
252
- text=True,
253
- creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
254
- if os.name == "nt"
255
- else 0,
256
- )
257
- except Exception as e:
258
- print(f"Error starting FFmpeg process: {e}")
259
- return 1
260
-
261
- try:
262
- if use_tqdm:
263
- return self._use_tqdm(process)
264
- else:
265
- return self._use_rich(process)
266
- except KeyboardInterrupt:
267
- try:
268
- psutil.Process(process.pid).terminate()
269
- except psutil.NoSuchProcess:
270
- pass
271
- return 1