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-?\d+(?:\.\d+)?)\D+" r"(?P\d+(?:\.\d+)?)?\D*" r"(?P\d+(?:\.\d+)?)?\D*" r"(?P[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(?P10|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)