644 lines
21 KiB
Python
644 lines
21 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]
|
|
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 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:
|
|
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)
|