better-ffmpeg-progress 5.2.0__tar.gz → 5.4.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.4.0}/PKG-INFO +30 -7
  2. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/README.md +29 -6
  3. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress/better_ffmpeg_progress.py +27 -3
  4. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress/progress_bars.py +53 -7
  5. better_ffmpeg_progress-5.4.0/better_ffmpeg_progress/utils.py +294 -0
  6. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress.egg-info/PKG-INFO +30 -7
  7. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.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.4.0}/LICENSE +0 -0
  10. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress/__init__.py +0 -0
  11. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress/enums.py +0 -0
  12. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress/exceptions.py +0 -0
  13. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress/terminate_process.py +0 -0
  14. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress.egg-info/SOURCES.txt +0 -0
  15. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress.egg-info/dependency_links.txt +0 -0
  16. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress.egg-info/requires.txt +0 -0
  17. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.0}/better_ffmpeg_progress.egg-info/top_level.txt +0 -0
  18. {better_ffmpeg_progress-5.2.0 → better_ffmpeg_progress-5.4.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.4.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
 
@@ -108,6 +108,29 @@ process = FfmpegProcess(
108
108
  process.run()
109
109
  ```
110
110
 
111
+ ## Generated (lavfi) Inputs
112
+ Inputs generated by FFmpeg's [lavfi](https://ffmpeg.org/ffmpeg-devices.html#lavfi) device, such as test patterns, are supported. FFprobe cannot detect the duration of these inputs, so provide it with `duration_override`:
113
+
114
+ ```python
115
+ from better_ffmpeg_progress import FfmpegProcess
116
+
117
+ command = [
118
+ "ffmpeg",
119
+ "-f",
120
+ "lavfi",
121
+ "-i",
122
+ "testsrc2=duration=30:size=1920x1080:rate=30",
123
+ "-c:v",
124
+ "libx264",
125
+ "output.mp4",
126
+ ]
127
+
128
+ process = FfmpegProcess(command, duration_override=30)
129
+ process.run()
130
+ ```
131
+
132
+ By default, the FFmpeg log file is named after the source, e.g. `lavfi_testsrc2_ffmpeg_log.txt`.
133
+
111
134
  ## Optional Arguments
112
135
  An instance of `FfmpegProcess` accepts the following optional arguments:
113
136
  * `ffmpeg_log_level` — Set a value for FFmpeg's `-loglevel` option. Default: `"verbose"`.
@@ -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
 
@@ -89,6 +89,29 @@ process = FfmpegProcess(
89
89
  process.run()
90
90
  ```
91
91
 
92
+ ## Generated (lavfi) Inputs
93
+ Inputs generated by FFmpeg's [lavfi](https://ffmpeg.org/ffmpeg-devices.html#lavfi) device, such as test patterns, are supported. FFprobe cannot detect the duration of these inputs, so provide it with `duration_override`:
94
+
95
+ ```python
96
+ from better_ffmpeg_progress import FfmpegProcess
97
+
98
+ command = [
99
+ "ffmpeg",
100
+ "-f",
101
+ "lavfi",
102
+ "-i",
103
+ "testsrc2=duration=30:size=1920x1080:rate=30",
104
+ "-c:v",
105
+ "libx264",
106
+ "output.mp4",
107
+ ]
108
+
109
+ process = FfmpegProcess(command, duration_override=30)
110
+ process.run()
111
+ ```
112
+
113
+ By default, the FFmpeg log file is named after the source, e.g. `lavfi_testsrc2_ffmpeg_log.txt`.
114
+
92
115
  ## Optional Arguments
93
116
  An instance of `FfmpegProcess` accepts the following optional arguments:
94
117
  * `ffmpeg_log_level` — Set a value for FFmpeg's `-loglevel` option. Default: `"verbose"`.
@@ -1,5 +1,6 @@
1
1
  import math
2
2
  import os
3
+ import re
3
4
  from pathlib import Path
4
5
  import subprocess
5
6
  from sys import exit
@@ -15,7 +16,9 @@ from .exceptions import (
15
16
  from .utils import (
16
17
  validate_ffmpeg_command,
17
18
  get_media_duration,
19
+ get_expected_output_duration,
18
20
  check_shell_needed_for_command,
21
+ is_lavfi_input,
19
22
  )
20
23
  from .terminate_process import terminate_ffmpeg_process
21
24
  from .progress_bars import use_rich, use_tqdm
@@ -111,7 +114,14 @@ class FfmpegProcess:
111
114
  self._output_filepath = Path(command[-1])
112
115
 
113
116
  if ffmpeg_log_file is None:
114
- self._ffmpeg_log_file = Path(f"{self._input_filepath.name}_ffmpeg_log.txt")
117
+ if is_lavfi_input(command, input_file_index):
118
+ # A filtergraph such as "testsrc2=size=1920x1080" can contain characters that
119
+ # are not valid in filenames (e.g. ":" on Windows), so name the log after the
120
+ # source instead.
121
+ source_name = re.sub(r"[^\w.,-]", "_", input_file_path_str.split("=", 1)[0])
122
+ self._ffmpeg_log_file = Path(f"lavfi_{source_name}_ffmpeg_log.txt")
123
+ else:
124
+ self._ffmpeg_log_file = Path(f"{self._input_filepath.name}_ffmpeg_log.txt")
115
125
  elif isinstance(ffmpeg_log_file, (str, os.PathLike)):
116
126
  self._ffmpeg_log_file = Path(ffmpeg_log_file)
117
127
  else:
@@ -135,13 +145,27 @@ class FfmpegProcess:
135
145
  else:
136
146
  self._duration_secs = get_media_duration(input_file_path_str)
137
147
 
148
+ input_duration_secs = self._duration_secs
149
+
150
+ # Account for trimming (-ss, -sseof, -t, -to) so the progress bar reflects the
151
+ # duration of the output rather than the whole input.
152
+ if self._duration_secs is not None:
153
+ self._duration_secs = get_expected_output_duration(command, self._duration_secs)
154
+
138
155
  if duration_override is not None:
139
- print(f"Using duration override: {self._duration_secs:.2f} seconds")
156
+ print(f"Using duration override: {input_duration_secs:.2f} seconds")
140
157
  elif self._print_detected_duration:
141
158
  if self._duration_secs is None:
142
159
  print("Could not detect duration. Progress bar may not show time remaining.")
143
160
  else:
144
- print(f"Detected duration: {self._duration_secs:.2f} seconds")
161
+ print(f"Detected duration: {input_duration_secs:.2f} seconds")
162
+
163
+ if (
164
+ self._duration_secs is not None
165
+ and self._duration_secs != input_duration_secs
166
+ and (duration_override is not None or self._print_detected_duration)
167
+ ):
168
+ print(f"Expected output duration after trimming: {self._duration_secs:.2f} seconds")
145
169
 
146
170
  self._ffmpeg_command = [
147
171
  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,294 @@
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 is_lavfi_input(command: List[str], input_flag_pos: int) -> bool:
19
+ """
20
+ Returns True if the input at input_flag_pos is preceded by "-f lavfi", meaning it is a
21
+ filtergraph (e.g. "testsrc2=duration=30") generated by FFmpeg rather than a file or URL.
22
+ """
23
+ return command[max(input_flag_pos - 2, 0) : input_flag_pos] == ["-f", "lavfi"]
24
+
25
+
26
+ def validate_ffmpeg_command(command: List[str]) -> None:
27
+ """
28
+ Validates the FFmpeg command.
29
+ Raises FfmpegCommandError if validation fails.
30
+ """
31
+ if not command:
32
+ raise FfmpegCommandError("FFmpeg command list cannot be empty.")
33
+
34
+ ffmpeg_executable = command[0]
35
+ if not shutil.which(ffmpeg_executable):
36
+ err_msg = (
37
+ f"'{ffmpeg_executable}' not found. "
38
+ "Ensure it's in your system's PATH or provide the full path of the FFmpeg executable."
39
+ )
40
+
41
+ raise FfmpegCommandError(err_msg)
42
+
43
+ if _FFMPEG_INPUT_FLAG not in command:
44
+ raise FfmpegCommandError(
45
+ f"FFmpeg command must include the input flag '{_FFMPEG_INPUT_FLAG}'."
46
+ )
47
+
48
+ try:
49
+ input_flag_pos = command.index(_FFMPEG_INPUT_FLAG)
50
+ except ValueError:
51
+ raise FfmpegCommandError(
52
+ f"Internal error: Input flag '{_FFMPEG_INPUT_FLAG}' not found after check."
53
+ )
54
+
55
+ input_file_idx = input_flag_pos + 1
56
+ if input_file_idx >= len(command):
57
+ raise FfmpegCommandError(f"No input file specified after '{_FFMPEG_INPUT_FLAG}'.")
58
+
59
+ input_file = command[input_file_idx]
60
+ if input_file.startswith("-") and len(input_file) == 2 and not input_file[1:].isdigit():
61
+ raise FfmpegCommandError(
62
+ f"Input file path '{input_file}' looks like an option flag. Please check your command."
63
+ )
64
+
65
+ # Validate input file/URL. lavfi inputs are generated by FFmpeg, so there is nothing to check.
66
+ if is_lavfi_input(command, input_flag_pos):
67
+ pass
68
+ elif input_file.startswith("http://") or input_file.startswith("https://"):
69
+ try:
70
+ response = requests.head(input_file, timeout=5, allow_redirects=True)
71
+ response.raise_for_status()
72
+ except requests.exceptions.RequestException as e:
73
+ raise FfmpegCommandError(f"Input URL not accessible: {input_file}. Error: {e}")
74
+ elif not Path(input_file).exists():
75
+ raise FfmpegCommandError(f"Input file not found: {input_file}")
76
+
77
+ if len(command) <= input_file_idx + 1:
78
+ raise FfmpegCommandError(
79
+ "No output file specified. The command seems to end after the input file path."
80
+ )
81
+
82
+ output_file_path_str = command[-1]
83
+ if output_file_path_str == input_file:
84
+ raise FfmpegCommandError(
85
+ "Output file path cannot be the same as the input file path in this command structure."
86
+ )
87
+
88
+
89
+ def get_media_duration(input_file: str) -> Optional[float]:
90
+ """
91
+ Retrieves the duration of a media file or URL using ffprobe.
92
+ Returns duration in seconds, or None if it cannot be determined.
93
+ Accepts input_file as a string to correctly handle URLs.
94
+ """
95
+ if not shutil.which("ffprobe"):
96
+ print("Warning: ffprobe not found in PATH. Cannot determine media duration.")
97
+ return None
98
+ try:
99
+ output = subprocess.check_output(
100
+ [
101
+ "ffprobe",
102
+ "-v",
103
+ "error",
104
+ "-show_entries",
105
+ "format=duration",
106
+ "-of",
107
+ "default=noprint_wrappers=1:nokey=1",
108
+ "-i",
109
+ input_file,
110
+ ],
111
+ text=True,
112
+ stderr=subprocess.DEVNULL,
113
+ )
114
+ return float(output)
115
+ except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
116
+ print(f"Warning: Could not determine duration for '{input_file}'.")
117
+ return None
118
+
119
+
120
+ def check_shell_needed_for_command(command: List[str]) -> bool:
121
+ """
122
+ Checks if any part of the command contains shell operators,
123
+ indicating that shell=True might be needed for subprocess.Popen.
124
+ This is a basic check and might not cover all edge cases.
125
+ """
126
+ shell_operators = {"|", ">", "<", ">>", "&&", "||"} # Basic set
127
+ return any(op in item for item in command for op in shell_operators)
128
+
129
+
130
+ def parse_ffmpeg_progress_line(
131
+ line: bytes, total_duration_secs: Optional[float]
132
+ ) -> Optional[float]:
133
+ """
134
+ Parses a progress line from FFmpeg's stdout.
135
+ Example line: b"out_time_us=12345678"
136
+ Returns the current progress time in seconds, or None if parsing fails or line is not a progress line.
137
+ Caps progress at total_duration_secs if provided.
138
+ Assumes `line` has already been stripped of leading/trailing whitespace.
139
+ """
140
+ if line.startswith(_OUT_TIME_US_PREFIX):
141
+ try:
142
+ # Get the value after out_time_us=
143
+ value_str = line[len(_OUT_TIME_US_PREFIX) :]
144
+
145
+ if not value_str:
146
+ return None
147
+
148
+ current_time_us = int(value_str)
149
+ current_time_secs = current_time_us * _MULTIPLIER
150
+
151
+ if total_duration_secs is not None:
152
+ return min(current_time_secs, total_duration_secs)
153
+ return current_time_secs
154
+ except (ValueError, IndexError):
155
+ return None
156
+
157
+ return None
158
+
159
+
160
+ # Matches FFmpeg's time duration syntax: [-][HH:]MM:SS[.m...] or [-]S+[.m...][s|ms|us]
161
+ _SEXAGESIMAL_RE = re.compile(r"^(-)?(?:(\d+):)?(\d+):(\d+(?:\.\d*)?)$")
162
+ _SECONDS_RE = re.compile(r"^(-)?(\d+(?:\.\d*)?|\.\d+)(s|ms|us)?$")
163
+ _TRIM_OPTIONS = ("-ss", "-sseof", "-t", "-to")
164
+
165
+
166
+ def parse_ffmpeg_time(value: str) -> Optional[float]:
167
+ """
168
+ Parses a time duration in FFmpeg's syntax (e.g. "90", "1.5", "500ms", "01:30", "00:01:30.5").
169
+ Returns the value in seconds, or None if it cannot be parsed.
170
+ """
171
+ value = value.strip()
172
+
173
+ match = _SEXAGESIMAL_RE.match(value)
174
+ if match:
175
+ sign, hours, minutes, seconds = match.groups()
176
+ total = int(hours or 0) * 3600 + int(minutes) * 60 + float(seconds)
177
+ return -total if sign else total
178
+
179
+ match = _SECONDS_RE.match(value)
180
+ if match:
181
+ sign, number, unit = match.groups()
182
+ total = float(number) / {None: 1, "s": 1, "ms": 1e3, "us": 1e6}[unit]
183
+ return -total if sign else total
184
+
185
+ return None
186
+
187
+
188
+ def _collect_trim_options(args: List[str]) -> Dict[str, float]:
189
+ """Returns the last value of each trim option found in args (FFmpeg uses the last occurrence)."""
190
+ options: Dict[str, float] = {}
191
+ for i, arg in enumerate(args[:-1]):
192
+ if arg in _TRIM_OPTIONS:
193
+ parsed = parse_ffmpeg_time(args[i + 1])
194
+ if parsed is not None:
195
+ options[arg] = parsed
196
+ return options
197
+
198
+
199
+ def _apply_trim(duration: float, options: Dict[str, float]) -> float:
200
+ """
201
+ Applies -ss/-sseof/-t/-to to a duration, mirroring FFmpeg's logic:
202
+ -t takes priority over -to, and -to is relative to -ss (recording_time = to - ss).
203
+ """
204
+ start = options.get("-ss", 0.0)
205
+
206
+ if "-sseof" in options and "-ss" not in options:
207
+ # -sseof is negative, relative to the end of the input
208
+ remaining = min(duration, abs(options["-sseof"]))
209
+ else:
210
+ remaining = duration - max(start, 0.0)
211
+
212
+ if "-t" in options:
213
+ remaining = min(remaining, options["-t"])
214
+ elif "-to" in options:
215
+ remaining = min(remaining, options["-to"] - max(start, 0.0))
216
+
217
+ return max(remaining, 0.0)
218
+
219
+
220
+ def get_expected_output_duration(command: List[str], input_duration: float) -> float:
221
+ """
222
+ Estimates the duration of the output by taking the trim options of the command into account.
223
+
224
+ Options before the first "-i" are input options for the first input, and options between the
225
+ last input path and the output path are output options. Input trimming is applied first
226
+ (seeking with an input -ss resets timestamps to 0), then output trimming.
227
+
228
+ If -copyts is used, timestamps are not reset, so no adjustment is made.
229
+ """
230
+ if "-copyts" in command:
231
+ return input_duration
232
+
233
+ first_input_idx = command.index(_FFMPEG_INPUT_FLAG)
234
+ last_input_idx = len(command) - 1 - command[::-1].index(_FFMPEG_INPUT_FLAG)
235
+
236
+ input_options = _collect_trim_options(command[1:first_input_idx])
237
+ output_options = _collect_trim_options(command[last_input_idx + 2 : -1])
238
+
239
+ duration = _apply_trim(input_duration, input_options)
240
+ duration = _apply_trim(duration, output_options)
241
+
242
+ # Never return 0, which would disable the progress bar.
243
+ return duration if duration > 0 else input_duration
244
+
245
+
246
+ def parse_ffmpeg_total_size_line(line: bytes) -> Optional[float]:
247
+ """
248
+ Parses a total_size line from FFmpeg's stdout.
249
+ Example line: b"total_size=12345678"
250
+ Returns the current output size in MB, or None if parsing fails or line is not a total_size line.
251
+ Assumes `line` has already been stripped of leading/trailing whitespace.
252
+ """
253
+ if line.startswith(_TOTAL_SIZE_PREFIX):
254
+ try:
255
+ value_str = line[len(_TOTAL_SIZE_PREFIX) :]
256
+
257
+ if not value_str:
258
+ return None
259
+
260
+ return int(value_str) / _BYTES_PER_MB
261
+ except ValueError:
262
+ return None
263
+
264
+ return None
265
+
266
+
267
+ def truncate_filename(filename: str, max_length: int = 60) -> str:
268
+ """
269
+ Truncates a filename to at most max_length characters, preserving the file extension.
270
+ """
271
+ if len(filename) <= max_length:
272
+ return filename
273
+
274
+ stem, ext = os.path.splitext(filename)
275
+ ellipsis = "..."
276
+ allowed_stem_len = max_length - len(ext) - len(ellipsis)
277
+
278
+ if allowed_stem_len < 1:
279
+ return filename[: max_length - len(ellipsis)] + ellipsis
280
+
281
+ return f"{stem[:allowed_stem_len]}{ellipsis}{ext}"
282
+
283
+
284
+ def estimate_final_size_mb(
285
+ current_size_mb: float, current_time_secs: float, total_duration_secs: float
286
+ ) -> Optional[float]:
287
+ """
288
+ Estimates the final output size in MB by extrapolating the current size over the total duration.
289
+ Returns None until enough of the input has been processed for the estimate to be meaningful.
290
+ """
291
+ min_secs = max(1.0, total_duration_secs * 0.01)
292
+ if current_time_secs < min_secs or current_size_mb <= 0:
293
+ return None
294
+ 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.4.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
 
@@ -108,6 +108,29 @@ process = FfmpegProcess(
108
108
  process.run()
109
109
  ```
110
110
 
111
+ ## Generated (lavfi) Inputs
112
+ Inputs generated by FFmpeg's [lavfi](https://ffmpeg.org/ffmpeg-devices.html#lavfi) device, such as test patterns, are supported. FFprobe cannot detect the duration of these inputs, so provide it with `duration_override`:
113
+
114
+ ```python
115
+ from better_ffmpeg_progress import FfmpegProcess
116
+
117
+ command = [
118
+ "ffmpeg",
119
+ "-f",
120
+ "lavfi",
121
+ "-i",
122
+ "testsrc2=duration=30:size=1920x1080:rate=30",
123
+ "-c:v",
124
+ "libx264",
125
+ "output.mp4",
126
+ ]
127
+
128
+ process = FfmpegProcess(command, duration_override=30)
129
+ process.run()
130
+ ```
131
+
132
+ By default, the FFmpeg log file is named after the source, e.g. `lavfi_testsrc2_ffmpeg_log.txt`.
133
+
111
134
  ## Optional Arguments
112
135
  An instance of `FfmpegProcess` accepts the following optional arguments:
113
136
  * `ffmpeg_log_level` — Set a value for FFmpeg's `-loglevel` option. Default: `"verbose"`.
@@ -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.4.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