Enhance Avisynth video workflow and metadata tools

This commit is contained in:
ajp_anton
2026-07-30 03:20:19 +00:00
parent d60ce51b14
commit 100cad1cb5
26 changed files with 2350 additions and 1161 deletions
+153 -33
View File
@@ -116,6 +116,15 @@ class VideoProbe:
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:
@@ -169,6 +178,19 @@ def probe_video(ffprobe: str, path: Path, metadata: dict) -> VideoProbe:
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")),
@@ -177,8 +199,8 @@ def probe_video(ffprobe: str, path: Path, metadata: dict) -> VideoProbe:
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"))),
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")),
@@ -201,26 +223,29 @@ def probe_video(ffprobe: str, path: Path, metadata: dict) -> VideoProbe:
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]:
command = [
data = _ffprobe_json(
ffprobe,
"-v",
"error",
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=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",
"-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}")
@@ -232,22 +257,16 @@ def _probe_audio_streams(
path: Path,
fallback_duration: float | None,
) -> list[AudioStreamProbe]:
command = [
data = _ffprobe_json(
ffprobe,
"-v",
"error",
path,
"-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 []:
@@ -272,21 +291,30 @@ def _probe_audio_streams(
]
def _probe_video_packets(ffprobe: str, path: Path) -> tuple[list[float], int]:
command = [
def _probe_first_frame_side_data(ffprobe: str, path: Path) -> list[dict]:
data = _ffprobe_json(
ffprobe,
"-v",
"error",
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",
"-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 []:
@@ -297,6 +325,16 @@ def _probe_video_packets(ffprobe: str, path: Path) -> tuple[list[float], int]:
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
@@ -465,7 +503,9 @@ def _to_float(value: object) -> float | None:
if value is None or value == "N/A":
return None
try:
return float(value)
text = str(value)
numerator, separator, denominator = text.partition("/")
return float(numerator) / float(denominator) if separator else float(text)
except (TypeError, ValueError):
return None
@@ -474,7 +514,8 @@ def _to_int(value: object) -> int | None:
if value is None or value == "N/A":
return None
try:
return int(value)
numeric = _to_float(value)
return int(numeric) if numeric is not None else None
except (TypeError, ValueError):
return None
@@ -507,6 +548,85 @@ def _avisynth_matrix_from_color_space(color_space: str | None) -> str | None:
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