mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
feat(hybrid): use a dv track that carries hdr10+ as the hybrid of its resolution
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+19
-12
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user