├── .gitignore ├── LICENSE ├── README.md ├── frame ├── __init__.py ├── _frame_class.py └── _frame_dataclass.py ├── plotbitrate.py ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | env-*/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # vim swap files 133 | *.swp 134 | 135 | .vscode/* 136 | !.vscode/settings.json 137 | !.vscode/tasks.json 138 | #!.vscode/launch.json 139 | !.vscode/extensions.json 140 | *.code-workspace 141 | 142 | .idea 143 | 144 | /sample data/ 145 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Original work Copyright (c) 2013-2024, Eric Work 2 | Modified work Copyright (c) 2019-2021, Steve Schmidt 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, this 12 | list of conditions and the following disclaimer in the documentation and/or 13 | other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 22 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PlotBitrate 2 | =========== 3 | 4 | FFProbe Bitrate Graph 5 | 6 | This project contains a script for plotting the bitrate of an audio or video 7 | stream over time. To get the frame bitrate data ffprobe is used from the ffmpeg 8 | package. The ffprobe data is streamed to python as xml frame metadata and 9 | optionally sorted by frame type. Matplotlib is used to plot the overall bitrate 10 | or each frame type on the same graph with lines for the peak and mean bitrates. 11 | The resulting bitrate graph can be saved as an image. 12 | 13 | Possible outputs are: 14 | * Image types (png, svg, pdf, ...) 15 | * Raw frame data (csv, xml) 16 | 17 | Requirements: 18 | * Python >= 3.6 19 | * FFmpeg >= 1.2 with the ffprobe command 20 | * Matplotlib 21 | * PyQt5 or PyQt6 (optional for image file output) 22 | 23 | For using the script from source, install the requirements with 24 | `pip install -r requirements.txt` or use the `requirements-dev.txt` 25 | for development purposes. 26 | 27 | Installation 28 | ------------ 29 | 30 | `pip install plotbitrate` 31 | 32 | If you encounter the error message `qt.qpa.plugin: Could not load the Qt 33 | platform plugin "xcb" in "" even though it was found.` while running 34 | `plotbitrate`, then you may need to install your distribution's 35 | `libxcb-cursor0` package. If this package is already installed and/or doesn't 36 | resolve the issue, then you can try using `QT_DEBUG_PLUGINS=1 plotbitrate 37 | input.mkv 2>&1 | grep "No such file"` to determine if other libraries are 38 | missing. 39 | 40 | Useful Options 41 | -------------- 42 | 43 | The raw frame data can be stored in an xml file with the option `-f xml_raw`, 44 | which the graph can be plotted from. This is useful if the graph should be 45 | shown multiple times with different options, as the source file doesn't need to 46 | be scanned again. 47 | 48 | The option `--downscale` (or `-d`) is useful if the video is very long and an 49 | overview of the bitrate fluctuation is sufficient and zooming in is not 50 | necessary. This behavior resembles that of the tool "Bitrate Viewer". With this 51 | option, videos will be shown as a downscaled graph, meaning not every second is 52 | being displayed. Multiple seconds will be grouped together and the max value 53 | will be drawn. This downscaling is not applied when viewing individual frame 54 | types as this would lead to wrong graphs. This behavior can be adjusted with 55 | the `--max-display-values` option. The default value is 700, meaning that at 56 | most 700 individual seconds/bars are drawn. 57 | 58 | CSV Output 59 | ---------- 60 | 61 | You may find it useful to save the raw frame data to a CSV file so the frame 62 | data can be processed using another tool. This turns `plotbitrate` into more of 63 | a helper tool rather than a visualization tool. 64 | 65 | One example may be using `gnuplot` to show an impulse plot for every single 66 | frame split by frame type. Below is an example `gnuplot` script that mimics an 67 | earlier version of `plotbitrate`. 68 | 69 | ``` 70 | #!/usr/bin/gnuplot -persist 71 | set datafile separator "," 72 | plot "< awk -F, '{if($3 == \"I\") print}' frames.csv" u 1:2 t "I" w impulses lt rgb "red", \ 73 | "< awk -F, '{if($3 == \"P\") print}' frames.csv" u 1:2 t "P" w impulses lt rgb "green", \ 74 | "< awk -F, '{if($3 == \"B\") print}' frames.csv" u 1:2 t "B" w impulses lt rgb "blue" 75 | ``` 76 | 77 | The necessary input data can be generated using: 78 | 79 | ``` 80 | plotbitrate -o frames.csv input.mkv 81 | ``` 82 | 83 | Usage Examples 84 | -------------- 85 | 86 | Show video stream bitrate in a window with progress. 87 | 88 | ``` 89 | plotbitrate input.mkv 90 | ``` 91 | 92 | Show downscaled video stream bitrate in a window. 93 | 94 | ``` 95 | plotbitrate -d input.mkv 96 | ``` 97 | 98 | Show video stream bitrate for each frame type in a window. 99 | 100 | ``` 101 | plotbitrate -t input.mkv 102 | ``` 103 | 104 | Save video stream bitrate to an SVG file. 105 | 106 | ``` 107 | plotbitrate -o output.svg input.mkv 108 | ``` 109 | 110 | Show audio stream bitrate in a window. 111 | 112 | ``` 113 | plotbitrate -s audio input.mkv 114 | ``` 115 | 116 | Save raw ffproble frame data as xml file. 117 | 118 | ``` 119 | plotbitrate -f xml_raw -o frames.xml input.mkv 120 | ``` 121 | 122 | Show bitrate graph from raw xml. 123 | 124 | ``` 125 | plotbitrate frames.xml 126 | ``` 127 | 128 | Show the bitrate, but fill the area below the curve with a solid color. 129 | 130 | ``` 131 | plotbitrate --solid input.mkv 132 | ``` 133 | 134 | It's possible to specify a custom `FFPROBE_PATH` in case you don't have it on your `PATH` or want a custom `ffprobe`: 135 | 136 | ``` 137 | # Unix 138 | FFPROBE_PATH=/tmp/ffprobe plotbitrate input.mkv 139 | 140 | # Windows 141 | set FFPROBE_PATH=C:\temp\ffmpeg\bin\ffprobe.exe 142 | plotbitrate input.mkv 143 | ``` 144 | -------------------------------------------------------------------------------- /frame/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["Frame"] 2 | 3 | from importlib import util 4 | 5 | # use the dataclass version of Frame if available. 6 | # this will work on python >= 3.7 or 3.6 with dataclasses 7 | # backport installed 8 | if util.find_spec("dataclasses") is not None: 9 | from ._frame_dataclass import Frame 10 | else: 11 | from ._frame_class import Frame # type: ignore 12 | -------------------------------------------------------------------------------- /frame/_frame_class.py: -------------------------------------------------------------------------------- 1 | class Frame: 2 | def __init__(self, time, size, pict_type): 3 | self.time = time 4 | self.size = size 5 | self.pict_type = pict_type 6 | 7 | @staticmethod 8 | def get_fields(): 9 | return ['time', 'size', 'pict_type'] 10 | -------------------------------------------------------------------------------- /frame/_frame_dataclass.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | 4 | @dataclasses.dataclass 5 | class Frame: 6 | __slots__ = ["time", "size", "pict_type"] 7 | time: float 8 | size: int 9 | pict_type: str 10 | 11 | @staticmethod 12 | def get_fields(): 13 | return [f.name for f in dataclasses.fields(Frame)] 14 | -------------------------------------------------------------------------------- /plotbitrate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # FFProbe Bitrate Graph 4 | # 5 | # Original work Copyright (c) 2013-2023, Eric Work 6 | # Modified work Copyright (c) 2019-2021, Steve Schmidt 7 | # All rights reserved. 8 | # 9 | # Redistribution and use in source and binary forms, with or without 10 | # modification, are permitted provided that the following conditions are met: 11 | # 12 | # Redistributions of source code must retain the above copyright notice, 13 | # this list of conditions and the following disclaimer. 14 | # 15 | # Redistributions in binary form must reproduce the above copyright notice, 16 | # this list of conditions and the following disclaimer in the documentation 17 | # and/or other materials provided with the distribution. 18 | # 19 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 22 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 23 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 24 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 25 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 26 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 27 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 28 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | # POSSIBILITY OF SUCH DAMAGE. 30 | # 31 | 32 | __version__ = "1.1.3.0" 33 | 34 | import argparse 35 | import csv 36 | import datetime 37 | import math 38 | import multiprocessing 39 | import os 40 | import platform 41 | import shutil 42 | import statistics 43 | import subprocess 44 | import sys 45 | from collections import OrderedDict 46 | from enum import Enum 47 | from importlib import util 48 | from typing import Callable, Union, List, IO, Iterable, Optional, Dict, Tuple, \ 49 | Generator 50 | from frame import Frame 51 | 52 | 53 | class Color(Enum): 54 | I = "red" 55 | P = "green" 56 | B = "blue" 57 | AUDIO = "C2" 58 | FRAME = "C0" 59 | 60 | 61 | class ConsoleColors: 62 | WARNING = "\033[93m" # yellow 63 | ERROR = "\033[91m" # red 64 | END_COLOR = "\033[0m" # restore default color 65 | 66 | 67 | def exit_with_error(error_message: str) -> None: 68 | sys.exit(ConsoleColors.ERROR + "Error: " + error_message + 69 | ConsoleColors.END_COLOR) 70 | 71 | 72 | def print_warning(warning_message: str) -> None: 73 | print(ConsoleColors.WARNING + "Warning: " + warning_message + 74 | ConsoleColors.END_COLOR) 75 | 76 | 77 | def is_wsl() -> bool: 78 | platform_info = platform.uname() 79 | return platform_info.system == "Linux" and "microsoft" in platform_info.release.lower() 80 | 81 | 82 | # prefer C-based ElementTree 83 | try: 84 | import xml.etree.cElementTree as eTree 85 | except ImportError: 86 | import xml.etree.ElementTree as eTree # type: ignore 87 | 88 | # check for matplot lib 89 | try: 90 | import matplotlib # type: ignore 91 | import matplotlib.pyplot as matplot # type: ignore 92 | except ImportError as err: 93 | # satisfy undefined variable warnings 94 | matplotlib = None 95 | matplot = None 96 | exit_with_error("Missing package 'python3-matplotlib'") 97 | 98 | 99 | # bring your own ffprobe, if you want 100 | ffprobe = os.environ.get('FFPROBE_PATH', 'ffprobe') 101 | 102 | # check for ffprobe in path 103 | if not shutil.which(ffprobe): 104 | exit_with_error("Missing ffprobe from package 'ffmpeg'") 105 | 106 | 107 | def parse_arguments() -> argparse.Namespace: 108 | """ Parses all arguments and returns them as an object. """ 109 | if sys.version_info >= (3, 6): 110 | supported_filetypes = matplotlib.figure.Figure().canvas \ 111 | .get_supported_filetypes().keys() 112 | else: 113 | fig = matplot.figure() 114 | supported_filetypes = fig.canvas.get_supported_filetypes().keys() 115 | matplot.close(fig) 116 | 117 | # get list of supported matplotlib formats 118 | format_list = list(supported_filetypes) 119 | 120 | format_list.append("xml_raw") 121 | format_list.append("csv_raw") 122 | 123 | # parse command line arguments 124 | parser = argparse.ArgumentParser( 125 | description="Graph bitrate for audio/video stream") 126 | parser.add_argument("input", help="input file/stream", metavar="INPUT") 127 | parser.add_argument("--version", action="version", 128 | version="%(prog)s {version}".format( 129 | version=__version__)) 130 | parser.add_argument("-s", "--stream", help="Stream type (default: video)", 131 | choices=["audio", "video"], default="video") 132 | parser.add_argument("-o", "--output", help="Output file") 133 | parser.add_argument("-f", "--format", help="Output file format", 134 | choices=format_list) 135 | parser.add_argument("--no-progress", help="Hides progress", 136 | action="store_true") 137 | parser.add_argument("--min", help="Set plot minimum (kbps)", type=int, default=0) 138 | parser.add_argument("--max", help="Set plot maximum (kbps)", type=int) 139 | parser.add_argument("--solid", help="Do not use transparency below the curve", action="store_true") 140 | parser.add_argument("-t", "--show-frame-types", 141 | help="Show bitrate of different frame types", 142 | action="store_true") 143 | parser.add_argument( 144 | "-d", 145 | "--downscale", 146 | help="Enable downscaling of values, so that the visible " 147 | "level of detail in the graph is reduced and rendered faster. " 148 | "This is useful if the video is very long and an overview " 149 | "of the bitrate fluctuation is sufficient.", 150 | action="store_true") 151 | parser.add_argument( 152 | "--max-display-values", 153 | help="If downscaling is enabled, set the maximum number of values " 154 | "shown on the x axis. Will downscale if video length is longer " 155 | "than the given value. Will not downscale if set to -1. " 156 | "Not compatible with option --show-frame-types (default: 700)", 157 | type=int, 158 | default=700) 159 | arguments = parser.parse_args() 160 | 161 | # check if format given without output file 162 | if arguments.format and not arguments.output: 163 | exit_with_error("Output format requires output file") 164 | 165 | # check given y-axis limits 166 | if arguments.min and arguments.max and (arguments.min >= arguments.max): 167 | exit_with_error("Maximum should be greater than minimum") 168 | 169 | # check if downscale is missing when max-display-values is given 170 | if arguments.max_display_values != \ 171 | parser.get_default("max_display_values") \ 172 | and not arguments.downscale: 173 | print_warning("Using --max-display-values without " 174 | "--downscale has no effect") 175 | 176 | # check if downscale and show-frame-types are both given 177 | if arguments.downscale and arguments.show_frame_types: 178 | exit_with_error("Options --downscale and --show-frame-types cannot " 179 | "both be given at the same time") 180 | 181 | arguments_dict = vars(arguments) 182 | 183 | # set ffprobe stream specifier 184 | if arguments.stream == "audio": 185 | arguments_dict["stream_spec"] = "a" 186 | elif arguments.stream == "video": 187 | arguments_dict["stream_spec"] = "V" 188 | else: 189 | raise RuntimeError("Invalid stream type") 190 | 191 | return arguments 192 | 193 | 194 | def open_ffprobe_get_format(file_path: str) -> subprocess.Popen: 195 | """ 196 | Opens an ffprobe process that reads the format data 197 | for file_path and returns the process. 198 | """ 199 | return subprocess.Popen( 200 | [ffprobe, 201 | "-hide_banner", 202 | "-loglevel", "error", 203 | "-show_entries", "format", 204 | "-print_format", "xml", 205 | file_path 206 | ], 207 | stdout=subprocess.PIPE) 208 | 209 | 210 | def open_ffprobe_get_frames( 211 | file_path: str, 212 | stream_selector: str 213 | ) -> subprocess.Popen: 214 | """ 215 | Opens an ffprobe process that reads all frame data for 216 | file_path and returns the process. 217 | """ 218 | return subprocess.Popen( 219 | [ffprobe, 220 | "-hide_banner", 221 | "-loglevel", "error", 222 | "-select_streams", stream_selector, 223 | "-threads", str(multiprocessing.cpu_count()), 224 | "-print_format", "xml", 225 | "-show_entries", 226 | "frame=pict_type,pkt_pts_time,best_effort_timestamp_time,pkt_size", 227 | file_path 228 | ], 229 | stdout=subprocess.PIPE) 230 | 231 | 232 | def save_raw_xml( 233 | file_path: str, 234 | target_path: str, 235 | stream_selector: str, 236 | no_progress: bool 237 | ) -> None: 238 | """ 239 | Reads all raw frame data from file_path 240 | and saves it to target_path. 241 | """ 242 | if not no_progress: 243 | last_percent = 0.0 244 | with open_ffprobe_get_format(file_path) as proc_format: 245 | assert proc_format.stdout is not None 246 | duration = parse_media_duration(proc_format.stdout) 247 | 248 | with open(target_path, "wb") as f: 249 | # open and clear file 250 | f.seek(0) 251 | f.truncate() 252 | 253 | with subprocess.Popen( 254 | [ffprobe, 255 | "-hide_banner", 256 | "-loglevel", "error", 257 | "-select_streams", stream_selector, 258 | "-threads", str(multiprocessing.cpu_count()), 259 | "-print_format", "xml", 260 | "-show_entries", 261 | "format:frame=pict_type,pkt_pts_time," 262 | "best_effort_timestamp_time,pkt_size", 263 | file_path 264 | ], 265 | stdout=subprocess.PIPE) as p: 266 | assert p.stdout is not None 267 | # start process and iterate over output lines 268 | for line in p.stdout: 269 | f.write(line) 270 | 271 | # for progress 272 | # look for lines starting with frame tag 273 | # try parsing the time from them and print percent 274 | if not no_progress \ 275 | and duration > 0 \ 276 | and line.lstrip().startswith(b""): 280 | line = line.replace(b">", b"/>") 281 | try: 282 | frame_time = \ 283 | try_get_frame_time_from_node(eTree.fromstring(line)) 284 | except eTree.ParseError: 285 | frame_time = None 286 | 287 | if frame_time is not None: 288 | percent = round((frame_time / duration) * 100.0, 1) 289 | else: 290 | percent = 0.0 291 | 292 | if percent > last_percent: 293 | print_progress(percent) 294 | last_percent = percent 295 | 296 | if not no_progress: 297 | print(flush=True) 298 | 299 | 300 | def save_raw_csv(raw_frames: Iterable[Frame], target_path: str) -> None: 301 | """ Saves raw_frames as a csv file. """ 302 | fields = Frame.get_fields() 303 | 304 | with open(target_path, "w", newline="") as file: 305 | wr = csv.writer(file, quoting=csv.QUOTE_NONE, lineterminator=os.linesep) 306 | wr.writerow(fields) 307 | for frame in raw_frames: 308 | wr.writerow(getattr(frame, field) for field in fields) 309 | 310 | 311 | def media_duration(source: str) -> float: 312 | if source.endswith(".xml"): 313 | return parse_media_duration(source) 314 | 315 | with open_ffprobe_get_format(source) as process: 316 | assert process.stdout is not None 317 | return parse_media_duration(process.stdout) 318 | 319 | 320 | def parse_media_duration(source: Union[str, IO]) -> float: 321 | """ Parses the source and returns the extracted total duration. """ 322 | format_data = eTree.parse(source) 323 | format_elem = format_data.find(".//format") 324 | duration_str = \ 325 | format_elem.get("duration") if format_elem is not None else None 326 | return float(duration_str) if duration_str is not None else 0 327 | 328 | 329 | def try_get_frame_time_from_node(node: eTree.Element) -> Optional[float]: 330 | for attribute_name in ["best_effort_timestamp_time", "pkt_pts_time"]: 331 | value = node.get(attribute_name) 332 | if value is not None: 333 | try: 334 | return float(value) 335 | except ValueError: 336 | continue 337 | return None 338 | 339 | 340 | def create_progress(duration: int): 341 | # set to negative, so 0% gets reported 342 | last_percent = -1.0 343 | offset = -1 344 | 345 | def report_progress(frame: Optional[Frame]): 346 | nonlocal last_percent 347 | nonlocal offset 348 | if frame: 349 | if offset == -1: 350 | offset = frame.time 351 | percent = round(((frame.time - offset) / duration) * 100.0, 1) 352 | if percent > last_percent: 353 | print_progress(percent) 354 | last_percent = percent 355 | else: 356 | last_percent = 100.0 357 | print_progress(last_percent) 358 | print() 359 | 360 | return report_progress 361 | 362 | 363 | def print_progress(percent: float) -> None: 364 | print("Progress: {:5.1f}%".format(percent), end="\r") 365 | 366 | 367 | def frame_elements(source_iterable: Iterable) -> Iterable[eTree.Element]: 368 | for _, node in source_iterable: 369 | if node.tag == "frame": 370 | yield node 371 | 372 | 373 | def read_frame_data_gen( 374 | source: str, 375 | stream_spec: str, 376 | frame_progress_func: Optional[Callable[[Optional[Frame]], None]] 377 | ) -> Generator[Frame, None, None]: 378 | source_iter = "" # type: Union[str, IO] 379 | if source.endswith(".xml"): 380 | source_iter = source 381 | else: 382 | proc = open_ffprobe_get_frames(source, stream_spec) 383 | assert proc.stdout is not None 384 | source_iter = proc.stdout 385 | 386 | for f in read_frame_data_gen_internal(source_iter): 387 | if frame_progress_func: 388 | frame_progress_func(f) 389 | yield f 390 | 391 | if frame_progress_func: 392 | frame_progress_func(None) 393 | 394 | 395 | def read_frame_data_gen_internal( 396 | source: Union[str, IO] 397 | ) -> Generator[Frame, None, None]: 398 | """ 399 | Creates an iterator from source_iterable and yields Frame objects. 400 | """ 401 | for node in frame_elements(eTree.iterparse(source)): 402 | # get data 403 | time = try_get_frame_time_from_node(node) 404 | size = node.get("pkt_size") 405 | pict_type = node.get("pict_type") 406 | # clear node to free parsed data 407 | node.clear() 408 | 409 | # construct and append frame 410 | yield Frame( 411 | time=time if time else 0, 412 | size=int(size) if size else 0, 413 | pict_type=pict_type if pict_type else "?" 414 | ) 415 | 416 | 417 | def frames_to_kbits( 418 | frames: Iterable[Frame], 419 | seconds_start: int, 420 | seconds_end: int, 421 | seconds_offset: int 422 | ) -> Generator[Tuple[int, int], None, None]: 423 | """ 424 | Creates a generator yielding every second between seconds_start 425 | and seconds_end (including both) and its summed size in kbit. 426 | 427 | The frames iterable must be sorted by frame time. 428 | """ 429 | frames_iter = iter(frames) 430 | last_frame_second = 0 431 | last_frame_size = 0 432 | 433 | # loop over every second 434 | for second in range(seconds_start + seconds_offset, seconds_end + seconds_offset + 1): 435 | 436 | # restore size of a saved frame from last iteration 437 | # if it's for the current second 438 | if last_frame_second == second: 439 | size = last_frame_size 440 | else: 441 | size = 0 442 | 443 | # advance iterator only if the saved frame data 444 | # is not for a future second 445 | if last_frame_second <= second: 446 | # advances the iterator until it's at a frame 447 | # belonging to a future second 448 | for frame in frames_iter: 449 | frame_second = math.floor(frame.time) 450 | if frame_second < second: 451 | continue 452 | if frame_second == second: 453 | # frame is current second, so sum up 454 | size += frame.size 455 | else: 456 | # current frame is not in current second 457 | # store its size and second and break iteration 458 | last_frame_second = frame_second 459 | last_frame_size = frame.size 460 | break 461 | 462 | yield (second - seconds_offset), int(size * 8 / 1000) 463 | 464 | 465 | def downscale_bitrate( 466 | bitrates: Dict[int, int], 467 | factor: int 468 | ) -> Generator[Tuple[int, int], None, None]: 469 | """ 470 | Groups bitrates together and takes the highest bitrate as the value. 471 | 472 | Args: 473 | bitrates: dict containing seconds with bitrates 474 | factor: which seconds to keep (1 is every, 2 is every other and so on) 475 | 476 | Example: 477 | 478 | given the parameters: 479 | 480 | bitrates = { 481 | 0: 3400, 482 | 1: 5290 483 | 2: 4999 484 | 3: 7500 485 | 4: 0 486 | 5: 7800 487 | 6: 3000 488 | } 489 | factor = 3 490 | 491 | this function will return an iterator giving: 492 | (0, 5290) 493 | (3, 7800) 494 | (6, 3000) 495 | """ 496 | # iterate over all seconds to yield 497 | for second in range(min(bitrates.keys()), max(bitrates.keys()) + 1, factor): 498 | # iterate over all seconds in between 499 | # and find the highest bitrate 500 | max_b = max(bitrates.get(s, 0) for s in range(second, second + factor)) 501 | yield second, max_b 502 | 503 | 504 | def prepare_matplot( 505 | window_title: str, 506 | duration: int, 507 | ) -> None: 508 | """ Prepares the chart and sets up a new figure """ 509 | 510 | matplot.figure(figsize=[10, 4]) 511 | matplot.get_current_fig_manager().set_window_title(window_title) 512 | matplot.title("Stream Bitrate over Time") 513 | matplot.xlabel("Time") 514 | matplot.ylabel("Bitrate (kbit/s)") 515 | matplot.grid(True, axis="y") 516 | 517 | # set 10 x axes ticks 518 | matplot.xticks(range(0, duration + 1, max(duration // 10, 1))) 519 | 520 | # format axes values 521 | matplot.gcf().axes[0].xaxis.set_major_formatter( 522 | matplotlib.ticker.FuncFormatter( 523 | lambda x, loc: datetime.timedelta(seconds=int(x)))) 524 | matplot.gca().get_yaxis().set_major_formatter( 525 | matplotlib.ticker.FuncFormatter( 526 | lambda x, loc: "{:,}".format(int(x)))) 527 | 528 | 529 | def add_stacked_areas( 530 | frames: Iterable[Frame], 531 | duration: int 532 | ) -> Tuple[int, int, Dict[str, matplotlib.collections.PolyCollection]]: 533 | """ Calculates the bitrate for each frame type 534 | and adds a stacking bar for each 535 | """ 536 | bars = {} 537 | sums_of_values = [] # type: List[int] 538 | frames_list = frames if isinstance(frames, list) else list(frames) 539 | 540 | # transport stream files may not start from time=0, so work out what the actual start time is 541 | # then work using that as an offset. 542 | offset = math.floor(frames_list[0].time) 543 | 544 | # calculate bitrate for each frame type 545 | # and add a stacking bar for each 546 | for frame_type in ["I", "B", "P", "?"]: 547 | filtered_frames = [f for f in frames_list if f.pict_type == frame_type] 548 | if len(filtered_frames) == 0: 549 | continue 550 | 551 | bitrates = OrderedDict(frames_to_kbits(filtered_frames, 0, duration, offset)) 552 | seconds = list(bitrates.keys()) 553 | values = list(bitrates.values()) 554 | 555 | if len(sums_of_values) == 0: 556 | values_min = [0] 557 | values_max = values 558 | else: 559 | values_min = sums_of_values 560 | values_max = [ 561 | sum(pair) for pair in zip(sums_of_values, values) 562 | ] 563 | sums_of_values = values_max 564 | color = Color[frame_type].value if frame_type in dir(Color) \ 565 | else Color.FRAME.value 566 | bars[frame_type] = matplot.fill_between( 567 | seconds, values_min, values_max, linewidth=0.5, color=color, 568 | zorder=2 569 | ) 570 | 571 | return max(sums_of_values), int(statistics.mean(sums_of_values)), bars 572 | 573 | 574 | def add_area( 575 | frames: Iterable[Frame], 576 | duration: int, 577 | downscale: bool, 578 | max_display_values: int, 579 | stream_type: str, 580 | solid: bool 581 | ) -> Tuple[int, int]: 582 | # transport stream files may not start from time=0, so work out what the actual start time is 583 | # then work using that as an offset. 584 | frames_list = frames if isinstance(frames, list) else list(frames) 585 | offset = math.floor(frames_list[0].time) 586 | 587 | bitrates = OrderedDict(frames_to_kbits(frames_list, 0, duration, offset)) 588 | bitrate_max = max(bitrates.values()) 589 | bitrate_mean = int(statistics.mean(bitrates.values())) 590 | 591 | if downscale and 0 < max_display_values < duration: 592 | factor = duration // max_display_values 593 | bitrates = OrderedDict(downscale_bitrate(bitrates, factor)) 594 | 595 | seconds = list(bitrates.keys()) 596 | values = list(bitrates.values()) 597 | color = Color.AUDIO.value if stream_type == "audio" else Color.FRAME.value 598 | matplot.plot(seconds, values, linewidth=0.35, color=color, alpha=1.0 if solid else 0.8) 599 | matplot.fill_between(seconds, 0, values, linewidth=0.5, color=color, 600 | zorder=2, alpha=1.0 if solid else 0.5) 601 | return bitrate_max, bitrate_mean 602 | 603 | 604 | def draw_horizontal_line_with_text( 605 | pos_y: int, 606 | pos_h_percent: float, 607 | text: str 608 | ) -> None: 609 | # calculate line position (above line) 610 | text_x = matplot.xlim()[1] * pos_h_percent 611 | text_y = pos_y + ((matplot.ylim()[1] - matplot.ylim()[0]) * 0.015) 612 | 613 | # draw as think black line with text 614 | matplot.axhline(pos_y, linewidth=1.5, color="black") 615 | matplot.text( 616 | text_x, text_y, text, 617 | horizontalalignment="center", fontweight="bold", color="black" 618 | ) 619 | 620 | 621 | def main(): 622 | args = parse_arguments() 623 | 624 | # check if an output is requested, otherwise try to initialize backend, and exit if it fails 625 | if not args.output: 626 | # init backend 627 | try: 628 | if is_wsl(): 629 | backend = "TkAgg" 630 | else: 631 | # check for PyQt5 or PyQt6 632 | if util.find_spec("PyQt6") is None and util.find_spec("PyQt5") is None: 633 | exit_with_error("Missing package 'PyQt5' or 'PyQt6'") 634 | backend = "QtAgg" 635 | matplotlib.use(backend) 636 | except ImportError as err: 637 | exit_with_error(err.msg) 638 | 639 | # if the output is raw xml, just call the function and exit 640 | if args.format == "xml_raw" \ 641 | or (args.output and args.output.endswith(".xml") and args.format is None): 642 | save_raw_xml( 643 | args.input, args.output, args.stream_spec, args.no_progress 644 | ) 645 | sys.exit(0) 646 | 647 | duration = math.floor(media_duration(args.input)) 648 | if duration == 0: 649 | exit_with_error("Failed to determine stream duration") 650 | 651 | progress_func = create_progress(duration) if not args.no_progress else None 652 | frames = read_frame_data_gen( 653 | args.input, args.stream_spec, progress_func 654 | ) 655 | 656 | # if the output is csv raw, write the file and we're done 657 | if args.format == "csv_raw" \ 658 | or (args.output and args.output.endswith(".csv") and args.format is None): 659 | save_raw_csv(frames, args.output) 660 | sys.exit(0) 661 | 662 | prepare_matplot(args.input, duration) 663 | 664 | legend = None 665 | if args.show_frame_types and args.stream == "video": 666 | peak, mean, legend = add_stacked_areas(frames, duration) 667 | else: 668 | peak, mean = add_area( 669 | frames, duration, args.downscale, args.max_display_values, 670 | args.stream, args.solid 671 | ) 672 | 673 | draw_horizontal_line_with_text( 674 | pos_y=peak, 675 | pos_h_percent=0.08, 676 | text="peak ({:,})".format(peak) 677 | ) 678 | draw_horizontal_line_with_text( 679 | pos_y=mean, 680 | pos_h_percent=0.92, 681 | text="mean ({:,})".format(mean) 682 | ) 683 | 684 | # set y-axis limits if requested 685 | matplot.ylim(ymin=args.min, ymax=args.max) 686 | 687 | if legend: 688 | matplot.legend(legend.values(), legend.keys(), loc="upper right") 689 | 690 | # render graph to file (if requested) or screen 691 | if args.output: 692 | matplot.savefig(args.output, format=args.format, dpi=300) 693 | else: 694 | matplot.tight_layout() 695 | matplot.show() 696 | 697 | 698 | if __name__ == "__main__": 699 | main() 700 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mypy 2 | pylint 3 | setuptools 4 | wheel 5 | twine 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib>=3.2.1 2 | PyQt6>=6.5.0 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from plotbitrate import __version__ 3 | 4 | with open("README.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | setup( 8 | name='plotbitrate', 9 | version=__version__, 10 | packages=find_packages(), 11 | description='A simple bitrate plotter for media files', 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | author='Eric Work', 15 | author_email='work.eric@gmail.com', 16 | license='BSD', 17 | url='https://github.com/zeroepoch/plotbitrate', 18 | py_modules=['plotbitrate'], 19 | classifiers=[ 20 | 'Topic :: Multimedia :: Sound/Audio', 21 | 'Natural Language :: English', 22 | 'Programming Language :: Python :: 3', 23 | ], 24 | keywords='ffprobe bitrate plot', 25 | python_requires='>=3.6', 26 | entry_points={ 27 | 'console_scripts': [ 28 | 'plotbitrate = plotbitrate:main' 29 | ] 30 | }, 31 | install_requires=[ 32 | 'matplotlib', 33 | 'pyqt6' 34 | ] 35 | ) 36 | --------------------------------------------------------------------------------