better-ffmpeg-progress 2.1.6__tar.gz → 2.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-ffmpeg-progress
3
- Version: 2.1.6
3
+ Version: 2.1.7
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
@@ -9,7 +9,7 @@ Keywords: ffmpeg,progress
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.6
12
+ Requires-Python: >=3.7
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Requires-Dist: ffmpeg-python
@@ -44,7 +44,9 @@ Where:
44
44
 
45
45
  ## Installation:
46
46
 
47
- `pip3 install better-ffmpeg-progress --upgrade`
47
+ ```
48
+ pip install better-ffmpeg-progress --upgrade
49
+ ```
48
50
 
49
51
  ## Usage:
50
52
 
@@ -141,3 +143,8 @@ The `run` method takes the following **optional** arguments:
141
143
  [02/05/2024]
142
144
 
143
145
  - [v2.1.5] Fix issue [#23](https://github.com/CrypticSignal/better-ffmpeg-progress/issues/23) and make an error message more specific. [Here](https://github.com/CrypticSignal/better-ffmpeg-progress/commit/a6ef7f26d080b684144021301f3b2aa5e0834dae) is the relevant commit.
146
+
147
+ [18/10/2024]
148
+
149
+ - [v2.1.6] Notify the user if the input filepath or filename is incorrect.
150
+ - [v2.1.7] Refactor to use threads and queues.
@@ -27,7 +27,9 @@ Where:
27
27
 
28
28
  ## Installation:
29
29
 
30
- `pip3 install better-ffmpeg-progress --upgrade`
30
+ ```
31
+ pip install better-ffmpeg-progress --upgrade
32
+ ```
31
33
 
32
34
  ## Usage:
33
35
 
@@ -124,3 +126,8 @@ The `run` method takes the following **optional** arguments:
124
126
  [02/05/2024]
125
127
 
126
128
  - [v2.1.5] Fix issue [#23](https://github.com/CrypticSignal/better-ffmpeg-progress/issues/23) and make an error message more specific. [Here](https://github.com/CrypticSignal/better-ffmpeg-progress/commit/a6ef7f26d080b684144021301f3b2aa5e0834dae) is the relevant commit.
129
+
130
+ [18/10/2024]
131
+
132
+ - [v2.1.6] Notify the user if the input filepath or filename is incorrect.
133
+ - [v2.1.7] Refactor to use threads and queues.
@@ -0,0 +1,209 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from queue import Empty, Queue
4
+ import subprocess
5
+ import sys
6
+ from threading import Thread
7
+ from typing import Callable, List, Optional, Union
8
+
9
+ from ffmpeg import probe
10
+ from tqdm import tqdm
11
+
12
+
13
+ @dataclass
14
+ class FfmpegProgress:
15
+ """Data class to store FFmpeg progress information."""
16
+ percentage: float = 0.0
17
+ speed: float = 0.0
18
+ eta: Optional[float] = None
19
+ estimated_size: Optional[int] = None
20
+
21
+
22
+ class FfmpegProcess:
23
+ """
24
+ Args:
25
+ command: A list of arguments to pass to FFmpeg.
26
+ ffmpeg_loglevel: Desired FFmpeg log level. Default is "verbose".
27
+
28
+ Raises:
29
+ FileNotFoundError: If FFmpeg cannot find the input filepath or filename.
30
+ ValueError: If the list of arguments does not include "-i"
31
+ """
32
+
33
+ WANTED_METRICS = frozenset({"out_time_ms", "total_size", "speed"})
34
+
35
+ def __init__(self, command: List[str], ffmpeg_loglevel: str = "verbose"):
36
+ if "-i" not in command:
37
+ raise ValueError("FFmpeg command must include '-i'")
38
+
39
+ self._ffmpeg_args = command + ["-hide_banner", "-loglevel", ffmpeg_loglevel]
40
+ self._output_filepath = Path(command[-1])
41
+ self._progress = FfmpegProgress()
42
+ self._duration_secs: Optional[float] = None
43
+ self._current_size: int = 0
44
+ self._previous_seconds_processed: float = 0
45
+ self._progress_bar: Optional[tqdm] = None
46
+
47
+ self._get_input_file_duration()
48
+
49
+ def _get_input_file_duration(self) -> None:
50
+ """Use ffprobe to get the duration of the input file"""
51
+ index_of_filepath = self._ffmpeg_args.index("-i") + 1
52
+ self._input_filepath = Path(self._ffmpeg_args[index_of_filepath])
53
+
54
+ try:
55
+ self._duration_secs = round(float(probe(self._input_filepath)["format"]["duration"]), 2)
56
+ print(f"The duration of {self._input_filepath.name} has been detected as {self._duration_secs} seconds")
57
+ # -progress pipe:1 sends progress metrics to stdout AND -stats_period sets the period at which encoding progress/statistics are updated
58
+ # -nostats disables encoding progress/statistics in stderr
59
+ self._ffmpeg_args.extend(["-progress", "pipe:1", "-stats_period", "0.1", "-nostats"])
60
+ except Exception:
61
+ print(f"Could not detect the duration of {self._input_filepath.name}. Percentage progress, ETA and estimated filesize will be unavailable.")
62
+ self._duration_secs = None
63
+
64
+ def _check_output_exists(self) -> bool:
65
+ """Check if output file exists and handle overwrite prompt."""
66
+ if self._output_filepath.exists() and "-y" not in self._ffmpeg_args:
67
+ choice = input(f"{self._output_filepath} already exists. Overwrite? [Y/N]: ").lower()
68
+ if choice != "y":
69
+ print("FFmpeg process cancelled. Output file exists and overwrite declined.")
70
+ return False
71
+ self._ffmpeg_args.insert(1, "-y")
72
+ return True
73
+
74
+ def _update_progress(self, line: str, metric: str, value: str,
75
+ progress_handler: Optional[Callable]) -> None:
76
+ """Update progress information based on FFmpeg output."""
77
+ if metric == "total_size":
78
+ self._current_size = int(value)
79
+
80
+ elif metric == "out_time_ms":
81
+ seconds_processed = round((int(value) / 1_000_000), 2)
82
+
83
+ if progress_handler is None and self._progress_bar:
84
+ seconds_increase = seconds_processed - self._previous_seconds_processed
85
+ self._progress_bar.update(seconds_increase)
86
+ self._previous_seconds_processed = seconds_processed
87
+ return
88
+
89
+ if self._duration_secs:
90
+ self._progress.percentage = (seconds_processed / self._duration_secs) * 100
91
+ if self._current_size and self._progress.percentage > 0:
92
+ self._progress.estimated_size = int(self._current_size * (100 / self._progress.percentage))
93
+
94
+ elif metric == "speed":
95
+ speed_str = value.rstrip('x')
96
+ if speed_str != "0":
97
+ self._progress.speed = float(speed_str)
98
+ if self._duration_secs:
99
+ seconds_remaining = self._duration_secs - (self._previous_seconds_processed or 0)
100
+ self._progress.eta = seconds_remaining / self._progress.speed
101
+
102
+ if line == "progress=end":
103
+ self._progress.percentage = 100
104
+ self._progress.eta = 0
105
+
106
+ if progress_handler:
107
+ progress_handler(
108
+ self._progress.percentage,
109
+ self._progress.speed,
110
+ self._progress.eta,
111
+ self._progress.estimated_size
112
+ )
113
+
114
+ @staticmethod
115
+ def _handle_pipe(pipe, queue: Queue) -> None:
116
+ """Read from pipe and put lines into queue."""
117
+ try:
118
+ for line in pipe:
119
+ queue.put(line.strip())
120
+ finally:
121
+ pipe.close()
122
+
123
+ def _write_ffmpeg_output(self, stderr_output: str, output_file: Optional[Union[str, Path]]) -> None:
124
+ """Write FFmpeg stderr to a txt file."""
125
+ output_dir = Path("ffmpeg_output")
126
+ output_dir.mkdir(exist_ok=True)
127
+
128
+ log_file = output_file or output_dir / f"[{self._input_filepath.name}].txt"
129
+ log_file = Path(log_file)
130
+
131
+ log_file.write_text(stderr_output)
132
+ print(f"Done! FFmpeg output logged to: {log_file}")
133
+
134
+ def run(self,
135
+ output_file: Optional[Union[str, Path]] = None,
136
+ progress_handler: Optional[Callable] = None,
137
+ success_handler: Optional[Callable] = None,
138
+ error_handler: Optional[Callable] = None) -> None:
139
+ """
140
+ Args:
141
+ output_file: Optional filepath to write FFmpeg output to
142
+ progress_handler: Optional function which receives progress metrics
143
+ success_handler: Optional function to handle successful completion of the FFmpeg process
144
+ error_handler: Optional function for FFmpeg process error handling
145
+ """
146
+ if not self._check_output_exists():
147
+ return
148
+
149
+ print(f"Executing: {' '.join(self._ffmpeg_args)}")
150
+
151
+ if self._duration_secs and not progress_handler:
152
+ self._progress_bar = tqdm(
153
+ total=self._duration_secs,
154
+ unit="s",
155
+ dynamic_ncols=True,
156
+ leave=False
157
+ )
158
+
159
+ process = subprocess.Popen(
160
+ self._ffmpeg_args,
161
+ stdout=subprocess.PIPE,
162
+ stderr=subprocess.PIPE,
163
+ text=True
164
+ )
165
+
166
+ stdout_queue, stderr_queue = Queue(), Queue()
167
+ # These threads put FFmpeg stdout and stderr into the queues
168
+ Thread(target=self._handle_pipe, args=(process.stdout, stdout_queue), daemon=True).start()
169
+ Thread(target=self._handle_pipe, args=(process.stderr, stderr_queue), daemon=True).start()
170
+
171
+ stderr_output = []
172
+
173
+ try:
174
+ while process.poll() is None:
175
+ # Handle stdout (progress metrics are sent here due as we are passing the -progress pipe:1 options to FFmpeg)
176
+ try:
177
+ stdout = stdout_queue.get_nowait()
178
+ if stdout:
179
+ try:
180
+ metric, value = stdout.split("=")
181
+ if metric in self.WANTED_METRICS and value and "N/A" not in value:
182
+ self._update_progress(stdout, metric, value, progress_handler)
183
+ except ValueError:
184
+ continue
185
+ except Empty:
186
+ pass
187
+ # Handle stderr (regular FFmpeg output)
188
+ try:
189
+ stderr = stderr_queue.get_nowait()
190
+ if "No such file" in stderr:
191
+ raise FileNotFoundError(f"Input file not found: {self._input_filepath}")
192
+ stderr_output.append(stderr)
193
+ except Empty:
194
+ pass
195
+
196
+ if process.returncode != 0:
197
+ if error_handler:
198
+ error_handler()
199
+ else:
200
+ sys.exit("\nFFmpeg process failed")
201
+
202
+ if success_handler:
203
+ success_handler()
204
+
205
+ finally:
206
+ if self._progress_bar:
207
+ self._progress_bar.close()
208
+
209
+ self._write_ffmpeg_output("\n".join(stderr_output), output_file)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-ffmpeg-progress
3
- Version: 2.1.6
3
+ Version: 2.1.7
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
@@ -9,7 +9,7 @@ Keywords: ffmpeg,progress
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.6
12
+ Requires-Python: >=3.7
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Requires-Dist: ffmpeg-python
@@ -44,7 +44,9 @@ Where:
44
44
 
45
45
  ## Installation:
46
46
 
47
- `pip3 install better-ffmpeg-progress --upgrade`
47
+ ```
48
+ pip install better-ffmpeg-progress --upgrade
49
+ ```
48
50
 
49
51
  ## Usage:
50
52
 
@@ -141,3 +143,8 @@ The `run` method takes the following **optional** arguments:
141
143
  [02/05/2024]
142
144
 
143
145
  - [v2.1.5] Fix issue [#23](https://github.com/CrypticSignal/better-ffmpeg-progress/issues/23) and make an error message more specific. [Here](https://github.com/CrypticSignal/better-ffmpeg-progress/commit/a6ef7f26d080b684144021301f3b2aa5e0834dae) is the relevant commit.
146
+
147
+ [18/10/2024]
148
+
149
+ - [v2.1.6] Notify the user if the input filepath or filename is incorrect.
150
+ - [v2.1.7] Refactor to use threads and queues.
@@ -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="2.1.6",
8
+ version="2.1.7",
9
9
  author="GitHub.com/CrypticSignal",
10
10
  author_email="theaudiophile@outlook.com",
11
11
  description="Run FFmpeg & see percentage progress + ETA.",
@@ -14,7 +14,7 @@ setuptools.setup(
14
14
  url="https://github.com/CrypticSignal/better-ffmpeg-progress",
15
15
  packages=["better_ffmpeg_progress"],
16
16
  install_requires=["ffmpeg-python", "tqdm"],
17
- python_requires=">=3.6",
17
+ python_requires=">=3.7",
18
18
  keywords=["ffmpeg", "progress"],
19
19
  classifiers=[
20
20
  "Programming Language :: Python :: 3",
@@ -1,183 +0,0 @@
1
- import os
2
- from pathlib import Path
3
- import subprocess
4
- import sys
5
-
6
- from ffmpeg import probe
7
- from tqdm import tqdm
8
-
9
-
10
- class FfmpegProcess:
11
- """
12
- Args:
13
- command (list): A list of arguments to pass to FFmpeg.
14
-
15
- ffmpeg_loglevel (str, optional): Desired FFmpeg log level. Default is "verbose".
16
-
17
- Raises:
18
- ValueError: If the list of arguments does not include "-i".
19
- """
20
-
21
- def __init__(self, command, ffmpeg_loglevel="verbose"):
22
- if "-i" not in command:
23
- raise ValueError("FFmpeg command must include '-i'")
24
-
25
- self._ffmpeg_args = command + ["-hide_banner", "-loglevel", ffmpeg_loglevel]
26
- self._output_filepath = command[-1]
27
-
28
- self._set_file_info()
29
-
30
- self._estimated_size = None
31
- self._eta = None
32
- self._percentage_progress = 0
33
- self._previous_seconds_processed = 0
34
- self._progress_bar = None
35
- self._seconds_processed = 0
36
- self._speed = 0
37
- self._current_size = 0
38
-
39
- def _set_file_info(self):
40
- index_of_filepath = self._ffmpeg_args.index("-i") + 1
41
- self._filepath = self._ffmpeg_args[index_of_filepath]
42
- self._can_get_duration = True
43
-
44
- try:
45
- self._duration_secs = float(probe(self._filepath)["format"]["duration"])
46
- print(
47
- f"The duration of {self._filepath} has been detected as {self._duration_secs} seconds."
48
- )
49
- except Exception:
50
- self._can_get_duration = False
51
-
52
- if self._can_get_duration:
53
- self._ffmpeg_args += ["-progress", "pipe:1", "-nostats"]
54
-
55
- def _should_run_ffmpeg(self):
56
- dirname = os.path.dirname(self._output_filepath)
57
- self._dir_files = (
58
- [file for file in os.listdir(dirname)] if dirname else [file for file in os.listdir()]
59
- )
60
-
61
- if "-y" not in self._ffmpeg_args and self._output_filepath in self._dir_files:
62
- choice = input(f"{self._output_filepath} already exists. Overwrite? [Y/N]: ").lower()
63
-
64
- if choice != "y":
65
- print(
66
- "FFmpeg will not run as the output filename already exists, and you do not want it to be overwritten."
67
- )
68
- return False
69
-
70
- self._ffmpeg_args.insert(1, "-y")
71
- return True
72
-
73
- return True
74
-
75
- def _update_progress(self, ffmpeg_output, metric_name, value, progress_handler):
76
- if progress_handler is None:
77
- if metric_name == "out_time_ms":
78
- seconds_processed = round(int(value) / 1_000_000, 1)
79
- seconds_increase = seconds_processed - self._previous_seconds_processed
80
- self._progress_bar.update(seconds_increase)
81
- self._previous_seconds_processed = seconds_processed
82
-
83
- else:
84
- if metric_name == "total_size":
85
- self._current_size = int(value)
86
-
87
- elif metric_name == "out_time_ms":
88
- self._seconds_processed = int(value) / 1_000_000
89
-
90
- if self._can_get_duration:
91
- self._percentage_progress = (
92
- self._seconds_processed / self._duration_secs
93
- ) * 100
94
-
95
- if self._current_size is not None and self._percentage_progress != 0.0:
96
- self._estimated_size = self._current_size * (
97
- 100 / self._percentage_progress
98
- )
99
-
100
- elif metric_name == "speed":
101
- speed_str = value[:-1]
102
-
103
- if speed_str != "0":
104
- self._speed = float(speed_str)
105
-
106
- if self._can_get_duration:
107
- self._eta = (self._duration_secs - self._seconds_processed) / self._speed
108
-
109
- if ffmpeg_output == "progress=end":
110
- self._percentage_progress = 100
111
- self._eta = 0
112
-
113
- progress_handler(
114
- self._percentage_progress, self._speed, self._eta, self._estimated_size
115
- )
116
-
117
- def run(
118
- self,
119
- progress_handler=None,
120
- ffmpeg_output_file=None,
121
- success_handler=None,
122
- error_handler=None,
123
- ):
124
- if not self._should_run_ffmpeg():
125
- return
126
-
127
- print(f"Attempting to run: {' '.join(self._ffmpeg_args)}\n")
128
- process = subprocess.Popen(self._ffmpeg_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
129
-
130
- stdout = process.stdout.readline().decode()
131
- stderr = process.stderr.readline().decode()
132
-
133
- if "No such file" in stderr:
134
- print(f"{stderr}It seems like the input filepath or filename is incorrect.")
135
- return
136
-
137
- if ffmpeg_output_file is None:
138
- os.makedirs("ffmpeg_output", exist_ok=True)
139
- ffmpeg_output_file = os.path.join("ffmpeg_output", f"[{Path(self._filepath).name}].txt")
140
-
141
- with open(ffmpeg_output_file, "a") as f:
142
- f.write(stdout)
143
-
144
- if progress_handler is None and self._can_get_duration:
145
- self._progress_bar = tqdm(
146
- total=round(self._duration_secs, 1),
147
- unit="s",
148
- dynamic_ncols=True,
149
- leave=False,
150
- )
151
-
152
- while process.poll() is None:
153
- stdout = process.stdout.readline().decode().strip()
154
-
155
- if not stdout:
156
- continue
157
-
158
- metric_name = stdout.split("=")[0]
159
- wanted_metrics = ["out_time_ms", "total_size", "speed"]
160
-
161
- if metric_name not in wanted_metrics:
162
- continue
163
-
164
- value = stdout.split("=")[1].strip()
165
-
166
- if not value or "N/A" in value:
167
- continue
168
-
169
- self._update_progress(stdout, metric_name, value, progress_handler)
170
-
171
- if process.returncode != 0:
172
- if error_handler:
173
- error_handler()
174
- return
175
-
176
- print(
177
- f"The FFmpeg process encountered an error. The output of FFmpeg can be found in {ffmpeg_output_file}"
178
- )
179
-
180
- if success_handler:
181
- success_handler()
182
-
183
- print(f"\n\nDone! To see FFmpeg's output, check out {ffmpeg_output_file}")