diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 1546a464..f86cfaa4 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -157,6 +157,11 @@ unshackle dl -q 2160 -r HDR10,DV EXAMPLE '...' and goes on with the other resolutions of the title. When no hybrid file at all was made, the title fails. + A DV track that carries HDR10+ (`DV+HDR10P` in `--list`) is already a hybrid. At a resolution + that has one, `-r HYBRID` selects the DV+HDR10P track with the highest bitrate (the lowest with + `--worst`) and muxes it as it is, with no base track and no dovi_tool step. The other + resolutions get a hybrid that unshackle makes. + To keep an HDR10+ deliverable *alongside* the hybrid, request both ranges: `-r HYBRID,HDR10P`. `-r HYBRID` on its own muxes only the merged hybrid. diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index bd22d04a..54922c21 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -380,7 +380,8 @@ whose first frame carries HDR10+ (SMPTE ST 2094-40) metadata becomes `HDR10P`. T `--list` and [`--generate-dv`](../../guide/downloading.md#dolby-vision-from-hdr10) use it. unshackle also probes HEVC Dolby Vision tracks. A DV track that carries HDR10+ keeps its `DV` -range, so `-r DV` and `-r HYBRID` select it as before, and `--list` shows it as `DV+HDR10P`. The +range, so `-r DV` selects it, and `--list` shows it as `DV+HDR10P`. `-r HYBRID` uses it as the +[hybrid](../../guide/downloading.md#colour-range) of its resolution, with no base track. The HDR10 tracks and the DV tracks of a title are sampled as two separate sets. When two HDR10 tracks have the same resolution, they can be an HDR10 encode and an HDR10+ encode diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index bb95c2dd..7c91a4ac 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -193,7 +193,7 @@ the remote client's `-q` when the remote session starts: The service sees the DRM system and security level of the client's device. A client with no local CDM gets a `SERVER_CDM_CAPPED` error. - A download job must pass a `quality` at or below the limit, and must not set `best_available` or - a `HYBRID` range. `HYBRID` always keeps the lowest Dolby Vision track, at any quality. A job with + a `HYBRID` range. `HYBRID` can keep the lowest Dolby Vision track, at any quality. A job with no `quality`, with a higher one, with `best_available`, or with `HYBRID` gets a `SERVER_CDM_CAPPED` error. The server checks the values the job runs with, after it applies the service defaults and the `serve:` download defaults. diff --git a/tests/core/test_hybrid_hdr10plus.py b/tests/core/test_hybrid_hdr10plus.py index 2d46e6d7..49dbd898 100644 --- a/tests/core/test_hybrid_hdr10plus.py +++ b/tests/core/test_hybrid_hdr10plus.py @@ -640,8 +640,14 @@ def test_a_remote_session_puts_the_flag_in_the_service_ctx(monkeypatch: pytest.M assert seen["ctx"].params["generate_dv"] is to_dv -def video(id_: str, range_: Video.Range, height: int = 2160, codec: Optional[Video.Codec] = Video.Codec.HEVC) -> Any: - return SimpleNamespace(id=id_, height=height, range=range_, codec=codec) +def video( + id_: str, + range_: Video.Range, + height: int = 2160, + codec: Optional[Video.Codec] = Video.Codec.HEVC, + native_hybrid: bool = False, +) -> Any: + return SimpleNamespace(id=id_, height=height, range=range_, codec=codec, native_hybrid=native_hybrid) def test_hybrid_fails_with_neither_a_base_nor_dv() -> None: @@ -734,3 +740,13 @@ def test_the_flag_without_dv_takes_only_hdr10plus_bases() -> None: def test_no_dv_and_no_flag_gives_no_bases() -> None: assert hybrid_plan([video("h", Video.Range.HDR10), video("p", Video.Range.HDR10P)], False) == ([], None) + + +def test_a_native_hybrid_passes_the_gate_and_takes_no_base_at_its_resolution() -> None: + videos = [video("dvp2160", Video.Range.DV, native_hybrid=True), video("h1080", Video.Range.HDR10, 1080)] + assert hybrid_gate_failure(videos[:1], False) is None + + bases, dv = hybrid_plan(videos + [video("p2160", Video.Range.HDR10P)], False) + + assert [v.id for v in bases] == ["h1080"] + assert dv is not None and dv.id == "dvp2160" diff --git a/tests/tracks/test_hybrid_selection.py b/tests/tracks/test_hybrid_selection.py index 85db2dc1..10009027 100644 --- a/tests/tracks/test_hybrid_selection.py +++ b/tests/tracks/test_hybrid_selection.py @@ -115,6 +115,49 @@ def test_select_hybrid_no_dv_selects_only_base() -> None: assert ids(chosen) == {"hdr10p-2160"} +def dv_hdr10p(track_id: str, height: int, bitrate: int) -> Video: + video = make_video(track_id, range_=Video.Range.DV, height=height, bitrate=bitrate, codec=Video.Codec.HEVC) + video.hdr10plus = True + return video + + +def test_select_hybrid_takes_the_highest_dv_hdr10p_track_over_a_base_and_the_lowest_dv() -> None: + H = Video.Codec.HEVC + tracks = [ + dv_hdr10p("dvp-2160-hi", 2160, 26_000_000), + dv_hdr10p("dvp-2160-lo", 2160, 21_000_000), + make_video("hdr10-2160", range_=Video.Range.HDR10, height=2160, bitrate=24_000_000, codec=H), + dv_hdr10p("dvp-360", 360, 700_000), + ] + assert ids(list(filter(Tracks().select_hybrid(tracks, [2160]), tracks))) == {"dvp-2160-hi"} + assert ids(list(filter(Tracks().select_hybrid(tracks, [2160], worst=True), tracks))) == {"dvp-2160-lo"} + + +def test_select_hybrid_builds_a_hybrid_only_where_no_dv_track_carries_hdr10p() -> None: + H = Video.Codec.HEVC + tracks = [ + dv_hdr10p("dvp-2160", 2160, 26_000_000), + make_video("hdr10-1080", range_=Video.Range.HDR10, height=1080, bitrate=8_000_000, codec=H), + make_video("dv-360", range_=Video.Range.DV, height=360, bitrate=1_000_000, codec=H), + ] + chosen = list(filter(Tracks().select_hybrid(tracks, [2160, 1080]), tracks)) + assert ids(chosen) == {"dvp-2160", "hdr10-1080", "dv-360"} + + Tracks.flag_hybrid_ingredients(chosen, []) + assert {t.id for t in chosen if t.hybrid_base_only} == {"hdr10-1080", "dv-360"} + + +def test_a_dv_hdr10p_track_used_only_for_its_rpu_stays_an_ingredient() -> None: + H = Video.Codec.HEVC + tracks = [ + make_video("hdr10-1080", range_=Video.Range.HDR10, height=1080, bitrate=8_000_000, codec=H), + dv_hdr10p("dvp-360", 360, 700_000), + ] + chosen = list(filter(Tracks().select_hybrid(tracks, [1080]), tracks)) + Tracks.flag_hybrid_ingredients(chosen, []) + assert all(t.hybrid_base_only for t in chosen) and ids(chosen) == {"hdr10-1080", "dvp-360"} + + def test_merge_dedups_shared_ingredient_and_deliverable() -> None: H = Video.Codec.HEVC dv = make_video("dv-1080", range_=Video.Range.DV, height=1080, bitrate=9_000_000, codec=H) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 2e46b0e5..2b30b347 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -235,6 +235,8 @@ def select_hybrid_bases(videos: Sequence[Video]) -> list[Video]: def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[tuple[str, str]]: """The (message, detail) that stops HYBRID for these selected videos, or None when it can go on.""" + if any(v.native_hybrid for v in videos): + return None hdr_tracks = [v for v in videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)] base_tracks = [v for v in hdr_tracks if v.codec in (Video.Codec.HEVC, None)] dv_tracks = [v for v in videos if v.range == Video.Range.DV] @@ -270,10 +272,16 @@ def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[ def hybrid_plan(videos: Sequence[Video], generate_dv: bool) -> tuple[list[Video], Optional[Video]]: """The hybrid bases and the DV track to pair with them. - With no DV track, only HDR10+ bases qualify, and only with ``--generate-dv``. + With no DV track, only HDR10+ bases qualify, and only with ``--generate-dv``. A resolution with a + DV track that carries HDR10+ gets no base: that track is its hybrid. """ dv_tracks = [v for v in videos if v.range == Video.Range.DV] - base_pool = [v for v in videos if dv_tracks or (generate_dv and v.range == Video.Range.HDR10P)] + native_heights = {v.height for v in videos if v.native_hybrid} + base_pool = [ + v + for v in videos + if v.height not in native_heights and (dv_tracks or (generate_dv and v.range == Video.Range.HDR10P)) + ] matching_dv = min(dv_tracks, key=lambda v: v.height or 0) if dv_tracks else None return select_hybrid_bases(base_pool), matching_dv @@ -2759,18 +2767,14 @@ class dl: title.tracks.videos, non_hybrid_ranges ) - if not effective_quality: + hybrid_quality = effective_quality + if not hybrid_quality: best_resolution = max((v.height for v in hybrid_candidate_tracks), default=None) - if best_resolution: - hybrid_filter = title.tracks.select_hybrid( - hybrid_candidate_tracks, [best_resolution], worst=worst - ) - hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks)) - else: - hybrid_selected = [] - else: + hybrid_quality = [best_resolution] if best_resolution else [] + hybrid_selected = [] + if hybrid_quality: hybrid_filter = title.tracks.select_hybrid( - hybrid_candidate_tracks, effective_quality, worst=worst + hybrid_candidate_tracks, hybrid_quality, worst=worst ) hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks)) @@ -3666,6 +3670,9 @@ class dl: hybrid_bases, matching_dv = hybrid_plan(title.tracks.videos, generate_dv) hybrid_base_ids.update(v.id for v in hybrid_bases) hybrids_made = 0 + for video in (v for v in original_videos if v.native_hybrid): + self.log.info(f"The {video.height}p DV track carries HDR10+: it is the hybrid") + hybrids_made += 1 for group in sorted(standalone_groups, key=group_file_size, reverse=True): mux_video_group(group) diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index 145ba1fb..d06b2b9f 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -41,6 +41,11 @@ MP4BOX_PROGRESS = re.compile(r"\((\d{1,3})/100\)") MP4BOX_OPEN_FAILED = re.compile(r"error while opening|Error opening file|Invalid IsoMedia File", re.I) +def at_resolution(video: Video, res: int) -> bool: + """Whether a track has this height, or this height from a 16:9 width (a letterboxed encode).""" + return video.height == res or bool(video.width and int(video.width * 9 / 16) == res) + + class Tracks: """ Video, Audio, Subtitle, Chapter, and Attachment Track Store. @@ -434,12 +439,27 @@ class Tracks: A track that the caller also selected as an explicit deliverable (same track in both selections) stays unflagged, and the standalone mux loop muxes it alongside the hybrid. + A ``native_hybrid`` track is the hybrid itself, so it stays unflagged too. """ for video in hybrid_selected: - if video not in non_hybrid_selected: + if video not in non_hybrid_selected and not video.native_hybrid: video.hybrid_base_only = True def select_hybrid(self, tracks, quality, worst: bool = False): + """A filter for the hybrid ingredients of each resolution. It sets ``native_hybrid`` on every track. + + A DV track that carries HDR10+ is already a hybrid, so its resolution takes that track and no base. + """ + pick = min if worst else max + natives = {} + for res in quality: + candidates = [v for v in tracks if v.range == Video.Range.DV and v.hdr10plus and at_resolution(v, res)] + if candidates: + natives[res] = pick(candidates, key=lambda v: v.bitrate or 0) + for video in tracks: + video.native_hybrid = any(video is v for v in natives.values()) + quality = [res for res in quality if res not in natives] + # Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata). # Only an HEVC base qualifies, because dovi_tool injects the RPU into an HEVC stream. base_ranges = (Video.Range.HDR10P, Video.Range.HDR10) @@ -450,24 +470,23 @@ class Tracks: for v in tracks if v.range == range_type and v.codec in (Video.Codec.HEVC, None) - and (v.height in quality or (v.width and int(v.width * 9 / 16) in quality)) + and any(at_resolution(v, res) for res in quality) ] if base_tracks: break - pick = min if worst else max base_selected = [] for res in quality: - candidates = [v for v in base_tracks if v.height == res or (v.width and int(v.width * 9 / 16) == res)] + candidates = [v for v in base_tracks if at_resolution(v, res)] if candidates: chosen = pick(candidates, key=lambda v: v.bitrate) base_selected.append(chosen) 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 + lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks and (base_selected or not natives) else None def select(x): - if x in base_selected: + if x in base_selected or x.native_hybrid: return True if lowest_dv and x is lowest_dv: return True diff --git a/unshackle/core/tracks/video.py b/unshackle/core/tracks/video.py index 0b71ad1c..6e0f5d63 100644 --- a/unshackle/core/tracks/video.py +++ b/unshackle/core/tracks/video.py @@ -303,6 +303,7 @@ class Video(Track): self.dv_compatible_bitstream = dv_compatible_bitstream self.hdr10plus = hdr10plus self.hybrid_base_only = False + self.native_hybrid = False def to_dict(self) -> dict[str, Any]: data = super().to_dict()