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.
Files changed (18) hide show
  1. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/PKG-INFO +7 -7
  2. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/README.md +6 -6
  3. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/better_ffmpeg_progress.py +17 -2
  4. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/progress_bars.py +53 -7
  5. better_ffmpeg_progress-5.3.0/better_ffmpeg_progress/utils.py +284 -0
  6. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/PKG-INFO +7 -7
  7. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/setup.py +1 -1
  8. better_ffmpeg_progress-5.2.0/better_ffmpeg_progress/utils.py +0 -143
  9. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/LICENSE +0 -0
  10. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/__init__.py +0 -0
  11. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/enums.py +0 -0
  12. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/exceptions.py +0 -0
  13. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress/terminate_process.py +0 -0
  14. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/SOURCES.txt +0 -0
  15. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
  16. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/requires.txt +0 -0
  17. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.3.0}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
  18. {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.2.0
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
- [![PyPI downloads](https://img.shields.io/pypi/dm/better-ffmpeg-progress?label=PyPI\&color=blue)](https://pypistats.org/packages/better-ffmpeg-progress)
22
- [![PyPI downloads](https://img.shields.io/pypi/dd/better-ffmpeg-progress?label=PyPI\&color=blue)](https://pypistats.org/packages/better-ffmpeg-progress)
23
- ![PyPI - Version](https://img.shields.io/pypi/v/better-ffmpeg-progress)
24
- [![GitHub](https://img.shields.io/github/license/crypticsignal/better-ffmpeg-progress?label=License\&color=blue)](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
- [![PyPI downloads](https://img.shields.io/pypi/dm/better-ffmpeg-progress?label=PyPI\&color=blue)](https://pypistats.org/packages/better-ffmpeg-progress)
3
- [![PyPI downloads](https://img.shields.io/pypi/dd/better-ffmpeg-progress?label=PyPI\&color=blue)](https://pypistats.org/packages/better-ffmpeg-progress)
4
- ![PyPI - Version](https://img.shields.io/pypi/v/better-ffmpeg-progress)
5
- [![GitHub](https://img.shields.io/github/license/crypticsignal/better-ffmpeg-progress?label=License\&color=blue)](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: {self._duration_secs:.2f} seconds")
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: {self._duration_secs:.2f} seconds")
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 parse_ffmpeg_progress_line
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 = ffmpeg_process_instance._input_filepath.name
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
- update_progress(task_id, completed=progress_val_secs)
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 = ffmpeg_process_instance._input_filepath.name
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
- progress_bar.refresh()
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.2.0
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
- [![PyPI downloads](https://img.shields.io/pypi/dm/better-ffmpeg-progress?label=PyPI\&color=blue)](https://pypistats.org/packages/better-ffmpeg-progress)
22
- [![PyPI downloads](https://img.shields.io/pypi/dd/better-ffmpeg-progress?label=PyPI\&color=blue)](https://pypistats.org/packages/better-ffmpeg-progress)
23
- ![PyPI - Version](https://img.shields.io/pypi/v/better-ffmpeg-progress)
24
- [![GitHub](https://img.shields.io/github/license/crypticsignal/better-ffmpeg-progress?label=License\&color=blue)](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.2.0",
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