From 5e801580a35c709bcbd4a76518f2703111343630 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Mon, 30 Mar 2026 17:02:06 -0600 Subject: [PATCH] fix(hybrid): read actual HDR metadata for HDR10+ to DV conversion The L6 metadata in convert_hdr10plus_to_dv was hardcoded to 1000 nits max mastering display luminance. Now probes the source stream via ffprobe to extract the real mastering display and content light level values, preserving accurate luminance for sources mastered above 1000 nits. --- unshackle/core/tracks/hybrid.py | 67 +++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index ecd44b6..4a66866 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -648,21 +648,80 @@ class Hybrid: success=True, ) + def _probe_hdr_metadata(self): + """Extract mastering display and content light level metadata from the HDR10 stream via ffprobe. + + Returns (max_mdl, min_mdl, max_cll, max_fall) in dovi_tool level6 units: + - max_mdl: nits (integer) + - min_mdl: 0.0001 nit units (integer) + - max_cll / max_fall: nits (integer) + """ + ffprobe_bin = str(FFProbe) if FFProbe else "ffprobe" + result = subprocess.run( + [ + ffprobe_bin, + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream_side_data=max_luminance,min_luminance,max_content,max_average", + "-of", "json", + str(config.directories.temp / "HDR10.hevc"), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + max_mdl = 1000 + min_mdl = 1 + max_cll = 0 + max_fall = 0 + + if result.returncode == 0 and result.stdout: + try: + probe = json.loads(result.stdout) + for stream in probe.get("streams", []): + for sd in stream.get("side_data_list", []): + if "max_luminance" in sd: + num, den = sd["max_luminance"].split("/") + max_mdl = int(int(num) / int(den)) + if "min_luminance" in sd: + num, den = sd["min_luminance"].split("/") + min_mdl = int(int(num) / int(den) * 10000) + if "max_content" in sd: + max_cll = int(sd["max_content"]) + if "max_average" in sd: + max_fall = int(sd["max_average"]) + except (json.JSONDecodeError, KeyError, ValueError, ZeroDivisionError): + pass + + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="hybrid_probe_hdr_metadata", + message="Probed HDR metadata from source stream", + context={"max_mdl": max_mdl, "min_mdl": min_mdl, "max_cll": max_cll, "max_fall": max_fall}, + ) + + return max_mdl, min_mdl, max_cll, max_fall + def convert_hdr10plus_to_dv(self): """Convert HDR10+ metadata to Dolby Vision RPU""" if os.path.isfile(config.directories.temp / "RPU.bin"): return with console.status("Converting HDR10+ metadata to Dolby Vision...", spinner="dots"): + # Extract actual HDR metadata from the source stream + max_mdl, min_mdl, max_cll, max_fall = self._probe_hdr_metadata() + # First create the extra metadata JSON for dovi_tool extra_metadata = { "cm_version": "V29", "length": 0, # dovi_tool will figure this out "level6": { - "max_display_mastering_luminance": 1000, - "min_display_mastering_luminance": 1, - "max_content_light_level": 0, - "max_frame_average_light_level": 0, + "max_display_mastering_luminance": max_mdl, + "min_display_mastering_luminance": min_mdl, + "max_content_light_level": max_cll, + "max_frame_average_light_level": max_fall, }, }