Files
media-batch-tools/tools/video_probe.py
T

599 lines
20 KiB
Python

from __future__ import annotations
import json
import re
import statistics
import subprocess
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from tools.executables import require_executable
from tools.media_metadata import first_tag, parse_exif_datetime, round_half_up
VIDEO_TIME_TAGS = (
"QuickTime:MediaCreateDate",
"QuickTime:TrackCreateDate",
"QuickTime:CreateDate",
"QuickTime:MediaModifyDate",
"QuickTime:TrackModifyDate",
"QuickTime:ModifyDate",
"XMP:DateTimeOriginal",
"XMP:CreateDate",
)
VIDEO_BEGIN_TIME_TAGS = (
"H264:DateTimeOriginal",
)
VIDEO_TIMEZONE_TAGS = (
"QuickTime:AndroidTimeZone",
"QuickTime:TimeZone",
"EXIF:OffsetTimeOriginal",
"XMP:OffsetTimeOriginal",
)
GPS_LATITUDE_TAGS = (
"Composite:GPSLatitude",
"EXIF:GPSLatitude",
"GPS:GPSLatitude",
"QuickTime:GPSLatitude",
)
GPS_LONGITUDE_TAGS = (
"Composite:GPSLongitude",
"EXIF:GPSLongitude",
"GPS:GPSLongitude",
"QuickTime:GPSLongitude",
)
GPS_POSITION_TAGS = (
"Composite:GPSPosition",
"QuickTime:GPSCoordinates",
"Composite:GPSCoordinates",
)
DMS_RE = re.compile(
r"(?P<deg>-?\d+(?:\.\d+)?)\D+"
r"(?P<min>\d+(?:\.\d+)?)?\D*"
r"(?P<sec>\d+(?:\.\d+)?)?\D*"
r"(?P<hem>[NSEW])?",
re.IGNORECASE,
)
@dataclass(frozen=True)
class FrameTimingSummary:
frame_count: int
first_pts: float | None
last_pts: float | None
min_delta_ms: float | None
median_delta_ms: float | None
max_delta_ms: float | None
unique_delta_count: int
common_deltas_ms: list[tuple[float, int]]
timing_kind: str
@dataclass(frozen=True)
class AudioStreamProbe:
codec: str | None
channels: int | None
sample_rate: int | None
bit_rate: int | None
@dataclass(frozen=True)
class VideoProbe:
path: Path
codec: str | None
width: int | None
height: int | None
pixel_format: str | None
bit_depth: int | None
video_bit_rate: int | None
color_space: str | None
color_matrix: str | None
expected_color_matrix: str | None
duration_seconds: float | None
frame_timestamps: list[float]
stream_frame_count: int | None
audio_streams: list[AudioStreamProbe]
audio_stream_count: int
audio_sample_rate: int | None
avg_frame_rate: str | None
r_frame_rate: str | None
time_base: str | None
timing: FrameTimingSummary
metadata_end: datetime | None
metadata_begin_tag: datetime | None
source_timezone: timezone | None
computed_begin: datetime | None
filename_begin: datetime | None
gps_latitude: float | None
gps_longitude: float | None
warnings: list[str]
color_range: str | None = None
color_transfer: str | None = None
color_primaries: str | None = None
mastering_display: str | None = None
max_content_light: int | None = None
max_frame_average_light: int | None = None
hdr_peak_luminance: float | None = None
hdr_dynamic: bool = False
hdr10plus: bool = False
def require_tool(name: str) -> str:
return require_executable(name)
def probe_video(ffprobe: str, path: Path, metadata: dict) -> VideoProbe:
stream, format_info = _probe_stream(ffprobe, path)
stream_duration = _to_float(stream.get("duration"))
format_duration = _to_float(format_info.get("duration"))
duration = stream_duration if stream_duration is not None else format_duration
audio_streams = _probe_audio_streams(ffprobe, path, duration)
audio_sample_rate = audio_streams[0].sample_rate if audio_streams else None
timestamps, video_packet_bytes = _probe_video_packets(ffprobe, path)
timing = summarize_frame_timing(timestamps)
video_bit_rate = _to_int(stream.get("bit_rate")) or _average_bit_rate(
video_packet_bytes,
duration,
)
end_value = first_tag(metadata, VIDEO_TIME_TAGS)
metadata_end = parse_exif_datetime(end_value, assume_utc=True)
metadata_begin_tag = parse_exif_datetime(
first_tag(metadata, VIDEO_BEGIN_TIME_TAGS), assume_utc=True
)
timezone_value = first_tag(metadata, VIDEO_TIMEZONE_TAGS)
metadata_timezone = _video_timezone(metadata)
source_timezone = metadata_timezone
explicit_end = parse_exif_datetime(end_value)
if source_timezone is None and explicit_end is not None:
source_timezone = explicit_end.tzinfo
computed_begin = None
filename_begin = None
if metadata_end is not None and duration is not None:
computed_begin = metadata_end - timedelta(seconds=duration)
filename_begin = metadata_end - timedelta(seconds=round_half_up(duration))
gps_latitude, gps_longitude = _gps_coordinates(metadata)
warnings: list[str] = []
if metadata_end is None:
warnings.append("No metadata end timestamp found.")
if timezone_value is not None and metadata_timezone is None:
warnings.append(f"Invalid source timezone metadata: {timezone_value}")
if duration is None:
warnings.append("No ffprobe duration found.")
if metadata_begin_tag is not None:
warnings.append(
"Found a possible beginning timestamp tag; v1 still treats the "
"normal video metadata timestamp as the end timestamp."
)
if timing.timing_kind == "unknown":
warnings.append("Not enough frame timestamps to classify CFR/VFR timing.")
color_space = _as_str(stream.get("color_space"))
color_range = _as_str(stream.get("color_range"))
color_transfer = _as_str(stream.get("color_transfer"))
color_primaries = _as_str(stream.get("color_primaries"))
side_data = list(stream.get("side_data_list") or [])
if is_hdr_transfer(color_transfer):
# HEVC commonly stores HDR10 static and HDR10+ dynamic metadata on
# decoded frames, not the stream header.
side_data.extend(_probe_first_frame_side_data(ffprobe, path))
mastering_display, max_content_light, max_frame_average_light, hdr_peak_luminance, hdr_dynamic, hdr10plus = (
_hdr_side_data(side_data)
)
return VideoProbe(
path=path,
codec=_as_str(stream.get("codec_name")),
width=_to_int(stream.get("width")),
height=_to_int(stream.get("height")),
pixel_format=_as_str(stream.get("pix_fmt")),
bit_depth=_video_bit_depth(stream),
video_bit_rate=video_bit_rate,
color_space=color_space,
color_matrix=_avisynth_matrix_from_color_space(color_space),
expected_color_matrix=_expected_color_matrix(
_to_int(stream.get("width")),
_to_int(stream.get("height")),
),
duration_seconds=duration,
frame_timestamps=timestamps,
stream_frame_count=_to_int(stream.get("nb_frames")),
audio_streams=audio_streams,
audio_stream_count=len(audio_streams),
audio_sample_rate=audio_sample_rate,
avg_frame_rate=_as_str(stream.get("avg_frame_rate")),
r_frame_rate=_as_str(stream.get("r_frame_rate")),
time_base=_as_str(stream.get("time_base")),
timing=timing,
metadata_end=metadata_end,
metadata_begin_tag=metadata_begin_tag,
source_timezone=source_timezone,
computed_begin=computed_begin,
filename_begin=filename_begin,
gps_latitude=gps_latitude,
gps_longitude=gps_longitude,
warnings=warnings,
color_range=color_range,
color_transfer=color_transfer,
color_primaries=color_primaries,
mastering_display=mastering_display,
max_content_light=max_content_light,
max_frame_average_light=max_frame_average_light,
hdr_peak_luminance=hdr_peak_luminance,
hdr_dynamic=hdr_dynamic,
hdr10plus=hdr10plus,
)
def _probe_stream(ffprobe: str, path: Path) -> tuple[dict, dict]:
data = _ffprobe_json(
ffprobe,
path,
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name,width,height,pix_fmt,bits_per_raw_sample,bits_per_sample,bit_rate,color_space,color_range,color_transfer,color_primaries,avg_frame_rate,r_frame_rate,time_base,duration,nb_frames:stream_side_data=side_data_type,red_x,red_y,green_x,green_y,blue_x,blue_y,white_point_x,white_point_y,min_luminance,max_luminance,max_content,max_average",
"-show_entries",
"format=duration",
)
streams = data.get("streams") or []
if not streams:
raise RuntimeError(f"No video stream found in {path}")
return streams[0], data.get("format") or {}
def _probe_audio_streams(
ffprobe: str,
path: Path,
fallback_duration: float | None,
) -> list[AudioStreamProbe]:
data = _ffprobe_json(
ffprobe,
path,
"-select_streams",
"a",
"-show_entries",
"stream=index,codec_name,channels,sample_rate,bit_rate,duration",
"-show_entries",
"packet=stream_index,size",
)
streams = data.get("streams") or []
packet_bytes: Counter[int] = Counter()
for packet in data.get("packets") or []:
stream_index = _to_int(packet.get("stream_index"))
size = _to_int(packet.get("size"))
if stream_index is not None and size is not None:
packet_bytes[stream_index] += size
return [
AudioStreamProbe(
codec=_as_str(stream.get("codec_name")),
channels=_to_int(stream.get("channels")),
sample_rate=_to_int(stream.get("sample_rate")),
bit_rate=(
_to_int(stream.get("bit_rate"))
or _average_bit_rate(
packet_bytes[_to_int(stream.get("index")) or 0],
_to_float(stream.get("duration")) or fallback_duration,
)
),
)
for stream in streams
]
def _probe_first_frame_side_data(ffprobe: str, path: Path) -> list[dict]:
data = _ffprobe_json(
ffprobe,
path,
"-select_streams",
"v:0",
"-read_intervals",
"%+#1",
"-show_entries",
"frame_side_data=side_data_type,red_x,red_y,green_x,green_y,blue_x,blue_y,white_point_x,white_point_y,min_luminance,max_luminance,max_content,max_average",
)
frames = data.get("frames") or []
return list((frames[0] if frames else {}).get("side_data_list") or [])
def _probe_video_packets(ffprobe: str, path: Path) -> tuple[list[float], int]:
data = _ffprobe_json(
ffprobe,
path,
"-select_streams",
"v:0",
"-show_entries",
"packet=pts_time,size",
)
timestamps: list[float] = []
total_bytes = 0
for packet in data.get("packets") or []:
value = _to_float(packet.get("pts_time"))
if value is not None:
timestamps.append(value)
total_bytes += _to_int(packet.get("size")) or 0
return sorted(timestamps), total_bytes
def _ffprobe_json(ffprobe: str, path: Path, *args: str) -> dict:
completed = subprocess.run(
[ffprobe, "-v", "error", *args, "-of", "json", str(path)],
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout or "{}")
def _average_bit_rate(total_bytes: int, duration: float | None) -> int | None:
if total_bytes <= 0 or duration is None or duration <= 0:
return None
return round(total_bytes * 8 / duration)
def summarize_frame_timing(timestamps: list[float]) -> FrameTimingSummary:
if len(timestamps) < 2:
return FrameTimingSummary(
frame_count=len(timestamps),
first_pts=timestamps[0] if timestamps else None,
last_pts=timestamps[-1] if timestamps else None,
min_delta_ms=None,
median_delta_ms=None,
max_delta_ms=None,
unique_delta_count=0,
common_deltas_ms=[],
timing_kind="unknown",
)
deltas = [
(current - previous) * 1000
for previous, current in zip(timestamps, timestamps[1:])
if current >= previous
]
if not deltas:
timing_kind = "unknown"
median_delta = None
max_deviation = None
else:
median_delta = statistics.median(deltas)
max_deviation = max(abs(delta - median_delta) for delta in deltas)
timing_kind = "vfr" if max_deviation > 2.0 else "cfr"
rounded = [round(delta, 3) for delta in deltas]
counter = Counter(rounded)
return FrameTimingSummary(
frame_count=len(timestamps),
first_pts=timestamps[0],
last_pts=timestamps[-1],
min_delta_ms=round(min(deltas), 3) if deltas else None,
median_delta_ms=round(median_delta, 3) if median_delta is not None else None,
max_delta_ms=round(max(deltas), 3) if deltas else None,
unique_delta_count=len(counter),
common_deltas_ms=counter.most_common(5),
timing_kind=timing_kind,
)
def _gps_coordinates(metadata: dict) -> tuple[float | None, float | None]:
latitude = _parse_gps_coordinate(first_tag(metadata, GPS_LATITUDE_TAGS))
longitude = _parse_gps_coordinate(first_tag(metadata, GPS_LONGITUDE_TAGS))
if latitude is not None and longitude is not None:
return latitude, longitude
position = first_tag(metadata, GPS_POSITION_TAGS)
if position is None:
return latitude, longitude
pair = _parse_gps_pair(position)
if pair == (None, None):
return latitude, longitude
return pair
def _parse_gps_pair(value: object) -> tuple[float | None, float | None]:
if isinstance(value, (list, tuple)) and len(value) >= 2:
return _parse_gps_coordinate(value[0]), _parse_gps_coordinate(value[1])
text = str(value).strip()
if not text:
return None, None
if "," in text:
left, right = text.split(",", 1)
return _parse_gps_coordinate(left), _parse_gps_coordinate(right)
parts = text.split()
if len(parts) >= 2:
return _parse_gps_coordinate(parts[0]), _parse_gps_coordinate(parts[1])
return None, None
def _parse_gps_coordinate(value: object) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return None
try:
return float(text)
except ValueError:
pass
hemisphere = text[-1:].upper()
sign = -1.0 if hemisphere in {"S", "W"} else 1.0
match = DMS_RE.search(text)
if not match:
return None
degrees = float(match.group("deg"))
minutes = float(match.group("min") or 0)
seconds = float(match.group("sec") or 0)
if degrees < 0:
sign = -1.0
degrees = abs(degrees)
return sign * (degrees + minutes / 60.0 + seconds / 3600.0)
def _parse_timezone_offset(value: str) -> timezone:
text = value.replace(":", "")
sign = 1 if text[0] == "+" else -1
hours = int(text[1:3])
minutes = int(text[3:5])
return timezone(sign * timedelta(hours=hours, minutes=minutes))
def _video_timezone(metadata: dict) -> timezone | None:
value = first_tag(metadata, VIDEO_TIMEZONE_TAGS)
if value is None:
return None
text = str(value).strip()
if not re.fullmatch(r"[+-]\d{2}:?\d{2}", text):
return None
return _parse_timezone_offset(text)
def _to_float(value: object) -> float | None:
if value is None or value == "N/A":
return None
try:
text = str(value)
numerator, separator, denominator = text.partition("/")
return float(numerator) / float(denominator) if separator else float(text)
except (TypeError, ValueError):
return None
def _to_int(value: object) -> int | None:
if value is None or value == "N/A":
return None
try:
numeric = _to_float(value)
return int(numeric) if numeric is not None else None
except (TypeError, ValueError):
return None
def _video_bit_depth(stream: dict) -> int | None:
for key in ("bits_per_raw_sample", "bits_per_sample"):
value = _to_int(stream.get(key))
if value:
return value
pix_fmt = _as_str(stream.get("pix_fmt")) or ""
match = re.search(r"p(?P<bits>10|12|14|16)(?:le|be)?$", pix_fmt)
if match:
return int(match.group("bits"))
if pix_fmt:
return 8
return None
def _avisynth_matrix_from_color_space(color_space: str | None) -> str | None:
if not color_space:
return None
normalized = color_space.lower()
if normalized in {"bt709", "bt709nc"}:
return "Rec709"
if normalized in {"smpte170m", "bt470bg", "fcc"}:
return "Rec601"
if normalized in {"bt2020nc", "bt2020ncl", "bt2020c", "bt2020cl"}:
return "Rec2020"
return None
def avisynth_primaries_from_color_primaries(value: str | None) -> int:
normalized = (value or "").lower()
if normalized in {"bt709", "smpte170m", "bt470bg", "smpte240m"}:
return 1
if normalized in {"bt2020", "bt2020nc", "bt2020ncl"}:
return 9
if normalized in {"smpte432", "displayp3", "display-p3"}:
return 12
return 2
def avisynth_transfer_from_color_transfer(value: str | None) -> int:
normalized = (value or "").lower()
if normalized in {"bt709", "smpte170m", "bt470bg", "bt601"}:
return 1
if normalized in {"smpte2084", "pq"}:
return 16
if normalized in {"arib-std-b67", "hlg"}:
return 18
return 2
def avisynth_range_from_color_range(value: str | None) -> int:
return 1 if (value or "").lower() in {"pc", "jpeg", "full"} else 0
def is_hdr_transfer(value: str | None) -> bool:
return (value or "").lower() in {"smpte2084", "pq", "arib-std-b67", "hlg"}
def hdr_kind(value: str | None, *, dynamic: bool = False) -> str | None:
if not is_hdr_transfer(value):
return None
if dynamic:
return "HDR10+"
return "HDR10" if (value or "").lower() in {"smpte2084", "pq"} else "HLG"
def _hdr_side_data(
side_data: list[dict],
) -> tuple[str | None, int | None, int | None, float | None, bool, bool]:
mastering: str | None = None
max_content: int | None = None
max_average: int | None = None
peak_luminance: float | None = None
dynamic = False
hdr10plus = False
for entry in side_data:
kind = str(entry.get("side_data_type") or "").lower()
if "mastering display" in kind:
mastering = _format_mastering_display(entry)
peak_luminance = _to_float(entry.get("max_luminance"))
elif "content light" in kind:
max_content = _to_int(entry.get("max_content"))
max_average = _to_int(entry.get("max_average"))
elif "dynamic hdr" in kind or "hdr10+" in kind or "dovi" in kind:
dynamic = True
hdr10plus = hdr10plus or "smpte2094-40" in kind or "hdr10+" in kind
return mastering, max_content, max_average, peak_luminance, dynamic, hdr10plus
def _format_mastering_display(entry: dict) -> str | None:
keys = (
"green_x", "green_y", "blue_x", "blue_y", "red_x", "red_y",
"white_point_x", "white_point_y", "max_luminance", "min_luminance",
)
values = [_to_float(entry.get(key)) for key in keys]
if any(value is None for value in values):
return None
green_x, green_y, blue_x, blue_y, red_x, red_y, white_x, white_y, maximum, minimum = values
return (
f"G({round(green_x * 50000)},{round(green_y * 50000)})"
f"B({round(blue_x * 50000)},{round(blue_y * 50000)})"
f"R({round(red_x * 50000)},{round(red_y * 50000)})"
f"WP({round(white_x * 50000)},{round(white_y * 50000)})"
f"L({round(maximum * 10000)},{round(minimum * 10000)})"
)
def _expected_color_matrix(width: int | None, height: int | None) -> str | None:
if width is None or height is None:
return None
if width >= 3840 or height >= 2160:
return "Rec2020"
if width >= 1280 or height >= 720:
return "Rec709"
return "Rec601"
def _as_str(value: object) -> str | None:
if value is None or value == "N/A":
return None
return str(value)