Skip to content

Commit d26d5f7

Browse files
committed
feat: add progress
1 parent 6a51dfb commit d26d5f7

File tree

5 files changed

+131
-15
lines changed

5 files changed

+131
-15
lines changed

README.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ Output is to STDOUT so you can redirect that to a file or another script.
5757
See `ffmpeg-bitrate-stats -h`:
5858

5959
```
60-
usage: __main__.py [-h] [-n] [-v] [-s {video,audio}] [-a {time,gop}]
60+
usage: __main__.py [-h] [-n] [-v] [-q] [-s {video,audio}] [-a {time,gop}]
6161
[-c CHUNK_SIZE] [-rs READ_START] [-rd READ_DURATION]
6262
[-of {json,csv}] [-p] [-pw PLOT_WIDTH] [-ph PLOT_HEIGHT]
6363
input
6464
65-
ffmpeg_bitrate_stats v1.0.2
65+
ffmpeg_bitrate_stats v1.1.4
6666
6767
positional arguments:
6868
input input file
@@ -72,32 +72,35 @@ options:
7272
-n, --dry-run Do not run command, just show what would be done
7373
(default: False)
7474
-v, --verbose Show verbose output (default: False)
75-
-s {video,audio}, --stream-type {video,audio}
75+
-q, --quiet Do not show progress bar (default: False)
76+
-s, --stream-type {video,audio}
7677
Stream type to analyze (default: video)
77-
-a {time,gop}, --aggregation {time,gop}
78+
-a, --aggregation {time,gop}
7879
Window for aggregating statistics, either time-based
7980
(per-second) or per GOP (default: time)
80-
-c CHUNK_SIZE, --chunk-size CHUNK_SIZE
81+
-c, --chunk-size CHUNK_SIZE
8182
Custom aggregation window size in seconds (default:
8283
1.0)
83-
-rs READ_START, --read-start READ_START
84+
-rs, --read-start READ_START
8485
Time to wait before sampling video (in HH:MM:SS.msec
8586
or seconds) (default: None)
86-
-rd READ_DURATION, --read-duration READ_DURATION
87+
-rd, --read-duration READ_DURATION
8788
Duration for sampling stream (in HH:MM:SS.msec or
8889
seconds). Note that seeking is not accurate, see
8990
ffprobe documentation on '-read_intervals'. (default:
9091
None)
91-
-of {json,csv}, --output-format {json,csv}
92+
-of, --output-format {json,csv}
9293
output in which format (default: json)
9394
-p, --plot Plot the bitrate over time (to STDERR) (default:
9495
False)
95-
-pw PLOT_WIDTH, --plot-width PLOT_WIDTH
96+
-pw, --plot-width PLOT_WIDTH
9697
Plot width (default: 70)
97-
-ph PLOT_HEIGHT, --plot-height PLOT_HEIGHT
98+
-ph, --plot-height PLOT_HEIGHT
9899
Plot height (default: 18)
99100
```
100101

102+
By default, a progress bar is shown during analysis. Use `-q`/`--quiet` to disable it, or `-v`/`--verbose` to show debug output instead.
103+
101104
## Output
102105

103106
The output can be JSON, which includes individual fields for each chunk, or CSV, which repeats each line for each chunk. The CSV adheres to the “tidy” data concept, so it's a little redundant.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
"numpy",
3030
"pandas",
3131
"plotille",
32+
"tqdm",
3233
]
3334

3435
[project.urls]

src/ffmpeg_bitrate_stats/__main__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ def main() -> None:
6060
"-v", "--verbose", action="store_true", help="Show verbose output"
6161
)
6262

