Created
February 19, 2026 09:19
-
-
Save darfink/0840c75173a1c98c5260838db7d65818 to your computer and use it in GitHub Desktop.
A script to visualize frames types in a video file using ffprobe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import argparse | |
| import json | |
| import shutil | |
| import subprocess | |
| import sys | |
| from dataclasses import dataclass | |
| RESET = "\x1b[0m" | |
| BOLD = "\x1b[1m" | |
| COLORS = { | |
| "I": "\x1b[1;32m", # bold green | |
| "i": "\x1b[35m", # magenta | |
| "P": "\x1b[36m", # cyan | |
| "B": "\x1b[33m", # yellow | |
| "?": "\x1b[90m", # gray | |
| } | |
| INDEX_COLOR = "\x1b[90m" # dim gray | |
| TS_COLOR = "\x1b[34m" # blue | |
| def supports_color(mode: str) -> bool: | |
| if mode == "always": | |
| return True | |
| if mode == "never": | |
| return False | |
| return sys.stdout.isatty() | |
| def colorize(ch: str, use_color: bool) -> str: | |
| if not use_color or ch == " ": | |
| return ch | |
| return f"{COLORS.get(ch, COLORS['?'])}{ch}{RESET}" | |
| def style(text: str, use_color: bool, color: str = "", bold: bool = False) -> str: | |
| if not use_color and not bold: | |
| return text | |
| parts = [] | |
| if bold: | |
| parts.append(BOLD) | |
| if use_color and color: | |
| parts.append(color) | |
| if not parts: | |
| return text | |
| return "".join(parts) + text + RESET | |
| @dataclass | |
| class Frame: | |
| idx: int | |
| typ: str | |
| key: int | |
| pts: str | |
| def ffprobe_frames(path: str, stream: str, offset: float, duration: float): | |
| interval = f"{max(0.0, offset)}%+{duration}" if duration > 0 else f"{max(0.0, offset)}%" | |
| cmd = [ | |
| "ffprobe", | |
| "-v", | |
| "error", | |
| "-read_intervals", | |
| interval, | |
| "-select_streams", | |
| stream, | |
| "-show_entries", | |
| "frame=pict_type,key_frame,best_effort_timestamp_time", | |
| "-of", | |
| "json", | |
| path, | |
| ] | |
| proc = subprocess.run(cmd, capture_output=True, text=True) | |
| if proc.returncode != 0: | |
| raise RuntimeError((proc.stderr or "").strip() or "ffprobe failed") | |
| try: | |
| payload = json.loads(proc.stdout or "{}") | |
| except Exception as e: | |
| raise RuntimeError(f"invalid ffprobe json: {e}") | |
| for idx, frame in enumerate(payload.get("frames", [])): | |
| typ = str(frame.get("pict_type", "?") or "?") | |
| try: | |
| key = int(frame.get("key_frame", 0)) | |
| except ValueError: | |
| key = 0 | |
| pts = str(frame.get("best_effort_timestamp_time", "")) | |
| yield Frame(idx=idx, typ=typ, key=key, pts=pts) | |
| def normalize_type(f: Frame, mark_nonkey_i: bool) -> str: | |
| if mark_nonkey_i and f.typ == "I" and f.key == 0: | |
| return "i" | |
| return f.typ | |
| def detect_ts_precision(frames) -> int: | |
| max_dec = 0 | |
| for f in frames: | |
| pts = (f.pts or "").strip() | |
| if not pts or "." not in pts: | |
| continue | |
| frac = pts.split(".", 1)[1].rstrip() | |
| max_dec = max(max_dec, len(frac)) | |
| return max_dec | |
| def format_ts(pts: str, decimals: int) -> str: | |
| if not pts: | |
| return "" | |
| try: | |
| val = float(pts) | |
| except ValueError: | |
| return pts | |
| if decimals <= 0: | |
| return str(int(round(val))) | |
| return f"{val:.{decimals}f}" | |
| def print_grid(rows, width: int, use_color: bool, ts_decimals: int): | |
| if width <= 0: | |
| width = 64 | |
| group = 8 | |
| sep_every = group | |
| ts_w = max(2, ts_decimals + 2) | |
| frame_w = width + (width - 1) // sep_every | |
| def fmt_frames(text: str) -> str: | |
| chunk = text[:width] | |
| out = [] | |
| for i in range(width): | |
| ch = chunk[i] if i < len(chunk) else " " | |
| out.append(colorize(ch, use_color)) | |
| if (i + 1) % sep_every == 0 and i + 1 < width: | |
| out.append(" ") | |
| return "".join(out) | |
| top = f"┌{'─'*8}┬{'─'*ts_w}┬─{'─'*frame_w}─┐" | |
| idx_h = style(f"{'index':>8}", use_color, INDEX_COLOR, bold=True) | |
| ts_h = style(f"{'ts':>{ts_w}}", use_color, TS_COLOR, bold=True) | |
| fr_h = style(f"{'frames'.ljust(frame_w)}", use_color, "", bold=True) | |
| head = f"│{idx_h}│{ts_h}│ {fr_h} │" | |
| sep = f"├{'─'*8}┼{'─'*ts_w}┼─{'─'*frame_w}─┤" | |
| row_sep = f"├{'╌'*8}┼{'╌'*ts_w}┼─{'╌'*frame_w}─┤" | |
| bot = f"└{'─'*8}┴{'─'*ts_w}┴─{'─'*frame_w}─┘" | |
| print(top) | |
| print(head) | |
| print(sep) | |
| for row_i, (idx, ts, framestr) in enumerate(rows): | |
| for offset in range(0, len(framestr), width): | |
| part = framestr[offset:offset + width] | |
| idx_raw = f"{idx:08d}" if offset == 0 else " " * 8 | |
| ts_fmt = format_ts(ts, ts_decimals) | |
| ts_raw = (ts_fmt[:ts_w].rjust(ts_w)) if offset == 0 else " " * ts_w | |
| idx_cell = style(idx_raw, use_color, INDEX_COLOR) | |
| ts_cell = style(ts_raw, use_color, TS_COLOR) | |
| print(f"│{idx_cell}│{ts_cell}│ {fmt_frames(part)} │") | |
| if row_i + 1 < len(rows): | |
| print(row_sep) | |
| print(bot) | |
| def build_idr_rows(frames, seq): | |
| key_i_positions = [i for i, f in enumerate(frames) if f.typ == "I" and f.key == 1] | |
| if not seq: | |
| return [] | |
| rows = [] | |
| if not key_i_positions: | |
| rows.append((frames[0].idx if frames else 0, frames[0].pts if frames else "", "".join(seq))) | |
| return rows | |
| for i, start in enumerate(key_i_positions): | |
| end = key_i_positions[i + 1] if i + 1 < len(key_i_positions) else len(seq) | |
| rows.append((frames[start].idx, frames[start].pts if start < len(frames) else "", "".join(seq[start:end]))) | |
| return rows | |
| def parse_args(): | |
| p = argparse.ArgumentParser(description="Inspect video GOP rows (one row per key I frame)") | |
| p.add_argument("input", help="Input video file") | |
| p.add_argument("-s", "--stream", default="v:0", help="ffprobe stream selector (default: v:0)") | |
| p.add_argument("-w", "--width", type=int, default=64, help="Frames column width (default: 64)") | |
| p.add_argument("-t", "--time", type=float, default=60.0, help="Time span in seconds to inspect (default: 60)") | |
| p.add_argument("-o", "--offset", type=float, default=0.0, help="Start offset in seconds (default: 0)") | |
| p.add_argument("--no-nonkey-i", action="store_true", help="Do not lowercase non-key I-frames") | |
| p.add_argument("--color", choices=["auto", "always", "never"], default="auto", help="Color output") | |
| return p.parse_args() | |
| def main() -> int: | |
| args = parse_args() | |
| if shutil.which("ffprobe") is None: | |
| print("vidframes: ffprobe not found", file=sys.stderr) | |
| return 127 | |
| try: | |
| frames = list(ffprobe_frames(args.input, args.stream, args.offset, args.time)) | |
| except RuntimeError as e: | |
| print(f"vidframes: {e}", file=sys.stderr) | |
| return 1 | |
| mark_nonkey_i = not args.no_nonkey_i | |
| seq = [normalize_type(f, mark_nonkey_i) for f in frames] | |
| rows = build_idr_rows(frames, seq) | |
| ts_decimals = detect_ts_precision(frames) | |
| print_grid(rows, args.width, supports_color(args.color), ts_decimals) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example output (has colors in CLI):