better-ffmpeg-progress 5.2.0__tar.gz → 5.3.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-5.2.0 → better_ffmpeg_progress-5.3.0}/PKG-INFO +7 -7
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/README.md +6 -6
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/better_ffmpeg_progress.py +17 -2
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/progress_bars.py +53 -7
- better_ffmpeg_progress-5.3.0/better_ffmpeg_progress/utils.py +284 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/PKG-INFO +7 -7
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/setup.py +1 -1
- better_ffmpeg_progress-5.2.0/better_ffmpeg_progress/utils.py +0 -143
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/LICENSE +0 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/__init__.py +0 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/enums.py +0 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/exceptions.py +0 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/terminate_process.py +0 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/SOURCES.txt +0 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/requires.txt +0 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
- {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: better-ffmpeg-progress
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.3.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
|
|
@@ -18,10 +18,6 @@ Requires-Dist: rich
|
|
|
18
18
|
Requires-Dist: tqdm
|
|
19
19
|
|
|
20
20
|
<div align="center">
|
|
21
|
-
[](https://pypistats.org/packages/better-ffmpeg-progress)
|
|
22
|
-
[](https://pypistats.org/packages/better-ffmpeg-progress)
|
|
23
|
-

|
|
24
|
-
[](LICENSE.txt)
|
|
25
21
|
|
|
26
22
|
# Better FFmpeg Progress
|
|
27
23
|
Runs an FFmpeg command and shows a progress bar with percentage progress, elapsed time and ETA.
|
|
@@ -33,10 +29,11 @@ frame= 692 fps= 58 q=28.0 size= 5376KiB time=00:00:28.77 bitrate=1530.3kbits
|
|
|
33
29
|
```
|
|
34
30
|
Better FFmpeg Progress outputs something like:
|
|
35
31
|
```
|
|
36
|
-
⠏ Processing abc.webm ━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 23% 0:00:04 00:15
|
|
32
|
+
⠏ Processing abc.webm (5.50 MB / ~23.91 MB) ━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 23% 0:00:04 00:15
|
|
37
33
|
```
|
|
38
34
|
Where:
|
|
39
|
-
- `Processing abc.webm` is the description of the progresss bar.
|
|
35
|
+
- `Processing abc.webm` is the description of the progresss bar. Filenames longer than 60 characters are truncated.
|
|
36
|
+
- `(5.50 MB / ~23.91 MB)` is the current output size and the estimated final output size.
|
|
40
37
|
- `23%` is the percentage progress.
|
|
41
38
|
- `0:00:04` is the time elapsed.
|
|
42
39
|
- `00:15` is the estimated time until the FFmpeg process completes.
|
|
@@ -80,6 +77,9 @@ except FfmpegProcessError as e:
|
|
|
80
77
|
)
|
|
81
78
|
```
|
|
82
79
|
|
|
80
|
+
## Trimming
|
|
81
|
+
If your command trims the input with `-ss`, `-sseof`, `-t` or `-to` (as input or output options), the expected duration of the output is calculated automatically, so the progress bar and ETA reflect the trimmed output rather than the whole input file. This adjustment is skipped when `-copyts` is used.
|
|
82
|
+
|
|
83
83
|
## Duration Override
|
|
84
84
|
By default, Better FFmpeg Progress detects the duration of the input file and uses it to calculate percentage progress and ETA.
|
|
85
85
|
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
[](https://pypistats.org/packages/better-ffmpeg-progress)
|
|
3
|
-
[](https://pypistats.org/packages/better-ffmpeg-progress)
|
|
4
|
-

|
|
5
|
-
[](LICENSE.txt)
|
|
6
2
|
|
|
7
3
|
# Better FFmpeg Progress
|
|
8
4
|
Runs an FFmpeg command and shows a progress bar with percentage progress, elapsed time and ETA.
|
|
@@ -14,10 +10,11 @@ frame= 692 fps= 58 q=28.0 size= 5376KiB time=00:00:28.77 bitrate=1530.3kbits
|
|
|
14
10
|
```
|
|
15
11
|
Better FFmpeg Progress outputs something like:
|
|
16
12
|
```
|
|
17
|
-
⠏ Processing abc.webm ━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 23% 0:00:04 00:15
|
|
13
|
+
⠏ Processing abc.webm (5.50 MB / ~23.91 MB) ━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 23% 0:00:04 00:15
|
|
18
14
|
```
|
|
19
15
|
Where:
|
|
20
|
-
- `Processing abc.webm` is the description of the progresss bar.
|
|
16
|
+
- `Processing abc.webm` is the description of the progresss bar. Filenames longer than 60 characters are truncated.
|
|
17
|
+
- `(5.50 MB / ~23.91 MB)` is the current output size and the estimated final output size.
|
|
21
18
|
- `23%` is the percentage progress.
|
|
22
19
|
- `0:00:04` is the time elapsed.
|
|
23
20
|
- `00:15` is the estimated time until the FFmpeg process completes.
|
|
@@ -61,6 +58,9 @@ except FfmpegProcessError as e:
|
|
|
61
58
|
)
|
|
62
59
|
```
|
|
63
60
|
|
|
61
|
+
## Trimming
|
|
62
|
+
If your command trims the input with `-ss`, `-sseof`, `-t` or `-to` (as input or output options), the expected duration of the output is calculated automatically, so the progress bar and ETA reflect the trimmed output rather than the whole input file. This adjustment is skipped when `-copyts` is used.
|
|
63
|
+
|
|
64
64
|
## Duration Override
|
|
65
65
|
By default, Better FFmpeg Progress detects the duration of the input file and uses it to calculate percentage progress and ETA.
|
|
66
66
|
|
|
@@ -15,6 +15,7 @@ from .exceptions import (
|
|
|
15
15
|
from .utils import (
|
|
16
16
|
validate_ffmpeg_command,
|
|
17
17
|
get_media_duration,
|
|
18
|
+
get_expected_output_duration,
|
|
18
19
|
check_shell_needed_for_command,
|
|
19
20
|
)
|
|
20
21
|
from .terminate_process import terminate_ffmpeg_process
|
|
@@ -135,13 +136,27 @@ class FfmpegProcess:
|
|
|
135
136
|
else:
|
|
136
137
|
self._duration_secs = get_media_duration(input_file_path_str)
|
|
137
138
|
|
|
139
|
+
input_duration_secs = self._duration_secs
|
|
140
|
+
|
|
141
|
+
# Account for trimming (-ss, -sseof, -t, -to) so the progress bar reflects the
|
|
142
|
+
# duration of the output rather than the whole input.
|
|
143
|
+
if self._duration_secs is not None:
|
|
144
|
+
self._duration_secs = get_expected_output_duration(command, self._duration_secs)
|
|
145
|
+
|
|
138
146
|
if duration_override is not None:
|
|
139
|
-
print(f"Using duration override: {
|
|
147
|
+
print(f"Using duration override: {input_duration_secs:.2f} seconds")
|
|
140
148
|
elif self._print_detected_duration:
|
|
141
149
|
if self._duration_secs is None:
|
|
142
150
|
print("Could not detect duration. Progress bar may not show time remaining.")
|
|
143
151
|
else:
|
|
144
|
-
print(f"Detected duration: {
|
|
152
|
+
print(f"Detected duration: {input_duration_secs:.2f} seconds")
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
self._duration_secs is not None
|
|
156
|
+
and self._duration_secs != input_duration_secs
|
|
157
|
+
and (duration_override is not None or self._print_detected_duration)
|
|
158
|
+
):
|
|
159
|
+
print(f"Expected output duration after trimming: {self._duration_secs:.2f} seconds")
|
|
145
160
|
|
|
146
161
|
self._ffmpeg_command = [
|
|
147
162
|
command[0],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import subprocess
|
|
3
|
-
from typing import TYPE_CHECKING
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
4
|
|
|
5
5
|
from rich.progress import (
|
|
6
6
|
Progress,
|
|
@@ -12,12 +12,24 @@ from rich.progress import (
|
|
|
12
12
|
)
|
|
13
13
|
from tqdm import tqdm
|
|
14
14
|
|
|
15
|
-
from .utils import
|
|
15
|
+
from .utils import (
|
|
16
|
+
estimate_final_size_mb,
|
|
17
|
+
parse_ffmpeg_progress_line,
|
|
18
|
+
parse_ffmpeg_total_size_line,
|
|
19
|
+
truncate_filename,
|
|
20
|
+
)
|
|
16
21
|
from .exceptions import FfmpegProcessError
|
|
17
22
|
|
|
18
23
|
if TYPE_CHECKING:
|
|
19
24
|
from .better_ffmpeg_progress import FfmpegProcess
|
|
20
25
|
|
|
26
|
+
_MAX_FILENAME_LENGTH = 60
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _format_size(current_size_mb: float, estimated_size_mb: Optional[float]) -> str:
|
|
30
|
+
estimate = f"~{estimated_size_mb:.2f} MB" if estimated_size_mb is not None else "~? MB"
|
|
31
|
+
return f"({current_size_mb:.2f} MB / {estimate})"
|
|
32
|
+
|
|
21
33
|
|
|
22
34
|
def use_rich(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen) -> None:
|
|
23
35
|
task_id = None
|
|
@@ -31,11 +43,14 @@ def use_rich(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
|
|
|
31
43
|
)
|
|
32
44
|
|
|
33
45
|
duration_secs = ffmpeg_process_instance._duration_secs
|
|
34
|
-
input_filename =
|
|
46
|
+
input_filename = truncate_filename(
|
|
47
|
+
ffmpeg_process_instance._input_filepath.name, _MAX_FILENAME_LENGTH
|
|
48
|
+
)
|
|
35
49
|
log_file = ffmpeg_process_instance._ffmpeg_log_file
|
|
36
50
|
|
|
37
51
|
with progress_bar_instance as progress_bar:
|
|
38
52
|
if duration_secs:
|
|
53
|
+
current_size_mb = 0.0
|
|
39
54
|
task_id = progress_bar.add_task(
|
|
40
55
|
f"Processing {input_filename}",
|
|
41
56
|
total=duration_secs,
|
|
@@ -44,9 +59,25 @@ def use_rich(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
|
|
|
44
59
|
|
|
45
60
|
for line_bytes in process.stdout:
|
|
46
61
|
stripped_line_bytes = line_bytes.strip()
|
|
62
|
+
|
|
63
|
+
size_mb = parse_ffmpeg_total_size_line(stripped_line_bytes)
|
|
64
|
+
if size_mb is not None:
|
|
65
|
+
current_size_mb = size_mb
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# FFmpeg writes total_size before out_time_us in each progress block,
|
|
69
|
+
# so updating here refreshes the bar once per block with the latest size.
|
|
47
70
|
progress_val_secs = parse_ffmpeg_progress_line(stripped_line_bytes, duration_secs)
|
|
48
71
|
if progress_val_secs is not None and task_id is not None:
|
|
49
|
-
|
|
72
|
+
estimated_size_mb = estimate_final_size_mb(
|
|
73
|
+
current_size_mb, progress_val_secs, duration_secs
|
|
74
|
+
)
|
|
75
|
+
update_progress(
|
|
76
|
+
task_id,
|
|
77
|
+
completed=progress_val_secs,
|
|
78
|
+
description=f"Processing {input_filename} "
|
|
79
|
+
f"{_format_size(current_size_mb, estimated_size_mb)}",
|
|
80
|
+
)
|
|
50
81
|
else:
|
|
51
82
|
print(f"Processing '{input_filename}'...")
|
|
52
83
|
|
|
@@ -62,7 +93,7 @@ def use_rich(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
|
|
|
62
93
|
)
|
|
63
94
|
update_progress(
|
|
64
95
|
task_id,
|
|
65
|
-
description=f"✓ Processed '{input_filename}'",
|
|
96
|
+
description=f"✓ Processed '{input_filename}' ({current_size_mb:.2f} MB)",
|
|
66
97
|
)
|
|
67
98
|
else:
|
|
68
99
|
if task_id is not None:
|
|
@@ -79,9 +110,13 @@ def use_tqdm(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
|
|
|
79
110
|
width = 80
|
|
80
111
|
|
|
81
112
|
duration_secs = ffmpeg_process_instance._duration_secs
|
|
82
|
-
input_filename =
|
|
113
|
+
input_filename = truncate_filename(
|
|
114
|
+
ffmpeg_process_instance._input_filepath.name, _MAX_FILENAME_LENGTH
|
|
115
|
+
)
|
|
83
116
|
log_file = ffmpeg_process_instance._ffmpeg_log_file
|
|
84
117
|
|
|
118
|
+
current_size_mb = 0.0
|
|
119
|
+
|
|
85
120
|
if duration_secs:
|
|
86
121
|
progress_bar = tqdm(
|
|
87
122
|
mininterval=0.5,
|
|
@@ -94,10 +129,20 @@ def use_tqdm(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
|
|
|
94
129
|
|
|
95
130
|
for line_bytes in process.stdout:
|
|
96
131
|
stripped_line_bytes = line_bytes.strip()
|
|
132
|
+
|
|
133
|
+
size_mb = parse_ffmpeg_total_size_line(stripped_line_bytes)
|
|
134
|
+
if size_mb is not None:
|
|
135
|
+
current_size_mb = size_mb
|
|
136
|
+
continue
|
|
137
|
+
|
|
97
138
|
progress_val_secs = parse_ffmpeg_progress_line(stripped_line_bytes, duration_secs)
|
|
98
139
|
if progress_val_secs is not None and progress_bar is not None:
|
|
140
|
+
estimated_size_mb = estimate_final_size_mb(
|
|
141
|
+
current_size_mb, progress_val_secs, duration_secs
|
|
142
|
+
)
|
|
99
143
|
progress_bar.n = progress_val_secs
|
|
100
|
-
|
|
144
|
+
# set_postfix_str refreshes the bar by default
|
|
145
|
+
progress_bar.set_postfix_str(_format_size(current_size_mb, estimated_size_mb))
|
|
101
146
|
else:
|
|
102
147
|
print(f"Processing '{input_filename}'...")
|
|
103
148
|
|
|
@@ -107,6 +152,7 @@ def use_tqdm(ffmpeg_process_instance: "FfmpegProcess", process: subprocess.Popen
|
|
|
107
152
|
if progress_bar:
|
|
108
153
|
progress_bar.n = duration_secs
|
|
109
154
|
progress_bar.set_description(f"✓ Processed '{input_filename}'")
|
|
155
|
+
progress_bar.set_postfix_str(f"{current_size_mb:.2f} MB")
|
|
110
156
|
progress_bar.close()
|
|
111
157
|
else:
|
|
112
158
|
print(f"✓ Processed '{input_filename}'")
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
import requests # Added for URL validation
|
|
8
|
+
|
|
9
|
+
from .exceptions import FfmpegCommandError
|
|
10
|
+
|
|
11
|
+
_FFMPEG_INPUT_FLAG = "-i"
|
|
12
|
+
_OUT_TIME_US_PREFIX = b"out_time_us="
|
|
13
|
+
_TOTAL_SIZE_PREFIX = b"total_size="
|
|
14
|
+
_BYTES_PER_MB = 1024 * 1024
|
|
15
|
+
_MULTIPLIER = 1e-6
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_ffmpeg_command(command: List[str]) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Validates the FFmpeg command.
|
|
21
|
+
Raises FfmpegCommandError if validation fails.
|
|
22
|
+
"""
|
|
23
|
+
if not command:
|
|
24
|
+
raise FfmpegCommandError("FFmpeg command list cannot be empty.")
|
|
25
|
+
|
|
26
|
+
ffmpeg_executable = command[0]
|
|
27
|
+
if not shutil.which(ffmpeg_executable):
|
|
28
|
+
err_msg = (
|
|
29
|
+
f"'{ffmpeg_executable}' not found. "
|
|
30
|
+
"Ensure it's in your system's PATH or provide the full path of the FFmpeg executable."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
raise FfmpegCommandError(err_msg)
|
|
34
|
+
|
|
35
|
+
if _FFMPEG_INPUT_FLAG not in command:
|
|
36
|
+
raise FfmpegCommandError(
|
|
37
|
+
f"FFmpeg command must include the input flag '{_FFMPEG_INPUT_FLAG}'."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
input_flag_pos = command.index(_FFMPEG_INPUT_FLAG)
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise FfmpegCommandError(
|
|
44
|
+
f"Internal error: Input flag '{_FFMPEG_INPUT_FLAG}' not found after check."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
input_file_idx = input_flag_pos + 1
|
|
48
|
+
if input_file_idx >= len(command):
|
|
49
|
+
raise FfmpegCommandError(f"No input file specified after '{_FFMPEG_INPUT_FLAG}'.")
|
|
50
|
+
|
|
51
|
+
input_file = command[input_file_idx]
|
|
52
|
+
if input_file.startswith("-") and len(input_file) == 2 and not input_file[1:].isdigit():
|
|
53
|
+
raise FfmpegCommandError(
|
|
54
|
+
f"Input file path '{input_file}' looks like an option flag. Please check your command."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Validate input file/URL
|
|
58
|
+
if input_file.startswith("http://") or input_file.startswith("https://"):
|
|
59
|
+
try:
|
|
60
|
+
response = requests.head(input_file, timeout=5, allow_redirects=True)
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
except requests.exceptions.RequestException as e:
|
|
63
|
+
raise FfmpegCommandError(f"Input URL not accessible: {input_file}. Error: {e}")
|
|
64
|
+
elif not Path(input_file).exists():
|
|
65
|
+
raise FfmpegCommandError(f"Input file not found: {input_file}")
|
|
66
|
+
|
|
67
|
+
if len(command) <= input_file_idx + 1:
|
|
68
|
+
raise FfmpegCommandError(
|
|
69
|
+
"No output file specified. The command seems to end after the input file path."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
output_file_path_str = command[-1]
|
|
73
|
+
if output_file_path_str == input_file:
|
|
74
|
+
raise FfmpegCommandError(
|
|
75
|
+
"Output file path cannot be the same as the input file path in this command structure."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_media_duration(input_file: str) -> Optional[float]:
|
|
80
|
+
"""
|
|
81
|
+
Retrieves the duration of a media file or URL using ffprobe.
|
|
82
|
+
Returns duration in seconds, or None if it cannot be determined.
|
|
83
|
+
Accepts input_file as a string to correctly handle URLs.
|
|
84
|
+
"""
|
|
85
|
+
if not shutil.which("ffprobe"):
|
|
86
|
+
print("Warning: ffprobe not found in PATH. Cannot determine media duration.")
|
|
87
|
+
return None
|
|
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
|
+
input_file,
|
|
100
|
+
],
|
|
101
|
+
text=True,
|
|
102
|
+
stderr=subprocess.DEVNULL,
|
|
103
|
+
)
|
|
104
|
+
return float(output)
|
|
105
|
+
except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
|
|
106
|
+
print(f"Warning: Could not determine duration for '{input_file}'.")
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check_shell_needed_for_command(command: List[str]) -> bool:
|
|
111
|
+
"""
|
|
112
|
+
Checks if any part of the command contains shell operators,
|
|
113
|
+
indicating that shell=True might be needed for subprocess.Popen.
|
|
114
|
+
This is a basic check and might not cover all edge cases.
|
|
115
|
+
"""
|
|
116
|
+
shell_operators = {"|", ">", "<", ">>", "&&", "||"} # Basic set
|
|
117
|
+
return any(op in item for item in command for op in shell_operators)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_ffmpeg_progress_line(
|
|
121
|
+
line: bytes, total_duration_secs: Optional[float]
|
|
122
|
+
) -> Optional[float]:
|
|
123
|
+
"""
|
|
124
|
+
Parses a progress line from FFmpeg's stdout.
|
|
125
|
+
Example line: b"out_time_us=12345678"
|
|
126
|
+
Returns the current progress time in seconds, or None if parsing fails or line is not a progress line.
|
|
127
|
+
Caps progress at total_duration_secs if provided.
|
|
128
|
+
Assumes `line` has already been stripped of leading/trailing whitespace.
|
|
129
|
+
"""
|
|
130
|
+
if line.startswith(_OUT_TIME_US_PREFIX):
|
|
131
|
+
try:
|
|
132
|
+
# Get the value after out_time_us=
|
|
133
|
+
value_str = line[len(_OUT_TIME_US_PREFIX) :]
|
|
134
|
+
|
|
135
|
+
if not value_str:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
current_time_us = int(value_str)
|
|
139
|
+
current_time_secs = current_time_us * _MULTIPLIER
|
|
140
|
+
|
|
141
|
+
if total_duration_secs is not None:
|
|
142
|
+
return min(current_time_secs, total_duration_secs)
|
|
143
|
+
return current_time_secs
|
|
144
|
+
except (ValueError, IndexError):
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Matches FFmpeg's time duration syntax: [-][HH:]MM:SS[.m...] or [-]S+[.m...][s|ms|us]
|
|
151
|
+
_SEXAGESIMAL_RE = re.compile(r"^(-)?(?:(\d+):)?(\d+):(\d+(?:\.\d*)?)$")
|
|
152
|
+
_SECONDS_RE = re.compile(r"^(-)?(\d+(?:\.\d*)?|\.\d+)(s|ms|us)?$")
|
|
153
|
+
_TRIM_OPTIONS = ("-ss", "-sseof", "-t", "-to")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_ffmpeg_time(value: str) -> Optional[float]:
|
|
157
|
+
"""
|
|
158
|
+
Parses a time duration in FFmpeg's syntax (e.g. "90", "1.5", "500ms", "01:30", "00:01:30.5").
|
|
159
|
+
Returns the value in seconds, or None if it cannot be parsed.
|
|
160
|
+
"""
|
|
161
|
+
value = value.strip()
|
|
162
|
+
|
|
163
|
+
match = _SEXAGESIMAL_RE.match(value)
|
|
164
|
+
if match:
|
|
165
|
+
sign, hours, minutes, seconds = match.groups()
|
|
166
|
+
total = int(hours or 0) * 3600 + int(minutes) * 60 + float(seconds)
|
|
167
|
+
return -total if sign else total
|
|
168
|
+
|
|
169
|
+
match = _SECONDS_RE.match(value)
|
|
170
|
+
if match:
|
|
171
|
+
sign, number, unit = match.groups()
|
|
172
|
+
total = float(number) / {None: 1, "s": 1, "ms": 1e3, "us": 1e6}[unit]
|
|
173
|
+
return -total if sign else total
|
|
174
|
+
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _collect_trim_options(args: List[str]) -> Dict[str, float]:
|
|
179
|
+
"""Returns the last value of each trim option found in args (FFmpeg uses the last occurrence)."""
|
|
180
|
+
options: Dict[str, float] = {}
|
|
181
|
+
for i, arg in enumerate(args[:-1]):
|
|
182
|
+
if arg in _TRIM_OPTIONS:
|
|
183
|
+
parsed = parse_ffmpeg_time(args[i + 1])
|
|
184
|
+
if parsed is not None:
|
|
185
|
+
options[arg] = parsed
|
|
186
|
+
return options
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _apply_trim(duration: float, options: Dict[str, float]) -> float:
|
|
190
|
+
"""
|
|
191
|
+
Applies -ss/-sseof/-t/-to to a duration, mirroring FFmpeg's logic:
|
|
192
|
+
-t takes priority over -to, and -to is relative to -ss (recording_time = to - ss).
|
|
193
|
+
"""
|
|
194
|
+
start = options.get("-ss", 0.0)
|
|
195
|
+
|
|
196
|
+
if "-sseof" in options and "-ss" not in options:
|
|
197
|
+
# -sseof is negative, relative to the end of the input
|
|
198
|
+
remaining = min(duration, abs(options["-sseof"]))
|
|
199
|
+
else:
|
|
200
|
+
remaining = duration - max(start, 0.0)
|
|
201
|
+
|
|
202
|
+
if "-t" in options:
|
|
203
|
+
remaining = min(remaining, options["-t"])
|
|
204
|
+
elif "-to" in options:
|
|
205
|
+
remaining = min(remaining, options["-to"] - max(start, 0.0))
|
|
206
|
+
|
|
207
|
+
return max(remaining, 0.0)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_expected_output_duration(command: List[str], input_duration: float) -> float:
|
|
211
|
+
"""
|
|
212
|
+
Estimates the duration of the output by taking the trim options of the command into account.
|
|
213
|
+
|
|
214
|
+
Options before the first "-i" are input options for the first input, and options between the
|
|
215
|
+
last input path and the output path are output options. Input trimming is applied first
|
|
216
|
+
(seeking with an input -ss resets timestamps to 0), then output trimming.
|
|
217
|
+
|
|
218
|
+
If -copyts is used, timestamps are not reset, so no adjustment is made.
|
|
219
|
+
"""
|
|
220
|
+
if "-copyts" in command:
|
|
221
|
+
return input_duration
|
|
222
|
+
|
|
223
|
+
first_input_idx = command.index(_FFMPEG_INPUT_FLAG)
|
|
224
|
+
last_input_idx = len(command) - 1 - command[::-1].index(_FFMPEG_INPUT_FLAG)
|
|
225
|
+
|
|
226
|
+
input_options = _collect_trim_options(command[1:first_input_idx])
|
|
227
|
+
output_options = _collect_trim_options(command[last_input_idx + 2 : -1])
|
|
228
|
+
|
|
229
|
+
duration = _apply_trim(input_duration, input_options)
|
|
230
|
+
duration = _apply_trim(duration, output_options)
|
|
231
|
+
|
|
232
|
+
# Never return 0, which would disable the progress bar.
|
|
233
|
+
return duration if duration > 0 else input_duration
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def parse_ffmpeg_total_size_line(line: bytes) -> Optional[float]:
|
|
237
|
+
"""
|
|
238
|
+
Parses a total_size line from FFmpeg's stdout.
|
|
239
|
+
Example line: b"total_size=12345678"
|
|
240
|
+
Returns the current output size in MB, or None if parsing fails or line is not a total_size line.
|
|
241
|
+
Assumes `line` has already been stripped of leading/trailing whitespace.
|
|
242
|
+
"""
|
|
243
|
+
if line.startswith(_TOTAL_SIZE_PREFIX):
|
|
244
|
+
try:
|
|
245
|
+
value_str = line[len(_TOTAL_SIZE_PREFIX) :]
|
|
246
|
+
|
|
247
|
+
if not value_str:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
return int(value_str) / _BYTES_PER_MB
|
|
251
|
+
except ValueError:
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def truncate_filename(filename: str, max_length: int = 60) -> str:
|
|
258
|
+
"""
|
|
259
|
+
Truncates a filename to at most max_length characters, preserving the file extension.
|
|
260
|
+
"""
|
|
261
|
+
if len(filename) <= max_length:
|
|
262
|
+
return filename
|
|
263
|
+
|
|
264
|
+
stem, ext = os.path.splitext(filename)
|
|
265
|
+
ellipsis = "..."
|
|
266
|
+
allowed_stem_len = max_length - len(ext) - len(ellipsis)
|
|
267
|
+
|
|
268
|
+
if allowed_stem_len < 1:
|
|
269
|
+
return filename[: max_length - len(ellipsis)] + ellipsis
|
|
270
|
+
|
|
271
|
+
return f"{stem[:allowed_stem_len]}{ellipsis}{ext}"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def estimate_final_size_mb(
|
|
275
|
+
current_size_mb: float, current_time_secs: float, total_duration_secs: float
|
|
276
|
+
) -> Optional[float]:
|
|
277
|
+
"""
|
|
278
|
+
Estimates the final output size in MB by extrapolating the current size over the total duration.
|
|
279
|
+
Returns None until enough of the input has been processed for the estimate to be meaningful.
|
|
280
|
+
"""
|
|
281
|
+
min_secs = max(1.0, total_duration_secs * 0.01)
|
|
282
|
+
if current_time_secs < min_secs or current_size_mb <= 0:
|
|
283
|
+
return None
|
|
284
|
+
return current_size_mb / current_time_secs * total_duration_secs
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: better-ffmpeg-progress
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.3.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
|
|
@@ -18,10 +18,6 @@ Requires-Dist: rich
|
|
|
18
18
|
Requires-Dist: tqdm
|
|
19
19
|
|
|
20
20
|
<div align="center">
|
|
21
|
-
[](https://pypistats.org/packages/better-ffmpeg-progress)
|
|
22
|
-
[](https://pypistats.org/packages/better-ffmpeg-progress)
|
|
23
|
-

|
|
24
|
-
[](LICENSE.txt)
|
|
25
21
|
|
|
26
22
|
# Better FFmpeg Progress
|
|
27
23
|
Runs an FFmpeg command and shows a progress bar with percentage progress, elapsed time and ETA.
|
|
@@ -33,10 +29,11 @@ frame= 692 fps= 58 q=28.0 size= 5376KiB time=00:00:28.77 bitrate=1530.3kbits
|
|
|
33
29
|
```
|
|
34
30
|
Better FFmpeg Progress outputs something like:
|
|
35
31
|
```
|
|
36
|
-
⠏ Processing abc.webm ━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 23% 0:00:04 00:15
|
|
32
|
+
⠏ Processing abc.webm (5.50 MB / ~23.91 MB) ━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 23% 0:00:04 00:15
|
|
37
33
|
```
|
|
38
34
|
Where:
|
|
39
|
-
- `Processing abc.webm` is the description of the progresss bar.
|
|
35
|
+
- `Processing abc.webm` is the description of the progresss bar. Filenames longer than 60 characters are truncated.
|
|
36
|
+
- `(5.50 MB / ~23.91 MB)` is the current output size and the estimated final output size.
|
|
40
37
|
- `23%` is the percentage progress.
|
|
41
38
|
- `0:00:04` is the time elapsed.
|
|
42
39
|
- `00:15` is the estimated time until the FFmpeg process completes.
|
|
@@ -80,6 +77,9 @@ except FfmpegProcessError as e:
|
|
|
80
77
|
)
|
|
81
78
|
```
|
|
82
79
|
|
|
80
|
+
## Trimming
|
|
81
|
+
If your command trims the input with `-ss`, `-sseof`, `-t` or `-to` (as input or output options), the expected duration of the output is calculated automatically, so the progress bar and ETA reflect the trimmed output rather than the whole input file. This adjustment is skipped when `-copyts` is used.
|
|
82
|
+
|
|
83
83
|
## Duration Override
|
|
84
84
|
By default, Better FFmpeg Progress detects the duration of the input file and uses it to calculate percentage progress and ETA.
|
|
85
85
|
|
|
@@ -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="5.
|
|
8
|
+
version="5.3.0",
|
|
9
9
|
author="GitHub.com/CrypticSignal",
|
|
10
10
|
author_email="theaudiophile@outlook.com",
|
|
11
11
|
description="Run FFmpeg & see percentage progress + ETA.",
|
|
@@ -1,143 +0,0 @@
|
|
|
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(f"No input file specified after '{_FFMPEG_INPUT_FLAG}'.")
|
|
46
|
-
|
|
47
|
-
input_file = command[input_file_idx]
|
|
48
|
-
if input_file.startswith("-") and len(input_file) == 2 and not input_file[1:].isdigit():
|
|
49
|
-
raise FfmpegCommandError(
|
|
50
|
-
f"Input file path '{input_file}' looks like an option flag. Please check your command."
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
# Validate input file/URL
|
|
54
|
-
if input_file.startswith("http://") or input_file.startswith("https://"):
|
|
55
|
-
try:
|
|
56
|
-
response = requests.head(input_file, timeout=5, allow_redirects=True)
|
|
57
|
-
response.raise_for_status()
|
|
58
|
-
except requests.exceptions.RequestException as e:
|
|
59
|
-
raise FfmpegCommandError(f"Input URL not accessible: {input_file}. Error: {e}")
|
|
60
|
-
elif not Path(input_file).exists():
|
|
61
|
-
raise FfmpegCommandError(f"Input file not found: {input_file}")
|
|
62
|
-
|
|
63
|
-
if len(command) <= input_file_idx + 1:
|
|
64
|
-
raise FfmpegCommandError(
|
|
65
|
-
"No output file specified. The command seems to end after the input file path."
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
output_file_path_str = command[-1]
|
|
69
|
-
if output_file_path_str == input_file:
|
|
70
|
-
raise FfmpegCommandError(
|
|
71
|
-
"Output file path cannot be the same as the input file path in this command structure."
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def get_media_duration(input_file: str) -> Optional[float]:
|
|
76
|
-
"""
|
|
77
|
-
Retrieves the duration of a media file or URL using ffprobe.
|
|
78
|
-
Returns duration in seconds, or None if it cannot be determined.
|
|
79
|
-
Accepts input_file as a string to correctly handle URLs.
|
|
80
|
-
"""
|
|
81
|
-
if not shutil.which("ffprobe"):
|
|
82
|
-
print("Warning: ffprobe not found in PATH. Cannot determine media duration.")
|
|
83
|
-
return None
|
|
84
|
-
try:
|
|
85
|
-
output = subprocess.check_output(
|
|
86
|
-
[
|
|
87
|
-
"ffprobe",
|
|
88
|
-
"-v",
|
|
89
|
-
"error",
|
|
90
|
-
"-show_entries",
|
|
91
|
-
"format=duration",
|
|
92
|
-
"-of",
|
|
93
|
-
"default=noprint_wrappers=1:nokey=1",
|
|
94
|
-
"-i",
|
|
95
|
-
input_file,
|
|
96
|
-
],
|
|
97
|
-
text=True,
|
|
98
|
-
stderr=subprocess.DEVNULL,
|
|
99
|
-
)
|
|
100
|
-
return float(output)
|
|
101
|
-
except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
|
|
102
|
-
print(f"Warning: Could not determine duration for '{input_file}'.")
|
|
103
|
-
return None
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def check_shell_needed_for_command(command: List[str]) -> bool:
|
|
107
|
-
"""
|
|
108
|
-
Checks if any part of the command contains shell operators,
|
|
109
|
-
indicating that shell=True might be needed for subprocess.Popen.
|
|
110
|
-
This is a basic check and might not cover all edge cases.
|
|
111
|
-
"""
|
|
112
|
-
shell_operators = {"|", ">", "<", ">>", "&&", "||"} # Basic set
|
|
113
|
-
return any(op in item for item in command for op in shell_operators)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def parse_ffmpeg_progress_line(
|
|
117
|
-
line: bytes, total_duration_secs: Optional[float]
|
|
118
|
-
) -> Optional[float]:
|
|
119
|
-
"""
|
|
120
|
-
Parses a progress line from FFmpeg's stdout.
|
|
121
|
-
Example line: b"out_time_us=12345678"
|
|
122
|
-
Returns the current progress time in seconds, or None if parsing fails or line is not a progress line.
|
|
123
|
-
Caps progress at total_duration_secs if provided.
|
|
124
|
-
Assumes `line` has already been stripped of leading/trailing whitespace.
|
|
125
|
-
"""
|
|
126
|
-
if line.startswith(_OUT_TIME_US_PREFIX):
|
|
127
|
-
try:
|
|
128
|
-
# Get the value after out_time_us=
|
|
129
|
-
value_str = line[len(_OUT_TIME_US_PREFIX) :]
|
|
130
|
-
|
|
131
|
-
if not value_str:
|
|
132
|
-
return None
|
|
133
|
-
|
|
134
|
-
current_time_us = int(value_str)
|
|
135
|
-
current_time_secs = current_time_us * _MULTIPLIER
|
|
136
|
-
|
|
137
|
-
if total_duration_secs is not None:
|
|
138
|
-
return min(current_time_secs, total_duration_secs)
|
|
139
|
-
return current_time_secs
|
|
140
|
-
except (ValueError, IndexError):
|
|
141
|
-
return None
|
|
142
|
-
|
|
143
|
-
return None
|
|
File without changes
|
{better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/__init__.py
RENAMED
|
File without changes
|
{better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/enums.py
RENAMED
|
File without changes
|
{better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|