feat(hybrid): Add HDR10+ support for conversion to Dolby Vision and enhance metadata extraction

This commit is contained in:
Andy
2025-07-30 21:14:50 +00:00
parent fdff3a1c56
commit d2fb409ad9
2 changed files with 140 additions and 27 deletions

View File

@@ -52,6 +52,7 @@ N_m3u8DL_RE = find("N_m3u8DL-RE", "n-m3u8dl-re")
MKVToolNix = find("mkvmerge") MKVToolNix = find("mkvmerge")
Mkvpropedit = find("mkvpropedit") Mkvpropedit = find("mkvpropedit")
DoviTool = find("dovi_tool") DoviTool = find("dovi_tool")
HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool")
__all__ = ( __all__ = (
@@ -69,5 +70,6 @@ __all__ = (
"MKVToolNix", "MKVToolNix",
"Mkvpropedit", "Mkvpropedit",
"DoviTool", "DoviTool",
"HDR10PlusTool",
"find", "find",
) )

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from rich.padding import Padding from rich.padding import Padding
from rich.rule import Rule 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.config import config
from unshackle.core.console import console from unshackle.core.console import console
@@ -20,6 +20,7 @@ class Hybrid:
""" """
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks. 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. 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 global directories
from unshackle.core.tracks import Video from unshackle.core.tracks import Video
@@ -29,10 +30,14 @@ class Hybrid:
self.rpu_file = "RPU.bin" self.rpu_file = "RPU.bin"
self.hdr_type = "HDR10" self.hdr_type = "HDR10"
self.hevc_file = f"{self.hdr_type}-DV.hevc" 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 # Get resolution info from HDR10 track for display
hdr10_track = next((v for v in videos if v.range == Video.Range.HDR10), None) 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))) 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): if not video.path or not os.path.exists(video.path):
self.log.exit(f" - Video track {video.id} was not downloaded before injection.") 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( # Check if we have DV track available
video.range == Video.Range.HDR10 for video in self.videos 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)
self.log.exit(" - Two VideoTracks available but one of them is not DV nor HDR10(+).") 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): if os.path.isfile(config.directories.temp / self.hevc_file):
self.log.info("✓ Already Injected") self.log.info("✓ Already Injected")
@@ -57,17 +72,28 @@ class Hybrid:
if video.range == Video.Range.HDR10: if video.range == Video.Range.HDR10:
self.extract_stream(save_path, "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: elif video.range == Video.Range.DV:
self.extract_stream(save_path, "DV") self.extract_stream(save_path, "DV")
self.extract_rpu([video for video in videos if video.range == Video.Range.DV][0]) if self.hdr10plus_to_dv:
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"): # Extract HDR10+ metadata and convert to DV
self.rpu_file = "RPU_UNT.bin" hdr10p_video = next(v for v in videos if v.range == Video.Range.HDR10P)
self.level_6() self.extract_hdr10plus(hdr10p_video)
# Mode 3 conversion already done during extraction when not untouched self.convert_hdr10plus_to_dv()
elif os.path.isfile(config.directories.temp / "RPU.bin"): else:
# RPU already extracted with mode 3 # Regular DV extraction
pass 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() self.injecting()
@@ -75,9 +101,9 @@ class Hybrid:
if self.source == ("itunes" or "appletvplus"): if self.source == ("itunes" or "appletvplus"):
Path.unlink(config.directories.temp / "hdr10.mkv") Path.unlink(config.directories.temp / "hdr10.mkv")
Path.unlink(config.directories.temp / "dv.mkv") Path.unlink(config.directories.temp / "dv.mkv")
Path.unlink(config.directories.temp / "DV.hevc") Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True)
Path.unlink(config.directories.temp / "HDR10.hevc") Path.unlink(config.directories.temp / "DV.hevc", missing_ok=True)
Path.unlink(config.directories.temp / f"{self.rpu_file}") Path.unlink(config.directories.temp / f"{self.rpu_file}", missing_ok=True)
def ffmpeg_simple(self, save_path, output): def ffmpeg_simple(self, save_path, output):
"""Simple ffmpeg execution without progress tracking""" """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") 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( inject = subprocess.run(
[ inject_cmd,
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,
],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
@@ -206,3 +240,80 @@ class Hybrid:
if inject.returncode: if inject.returncode:
Path.unlink(config.directories.temp / self.hevc_file) Path.unlink(config.directories.temp / self.hevc_file)
self.log.exit("x Failed injecting Dolby Vision metadata into HDR10 stream") 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)