better-ffmpeg-progress 3.2.1__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.
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/PKG-INFO +1 -2
- better_ffmpeg_progress-3.2.3/better_ffmpeg_progress/better_ffmpeg_progress.py +350 -0
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/better_ffmpeg_progress.egg-info/PKG-INFO +1 -2
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/better_ffmpeg_progress.egg-info/requires.txt +0 -1
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/setup.py +2 -2
- better_ffmpeg_progress-3.2.1/better_ffmpeg_progress/better_ffmpeg_progress.py +0 -328
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/LICENSE +0 -0
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/README.md +0 -0
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/better_ffmpeg_progress/__init__.py +0 -0
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/better_ffmpeg_progress/utils.py +0 -0
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/better_ffmpeg_progress.egg-info/SOURCES.txt +0 -0
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
- {better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: better-ffmpeg-progress
|
|
3
|
-
Version: 3.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.
|
|
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.
|
|
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=["
|
|
16
|
+
install_requires=["psutil", "rich", "tqdm"],
|
|
17
17
|
python_requires=">=3.7",
|
|
18
18
|
keywords=["ffmpeg", "progress"],
|
|
19
19
|
classifiers=[
|
|
@@ -1,328 +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 threading import Thread
|
|
9
|
-
from time import sleep
|
|
10
|
-
from typing import List, Optional, Union
|
|
11
|
-
|
|
12
|
-
from ffmpeg import probe
|
|
13
|
-
from rich.progress import (
|
|
14
|
-
Progress,
|
|
15
|
-
TextColumn,
|
|
16
|
-
BarColumn,
|
|
17
|
-
TaskProgressColumn,
|
|
18
|
-
TimeRemainingColumn,
|
|
19
|
-
TimeElapsedColumn,
|
|
20
|
-
SpinnerColumn,
|
|
21
|
-
)
|
|
22
|
-
from tqdm import tqdm
|
|
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 _read_pipe(pipe: subprocess.PIPE, queue: Queue, stdout: bool = False) -> None:
|
|
40
|
-
"""Read from pipe and put lines into queue."""
|
|
41
|
-
try:
|
|
42
|
-
with pipe:
|
|
43
|
-
for line in iter(pipe.readline, ""):
|
|
44
|
-
line = line.strip()
|
|
45
|
-
if line:
|
|
46
|
-
if stdout:
|
|
47
|
-
if line.startswith("out_time_ms="):
|
|
48
|
-
queue.put(line)
|
|
49
|
-
else:
|
|
50
|
-
queue.put(line)
|
|
51
|
-
except (IOError, ValueError) as e:
|
|
52
|
-
print(f"Error reading pipe: {e}")
|
|
53
|
-
finally:
|
|
54
|
-
pipe.close()
|
|
55
|
-
|
|
56
|
-
@classmethod
|
|
57
|
-
def _validate_command(cls, command: List[str]) -> bool:
|
|
58
|
-
if not shutil.which("ffmpeg"):
|
|
59
|
-
print("Error: FFmpeg executable not found in PATH")
|
|
60
|
-
return False
|
|
61
|
-
|
|
62
|
-
if "-i" not in command:
|
|
63
|
-
print("Error: FFmpeg command must include '-i'")
|
|
64
|
-
return False
|
|
65
|
-
|
|
66
|
-
input_idx = command.index("-i") + 1
|
|
67
|
-
if input_idx >= len(command):
|
|
68
|
-
print("Error: No input file specified after -i")
|
|
69
|
-
return False
|
|
70
|
-
|
|
71
|
-
input_file = Path(command[input_idx])
|
|
72
|
-
if not input_file.exists():
|
|
73
|
-
print(f"Error: Input file not found: {input_file}")
|
|
74
|
-
return False
|
|
75
|
-
|
|
76
|
-
if input_idx + 1 >= len(command):
|
|
77
|
-
print("Error: No output file specified")
|
|
78
|
-
return False
|
|
79
|
-
|
|
80
|
-
return True
|
|
81
|
-
|
|
82
|
-
def __init__(
|
|
83
|
-
self,
|
|
84
|
-
command: List[str],
|
|
85
|
-
ffmpeg_log_level: Optional[FfmpegLogLevel] = None,
|
|
86
|
-
ffmpeg_log_file: Optional[Union[str, Path]] = None,
|
|
87
|
-
print_detected_duration: bool = False,
|
|
88
|
-
print_stderr_new_line: bool = False,
|
|
89
|
-
):
|
|
90
|
-
if not self._validate_command(command):
|
|
91
|
-
self.return_code = 1
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
if ffmpeg_log_level and ffmpeg_log_level not in FfmpegLogLevel:
|
|
95
|
-
print(
|
|
96
|
-
f"ffmpeg_log_level must be a lowercase variant of: {list(FfmpegLogLevel.__members__)}"
|
|
97
|
-
)
|
|
98
|
-
self.return_code = 1
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
input_idx = command.index("-i") + 1
|
|
102
|
-
self._input_filepath = Path(command[input_idx])
|
|
103
|
-
self._output_filepath = Path(command[-1])
|
|
104
|
-
self._ffmpeg_log_level = (
|
|
105
|
-
ffmpeg_log_level if ffmpeg_log_level else FfmpegLogLevel.VERBOSE.value
|
|
106
|
-
)
|
|
107
|
-
self._ffmpeg_log_file = Path(
|
|
108
|
-
ffmpeg_log_file
|
|
109
|
-
if ffmpeg_log_file
|
|
110
|
-
else f"{self._input_filepath.name}_log.txt"
|
|
111
|
-
)
|
|
112
|
-
self._print_detected_duration = print_detected_duration
|
|
113
|
-
self._print_stderr_new_line = print_stderr_new_line
|
|
114
|
-
|
|
115
|
-
with open(self._ffmpeg_log_file, "w"):
|
|
116
|
-
pass
|
|
117
|
-
|
|
118
|
-
try:
|
|
119
|
-
probe_data = probe(self._input_filepath)
|
|
120
|
-
self._duration_secs = float(probe_data["format"]["duration"])
|
|
121
|
-
if self._print_detected_duration:
|
|
122
|
-
print(f"Detected duration: {self._duration_secs} seconds")
|
|
123
|
-
except Exception:
|
|
124
|
-
self._duration_secs = None
|
|
125
|
-
|
|
126
|
-
self._ffmpeg_command = [
|
|
127
|
-
command[0],
|
|
128
|
-
"-hide_banner",
|
|
129
|
-
"-loglevel",
|
|
130
|
-
self._ffmpeg_log_level,
|
|
131
|
-
"-progress",
|
|
132
|
-
"pipe:1",
|
|
133
|
-
"-nostats",
|
|
134
|
-
*command[1:],
|
|
135
|
-
]
|
|
136
|
-
|
|
137
|
-
self.return_code = 0
|
|
138
|
-
|
|
139
|
-
def _process_stderr(self, stderr: str) -> None:
|
|
140
|
-
if self._duration_secs is None:
|
|
141
|
-
print(stderr, end="\n" if self._print_stderr_new_line else "\r", flush=True)
|
|
142
|
-
|
|
143
|
-
try:
|
|
144
|
-
with open(self._ffmpeg_log_file, "a", encoding="utf-8") as f:
|
|
145
|
-
f.write(f"{stderr}\n")
|
|
146
|
-
except IOError as e:
|
|
147
|
-
print(f"Error writing to log file: {e}")
|
|
148
|
-
self.return_code = 1
|
|
149
|
-
|
|
150
|
-
def _use_rich(self, process, stdout_queue, stderr_queue) -> int:
|
|
151
|
-
with Progress(
|
|
152
|
-
SpinnerColumn(),
|
|
153
|
-
TextColumn("[progress.description]{task.description}"),
|
|
154
|
-
BarColumn(),
|
|
155
|
-
TaskProgressColumn(),
|
|
156
|
-
TimeElapsedColumn(),
|
|
157
|
-
TimeRemainingColumn(compact=True),
|
|
158
|
-
refresh_per_second=10,
|
|
159
|
-
) as progress_bar:
|
|
160
|
-
task_id = None
|
|
161
|
-
|
|
162
|
-
if self._duration_secs:
|
|
163
|
-
task_id = progress_bar.add_task(
|
|
164
|
-
f"Processing {self._input_filepath.name}",
|
|
165
|
-
total=self._duration_secs,
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
while process.poll() is None:
|
|
169
|
-
# FFmpeg only updates encoding progress/statistics every 0.5 seconds
|
|
170
|
-
sleep(0.5)
|
|
171
|
-
# stdout
|
|
172
|
-
if task_id is not None and not stdout_queue.empty():
|
|
173
|
-
line = stdout_queue.get()
|
|
174
|
-
try:
|
|
175
|
-
value = int(line.split("=")[1]) / 1_000_000
|
|
176
|
-
if value <= self._duration_secs:
|
|
177
|
-
progress_bar.update(task_id, completed=value)
|
|
178
|
-
except ValueError:
|
|
179
|
-
pass
|
|
180
|
-
# stderr
|
|
181
|
-
if not stderr_queue.empty():
|
|
182
|
-
self._process_stderr(stderr_queue.get())
|
|
183
|
-
|
|
184
|
-
if process.returncode == 0 and task_id is not None:
|
|
185
|
-
progress_bar.update(task_id, completed=self._duration_secs)
|
|
186
|
-
progress_bar.columns = (
|
|
187
|
-
TextColumn("[progress.description]{task.description}"),
|
|
188
|
-
BarColumn(),
|
|
189
|
-
TaskProgressColumn(),
|
|
190
|
-
)
|
|
191
|
-
progress_bar.update(
|
|
192
|
-
task_id,
|
|
193
|
-
description=f"✓ Processed {self._input_filepath.name}",
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
# Process remaining stderr
|
|
197
|
-
while not stderr_queue.empty():
|
|
198
|
-
self._process_stderr(stderr_queue.get())
|
|
199
|
-
|
|
200
|
-
if process.returncode != 0:
|
|
201
|
-
progress_bar.update(
|
|
202
|
-
task_id,
|
|
203
|
-
description=f"The FFmpeg process did not complete successfully. Check out {self._ffmpeg_log_file} for details.",
|
|
204
|
-
)
|
|
205
|
-
return 1
|
|
206
|
-
|
|
207
|
-
return 0
|
|
208
|
-
|
|
209
|
-
def _use_tqdm(self, process, stdout_queue, stderr_queue) -> int:
|
|
210
|
-
pbar = None
|
|
211
|
-
width, _ = os.get_terminal_size()
|
|
212
|
-
|
|
213
|
-
if self._duration_secs:
|
|
214
|
-
pbar = tqdm(
|
|
215
|
-
total=self._duration_secs,
|
|
216
|
-
desc=f"Processing {self._input_filepath.name}",
|
|
217
|
-
ncols=80,
|
|
218
|
-
dynamic_ncols=True if width < 80 else False,
|
|
219
|
-
bar_format="{desc} {bar} {percentage:.1f}% {elapsed} {remaining}",
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
while process.poll() is None:
|
|
223
|
-
# FFmpeg only updates encoding progress/statistics every 0.5 seconds
|
|
224
|
-
sleep(0.5)
|
|
225
|
-
# stdout
|
|
226
|
-
if pbar and not stdout_queue.empty():
|
|
227
|
-
line = stdout_queue.get()
|
|
228
|
-
try:
|
|
229
|
-
value = int(line.split("=")[1]) / 1_000_000
|
|
230
|
-
if value <= self._duration_secs:
|
|
231
|
-
pbar.n = value
|
|
232
|
-
pbar.refresh()
|
|
233
|
-
except ValueError:
|
|
234
|
-
pass
|
|
235
|
-
# stderr
|
|
236
|
-
if not stderr_queue.empty():
|
|
237
|
-
self._process_stderr(stderr_queue.get())
|
|
238
|
-
|
|
239
|
-
if process.returncode == 0 and pbar:
|
|
240
|
-
pbar.n = self._duration_secs
|
|
241
|
-
pbar.set_description(f"✓ Processed {self._input_filepath.name}")
|
|
242
|
-
|
|
243
|
-
if pbar:
|
|
244
|
-
pbar.close()
|
|
245
|
-
|
|
246
|
-
# Process remaining stderr
|
|
247
|
-
while not stderr_queue.empty():
|
|
248
|
-
self._process_stderr(stderr_queue.get())
|
|
249
|
-
|
|
250
|
-
if process.returncode != 0:
|
|
251
|
-
print(
|
|
252
|
-
f"The FFmpeg process did not complete successfully. Check out {self._ffmpeg_log_file} for details."
|
|
253
|
-
)
|
|
254
|
-
return 1
|
|
255
|
-
|
|
256
|
-
return 0
|
|
257
|
-
|
|
258
|
-
def run(
|
|
259
|
-
self,
|
|
260
|
-
print_command: bool = False,
|
|
261
|
-
use_tqdm: bool = False,
|
|
262
|
-
) -> int:
|
|
263
|
-
if hasattr(self, "return_code") and self.return_code != 0:
|
|
264
|
-
return 1
|
|
265
|
-
|
|
266
|
-
if self._output_filepath.exists() and "-y" not in self._ffmpeg_command:
|
|
267
|
-
if (
|
|
268
|
-
input(
|
|
269
|
-
f"{self._output_filepath} already exists. Overwrite? [Y/N]: "
|
|
270
|
-
).lower()
|
|
271
|
-
!= "y"
|
|
272
|
-
):
|
|
273
|
-
print(
|
|
274
|
-
"FFmpeg process cancelled. Output file exists and overwrite declined."
|
|
275
|
-
)
|
|
276
|
-
return 1
|
|
277
|
-
self._ffmpeg_command.insert(1, "-y")
|
|
278
|
-
|
|
279
|
-
if print_command:
|
|
280
|
-
print(f"Executing: {' '.join(self._ffmpeg_command)}")
|
|
281
|
-
|
|
282
|
-
def _contains_shell_operators(command: List[str]) -> bool:
|
|
283
|
-
shell_operators = {"|", ">", "<", ">>", "&&", "||"}
|
|
284
|
-
return any(op in command for op in shell_operators)
|
|
285
|
-
|
|
286
|
-
# If command contains shell operators, turn the list into a string.
|
|
287
|
-
if _contains_shell_operators(self._ffmpeg_command):
|
|
288
|
-
self._ffmpeg_command = " ".join(self._ffmpeg_command)
|
|
289
|
-
|
|
290
|
-
try:
|
|
291
|
-
process = subprocess.Popen(
|
|
292
|
-
self._ffmpeg_command,
|
|
293
|
-
shell=isinstance(self._ffmpeg_command, str),
|
|
294
|
-
stdout=subprocess.PIPE,
|
|
295
|
-
stderr=subprocess.PIPE,
|
|
296
|
-
text=True,
|
|
297
|
-
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
|
298
|
-
if os.name == "nt"
|
|
299
|
-
else 0,
|
|
300
|
-
)
|
|
301
|
-
except Exception as e:
|
|
302
|
-
print(f"Error starting FFmpeg process: {e}")
|
|
303
|
-
return 1
|
|
304
|
-
|
|
305
|
-
stdout_queue, stderr_queue = Queue(), Queue()
|
|
306
|
-
# Start stdout and stderr pipe readers
|
|
307
|
-
Thread(
|
|
308
|
-
target=self._read_pipe,
|
|
309
|
-
args=(process.stdout, stdout_queue, True),
|
|
310
|
-
daemon=True,
|
|
311
|
-
).start()
|
|
312
|
-
Thread(
|
|
313
|
-
target=self._read_pipe,
|
|
314
|
-
args=(process.stderr, stderr_queue, False),
|
|
315
|
-
daemon=True,
|
|
316
|
-
).start()
|
|
317
|
-
|
|
318
|
-
try:
|
|
319
|
-
if use_tqdm:
|
|
320
|
-
return self._use_tqdm(process, stdout_queue, stderr_queue)
|
|
321
|
-
else:
|
|
322
|
-
return self._use_rich(process, stdout_queue, stderr_queue)
|
|
323
|
-
except KeyboardInterrupt:
|
|
324
|
-
try:
|
|
325
|
-
psutil.Process(process.pid).terminate()
|
|
326
|
-
except psutil.NoSuchProcess:
|
|
327
|
-
pass
|
|
328
|
-
return 1
|
|
File without changes
|
|
File without changes
|
{better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/better_ffmpeg_progress/__init__.py
RENAMED
|
File without changes
|
{better_ffmpeg_progress-3.2.1 → better_ffmpeg_progress-3.2.3}/better_ffmpeg_progress/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|