feat(hybrid): Implement HDR10+DV hybrid processing and injection support

Original code by @P0llUx12 - Discord
This commit is contained in:
Andy
2025-07-29 17:40:02 +00:00
parent c81b7f192e
commit c97de0c32b
13 changed files with 522 additions and 51 deletions

View File

@@ -2,9 +2,10 @@ from .attachment import Attachment
from .audio import Audio
from .chapter import Chapter
from .chapters import Chapters
from .hybrid import Hybrid
from .subtitle import Subtitle
from .track import Track
from .tracks import Tracks
from .video import Video
__all__ = ("Audio", "Attachment", "Chapter", "Chapters", "Subtitle", "Track", "Tracks", "Video")
__all__ = ("Audio", "Attachment", "Chapter", "Chapters", "Hybrid", "Subtitle", "Track", "Tracks", "Video")

View File

@@ -0,0 +1,369 @@
import logging
import subprocess
import os
import sys
import json
from pathlib import Path
from rich.rule import Rule
from rich.padding import Padding
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.binaries import DoviTool
class Hybrid:
def __init__(self, videos, source) -> None:
self.log = logging.getLogger("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.
"""
global directories
from unshackle.core.tracks import Video
self.videos = videos
self.source = source
self.rpu_file = "RPU.bin"
self.hdr_type = "HDR10"
self.hevc_file = f"{self.hdr_type}-DV.hevc"
console.print(Padding(Rule("[rule.text]HDR10+DV Hybrid"), (1, 2)))
for video in self.videos:
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(+).")
if os.path.isfile(config.directories.temp / self.hevc_file):
self.log.info("✓ Already Injected")
return
for video in videos:
# Use the actual path from the video track
save_path = video.path
if not save_path or not os.path.exists(save_path):
self.log.exit(f" - Video track {video.id} was not downloaded or path not found: {save_path}")
if video.range == Video.Range.HDR10:
self.extract_stream(save_path, "HDR10")
elif video.range == Video.Range.DV:
self.extract_stream(save_path, "DV")
# self.extract_dv_stream(video, save_path)
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
self.injecting()
self.log.info("✓ Injection Completed")
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}")
def ffmpeg_simple(self, save_path, output):
"""Simple ffmpeg execution without progress tracking"""
p = subprocess.run(
[
"ffmpeg",
"-nostdin",
"-i",
str(save_path),
"-c:v",
"copy",
str(output),
"-y", # overwrite output
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return p.returncode
def extract_stream(self, save_path, type_):
output = Path(config.directories.temp / f"{type_}.hevc")
self.log.info(f"+ Extracting {type_} stream")
returncode = self.ffmpeg_simple(save_path, output)
if returncode:
output.unlink(missing_ok=True)
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
def ffmpeg_task(self, save_path, output, task_id):
p = subprocess.Popen(
[
"ffmpeg",
"-nostdin",
"-i",
str(save_path),
"-c:v",
"copy",
str(output),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
universal_newlines=True,
)
self.progress.start_task(task_id)
for line in p.stderr:
if "frame=" in line:
self.progress.update(task_id, advance=0)
p.wait()
return p.returncode
def extract_hdr10_stream(self, video, save_path):
type_ = "HDR10"
if os.path.isfile(Path(config.directories.temp / f"{type_}.hevc")):
return
if self.source == "itunes" or self.source == "appletvplus":
self.log.info("+ Muxing HDR10 stream for fixing MP4 file")
subprocess.run(
[
"mkvmerge",
"-o",
Path(config.directories.temp / "hdr10.mkv"),
save_path,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.log.info(f"+ Extracting {type_} stream")
extract_stream = subprocess.run(
[
"ffmpeg",
"-nostdin",
"-stats",
"-i",
Path(config.directories.temp / "hdr10.mkv"),
"-c:v",
"copy",
Path(config.directories.temp / f"{type_}.hevc"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if extract_stream.returncode:
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
else:
extract_stream = subprocess.run(
[
"ffmpeg",
"-nostdin",
"-stats",
"-i",
save_path,
"-c:v",
"copy",
Path(config.directories.temp / f"{type_}.hevc"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if extract_stream.returncode:
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
def extract_dv_stream(self, video, save_path):
type_ = "DV"
if os.path.isfile(Path(config.directories.temp / f"{type_}.hevc")):
return
if self.source == "itunes" or self.source == "appletvplus":
self.log.info("+ Muxing Dolby Vision stream for fixing MP4 file")
subprocess.run(
[
"mkvmerge",
"-o",
Path(config.directories.temp / "dv.mkv"),
save_path,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.log.info("+ Extracting Dolby Vision stream")
extract_stream = subprocess.run(
[
"ffmpeg",
"-nostdin",
"-stats",
"-i",
Path(config.directories.temp / "dv.mkv"),
"-an",
"-c:v",
"copy",
"-f",
"hevc",
Path(config.directories.temp / "out_1.h265"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if extract_stream.returncode:
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
else:
extract_stream = subprocess.run(
[
"mp4demuxer",
"--input-file",
save_path,
"--output-folder",
Path(config.directories.temp),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if extract_stream.returncode:
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
def extract_rpu(self, video, untouched=False):
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
config.directories.temp / "RPU_UNT.bin"
):
return
self.log.info(f"+ Extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
extraction_args = [str(DoviTool)]
if not untouched:
extraction_args += ["-m", "3"]
extraction_args += [
"extract-rpu",
config.directories.temp / "DV.hevc",
"-o",
config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin",
]
rpu_extraction = subprocess.run(
extraction_args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if rpu_extraction.returncode:
Path.unlink(config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin")
if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr:
self.extract_rpu(video, untouched=True)
elif b"Invalid PPS index" in rpu_extraction.stderr:
self.log.exit("x Dolby Vision VideoTrack seems to be corrupt")
else:
self.log.exit(f"x Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
def level_6(self):
"""Edit RPU Level 6 values"""
with open(config.directories.temp / "L6.json", "w+") as level6_file:
level6 = {
"cm_version": "V29",
"length": 0,
"level6": {
"max_display_mastering_luminance": 1000,
"min_display_mastering_luminance": 1,
"max_content_light_level": 0,
"max_frame_average_light_level": 0,
},
}
json.dump(level6, level6_file, indent=3)
if not os.path.isfile(config.directories.temp / "RPU_L6.bin"):
self.log.info("+ Editing RPU Level 6 values")
level6 = subprocess.run(
[
str(DoviTool),
"editor",
"-i",
config.directories.temp / self.rpu_file,
"-j",
config.directories.temp / "L6.json",
"-o",
config.directories.temp / "RPU_L6.bin",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if level6.returncode:
Path.unlink(config.directories.temp / "RPU_L6.bin")
self.log.exit("x Failed editing RPU Level 6 values")
# Update rpu_file to use the edited version
self.rpu_file = "RPU_L6.bin"
def mode_3(self):
"""Convert RPU to Mode 3"""
with open(config.directories.temp / "M3.json", "w+") as mode3_file:
json.dump({"mode": 3}, mode3_file, indent=3)
if not os.path.isfile(config.directories.temp / "RPU_M3.bin"):
self.log.info("+ Converting RPU to Mode 3")
mode3 = subprocess.run(
[
str(DoviTool),
"editor",
"-i",
config.directories.temp / self.rpu_file,
"-j",
config.directories.temp / "M3.json",
"-o",
config.directories.temp / "RPU_M3.bin",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if mode3.returncode:
Path.unlink(config.directories.temp / "RPU_M3.bin")
self.log.exit("x Failed converting RPU to Mode 3")
self.rpu_file = "RPU_M3.bin"
def injecting(self):
if os.path.isfile(config.directories.temp / self.hevc_file):
return
self.log.info(f"+ Injecting Dolby Vision metadata into {self.hdr_type} stream")
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,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if inject.returncode:
Path.unlink(config.directories.temp / self.hevc_file)
self.log.exit("x Failed injecting Dolby Vision metadata into HDR10 stream")

View File

@@ -254,6 +254,31 @@ class Tracks:
def select_subtitles(self, x: Callable[[Subtitle], bool]) -> None:
self.subtitles = list(filter(x, self.subtitles))
def select_hybrid(self, tracks, quality):
hdr10_tracks = [
v
for v in tracks
if v.range == Video.Range.HDR10 and (v.height in quality or int(v.width * 9 / 16) in quality)
]
hdr10 = []
for res in quality:
candidates = [v for v in hdr10_tracks if v.height == res or int(v.width * 9 / 16) == res]
if candidates:
best = max(candidates, key=lambda v: v.bitrate) # assumes .bitrate exists
hdr10.append(best)
dv_tracks = [v for v in tracks if v.range == Video.Range.DV]
lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None
def select(x):
if x in hdr10:
return True
if lowest_dv and x is lowest_dv:
return True
return False
return select
def by_resolutions(self, resolutions: list[int], per_resolution: int = 0) -> None:
# Note: Do not merge these list comprehensions. They must be done separately so the results
# from the 16:9 canvas check is only used if there's no exact height resolution match.

View File

@@ -94,6 +94,7 @@ class Video(Track):
HDR10 = "HDR10" # https://en.wikipedia.org/wiki/HDR10
HDR10P = "HDR10+" # https://en.wikipedia.org/wiki/HDR10%2B
DV = "DV" # https://en.wikipedia.org/wiki/Dolby_Vision
HYBRID = "HYBRID" # Selects both HDR10 and DV tracks for hybrid processing with DoviTool
@staticmethod
def from_cicp(primaries: int, transfer: int, matrix: int) -> Video.Range: