diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index 0d8f4693..7083a124 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -454,7 +454,8 @@ the API surface. ### Dolby Vision and hybrid Two DV mechanisms sit on top of the track model. **DV fixup** re-exposes DV signalling on -HLS streams that carry a valid DV RPU but advertise plain HEVC (`dv_compatible_bitstream`). +HLS and DASH streams that carry a valid DV RPU but advertise plain HEVC, with DV only in the +supplemental codecs (`dv_compatible_bitstream`). `--list` shows such a track as `HDR10+DV` or `HDR10P+DV`. **Hybrid** processing (`Video.Range.HYBRID`) selects an HDR10(+) base and a DV donor, extracts the RPU with `dovi_tool`, and injects it onto the base layer, to make a single track that carries both HDR10 metadata and the DV RPU. Both are automatic once the diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index f86cfaa4..9703dcdf 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -145,6 +145,21 @@ unshackle dl -v hevc,avc EXAMPLE 81234567 unshackle dl -q 2160 -r HDR10,DV EXAMPLE '...' ``` +`--list` labels a track that carries a second format as `+`: + +- The first part is the range that `-r` selects the track by. +- The second part is the format that the track also carries. +- The `+` only joins the two parts. unshackle writes HDR10+ as `HDR10P`, so `HDR10+DV` is HDR10 with + Dolby Vision, not HDR10+ with Dolby Vision. + +| Label | Track | Selected by | +| --- | --- | --- | +| `DV+HDR10P` | A Dolby Vision track that also carries HDR10+ | `-r DV` | +| `HDR10+DV` | An HDR10 track whose frames also carry a Dolby Vision profile 8 RPU | `-r HDR10` | +| `HDR10P+DV` | An HDR10+ track whose frames also carry a Dolby Vision profile 8 RPU | `-r HDR10P` | + +The output file of a track with a Dolby Vision profile 8 RPU plays as Dolby Vision on a DV screen. + !!! warning "HYBRID requires dovi_tool" `-r HYBRID` makes a single hybrid track by injecting the Dolby Vision RPU metadata onto an HDR10/HDR10+ base layer with **dovi_tool**. @@ -157,10 +172,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. + A track that is `DV+HDR10P`, `HDR10+DV` or `HDR10P+DV` in `--list` already carries Dolby Vision and an HDR10 + or HDR10+ base, so it is already a hybrid. At a resolution that has one, `-r HYBRID` selects it + and muxes it as it is, with no base track and no dovi_tool step. A track that carries HDR10+ + comes first, then the highest bitrate (the lowest with `--worst`). 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/tests/core/test_dash_dv_composite.py b/tests/core/test_dash_dv_composite.py new file mode 100644 index 00000000..fc914c64 --- /dev/null +++ b/tests/core/test_dash_dv_composite.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import pytest + +from unshackle.core.manifests import DASH +from unshackle.core.tracks import Video + +MPD = """ + + + + + + + + hdr.mp4 + + + scte.mp4 + + + plain.mp4 + + + + + dv.mp4 + + + +""" + + +def test_a_dv_codec_in_supplemental_codecs_marks_an_hdr10_track_dv_compatible() -> None: + videos = { + v.data["dash"]["representation_id"]: v + for v in DASH.from_text(MPD, url="https://example.test/m.mpd").to_tracks("en").videos + } + + assert {k: (v.range, v.dv_compatible_bitstream) for k, v in videos.items()} == { + "hdr": (Video.Range.HDR10, True), + "scte": (Video.Range.HDR10, True), + "plain": (Video.Range.HDR10, False), + "dv": (Video.Range.DV, False), + } + assert "[H.265, HDR10+DV]" in str(videos["hdr"]) + assert "[H.265, HDR10]" in str(videos["plain"]) + + +@pytest.mark.parametrize( + ("codecs", "expected"), + [ + ("dvh1.08.06/db1p", True), + ("hvc1.2.4 dvhe.08.06", True), + ("hvc1.2.4.L150.90,DVH1.08.06", True), + ("cdm4", False), + ("hvc1.2.4.L150.90", False), + (None, False), + ], +) +def test_has_dv_codec(codecs: str | None, expected: bool) -> None: + assert Video.has_dv_codec(codecs) is expected + + +@pytest.mark.parametrize( + ("codec", "codecs", "supplemental", "expected"), + [ + (Video.Codec.HEVC, "hvc1.2.4.L150.90", "dvh1.08.06", True), + (Video.Codec.HEVC, "hev1.2.4.L150.90", "dvhe.08.06/db1p", True), + (Video.Codec.AVC, "avc1.640028", "dvav.09.05/db2g", False), + (Video.Codec.HEVC, "hvc1.2.4.L150.90", "dvav.09.05", False), + (Video.Codec.HEVC, "dvh1.05.06", "dvh1.05.06", False), + (None, None, "dvh1.08.06", False), + ], +) +def test_only_an_hevc_track_with_an_hevc_dv_supplemental_codec_is_dv_composite( + codec: Video.Codec | None, codecs: str | None, supplemental: str, expected: bool +) -> None: + assert Video.is_dv_composite(codec, codecs, supplemental) is expected + + +def test_an_hdr10p_track_with_a_dv_rpu_lists_as_hdr10p_plus_dv() -> None: + video = Video(url="https://example.test/v.mp4", language="en", codec=Video.Codec.HEVC, range_=Video.Range.HDR10P) + video.dv_compatible_bitstream = True + assert "[H.265, HDR10P+DV]" in str(video) diff --git a/tests/tracks/test_hybrid_selection.py b/tests/tracks/test_hybrid_selection.py index 10009027..76de5c67 100644 --- a/tests/tracks/test_hybrid_selection.py +++ b/tests/tracks/test_hybrid_selection.py @@ -158,6 +158,44 @@ def test_a_dv_hdr10p_track_used_only_for_its_rpu_stays_an_ingredient() -> None: assert all(t.hybrid_base_only for t in chosen) and ids(chosen) == {"hdr10-1080", "dvp-360"} +def dv_hdr10(track_id: str, height: int, bitrate: int, range_: Video.Range = Video.Range.HDR10) -> Video: + video = make_video(track_id, range_=range_, height=height, bitrate=bitrate, codec=Video.Codec.HEVC) + video.dv_compatible_bitstream = True + return video + + +def test_select_hybrid_takes_a_dv_hdr10_track_and_no_profile_5_donor() -> None: + H = Video.Codec.HEVC + tracks = [ + dv_hdr10("dvhdr-2160", 2160, 7_000_000), + make_video("dv5-2160", range_=Video.Range.DV, height=2160, bitrate=6_800_000, codec=H), + make_video("dv5-512", range_=Video.Range.DV, height=512, bitrate=400_000, codec=H), + ] + chosen = list(filter(Tracks().select_hybrid(tracks, [2160]), tracks)) + assert ids(chosen) == {"dvhdr-2160"} and chosen[0].native_hybrid + + +def test_select_hybrid_prefers_a_finished_hybrid_that_carries_hdr10p() -> None: + tracks = [ + dv_hdr10("dvhdr-2160", 2160, 9_000_000), + dv_hdr10("dvhdrp-2160", 2160, 7_000_000, Video.Range.HDR10P), + dv_hdr10p("dvp-2160", 2160, 6_000_000), + ] + assert ids(list(filter(Tracks().select_hybrid(tracks, [2160]), tracks))) == {"dvhdrp-2160"} + assert ids(list(filter(Tracks().select_hybrid(tracks, [2160], worst=True), tracks))) == {"dvp-2160"} + + +def test_select_hybrid_picks_no_base_that_has_no_dv_track_to_take_an_rpu_from() -> None: + H = Video.Codec.HEVC + tracks = [ + dv_hdr10("dvhdr-2160", 2160, 7_000_000), + make_video("hdr10p-1080", range_=Video.Range.HDR10P, height=1080, bitrate=4_000_000, codec=H), + ] + assert ids(list(filter(Tracks().select_hybrid(tracks, [2160, 1080]), tracks))) == {"dvhdr-2160"} + with_generate = Tracks().select_hybrid(tracks, [2160, 1080], generate_dv=True) + assert ids(list(filter(with_generate, tracks))) == {"dvhdr-2160", "hdr10p-1080"} + + 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 4edf14a3..99e6256b 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -80,6 +80,7 @@ from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.dv_fixup import apply_dv_fixup from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.tracks.track import assert_fragments_decrypted, has_encrypted_sample_entry +from unshackle.core.tracks.tracks import at_resolution from unshackle.core.utilities import ( apply_original_language, as_requested, @@ -273,7 +274,7 @@ def hybrid_plan(videos: Sequence[Video], generate_dv: bool) -> tuple[list[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``. A resolution with a - DV track that carries HDR10+ gets no base: that track is its hybrid. + finished hybrid (``native_hybrid``) gets no base: that track is its hybrid. """ dv_tracks = [v for v in videos if v.range == Video.Range.DV] native_heights = {v.height for v in videos if v.native_hybrid} @@ -2715,7 +2716,7 @@ class dl: effective_quality = [] else: hybrid_filter = title.tracks.select_hybrid( - hybrid_candidate_tracks, quality, worst=worst + hybrid_candidate_tracks, quality, worst=worst, generate_dv=generate_dv ) hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks)) @@ -2777,9 +2778,12 @@ class dl: hybrid_selected = [] if hybrid_quality: hybrid_filter = title.tracks.select_hybrid( - hybrid_candidate_tracks, hybrid_quality, worst=worst + hybrid_candidate_tracks, hybrid_quality, worst=worst, generate_dv=generate_dv ) hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks)) + for res in hybrid_quality: + if not any(at_resolution(v, res) for v in hybrid_selected): + self.log.warning(f"Skipping {res}p: nothing at that resolution can make a hybrid") non_hybrid_selected: list[Video] = [] if non_hybrid_ranges and non_hybrid_tracks: @@ -2840,7 +2844,9 @@ class dl: if keep_videos and any(r == Video.Range.HYBRID for r in range_): gate_failure = hybrid_gate_failure(title.tracks.videos, generate_dv) - if not gate_failure and not any(v.range == Video.Range.DV for v in title.tracks.videos): + if not gate_failure and not any( + v.range == Video.Range.DV or v.native_hybrid for v in title.tracks.videos + ): self.log.info("No Dolby Vision track: Dolby Vision will be generated from HDR10+") if gate_failure: @@ -3674,7 +3680,9 @@ class dl: 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") + self.log.info( + f"The {video.height}p {video.range_label} track is already the hybrid" + ) hybrids_made += 1 for group in sorted(standalone_groups, key=group_file_size, reverse=True): diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index fd89d443..add9b158 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -1752,7 +1752,7 @@ def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, "resolution": f"{track.width}x{track.height}" if track.width and track.height else None, "fps": track.fps if track.fps else None, "range": range_name, - "range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name) + ("+HDR10P" if track.hdr10plus else ""), + "range_display": DYNAMIC_RANGE_MAP.get(track.range_label, track.range_label), "scan_type": enum_name(track.scan_type) if track.scan_type else None, "closed_captions": list(getattr(track, "closed_captions", None) or []), "dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)), diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index 4bce8ed0..c01ca039 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -230,6 +230,7 @@ class DASH: # mimeType likely specifies the subtitle codec better than `codecs` codecs = mime_type.split("/")[1] + video_args: dict[str, Any] = {} if content_type == "video": track_type = Video track_codec = Video.Codec.from_codecs(codecs) @@ -252,6 +253,9 @@ class DASH: fps=track_fps or None, scan_type=scan_type, ) + video_args["dv_compatible_bitstream"] = Video.is_dv_composite( + track_codec, codecs, get("supplementalCodecs") + ) elif content_type == "audio": track_type = Audio track_codec = Audio.Codec.from_codecs(codecs) @@ -326,6 +330,7 @@ class DASH: } }, **track_args, + **video_args, ) ) @@ -1161,11 +1166,7 @@ class DASH: def get_video_range( codecs: str, all_supplemental_props: list[Element], all_essential_props: list[Element] ) -> Video.Range: - # TODO: Detect Dolby Vision composite streams in DASH manifests (DV RPU embedded but - # primary codec is plain hvc1, signaled via a separate AdaptationSet/Representation - # with DV codec strings or DolbyVisionConfigurationBox). When found, mark the track - # with dv_compatible_bitstream=True so DVFixup runs pre-mux. No DASH samples seen yet. - if codecs.startswith(("dva1", "dvav", "dvhe", "dvh1")): + if Video.has_dv_codec(codecs): return Video.Range.DV return Video.Range.from_cicp( diff --git a/unshackle/core/manifests/hls.py b/unshackle/core/manifests/hls.py index 0d503a22..166cedc9 100644 --- a/unshackle/core/manifests/hls.py +++ b/unshackle/core/manifests/hls.py @@ -222,28 +222,19 @@ class HLS: tracks = Tracks() supplemental_codecs = self.supplemental_codecs_by_uri() - dv_supp_prefixes = ("dva1", "dvav", "dvhe", "dvh1") for playlist in self.manifest.playlists: + video_codec: Optional[Video.Codec] = None try: # TODO: Any better way to figure out the primary track type? if playlist.stream_info.codecs: - Video.Codec.from_codecs(playlist.stream_info.codecs) + video_codec = Video.Codec.from_codecs(playlist.stream_info.codecs) except ValueError: primary_track_type = Audio else: primary_track_type = Video - primary_codecs = (playlist.stream_info.codecs or "").lower() - primary_has_dv = any(codec.split(".")[0] in dv_supp_prefixes for codec in primary_codecs.split(",")) - - supp_codecs_str = supplemental_codecs.get(playlist.uri, "") - supp_dv_codec: Optional[str] = None - for codec in supp_codecs_str.lower().split(","): - token = codec.strip().split("/")[0] - if token.split(".")[0] in dv_supp_prefixes: - supp_dv_codec = token - break + primary_has_dv = Video.has_dv_codec(playlist.stream_info.codecs) video_range = ( Video.Range.DV if primary_has_dv else Video.Range.from_m3u_range_tag(playlist.stream_info.video_range) @@ -252,7 +243,9 @@ class HLS: # a DV codec. Range stays whatever VIDEO-RANGE signaled (HDR10/HLG/SDR); DVFixup will # restore DV signaling post-download. HDR10+ SEI in an HDR10 track is found later by the # core HDR10+ probe. - dv_compatible_bitstream = primary_track_type is Video and not primary_has_dv and supp_dv_codec is not None + dv_compatible_bitstream = primary_track_type is Video and Video.is_dv_composite( + video_codec, playlist.stream_info.codecs, supplemental_codecs.get(playlist.uri) + ) tracks.add( primary_track_type( @@ -293,7 +286,9 @@ class HLS: joc = 0 if media.type == "AUDIO": track_type = Audio - codec = HLS.select_audio_codec(audio_codecs_by_group_id.get(media.group_id), media.uri) + codec: Union[Audio.Codec, Subtitle.Codec, None] = HLS.select_audio_codec( + audio_codecs_by_group_id.get(media.group_id), media.uri + ) if media.channels and media.channels.endswith("/JOC"): joc = int(media.channels.split("/JOC")[0]) media.channels = "5.1" @@ -354,11 +349,11 @@ class HLS: try: probe = HLS.probe_ts_info(video.url, self.session) if probe: - width, height, codec = probe + width, height, probed_codec = probe if not has_resolution: video.width, video.height = width, height if not has_codec: - video.codec = codec + video.codec = probed_codec # best-effort probe over network + ffprobe; the track stays usable without it except Exception as e: logging.getLogger("HLS").debug(f"TS probe failed for {video.url}: {e!r}") diff --git a/unshackle/core/tracks/dv_fixup.py b/unshackle/core/tracks/dv_fixup.py index 0f2c1c0c..b52389e4 100644 --- a/unshackle/core/tracks/dv_fixup.py +++ b/unshackle/core/tracks/dv_fixup.py @@ -1,8 +1,8 @@ """ -DV exposure for HLS composite HEVC streams. +DV exposure for HLS and DASH composite HEVC streams. -Some services deliver DV Profile 8.1 in a track whose primary CODECS is plain -hvc1, with DV advertised only in SUPPLEMENTAL-CODECS. The fMP4 carries valid DV RPU NALs +Some services deliver DV Profile 8.1 in a track whose primary codec is plain +hvc1, with DV advertised only in HLS SUPPLEMENTAL-CODECS or DASH @supplementalCodecs. The fMP4 carries valid DV RPU NALs but the container does not signal DV, so muxing the MP4 directly produces an MKV that MediaInfo and DV-capable TVs see as plain HDR10/HDR10+. diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index d06b2b9f..2eaceb25 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -445,20 +445,27 @@ class Tracks: 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): + def select_hybrid(self, tracks, quality, worst: bool = False, generate_dv: 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. + A track that already carries DV and an HDR10 or HDR10+ base (DV+HDR10P, HDR10+DV, HDR10P+DV) is a finished + hybrid, so its resolution takes that track and no base. One that carries HDR10+ comes first. The other + resolutions get a base only when a DV track can give it an RPU, or with ``generate_dv``. """ 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)] + candidates = [v for v in tracks if v.finished_hybrid and at_resolution(v, res)] if candidates: - natives[res] = pick(candidates, key=lambda v: v.bitrate or 0) + with_hdr10plus = [v for v in candidates if v.carries_hdr10plus] + natives[res] = pick(with_hdr10plus or 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] + dv_tracks = [v for v in tracks if v.range == Video.Range.DV] + if natives and not dv_tracks and not generate_dv: + quality = [] # no DV track to take an RPU from + else: + 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. @@ -482,7 +489,6 @@ class Tracks: 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 and (base_selected or not natives) else None def select(x): diff --git a/unshackle/core/tracks/video.py b/unshackle/core/tracks/video.py index 6e0f5d63..f2b0d6bc 100644 --- a/unshackle/core/tracks/video.py +++ b/unshackle/core/tracks/video.py @@ -338,10 +338,45 @@ class Video(Track): hdr10plus=data.get("hdr10plus", False), ) + @staticmethod + def dv_fourccs(codecs: Optional[str]) -> set[str]: + """The Dolby Vision fourccs in a codecs list (comma or space separated, ``/brand`` suffixes allowed).""" + fourccs = {token.split("/")[0].split(".")[0] for token in re.split(r"[\s,]+", (codecs or "").lower())} + return fourccs & {"dva1", "dvav", "dvhe", "dvh1"} + + @staticmethod + def has_dv_codec(codecs: Optional[str]) -> bool: + """Whether a codecs list names a Dolby Vision codec.""" + return bool(Video.dv_fourccs(codecs)) + + @staticmethod + def is_dv_composite( + codec: Optional[Video.Codec], codecs: Optional[str], supplemental_codecs: Optional[str] + ) -> bool: + """Whether an HEVC track names DV only in its supplemental codecs: DV profile 8 on an HDR10, SDR or HLG base.""" + return ( + codec == Video.Codec.HEVC + and not Video.has_dv_codec(codecs) + and bool(Video.dv_fourccs(supplemental_codecs) & {"dvh1", "dvhe"}) + ) + + @property + def carries_hdr10plus(self) -> bool: + """Whether the track carries HDR10+, as its range or, on a DV track, found by the HDR10+ probe.""" + return self.hdr10plus or self.range == Video.Range.HDR10P + + @property + def finished_hybrid(self) -> bool: + """Whether the track already carries DV and an HDR10 or HDR10+ base, so ``-r HYBRID`` needs no build.""" + if self.range == Video.Range.DV: + return self.hdr10plus + return self.dv_compatible_bitstream and self.range in (Video.Range.HDR10, Video.Range.HDR10P) + @property def range_label(self) -> str: - """The range name, with ``+HDR10P`` on a DV track that also carries HDR10+.""" - return self.range.name + ("+HDR10P" if self.hdr10plus else "") + """The range that ``-r`` selects the track by, then any second format it carries: HDR10+DV, DV+HDR10P.""" + label = self.range.name + ("+HDR10P" if self.hdr10plus else "") + return f"{label}+DV" if self.dv_compatible_bitstream and self.range != Video.Range.DV else label def __str__(self) -> str: return " | ".join(