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

524 lines
16 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
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",
)
DATETIME_RE = re.compile(
r"(?P<Y>\d{4})[:\-]?(?P<M>\d{2})[:\-]?(?P<D>\d{2})"
r"(?:[ T_])?"
r"(?P<h>\d{2}):?(?P<m>\d{2}):?(?P<s>\d{2})"
r"(?:[.,](?P<sub>\d+))?"
r"(?:\s*(?P<tz>Z|[+-]\d{2}:?\d{2}))?"
)
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]
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.")
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=_as_str(stream.get("color_space")),
color_matrix=_avisynth_matrix_from_color_space(_as_str(stream.get("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,
)
def _probe_stream(ffprobe: str, path: Path) -> tuple[dict, dict]:
command = [
ffprobe,
"-v",
"error",
"-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",
"-show_entries",
"format=duration",
"-of",
"json",
str(path),
]
completed = subprocess.run(command, check=True, capture_output=True, text=True)
data = json.loads(completed.stdout or "{}")
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]:
command = [
ffprobe,
"-v",
"error",
"-select_streams",
"a",
"-show_entries",
"stream=index,codec_name,channels,sample_rate,bit_rate,duration",
"-show_entries",
"packet=stream_index,size",
"-of",
"json",
str(path),
]
completed = subprocess.run(command, check=True, capture_output=True, text=True)
data = json.loads(completed.stdout or "{}")
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_video_packets(ffprobe: str, path: Path) -> tuple[list[float], int]:
command = [
ffprobe,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"packet=pts_time,size",
"-of",
"json",
str(path),
]
completed = subprocess.run(command, check=True, capture_output=True, text=True)
data = json.loads(completed.stdout or "{}")
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 _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 first_tag(metadata: dict, tags: tuple[str, ...]) -> object | None:
for tag in tags:
if tag in metadata:
return metadata[tag]
return None
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_exif_datetime(value: object, assume_utc: bool = False) -> datetime | None:
if value is None:
return None
match = DATETIME_RE.search(str(value).strip())
if not match:
return None
tzinfo = None
tz_text = match.group("tz")
if tz_text == "Z":
tzinfo = timezone.utc
elif tz_text:
tzinfo = _parse_timezone_offset(tz_text)
parsed = datetime(
int(match.group("Y")),
int(match.group("M")),
int(match.group("D")),
int(match.group("h")),
int(match.group("m")),
int(match.group("s")),
tzinfo=tzinfo,
)
if assume_utc and parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
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 round_half_up(value: float) -> int:
return int(value + 0.5)
def _to_float(value: object) -> float | None:
if value is None or value == "N/A":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _to_int(value: object) -> int | None:
if value is None or value == "N/A":
return None
try:
return int(value)
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 _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)