From 829ae01000e3c082c36e20f76321849e7b4dedaa Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 18 Feb 2026 15:56:55 -0700 Subject: [PATCH] fix(hybrid): accept HDR10+ tracks as valid base layer for HYBRID mode HYBRID mode previously required a plain HDR10 track, rejecting HDR10+ (HDR10P) even though it's a perfectly valid (and superior) base layer. HDR10+ is now preferred over HDR10 when both are available, preserving dynamic metadata in the final DV Profile 8 output. --- unshackle/commands/dl.py | 30 +++++++++++++++++++++--------- unshackle/core/tracks/hybrid.py | 19 +++++++------------ unshackle/core/tracks/tracks.py | 27 +++++++++++++++++---------- 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index c937f57..c03803d 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1496,24 +1496,33 @@ class dl: # validate hybrid mode requirements if any(r == Video.Range.HYBRID for r in range_): - hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10] + base_tracks = [ + v for v in title.tracks.videos + if v.range in (Video.Range.HDR10, Video.Range.HDR10P) + ] dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV] - if not hdr10_tracks and not dv_tracks: + if not base_tracks and not dv_tracks: available_ranges = sorted(set(v.range.name for v in title.tracks.videos)) - self.log.error("HYBRID mode requires both HDR10 and DV tracks, but neither is available") + self.log.error( + "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available" + ) self.log.error( f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}" ) sys.exit(1) - elif not hdr10_tracks: + elif not base_tracks: available_ranges = sorted(set(v.range.name for v in title.tracks.videos)) - self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only DV is available") + self.log.error( + "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available" + ) self.log.error(f"Available ranges: {', '.join(available_ranges)}") sys.exit(1) elif not dv_tracks: available_ranges = sorted(set(v.range.name for v in title.tracks.videos)) - self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only HDR10 is available") + self.log.error( + "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available" + ) self.log.error(f"Available ranges: {', '.join(available_ranges)}") sys.exit(1) @@ -2038,12 +2047,15 @@ class dl: # Hybrid mode: process DV and HDR10 tracks separately for each resolution self.log.info("Processing Hybrid HDR10+DV tracks...") - # Group video tracks by resolution + # Group video tracks by resolution (prefer HDR10+ over HDR10 as base) resolutions_processed = set() - hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10] + base_tracks_list = [ + v for v in title.tracks.videos + if v.range in (Video.Range.HDR10P, Video.Range.HDR10) + ] dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV] - for hdr10_track in hdr10_tracks: + for hdr10_track in base_tracks_list: resolution = hdr10_track.height if resolution in resolutions_processed: continue diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index 7c764f7..ecd44b6 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -67,8 +67,8 @@ class Hybrid: 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: - raise ValueError("No HDR10 track available for hybrid processing.") + if not has_hdr10 and not has_hdr10p: + raise ValueError("No HDR10 or 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: @@ -113,10 +113,11 @@ class Hybrid: # Edit L6 with actual luminance values from RPU, then L5 active area self.level_6() - hdr10_video = next((v for v in videos if v.range == Video.Range.HDR10), None) - hdr10_input = hdr10_video.path if hdr10_video else None - if hdr10_input: - self.level_5(hdr10_input) + base_video = next( + (v for v in videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)), None + ) + if base_video and base_video.path: + self.level_5(base_video.path) self.injecting() @@ -557,12 +558,6 @@ class Hybrid: 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") - console.status("Removing HDR10+ metadata during injection") - inject_cmd.extend(["-o", config.directories.temp / self.hevc_file]) inject = subprocess.run( diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index e815f7e..e1770a5 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -278,23 +278,30 @@ class Tracks: 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 = [] + # Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata) + base_ranges = (Video.Range.HDR10P, Video.Range.HDR10) + base_tracks = [] + for range_type in base_ranges: + base_tracks = [ + v + for v in tracks + if v.range == range_type and (v.height in quality or int(v.width * 9 / 16) in quality) + ] + if base_tracks: + break + + base_selected = [] for res in quality: - candidates = [v for v in hdr10_tracks if v.height == res or int(v.width * 9 / 16) == res] + candidates = [v for v in base_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) + best = max(candidates, key=lambda v: v.bitrate) + base_selected.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: + if x in base_selected: return True if lowest_dv and x is lowest_dv: return True