From d2fb409ad971e19e894d1f8371daeb7dd452aed2 Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 30 Jul 2025 21:14:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(hybrid):=20=E2=9C=A8=20Add=20HDR10+=20supp?= =?UTF-8?q?ort=20for=20conversion=20to=20Dolby=20Vision=20and=20enhance=20?= =?UTF-8?q?metadata=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unshackle/core/binaries.py | 2 + unshackle/core/tracks/hybrid.py | 165 ++++++++++++++++++++++++++------ 2 files changed, 140 insertions(+), 27 deletions(-) diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index 287bdb7..ccc5267 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -52,6 +52,7 @@ N_m3u8DL_RE = find("N_m3u8DL-RE", "n-m3u8dl-re") MKVToolNix = find("mkvmerge") Mkvpropedit = find("mkvpropedit") DoviTool = find("dovi_tool") +HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool") __all__ = ( @@ -69,5 +70,6 @@ __all__ = ( "MKVToolNix", "Mkvpropedit", "DoviTool", + "HDR10PlusTool", "find", ) diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index 6f8ee43..7100c6f 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -8,7 +8,7 @@ from pathlib import Path from rich.padding import Padding from rich.rule import Rule -from unshackle.core.binaries import DoviTool +from unshackle.core.binaries import DoviTool, HDR10PlusTool from unshackle.core.config import config from unshackle.core.console import console @@ -20,6 +20,7 @@ class Hybrid: """ Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks. It will then attempt to inject the Dolby Vision metadata layer to the HDR10(+) stream. + If no DV track is available but HDR10+ is present, it will convert HDR10+ to DV. """ global directories from unshackle.core.tracks import Video @@ -29,10 +30,14 @@ class Hybrid: self.rpu_file = "RPU.bin" self.hdr_type = "HDR10" self.hevc_file = f"{self.hdr_type}-DV.hevc" + self.hdr10plus_to_dv = False + self.hdr10plus_file = "HDR10Plus.json" # Get resolution info from HDR10 track for display hdr10_track = next((v for v in videos if v.range == Video.Range.HDR10), None) - self.resolution = f"{hdr10_track.height}p" if hdr10_track and hdr10_track.height else "Unknown" + hdr10p_track = next((v for v in videos if v.range == Video.Range.HDR10P), None) + track_for_res = hdr10_track or hdr10p_track + self.resolution = f"{track_for_res.height}p" if track_for_res and track_for_res.height else "Unknown" console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2))) @@ -40,10 +45,20 @@ class Hybrid: if not video.path or not os.path.exists(video.path): self.log.exit(f" - Video track {video.id} was not downloaded before injection.") - if not any(video.range == Video.Range.DV for video in self.videos) or not any( - video.range == Video.Range.HDR10 for video in self.videos - ): - self.log.exit(" - Two VideoTracks available but one of them is not DV nor HDR10(+).") + # Check if we have DV track available + has_dv = any(video.range == Video.Range.DV for video in self.videos) + has_hdr10 = any(video.range == Video.Range.HDR10 for video in self.videos) + has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos) + + if not has_hdr10: + self.log.exit(" - No HDR10 track available for hybrid processing.") + + # If we have HDR10+ but no DV, we can convert HDR10+ to DV + if not has_dv and has_hdr10p: + self.log.info("✓ No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.") + self.hdr10plus_to_dv = True + elif not has_dv: + self.log.exit(" - No DV track available and no HDR10+ to convert.") if os.path.isfile(config.directories.temp / self.hevc_file): self.log.info("✓ Already Injected") @@ -57,17 +72,28 @@ class Hybrid: if video.range == Video.Range.HDR10: self.extract_stream(save_path, "HDR10") + elif video.range == Video.Range.HDR10P: + self.extract_stream(save_path, "HDR10") + self.hdr_type = "HDR10+" elif video.range == Video.Range.DV: self.extract_stream(save_path, "DV") - self.extract_rpu([video for video in videos if video.range == Video.Range.DV][0]) - if os.path.isfile(config.directories.temp / "RPU_UNT.bin"): - self.rpu_file = "RPU_UNT.bin" - self.level_6() - # Mode 3 conversion already done during extraction when not untouched - elif os.path.isfile(config.directories.temp / "RPU.bin"): - # RPU already extracted with mode 3 - pass + if self.hdr10plus_to_dv: + # Extract HDR10+ metadata and convert to DV + hdr10p_video = next(v for v in videos if v.range == Video.Range.HDR10P) + self.extract_hdr10plus(hdr10p_video) + self.convert_hdr10plus_to_dv() + else: + # Regular DV extraction + dv_video = next(v for v in videos if v.range == Video.Range.DV) + self.extract_rpu(dv_video) + if os.path.isfile(config.directories.temp / "RPU_UNT.bin"): + self.rpu_file = "RPU_UNT.bin" + self.level_6() + # Mode 3 conversion already done during extraction when not untouched + elif os.path.isfile(config.directories.temp / "RPU.bin"): + # RPU already extracted with mode 3 + pass self.injecting() @@ -75,9 +101,9 @@ class Hybrid: if self.source == ("itunes" or "appletvplus"): Path.unlink(config.directories.temp / "hdr10.mkv") Path.unlink(config.directories.temp / "dv.mkv") - Path.unlink(config.directories.temp / "DV.hevc") - Path.unlink(config.directories.temp / "HDR10.hevc") - Path.unlink(config.directories.temp / f"{self.rpu_file}") + Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True) + Path.unlink(config.directories.temp / "DV.hevc", missing_ok=True) + Path.unlink(config.directories.temp / f"{self.rpu_file}", missing_ok=True) def ffmpeg_simple(self, save_path, output): """Simple ffmpeg execution without progress tracking""" @@ -188,17 +214,25 @@ class Hybrid: self.log.info(f"+ Injecting Dolby Vision metadata into {self.hdr_type} stream") + inject_cmd = [ + str(DoviTool), + "inject-rpu", + "-i", + config.directories.temp / "HDR10.hevc", + "--rpu-in", + config.directories.temp / self.rpu_file, + ] + + # If we converted from HDR10+, optionally remove HDR10+ metadata during injection + # Default to removing HDR10+ metadata since we're converting to DV + if self.hdr10plus_to_dv: + inject_cmd.append("--drop-hdr10plus") + self.log.info(" - Removing HDR10+ metadata during injection") + + inject_cmd.extend(["-o", config.directories.temp / self.hevc_file]) + inject = subprocess.run( - [ - str(DoviTool), - "inject-rpu", - "-i", - config.directories.temp / f"{self.hdr_type}.hevc", - "--rpu-in", - config.directories.temp / self.rpu_file, - "-o", - config.directories.temp / self.hevc_file, - ], + inject_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -206,3 +240,80 @@ class Hybrid: if inject.returncode: Path.unlink(config.directories.temp / self.hevc_file) self.log.exit("x Failed injecting Dolby Vision metadata into HDR10 stream") + + def extract_hdr10plus(self, _video): + """Extract HDR10+ metadata from the video stream""" + if os.path.isfile(config.directories.temp / self.hdr10plus_file): + return + + if not HDR10PlusTool: + self.log.exit("x HDR10Plus_tool not found. Please install it to use HDR10+ to DV conversion.") + + self.log.info("+ Extracting HDR10+ metadata") + + # HDR10Plus_tool needs raw HEVC stream + extraction = subprocess.run( + [ + str(HDR10PlusTool), + "extract", + str(config.directories.temp / "HDR10.hevc"), + "-o", + str(config.directories.temp / self.hdr10plus_file), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if extraction.returncode: + self.log.exit("x Failed extracting HDR10+ metadata") + + # Check if the extracted file has content + if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0: + self.log.exit("x No HDR10+ metadata found in the stream") + + def convert_hdr10plus_to_dv(self): + """Convert HDR10+ metadata to Dolby Vision RPU""" + if os.path.isfile(config.directories.temp / "RPU.bin"): + return + + self.log.info("+ Converting HDR10+ metadata to Dolby Vision") + + # 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, + }, + } + + with open(config.directories.temp / "extra.json", "w") as f: + json.dump(extra_metadata, f, indent=2) + + # Generate DV RPU from HDR10+ metadata + conversion = subprocess.run( + [ + str(DoviTool), + "generate", + "-j", + str(config.directories.temp / "extra.json"), + "--hdr10plus-json", + str(config.directories.temp / self.hdr10plus_file), + "-o", + str(config.directories.temp / "RPU.bin"), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if conversion.returncode: + self.log.exit("x Failed converting HDR10+ to Dolby Vision") + + self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8") + + # Clean up temporary files + Path.unlink(config.directories.temp / "extra.json") + Path.unlink(config.directories.temp / self.hdr10plus_file)