mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-13 12:35:05 +00:00
feat(dash): flag dv profile 8 tracks from supplementalCodecs and use hdr10+dv as the hybrid
This commit is contained in:
@@ -454,7 +454,8 @@ the API surface.
|
|||||||
### Dolby Vision and hybrid
|
### Dolby Vision and hybrid
|
||||||
|
|
||||||
Two DV mechanisms sit on top of the track model. **DV fixup** re-exposes DV signalling on
|
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,
|
**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
|
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
|
track that carries both HDR10 metadata and the DV RPU. Both are automatic once the
|
||||||
|
|||||||
@@ -145,6 +145,21 @@ unshackle dl -v hevc,avc EXAMPLE 81234567
|
|||||||
unshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'
|
unshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--list` labels a track that carries a second format as `<range>+<format>`:
|
||||||
|
|
||||||
|
- 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"
|
!!! warning "HYBRID requires dovi_tool"
|
||||||
`-r HYBRID` makes a single hybrid track by injecting the Dolby Vision RPU
|
`-r HYBRID` makes a single hybrid track by injecting the Dolby Vision RPU
|
||||||
metadata onto an HDR10/HDR10+ base layer with **dovi_tool**.
|
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,
|
and goes on with the other resolutions of the title. When no hybrid file at all was made,
|
||||||
the title fails.
|
the title fails.
|
||||||
|
|
||||||
A DV track that carries HDR10+ (`DV+HDR10P` in `--list`) is already a hybrid. At a resolution
|
A track that is `DV+HDR10P`, `HDR10+DV` or `HDR10P+DV` in `--list` already carries Dolby Vision and an HDR10
|
||||||
that has one, `-r HYBRID` selects the DV+HDR10P track with the highest bitrate (the lowest with
|
or HDR10+ base, so it is already a hybrid. At a resolution that has one, `-r HYBRID` selects it
|
||||||
`--worst`) and muxes it as it is, with no base track and no dovi_tool step. The other
|
and muxes it as it is, with no base track and no dovi_tool step. A track that carries HDR10+
|
||||||
resolutions get a hybrid that unshackle makes.
|
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:
|
To keep an HDR10+ deliverable *alongside* the hybrid, request both ranges:
|
||||||
`-r HYBRID,HDR10P`. `-r HYBRID` on its own muxes only the merged hybrid.
|
`-r HYBRID,HDR10P`. `-r HYBRID` on its own muxes only the merged hybrid.
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from unshackle.core.manifests import DASH
|
||||||
|
from unshackle.core.tracks import Video
|
||||||
|
|
||||||
|
MPD = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:scte214="urn:scte:dash:scte214-extensions"
|
||||||
|
type="static" mediaPresentationDuration="PT4S">
|
||||||
|
<Period id="p0">
|
||||||
|
<AdaptationSet contentType="video" mimeType="video/mp4">
|
||||||
|
<EssentialProperty schemeIdUri="urn:mpeg:mpegB:cicp:ColourPrimaries" value="9"/>
|
||||||
|
<EssentialProperty schemeIdUri="urn:mpeg:mpegB:cicp:TransferCharacteristics" value="16"/>
|
||||||
|
<EssentialProperty schemeIdUri="urn:mpeg:mpegB:cicp:MatrixCoefficients" value="9"/>
|
||||||
|
<Representation id="hdr" codecs="hvc1.2.4.L150.90" width="3840" height="1920" bandwidth="7000000"
|
||||||
|
supplementalCodecs="dvh1.08.06" supplementalProfiles="db1p">
|
||||||
|
<BaseURL>hdr.mp4</BaseURL><SegmentBase indexRange="0-99"/>
|
||||||
|
</Representation>
|
||||||
|
<Representation id="scte" codecs="hvc1.2.4.L120.90" width="1600" height="800" bandwidth="700000"
|
||||||
|
scte214:supplementalCodecs="dvh1.08.03">
|
||||||
|
<BaseURL>scte.mp4</BaseURL><SegmentBase indexRange="0-99"/>
|
||||||
|
</Representation>
|
||||||
|
<Representation id="plain" codecs="hvc1.2.4.L90.90" width="1024" height="512" bandwidth="400000">
|
||||||
|
<BaseURL>plain.mp4</BaseURL><SegmentBase indexRange="0-99"/>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet>
|
||||||
|
<AdaptationSet contentType="video" mimeType="video/mp4">
|
||||||
|
<Representation id="dv" codecs="dvh1.05.06" width="3840" height="1920" bandwidth="6800000"
|
||||||
|
supplementalCodecs="dvh1.05.06">
|
||||||
|
<BaseURL>dv.mp4</BaseURL><SegmentBase indexRange="0-99"/>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet>
|
||||||
|
</Period>
|
||||||
|
</MPD>"""
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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"}
|
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:
|
def test_merge_dedups_shared_ingredient_and_deliverable() -> None:
|
||||||
H = Video.Codec.HEVC
|
H = Video.Codec.HEVC
|
||||||
dv = make_video("dv-1080", range_=Video.Range.DV, height=1080, bitrate=9_000_000, codec=H)
|
dv = make_video("dv-1080", range_=Video.Range.DV, height=1080, bitrate=9_000_000, codec=H)
|
||||||
|
|||||||
@@ -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.dv_fixup import apply_dv_fixup
|
||||||
from unshackle.core.tracks.hybrid import Hybrid
|
from unshackle.core.tracks.hybrid import Hybrid
|
||||||
from unshackle.core.tracks.track import assert_fragments_decrypted, has_encrypted_sample_entry
|
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 (
|
from unshackle.core.utilities import (
|
||||||
apply_original_language,
|
apply_original_language,
|
||||||
as_requested,
|
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.
|
"""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
|
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]
|
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}
|
native_heights = {v.height for v in videos if v.native_hybrid}
|
||||||
@@ -2715,7 +2716,7 @@ class dl:
|
|||||||
effective_quality = []
|
effective_quality = []
|
||||||
else:
|
else:
|
||||||
hybrid_filter = title.tracks.select_hybrid(
|
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))
|
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
|
||||||
|
|
||||||
@@ -2777,9 +2778,12 @@ class dl:
|
|||||||
hybrid_selected = []
|
hybrid_selected = []
|
||||||
if hybrid_quality:
|
if hybrid_quality:
|
||||||
hybrid_filter = title.tracks.select_hybrid(
|
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))
|
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] = []
|
non_hybrid_selected: list[Video] = []
|
||||||
if non_hybrid_ranges and non_hybrid_tracks:
|
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_):
|
if keep_videos and any(r == Video.Range.HYBRID for r in range_):
|
||||||
gate_failure = hybrid_gate_failure(title.tracks.videos, generate_dv)
|
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+")
|
self.log.info("No Dolby Vision track: Dolby Vision will be generated from HDR10+")
|
||||||
|
|
||||||
if gate_failure:
|
if gate_failure:
|
||||||
@@ -3674,7 +3680,9 @@ class dl:
|
|||||||
hybrid_base_ids.update(v.id for v in hybrid_bases)
|
hybrid_base_ids.update(v.id for v in hybrid_bases)
|
||||||
hybrids_made = 0
|
hybrids_made = 0
|
||||||
for video in (v for v in original_videos if v.native_hybrid):
|
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
|
hybrids_made += 1
|
||||||
|
|
||||||
for group in sorted(standalone_groups, key=group_file_size, reverse=True):
|
for group in sorted(standalone_groups, key=group_file_size, reverse=True):
|
||||||
|
|||||||
@@ -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,
|
"resolution": f"{track.width}x{track.height}" if track.width and track.height else None,
|
||||||
"fps": track.fps if track.fps else None,
|
"fps": track.fps if track.fps else None,
|
||||||
"range": range_name,
|
"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,
|
"scan_type": enum_name(track.scan_type) if track.scan_type else None,
|
||||||
"closed_captions": list(getattr(track, "closed_captions", None) or []),
|
"closed_captions": list(getattr(track, "closed_captions", None) or []),
|
||||||
"dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)),
|
"dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)),
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ class DASH:
|
|||||||
# mimeType likely specifies the subtitle codec better than `codecs`
|
# mimeType likely specifies the subtitle codec better than `codecs`
|
||||||
codecs = mime_type.split("/")[1]
|
codecs = mime_type.split("/")[1]
|
||||||
|
|
||||||
|
video_args: dict[str, Any] = {}
|
||||||
if content_type == "video":
|
if content_type == "video":
|
||||||
track_type = Video
|
track_type = Video
|
||||||
track_codec = Video.Codec.from_codecs(codecs)
|
track_codec = Video.Codec.from_codecs(codecs)
|
||||||
@@ -252,6 +253,9 @@ class DASH:
|
|||||||
fps=track_fps or None,
|
fps=track_fps or None,
|
||||||
scan_type=scan_type,
|
scan_type=scan_type,
|
||||||
)
|
)
|
||||||
|
video_args["dv_compatible_bitstream"] = Video.is_dv_composite(
|
||||||
|
track_codec, codecs, get("supplementalCodecs")
|
||||||
|
)
|
||||||
elif content_type == "audio":
|
elif content_type == "audio":
|
||||||
track_type = Audio
|
track_type = Audio
|
||||||
track_codec = Audio.Codec.from_codecs(codecs)
|
track_codec = Audio.Codec.from_codecs(codecs)
|
||||||
@@ -326,6 +330,7 @@ class DASH:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
**track_args,
|
**track_args,
|
||||||
|
**video_args,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1161,11 +1166,7 @@ class DASH:
|
|||||||
def get_video_range(
|
def get_video_range(
|
||||||
codecs: str, all_supplemental_props: list[Element], all_essential_props: list[Element]
|
codecs: str, all_supplemental_props: list[Element], all_essential_props: list[Element]
|
||||||
) -> Video.Range:
|
) -> Video.Range:
|
||||||
# TODO: Detect Dolby Vision composite streams in DASH manifests (DV RPU embedded but
|
if Video.has_dv_codec(codecs):
|
||||||
# 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")):
|
|
||||||
return Video.Range.DV
|
return Video.Range.DV
|
||||||
|
|
||||||
return Video.Range.from_cicp(
|
return Video.Range.from_cicp(
|
||||||
|
|||||||
@@ -222,28 +222,19 @@ class HLS:
|
|||||||
tracks = Tracks()
|
tracks = Tracks()
|
||||||
|
|
||||||
supplemental_codecs = self.supplemental_codecs_by_uri()
|
supplemental_codecs = self.supplemental_codecs_by_uri()
|
||||||
dv_supp_prefixes = ("dva1", "dvav", "dvhe", "dvh1")
|
|
||||||
|
|
||||||
for playlist in self.manifest.playlists:
|
for playlist in self.manifest.playlists:
|
||||||
|
video_codec: Optional[Video.Codec] = None
|
||||||
try:
|
try:
|
||||||
# TODO: Any better way to figure out the primary track type?
|
# TODO: Any better way to figure out the primary track type?
|
||||||
if playlist.stream_info.codecs:
|
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:
|
except ValueError:
|
||||||
primary_track_type = Audio
|
primary_track_type = Audio
|
||||||
else:
|
else:
|
||||||
primary_track_type = Video
|
primary_track_type = Video
|
||||||
|
|
||||||
primary_codecs = (playlist.stream_info.codecs or "").lower()
|
primary_has_dv = Video.has_dv_codec(playlist.stream_info.codecs)
|
||||||
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
|
|
||||||
|
|
||||||
video_range = (
|
video_range = (
|
||||||
Video.Range.DV if primary_has_dv else Video.Range.from_m3u_range_tag(playlist.stream_info.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
|
# 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
|
# restore DV signaling post-download. HDR10+ SEI in an HDR10 track is found later by the
|
||||||
# core HDR10+ probe.
|
# 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(
|
tracks.add(
|
||||||
primary_track_type(
|
primary_track_type(
|
||||||
@@ -293,7 +286,9 @@ class HLS:
|
|||||||
joc = 0
|
joc = 0
|
||||||
if media.type == "AUDIO":
|
if media.type == "AUDIO":
|
||||||
track_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"):
|
if media.channels and media.channels.endswith("/JOC"):
|
||||||
joc = int(media.channels.split("/JOC")[0])
|
joc = int(media.channels.split("/JOC")[0])
|
||||||
media.channels = "5.1"
|
media.channels = "5.1"
|
||||||
@@ -354,11 +349,11 @@ class HLS:
|
|||||||
try:
|
try:
|
||||||
probe = HLS.probe_ts_info(video.url, self.session)
|
probe = HLS.probe_ts_info(video.url, self.session)
|
||||||
if probe:
|
if probe:
|
||||||
width, height, codec = probe
|
width, height, probed_codec = probe
|
||||||
if not has_resolution:
|
if not has_resolution:
|
||||||
video.width, video.height = width, height
|
video.width, video.height = width, height
|
||||||
if not has_codec:
|
if not has_codec:
|
||||||
video.codec = codec
|
video.codec = probed_codec
|
||||||
# best-effort probe over network + ffprobe; the track stays usable without it
|
# best-effort probe over network + ffprobe; the track stays usable without it
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger("HLS").debug(f"TS probe failed for {video.url}: {e!r}")
|
logging.getLogger("HLS").debug(f"TS probe failed for {video.url}: {e!r}")
|
||||||
|
|||||||
@@ -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
|
Some services deliver DV Profile 8.1 in a track whose primary codec is plain
|
||||||
hvc1, with DV advertised only in SUPPLEMENTAL-CODECS. The fMP4 carries valid DV RPU NALs
|
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
|
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+.
|
MediaInfo and DV-capable TVs see as plain HDR10/HDR10+.
|
||||||
|
|
||||||
|
|||||||
@@ -445,20 +445,27 @@ class Tracks:
|
|||||||
if video not in non_hybrid_selected and not video.native_hybrid:
|
if video not in non_hybrid_selected and not video.native_hybrid:
|
||||||
video.hybrid_base_only = True
|
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 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
|
pick = min if worst else max
|
||||||
natives = {}
|
natives = {}
|
||||||
for res in quality:
|
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:
|
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:
|
for video in tracks:
|
||||||
video.native_hybrid = any(video is v for v in natives.values())
|
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).
|
# 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.
|
# 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)
|
chosen = pick(candidates, key=lambda v: v.bitrate)
|
||||||
base_selected.append(chosen)
|
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
|
lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks and (base_selected or not natives) else None
|
||||||
|
|
||||||
def select(x):
|
def select(x):
|
||||||
|
|||||||
@@ -338,10 +338,45 @@ class Video(Track):
|
|||||||
hdr10plus=data.get("hdr10plus", False),
|
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
|
@property
|
||||||
def range_label(self) -> str:
|
def range_label(self) -> str:
|
||||||
"""The range name, with ``+HDR10P`` on a DV track that also carries HDR10+."""
|
"""The range that ``-r`` selects the track by, then any second format it carries: HDR10+DV, DV+HDR10P."""
|
||||||
return self.range.name + ("+HDR10P" if self.hdr10plus else "")
|
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:
|
def __str__(self) -> str:
|
||||||
return " | ".join(
|
return " | ".join(
|
||||||
|
|||||||
Reference in New Issue
Block a user