63+
parser.add_argument(
64+
"-q",
65+
"--quiet",
66+
action="store_true",
67+
help="Do not show progress bar",
68+
)
69+
6370
parser.add_argument(
6471
"-s",
6572
"--stream-type",
@@ -134,6 +141,9 @@ def main() -> None:
134141

135142
setup_logger(logging.DEBUG if cli_args.verbose else logging.INFO)
136143

144+
# Show progress by default, but not when quiet or verbose mode is enabled
145+
show_progress = not cli_args.quiet and not cli_args.verbose
146+
137147
br = BitrateStats(
138148
cli_args.input,
139149
stream_type=cli_args.stream_type,
@@ -142,8 +152,10 @@ def main() -> None:
142152
read_start=cli_args.read_start,
143153
read_duration=cli_args.read_duration,
144154
dry_run=cli_args.dry_run,
155+
show_progress=show_progress,
145156
)
146157
br.calculate_statistics()
158+
147159
br.print_statistics(cli_args.output_format)
148160

149161
if cli_args.plot:

src/ffmpeg_bitrate_stats/bitrate_stats.py

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,25 @@
1010
import numpy as np
1111
import pandas as pd
1212
import plotille
13+
from tqdm import tqdm
1314

1415
logger = logging.getLogger("ffmpeg-bitrate-stats")
1516

1617

1718
def run_command(
18-
cmd: List[str], dry_run: bool = False
19+
cmd: List[str],
20+
dry_run: bool = False,
21+
show_progress: bool = False,
22+
total_frames: Optional[int] = None,
1923
) -> tuple[str, str] | tuple[None, None]:
2024
"""
2125
Run a command directly
26+
27+
Args:
28+
cmd: The command to run
29+
dry_run: If True, just print the command without running
30+
show_progress: If True, show a progress bar
31+
total_frames: Total number of frames for progress estimation
2232
"""
2333

2434
# for verbose mode
@@ -29,13 +39,48 @@ def run_command(
2939
return None, None
3040

3141
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
32-
stdout, stderr = process.communicate()
42+
43+
if show_progress:
44+
# Read stdout line by line and show progress
45+
stdout_lines: List[str] = []
46+
packet_count = 0
47+
48+
# Create progress bar - if we have frame count use it, otherwise use indeterminate
49+
pbar = tqdm(
50+
total=total_frames,
51+
desc="Analyzing",
52+
unit=" packets",
53+
file=sys.stderr,
54+
leave=False,
55+
)
56+
57+
assert process.stdout is not None
58+
for line in process.stdout:
59+
decoded_line = line.decode("utf-8")
60+
stdout_lines.append(decoded_line)
61+
# Count packet entries in JSON output
62+
if '"pts_time"' in decoded_line or '"size"' in decoded_line:
63+
packet_count += 1
64+
if total_frames:
65+
pbar.update(1)
66+
else:
67+
pbar.update(1)
68+
pbar.total = packet_count
69+
70+
pbar.close()
71+
stdout = "".join(stdout_lines)
72+
stderr = process.stderr.read().decode("utf-8") if process.stderr else ""
73+
process.wait()
74+
else:
75+
stdout_bytes, stderr_bytes = process.communicate()
76+
stdout = stdout_bytes.decode("utf-8")
77+
stderr = stderr_bytes.decode("utf-8")
3378

3479
if process.returncode == 0:
35-
return stdout.decode("utf-8"), stderr.decode("utf-8")
80+
return stdout, stderr
3681
else:
3782
logger.error("error running command: {}".format(" ".join(cmd)))
38-
logger.error(stderr.decode("utf-8"))
83+
logger.error(stderr)
3984
sys.exit(1)
4085

4186

@@ -118,6 +163,7 @@ class BitrateStats:
118163
read_start (str, optional): Start time for reading in HH:MM:SS.msec or seconds. Defaults to None.
119164
read_duration (str, optional): Duration for reading in HH:MM:SS.msec or seconds. Defaults to None.
120165
dry_run (bool, optional): Dry run. Defaults to False.
166+
show_progress (bool, optional): Show progress bar. Defaults to True.
121167
"""
122168

123169
def __init__(
@@ -129,6 +175,7 @@ def __init__(
129175
read_start: Optional[str] = None,
130176
read_duration: Optional[str] = None,
131177
dry_run: bool = False,
178+
show_progress: bool = True,
132179
):
133180
self.input_file = input_file
134181

@@ -154,6 +201,7 @@ def __init__(
154201
)
155202

156203
self.dry_run = dry_run
204+
self.show_progress = show_progress
157205

158206
self.duration: float = 0
159207
self.fps: float = 0
@@ -189,6 +237,34 @@ def calculate_statistics(self) -> BitrateStatsSummary:
189237

190238
return self.bitrate_stats
191239

240+
def _get_frame_count(self) -> Optional[int]:
241+
"""
242+
Get the total frame/packet count for progress estimation.
243+
244+
Returns:
245+
int or None: The frame count, or None if unavailable.
246+
"""
247+
cmd = [
248+
"ffprobe",
249+
"-loglevel",
250+
"error",
251+
"-select_streams",
252+
self.stream_type[0] + ":0",
253+
"-show_entries",
254+
"stream=nb_frames",
255+
"-of",
256+
"default=noprint_wrappers=1:nokey=1",
257+
self.input_file,
258+
]
259+
260+
try:
261+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
262+
if result.returncode == 0 and result.stdout.strip():
263+
return int(result.stdout.strip())
264+
except (subprocess.TimeoutExpired, ValueError):
265+
pass
266+
return None
267+
192268
def _calculate_frame_sizes(self) -> list[FrameEntry]:
193269
"""
194270
Get the frame sizes via ffprobe using the -show_packets option.
@@ -221,7 +297,17 @@ def _calculate_frame_sizes(self) -> list[FrameEntry]:
221297
]
222298
)
223299

224-
stdout, _ = run_command(base_cmd, self.dry_run)
300+
# Get frame count for progress bar (only if showing progress)
301+
frame_count = None
302+
if self.show_progress:
303+
frame_count = self._get_frame_count()
304+
305+
stdout, _ = run_command(
306+
base_cmd,
307+
self.dry_run,
308+
show_progress=self.show_progress,
309+
total_frames=frame_count,
310+
)
225311
if self.dry_run or stdout is None:
226312
logger.error("Aborting prematurely, dry-run specified or stdout was empty")
227313
sys.exit(0)

uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)