feat(hybrid): generate dolby vision from hdr10+ with --generate-dv

Builds per-scene L1 in unshackle (PQ mean, letterbox-aware) with a generate_dv_mode config (v29/v40/legacy); a failed hybrid no longer exits 0 with no video.
This commit is contained in:
imSp4rky
2026-10-17 16:40:14 -06:00
parent 14bd2f2c7e
commit 35871f3a52
22 changed files with 1370 additions and 286 deletions
+7
View File
@@ -156,8 +156,15 @@ class TrackRequest:
codecs: list[Video.Codec] = [] # empty == accept any codec
ranges: list[Video.Range] = [Video.Range.SDR]
best_available: bool = False
generate_dv: bool = False # --generate-dv: make Dolby Vision from HDR10+
```
!!! note "`generate_dv` and a hand-made HYBRID"
A service that fetches the HYBRID variants itself (HDR10 and DV) must also fetch
HDR10P when `track_request.generate_dv` is set and the title has no DV track.
The conversion needs an HEVC HDR10+ track as its base.
`get_tracks_for_variants` does this for you.
!!! note "Read the request, don't second-guess the user"
Services may narrow `track_request` for hard technical constraints (e.g.
*"this service delivers HDR only as HEVC"*), but must **not** filter
+1
View File
@@ -471,6 +471,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is
| `downloads` | int | `1` | Concurrent tracks. |
| `worst` | boolean | `false` | Lowest bitrate within quality. |
| `best_available` | boolean | `false` | Fall back to best available. With `all_drm`, a DRM system that does not license is a warning. |
| `generate_dv` | boolean | `false` | With `HYBRID` in `range`, make Dolby Vision from the HDR10+ metadata when a title has no DV track. Needs `hdr10plus_tool` on the server. Without `HYBRID` in `range`, the request returns `400`. |
| `repack` | boolean | `false` | Add REPACK tag. |
| `tag` | string | `null` | Release group tag. |
| `tmdb_id` / `imdb_id` / `tvdb_id` / `anilist_id` | - | `null` | External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set `enrich` to also take the title, year and original language. Give at most one of `tmdb_id`, `imdb_id` and `tvdb_id`, since unshackle resolves the others from it. Sending two returns `400`. `anilist_id` still combines with one of them. `tmdb_id` and `tvdb_id` must be positive integers, `imdb_id` must look like `tt1375666`, and `anilist_id` must be a positive integer or a string like `mal:12345`, or the request returns `400`. An ID whose provider is unconfigured, such as `tmdb_id` with no `tmdb_api_key`, fails the job rather than returning `400`. `anilist_id` needs no API key. |
+3 -3
View File
@@ -130,8 +130,8 @@ hood the client walks a remote session through its lifecycle.
The client also downloads playlists and segments through the same proxy,
as a local run does. `--no-proxy-download` and `--proxy-download` change
that for downloads only.
- Track-selection hints (`range_`, `vcodec`, `quality`, `best_available`) so
the server fetches the right manifests
- Track-selection hints (`range_`, `vcodec`, `quality`, `best_available`,
`generate_dv`) so the server fetches the right manifests
- Your language and audio codec selection (`-l`, `-vl`, `-al`, `-a`, `-fs`)
in a `dl_params` object. Some services fetch one manifest
for each audio language, and pick the languages from these values
@@ -428,7 +428,7 @@ The server mounts all these routes, even in `--remote-only` mode. Paths use the
answers CDM calls), `cdm_system_id` and `cdm_device_type` (Widevine only), `service_config`
(the identity config keys the service lists in `client_config`), `client` (a freeform identity
object the dashboard shows as sent), and the track-selection hints `range_`, `vcodec`,
`quality`, `best_available`, plus arbitrary service CLI options
`quality`, `best_available`, `generate_dv`, plus arbitrary service CLI options
(`additionalProperties: true`).
`dl_params` holds the `dl` track selection: `lang`, `v_lang` and `a_lang`
+4 -2
View File
@@ -133,8 +133,10 @@ See [Proxies and VPN](../guide/proxies-and-vpn.md).
!!! note "`TrackRequest`"
`self.track_request` is a small dataclass built from the CLI: `codecs`
(from `--vcodec`, empty means "accept any"), `ranges` (from `--range`, default
`[Video.Range.SDR]`), and `best_available`. Use it in `get_tracks` to decide which
variants to fetch.
`[Video.Range.SDR]`), `best_available`, and `generate_dv` (from `--generate-dv`).
Use it in `get_tracks` to decide which variants to fetch. A service that fetches
the HYBRID variants itself must also fetch HDR10P when `generate_dv` is set and
the title has no DV track, because the conversion needs an HEVC HDR10+ base.
## Titles and the `Title` hierarchy
+1
View File
@@ -80,6 +80,7 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
| `-vb-range`, `--vbitrate-range` | - | Video bitrate range in kbps, e.g. `6000-7000`; picks highest within. Mutually exclusive with `--vbitrate`. |
| `-ab-range`, `--abitrate-range` | - | Audio bitrate range in kbps, e.g. `128-256`. Mutually exclusive with `--abitrate`. |
| `-r`, `--range` | `SDR` | Colour range(s): `SDR`, `HLG`, `HDR10`, `HDR10P` (HDR10+), `DV`, `HYBRID`. |
| `--generate-dv` | off | With `-r HYBRID`, make Dolby Vision from the HDR10+ metadata when a title has no Dolby Vision track. Needs `hdr10plus_tool`. See [Dolby Vision from HDR10+](downloading.md#dolby-vision-from-hdr10). |
| `-c`, `--channels` | - | Audio channels; matches sub-layouts (5.1 ≈ 6.0). |
| `-naa`, `--noatmos` | off | Exclude Dolby Atmos audio tracks. |
| `--worst` | off | Pick the lowest bitrate within the requested quality. **Requires `-q`.** |
+44 -4
View File
@@ -149,14 +149,54 @@ unshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'
`-r HYBRID` makes a single hybrid track by injecting the Dolby Vision RPU
metadata onto an HDR10/HDR10+ base layer with **dovi_tool**.
It requires the `dovi_tool` binary, resolved from unshackle's `binaries/` folder,
the `binaries` folder of your user data directory, or your `PATH`. The normal case is a DV track plus an HDR10 or HDR10+ base. With HDR10+
and no DV, unshackle converts the HDR10+ metadata to DV instead, which also needs
`hdr10plus_tool`. With no DV and no HDR10+, the title fails.
When HDR10+ is present, unshackle prefers it over HDR10 as the base layer.
the `binaries` folder of your user data directory, or your `PATH`. A hybrid needs a DV track plus an HDR10 or
HDR10+ base. With no DV track, the title fails, unless you add `--generate-dv` (see below).
When HDR10+ is present, unshackle prefers it over HDR10 as the base layer. The base must be
HEVC; a title whose HDR10/HDR10+ tracks are all in another codec fails before it downloads.
If the hybrid step fails for one resolution, unshackle logs an error, skips that resolution
and goes on with the other resolutions of the title. When no hybrid file at all was made,
the title fails.
To keep an HDR10+ deliverable *alongside* the hybrid, request both ranges:
`-r HYBRID,HDR10P`. `-r HYBRID` on its own muxes only the merged hybrid.
#### Dolby Vision from HDR10+
When a title has HDR10+ but no DV track, `-r HYBRID --generate-dv` makes a Dolby Vision
Profile 8.1 track from the HDR10+ metadata. It needs `hdr10plus_tool` as well as `dovi_tool`.
When a title has a DV track, unshackle uses that track and ignores the flag. When it has none,
the flag also makes unshackle ask the service for its HDR10+ tracks.
```shell title="Generate Dolby Vision from HDR10+, and keep the HDR10+ file too"
unshackle dl -q 2160 -r HYBRID,HDR10P --generate-dv EXAMPLE '...'
```
Each HDR10+ scene becomes one Dolby Vision shot, with its own scene cut and its own Level 1
brightness values (peak and average). unshackle calculates the average itself, as a PQ mean of
the HDR10+ brightness distribution without the letterbox bars. A scene with no black pixels keeps
its whole frame, so a title that changes aspect ratio is also correct. The `dovi_tool generate
--hdr10plus-json` average makes most scenes too bright.
The [`generate_dv_mode`](../reference/configuration/download.md#generate_dv_mode) setting in
`unshackle.yaml` changes how the average is set: `v29` (default), `v40` for the 10-nit floor of studio
Dolby Vision, or `legacy` for the `dovi_tool` average.
!!! warning "Generated Dolby Vision is not studio Dolby Vision"
- There are no colourist trims (Level 2 and Level 8). The TV does all the tone mapping
from the Level 1 values.
- Dolby Vision does not let the average go below about 2.4 nits (10 nits with
`generate_dv_mode: v40`), so a very dark scene reads a little brighter than its pixels.
- The HDR10+ tone-mapping curve (Profile B) has no Dolby Vision equivalent. The HDR10+
metadata stays in the file, so an HDR10+ TV still reads it.
- The result can be no better than the HDR10+ metadata. Profile A metadata is often less
reliable than Profile B, and unshackle shows a warning for Profile A.
- A TV or player that supports both formats almost always picks Dolby Vision. On such a
TV, the generated Dolby Vision replaces the original HDR10+. To keep the original, also
request `HDR10P`, as in the example above.
An HDR10 track that a service labels HDR10 but that carries HDR10+ metadata does not
convert. Only a track with the `HDR10P` range does.
## Bitrate selection
By default unshackle keeps the highest-bitrate rendition for each selected
+22
View File
@@ -52,6 +52,7 @@ Common config keys. This is a useful subset, and every `dl` flag works:
| `speed_limit` | str | *(unlimited)* | Total download speed cap across all downloads combined, e.g. `500k`, `5M`, `1.5G` or plain bytes/sec. Values are bytes, not bits (`5M` = 5.0 MB/s). |
| `slow` | str | *(unset)* | Inter-title delay, e.g. `"20-40"`. |
| `best_available` | bool | `false` | Warn instead of failing when a requested resolution, range, or language is missing, or when one DRM system does not license under `--all-drm`. The CLI flag is `--best-available`, also spelled `--warn-only`. |
| `generate_dv` | bool | `false` | With `-r HYBRID`, make Dolby Vision from the HDR10+ metadata when a title has no DV track. Needs `hdr10plus_tool`. See [Dolby Vision from HDR10+](../../guide/downloading.md#dolby-vision-from-hdr10). |
| `proxy` | str | *(unset)* | Default proxy URI or 2-letter country. |
| `no_folder` | bool | `false` | Do not create a per-title folder. |
@@ -368,3 +369,24 @@ dual_multi_mode: strict
!!! note "Dialects count as one language"
Regional variants of the same base language (e.g. `en-US` and `en-GB`) collapse to a single
language in both modes, so they never trigger `{dual}` on their own.
## `generate_dv_mode`
- **Type:** `str`  ·  **Default:** `v29`
How [`--generate-dv`](../../guide/downloading.md#dolby-vision-from-hdr10) sets the Level 1 brightness
values of the Dolby Vision it makes from HDR10+. All modes make one shot per HDR10+ scene and keep the
same peak and letterbox area. They differ in the scene average, which the TV uses to tone-map.
| Mode | Scene average |
|------|---------------|
| `v29` (default) | unshackle's average from the HDR10+ brightness distribution, at least about 2.4 nits. Closest to the pixels. |
| `v40` | The same average, at least about 10 nits, as in studio Dolby Vision (CM v4.0). Dark scenes read brighter than `v29`. |
| `legacy` | The `dovi_tool generate --hdr10plus-json` average. Most scenes read brighter than the pixels. |
```yaml
generate_dv_mode: v29
```
The setting applies on the machine that runs the hybrid step: the client for a remote download, the
server for a REST API job.
+1 -1
View File
@@ -31,7 +31,7 @@ Where the file lives, how unshackle discovers it, and how to edit it:
|------|------|
| [Directories](directories.md) | `directories`, `filenames`, `services_repo_force` |
| [Services & authentication](services.md) | `services`, `credentials`, `firefox_cookies`, `remote_services`, `serve` |
| [Download & processing](download.md) | `dl`, `continue_downloads`, `merge_segments`, `subtitle`, `audio`, `muxing`, `language_tags`, `dual_multi_mode` |
| [Download & processing](download.md) | `dl`, `continue_downloads`, `merge_segments`, `subtitle`, `audio`, `muxing`, `language_tags`, `dual_multi_mode`, `generate_dv_mode` |
| [Output & naming](output.md) | `output_template`, `tag`, `tag_rules`, `tag_group_name`, `tag_imdb_tmdb`, `chapter_fallback_name`, `unicode_filenames` |
| [DRM & CDM](drm.md) | `cdm`, `remote_cdm`, `decryption` |
| [Network & proxy](network.md) | `network`, `headers`, `proxy_providers` |
+3 -1
View File
@@ -20,7 +20,9 @@ pytestmark = pytest.mark.unit
def fake_video(tmp_path: Path, track_id: str, range_: Video.Range, height: int = 2160) -> Any:
path = tmp_path / f"{track_id}.mp4"
path.write_bytes(b"x")
return SimpleNamespace(id=track_id, path=path, height=height, range=range_, dv_compatible_bitstream=True)
return SimpleNamespace(
id=track_id, path=path, height=height, range=range_, codec=Video.Codec.HEVC, dv_compatible_bitstream=True
)
@pytest.fixture
+736
View File
@@ -0,0 +1,736 @@
"""Unit tests for the HDR10+ to Dolby Vision conversion: the L1 built from HDR10+ histograms, the
generator config, the active-area share, and the dl and REST API entry points. Nothing runs
dovi_tool, hdr10plus_tool or FFmpeg."""
from __future__ import annotations
import contextlib
import gc
import json
import logging
import re
import weakref
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Iterator, Optional
import pytest
import unshackle.commands.dl as dl_module
from tests.remote.unit.test_remote_service_helpers import _sent_create_data
from unshackle.commands.dl import (
hybrid_gate_failure,
hybrid_plan,
hybrid_source_tracks,
hybrid_step_error,
missing_hybrid_error,
select_hybrid_bases,
)
from unshackle.core.api import download_manager, handlers
from unshackle.core.api.handlers import validate_download_parameters
from unshackle.core.config import config
from unshackle.core.service import Service, TrackRequest
from unshackle.core.services import Services
from unshackle.core.tracks import Tracks, Video
from unshackle.core.tracks import hybrid as hybrid_mod
from unshackle.core.tracks.hybrid import Hybrid, hdr10plus_mean_pq, hdr10plus_shots, nits_to_pq
pytestmark = pytest.mark.unit
INDEXES = [1, 5, 10, 25, 50, 75, 90, 95, 99]
def histogram(nits: dict[int, float]) -> dict[int, float]:
"""A full 9-slot histogram: every distribution slot at 50 nits unless given."""
return {index: nits.get(index, 50.0) for index in INDEXES}
def scene_info(values: dict[int, float], average_rgb: float = 30.0) -> dict[str, Any]:
return {
"LuminanceParameters": {
"AverageRGB": round(average_rgb * 10),
"LuminanceDistributions": {
"DistributionIndex": list(values),
"DistributionValues": [round(v * 10) for v in values.values()],
},
"MaxScl": [0, 0, 0],
}
}
def metadata(
scenes: list[tuple[int, dict[int, float]]], first_frame: int = 0, average_rgb: float = 30.0
) -> dict[str, Any]:
"""HDR10+ JSON in hdr10plus_tool's layout, one SceneInfo entry per frame."""
frames = [scene_info(values, average_rgb) for length, values in scenes for _ in range(length)]
firsts, frame = [], first_frame
for length, _ in scenes:
firsts.append(frame)
frame += length
return {
"JSONInfo": {"HDR10plusProfile": "B", "Version": "1.0"},
"SceneInfo": frames,
"SceneInfoSummary": {"SceneFirstFrameIndex": firsts, "SceneFrameNumbers": [n for n, _ in scenes]},
}
def test_pq_matches_the_st2084_reference_points() -> None:
assert round(nits_to_pq(100)) == 2081
assert round(nits_to_pq(1000)) == 3079
assert round(nits_to_pq(10000)) == 4095
assert round(nits_to_pq(0)) == 0
@pytest.mark.parametrize("active", [1.0, 0.75])
def test_a_flat_histogram_averages_to_its_own_level(active: float) -> None:
values = {index: 120.0 for index in INDEXES}
mean = hdr10plus_mean_pq(values, active)
assert mean is not None and mean == pytest.approx(nits_to_pq(120.0))
def test_the_slots_outside_the_distribution_do_not_move_the_mean() -> None:
"""Index 5 is a luminance-Y value and index 10 a pixel share, so neither is a maxRGB percentile."""
base = hdr10plus_mean_pq(histogram({5: 50.0, 10: 50.0}))
changed = hdr10plus_mean_pq(histogram({5: 4000.0, 10: 9.7}))
assert base == changed
def test_the_mean_is_taken_in_pq_not_in_linear_light() -> None:
"""Half the pixels at 1 nit and half at 1000: PQ of the linear mean is far brighter than the PQ mean."""
values = histogram({1: 1.0, 25: 1.0, 50: 1.0, 75: 1000.0, 90: 1000.0, 95: 1000.0, 99: 1000.0})
mean = hdr10plus_mean_pq(values)
assert mean is not None
assert mean < nits_to_pq(500.5) - 400
LETTERBOXED = histogram({1: 0.0, 25: 20.0, 50: 40.0, 75: 80.0, 90: 150.0, 95: 200.0, 99: 400.0})
def test_letterbox_bars_are_left_out_of_the_mean() -> None:
"""Black up to the bar share and flat above it: the active mean is the plateau itself."""
bars_then_plateau = histogram({1: 0.0, 25: 200.0, 50: 200.0, 75: 200.0, 90: 200.0, 95: 200.0, 99: 200.0})
full, active = hdr10plus_mean_pq(bars_then_plateau, 1.0), hdr10plus_mean_pq(bars_then_plateau, 0.75)
assert full is not None and active is not None
assert active == pytest.approx(nits_to_pq(200.0))
assert full < active
def test_a_scene_with_no_black_pixels_keeps_its_whole_frame() -> None:
"""A title that changes aspect ratio has full-frame scenes, and those have no bars to leave out."""
full_frame = {**LETTERBOXED, 1: 0.5}
assert hdr10plus_mean_pq(full_frame, 0.75) == hdr10plus_mean_pq(full_frame, 1.0)
def test_a_ten_slot_histogram_uses_the_same_lookup() -> None:
nine = histogram({99: 300.0})
ten = {**nine, 98: 250.0}
assert hdr10plus_mean_pq(ten) == hdr10plus_mean_pq(nine)
def test_a_histogram_without_a_distribution_slot_has_no_mean() -> None:
values = histogram({})
del values[75]
assert hdr10plus_mean_pq(values) is None
def test_each_scene_becomes_one_shot_with_its_own_cut() -> None:
meta = metadata([(3, histogram({99: 200.0})), (5, histogram({99: 800.0}))], first_frame=1000)
shots = hdr10plus_shots(meta)
assert [(s["start"], s["duration"]) for s in shots] == [(0, 3), (3, 5)]
levels = [s["metadata_blocks"][0]["Level1"] for s in shots]
assert [lv["max_pq"] for lv in levels] == [round(nits_to_pq(200.0)), round(nits_to_pq(800.0))]
assert all(lv["min_pq"] == 0 and lv["avg_pq"] < lv["max_pq"] for lv in levels)
def test_avg_is_the_pq_mean_and_not_the_average_rgb() -> None:
"""dovi_tool's PQ(AverageMaxRGB) is far brighter than the PQ mean; the shot must carry the PQ mean."""
meta = metadata([(2, LETTERBOXED)], average_rgb=150.0)
(shot,) = hdr10plus_shots(meta, 0.8)
mean = hdr10plus_mean_pq(LETTERBOXED, 0.8)
assert mean is not None and mean != hdr10plus_mean_pq(LETTERBOXED, 1.0)
assert shot["metadata_blocks"][0]["Level1"]["avg_pq"] == round(mean)
assert round(mean) < round(nits_to_pq(150.0)) - 100
def test_a_scene_without_a_full_distribution_falls_back_to_the_average_rgb() -> None:
values = histogram({})
del values[25]
meta = metadata([(2, values)])
(shot,) = hdr10plus_shots(meta)
assert shot["metadata_blocks"][0]["Level1"]["avg_pq"] == round(nits_to_pq(30.0))
def test_the_peak_skips_the_pixel_share_slot_but_keeps_the_luminance_slot() -> None:
"""Index 10 holds a percentage (100 = every pixel under 100 nits), which would read as 10 nits in a
near-black scene. Index 5 holds the 99.99% luminance, a real brightness that can top index 99."""
dark = {index: 0.2 for index in INDEXES}
dark[10] = 10.0
(dark_shot,) = hdr10plus_shots(metadata([(2, dark)]))
assert dark_shot["metadata_blocks"][0]["Level1"]["max_pq"] == round(nits_to_pq(0.2))
(bright_shot,) = hdr10plus_shots(metadata([(2, histogram({5: 1400.0, 99: 1200.0}))]))
assert bright_shot["metadata_blocks"][0]["Level1"]["max_pq"] == round(nits_to_pq(1400.0))
def test_an_empty_histogram_takes_its_peak_from_maxscl_and_caps_the_avg() -> None:
meta = metadata([(2, {})], average_rgb=900.0)
meta["SceneInfo"][0]["LuminanceParameters"]["MaxScl"] = [4000, 6000, 5000]
(shot,) = hdr10plus_shots(meta)
level1 = shot["metadata_blocks"][0]["Level1"]
assert level1["max_pq"] == round(nits_to_pq(600.0))
assert level1["avg_pq"] == level1["max_pq"]
@pytest.fixture
def quiet(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config.directories, "temp", tmp_path)
monkeypatch.setattr(config, "generate_dv_mode", "v29")
monkeypatch.setattr(hybrid_mod, "log_event", lambda *a, **k: None)
@contextlib.contextmanager
def no_status(*args: Any, **kwargs: Any) -> Iterator[None]:
yield
monkeypatch.setattr(hybrid_mod.console, "status", no_status)
def make_hybrid() -> Hybrid:
"""Build a Hybrid without __init__, which would demux real video tracks."""
obj = Hybrid.__new__(Hybrid)
obj.log = logging.getLogger("hybrid-test")
obj.rpu_file = "RPU.bin"
obj.hdr10plus_file = "HDR10Plus.json"
return obj
@pytest.mark.parametrize("top, expected", [(277, (2160 - 554) / 2160), (1000, None)])
def test_active_area_share(top: int, expected: Optional[float], quiet: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""A letterboxed picture gives its crop and share of the frame; an implausibly small one gives None.
cropdetect settles over its frames, so the last crop line counts.
"""
def fake_run(args: list[Any], **kwargs: Any) -> SimpleNamespace:
if "format=duration" in args:
return SimpleNamespace(returncode=0, stdout='{"format": {"duration": "100"}}', stderr="")
if "stream=width,height" in args:
return SimpleNamespace(returncode=0, stdout='{"streams": [{"width": 3840, "height": 2160}]}', stderr="")
return SimpleNamespace(
returncode=0, stdout="", stderr=f"crop=3840:2160:0:0\ncrop=3840:{2160 - 2 * top}:0:{top}"
)
monkeypatch.setattr(hybrid_mod.subprocess, "run", fake_run)
area = make_hybrid().detect_active_area(Path("base.mkv"))
if expected is None:
assert area is None
else:
assert area is not None
assert area[0] == (0, top, 0, top)
assert area[1] == pytest.approx(expected)
def test_each_crop_sample_evaluates_enough_frames(quiet: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""cropdetect ignores its first two frames (skip=2) and only grows the picture over the rest."""
cropdetect_calls: list[list[str]] = []
def fake_run(args: list[Any], **kwargs: Any) -> SimpleNamespace:
if "format=duration" in args:
return SimpleNamespace(returncode=0, stdout='{"format": {"duration": "100"}}', stderr="")
if "stream=width,height" in args:
return SimpleNamespace(returncode=0, stdout='{"streams": [{"width": 3840, "height": 2160}]}', stderr="")
cropdetect_calls.append([str(a) for a in args])
return SimpleNamespace(returncode=0, stdout="", stderr="crop=3840:1600:0:280")
monkeypatch.setattr(hybrid_mod.subprocess, "run", fake_run)
make_hybrid().detect_active_area(Path("base.mkv"))
assert cropdetect_calls
for args in cropdetect_calls:
filtergraph = args[args.index("-vf") + 1]
skip_match = re.search(r"skip=(\d+)", filtergraph)
skip = int(skip_match.group(1)) if skip_match else 2
assert int(args[args.index("-vframes") + 1]) - skip >= 8
def test_the_parsed_metadata_is_released_before_dovi_tool_runs(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""hdr10plus_tool writes one entry per frame, so a film's parsed JSON is hundreds of MB of objects."""
class Tracked(dict): # type: ignore[type-arg]
pass
(tmp_path / "HDR10Plus.json").write_text(json.dumps(metadata([(2, histogram({}))])))
hybrid = make_hybrid()
monkeypatch.setattr(hybrid, "probe_hdr_metadata", lambda: (1000, 1, 0, 0))
real_load = json.load
refs: list[weakref.ReferenceType[Tracked]] = []
def tracked_load(fp: Any) -> Tracked:
loaded = Tracked(real_load(fp))
refs.append(weakref.ref(loaded))
return loaded
def fake_generate(config_json: Path, output: Path, **kwargs: Any) -> bytes:
gc.collect()
assert refs and refs[0]() is None, "the parsed HDR10+ metadata is still alive"
output.write_bytes(b"rpu")
return b""
monkeypatch.setattr(hybrid_mod.json, "load", tracked_load)
monkeypatch.setattr(hybrid_mod.dovi, "generate", fake_generate)
hybrid.convert_hdr10plus_to_dv()
def test_conversion_writes_a_cm29_config_with_shots_and_cleans_up(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
(tmp_path / "HDR10Plus.json").write_text(json.dumps(metadata([(4, LETTERBOXED), (2, histogram({99: 900.0}))])))
hybrid = make_hybrid()
monkeypatch.setattr(hybrid, "probe_hdr_metadata", lambda: (1000, 1, 650, 120))
seen: dict[str, Any] = {}
def fake_generate(config_json: Path, output: Path, **kwargs: Any) -> bytes:
seen.update(json.loads(config_json.read_text()))
output.write_bytes(b"rpu")
return b""
monkeypatch.setattr(hybrid_mod.dovi, "generate", fake_generate)
hybrid.convert_hdr10plus_to_dv(0.9)
assert seen["cm_version"] == "V29"
assert seen["level6"]["max_content_light_level"] == 650
assert [s["duration"] for s in seen["shots"]] == [4, 2]
assert seen["shots"][0]["metadata_blocks"][0]["Level1"]["avg_pq"] == round(hdr10plus_mean_pq(LETTERBOXED, 0.9) or 0)
assert "length" not in seen
assert (tmp_path / "RPU.bin").exists()
assert not (tmp_path / "HDR10Plus.json").exists() and not (tmp_path / "generate.json").exists()
def run_conversion(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, mode: str) -> tuple[dict, dict]:
"""Convert a two-scene HDR10+ file in the given generate_dv_mode; return the config and generate's kwargs."""
monkeypatch.setattr(config, "generate_dv_mode", mode)
(tmp_path / "HDR10Plus.json").write_text(json.dumps(metadata([(4, LETTERBOXED), (2, histogram({99: 900.0}))])))
hybrid = make_hybrid()
monkeypatch.setattr(hybrid, "probe_hdr_metadata", lambda: (1000, 1, 650, 120))
seen: dict[str, Any] = {}
called: dict[str, Any] = {}
def fake_generate(config_json: Path, output: Path, **kwargs: Any) -> bytes:
seen.update(json.loads(config_json.read_text()))
called.update(
kwargs, hdr10plus_existed=bool(kwargs.get("hdr10plus_json") and kwargs["hdr10plus_json"].exists())
)
output.write_bytes(b"rpu")
return b""
monkeypatch.setattr(hybrid_mod.dovi, "generate", fake_generate)
hybrid.convert_hdr10plus_to_dv(0.9)
return seen, called
def test_v40_mode_keeps_the_shots_and_asks_for_the_cm40_avg_floor(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
seen, called = run_conversion(monkeypatch, tmp_path, "v40")
assert seen["cm_version"] == "V29" and seen["l1_avg_pq_cm_version"] == "V40"
assert [s["duration"] for s in seen["shots"]] == [4, 2]
assert not called.get("hdr10plus_json")
def test_v29_mode_leaves_the_avg_floor_to_the_cm_version(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
seen, called = run_conversion(monkeypatch, tmp_path, "v29")
assert "l1_avg_pq_cm_version" not in seen and not called.get("hdr10plus_json")
def test_legacy_mode_hands_the_hdr10plus_json_to_dovi_tool(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
seen, called = run_conversion(monkeypatch, tmp_path, "legacy")
assert "shots" not in seen and seen["length"] == 0 and seen["level6"]["max_content_light_level"] == 650
assert called["hdr10plus_existed"]
assert not (tmp_path / "HDR10Plus.json").exists() and not (tmp_path / "generate.json").exists()
def test_an_unknown_mode_is_refused_before_dovi_tool_runs(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
with pytest.raises(ValueError, match="v29, v40, legacy"):
run_conversion(monkeypatch, tmp_path, "v50")
def test_a_failed_generate_still_removes_the_temp_files(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
(tmp_path / "HDR10Plus.json").write_text(json.dumps(metadata([(2, histogram({}))])))
hybrid = make_hybrid()
monkeypatch.setattr(hybrid, "probe_hdr_metadata", lambda: (1000, 1, 0, 0))
def failed_generate(*args: Any, **kwargs: Any) -> bytes:
raise RuntimeError("dovi_tool failed")
monkeypatch.setattr(hybrid_mod.dovi, "generate", failed_generate)
with pytest.raises(ValueError):
hybrid.convert_hdr10plus_to_dv()
assert not (tmp_path / "HDR10Plus.json").exists() and not (tmp_path / "generate.json").exists()
def test_a_conversion_run_removes_its_rpus_so_the_next_base_makes_its_own(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Each base's RPU comes from its own HDR10+, and level_6 reuses an RPU_L6.bin that it finds."""
base = tmp_path / "base.mkv"
base.write_bytes(b"")
(tmp_path / "RPU_L6.bin").write_bytes(b"stale")
seen: dict[str, Any] = {}
def convert(self: Hybrid, active_fraction: float = 1.0) -> None:
seen["stale_l6"] = (tmp_path / "RPU_L6.bin").exists()
seen["active_fraction"] = active_fraction
(tmp_path / "RPU.bin").write_bytes(b"rpu")
def level_6(self: Hybrid) -> None:
(tmp_path / "RPU_L6.bin").write_bytes(b"rpu")
for name in ("extract_stream", "extract_hdr10plus", "level_5", "injecting"):
monkeypatch.setattr(Hybrid, name, lambda self, *a, **k: None)
monkeypatch.setattr(Hybrid, "detect_active_area", lambda self, path: ((0, 100, 0, 100), 0.9))
monkeypatch.setattr(Hybrid, "convert_hdr10plus_to_dv", convert)
monkeypatch.setattr(Hybrid, "level_6", level_6)
monkeypatch.setattr(hybrid_mod.console, "print", lambda *a, **k: None)
Hybrid([SimpleNamespace(id="b", path=base, height=2160, range=Video.Range.HDR10P)], "TEST")
assert seen == {"stale_l6": False, "active_fraction": 0.9}
assert not (tmp_path / "RPU.bin").exists() and not (tmp_path / "RPU_L6.bin").exists()
def test_a_failed_resolution_removes_its_crop_edits_and_keeps_the_dv_files(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""level_5 reuses an RPU_L5.bin it finds, so a failure must not leave the crop of another resolution.
The RPU and DV stream come from the same DV track for every resolution, so those stay.
"""
for name in ("base.mkv", "dv.mkv", "DV.hevc"):
(tmp_path / name).write_bytes(b"")
writes = {"extract_rpu": "RPU.bin", "level_6": "RPU_L6.bin", "level_5": "RPU_L5.bin"}
for name, written in writes.items():
monkeypatch.setattr(Hybrid, name, lambda self, *a, _w=written, **k: (tmp_path / _w).write_bytes(b"x"))
monkeypatch.setattr(Hybrid, "extract_stream", lambda self, *a, **k: (tmp_path / "HDR10.hevc").write_bytes(b""))
monkeypatch.setattr(Hybrid, "detect_active_area", lambda self, path: ((0, 100, 0, 100), 0.9))
def failed_inject(self: Hybrid) -> None:
(tmp_path / "L5.json").write_text("{}")
raise ValueError("dovi_tool failed")
monkeypatch.setattr(Hybrid, "injecting", failed_inject)
monkeypatch.setattr(hybrid_mod.console, "print", lambda *a, **k: None)
videos = [
SimpleNamespace(id="b", path=tmp_path / "base.mkv", height=2160, range=Video.Range.HDR10),
SimpleNamespace(id="d", path=tmp_path / "dv.mkv", height=1080, range=Video.Range.DV),
]
with pytest.raises(ValueError):
Hybrid(videos, "TEST")
left = sorted(p.name for p in tmp_path.iterdir())
assert left == ["DV.hevc", "RPU.bin", "RPU_L6.bin", "base.mkv", "dv.mkv"]
def test_a_failed_extract_raises_instead_of_exiting(quiet: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""A SystemExit would kill a REST worker; the title must be able to go on without this resolution."""
def failed_step(*args: Any, **kwargs: Any) -> None:
raise RuntimeError("ffmpeg failed")
monkeypatch.setattr(hybrid_mod, "run_step", failed_step)
with pytest.raises(ValueError, match="HDR10 stream"):
make_hybrid().extract_stream(Path("base.mkv"), "HDR10")
def test_an_empty_hdr10plus_file_is_removed_before_the_error(
quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The next resolution returns early on a metadata file it finds, so an empty one must not stay."""
monkeypatch.setattr(hybrid_mod, "HDR10PlusTool", "hdr10plus_tool")
monkeypatch.setattr(hybrid_mod, "run_step", lambda *a, output, **k: output.write_bytes(b""))
with pytest.raises(ValueError, match="No HDR10\\+ metadata"):
make_hybrid().extract_hdr10plus(SimpleNamespace(id="b"))
assert not (tmp_path / "HDR10Plus.json").exists()
@pytest.mark.parametrize(
"error", [ValueError("bad"), KeyError("duration"), FileNotFoundError("tool"), RuntimeError("x")]
)
def test_a_hybrid_failure_is_reported_so_the_title_goes_on(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None:
def failed_hybrid(*args: Any, **kwargs: Any) -> None:
raise error
monkeypatch.setattr(dl_module, "Hybrid", failed_hybrid)
assert hybrid_step_error([], "TEST", "HDR10-DV-2160p.hevc") == str(error)
def test_a_working_hybrid_reports_no_error_and_an_exit_passes_through(monkeypatch: pytest.MonkeyPatch) -> None:
seen: dict[str, Any] = {}
monkeypatch.setattr(dl_module, "Hybrid", lambda tracks, source, output_name: seen.update(name=output_name))
assert hybrid_step_error([], "TEST", "HDR10-DV-2160p.hevc") is None and seen == {"name": "HDR10-DV-2160p.hevc"}
def exiting(*args: Any, **kwargs: Any) -> None:
raise SystemExit(1)
monkeypatch.setattr(dl_module, "Hybrid", exiting)
with pytest.raises(SystemExit):
hybrid_step_error([], "TEST", "HDR10-DV-2160p.hevc")
def test_a_title_without_a_hybrid_file_fails() -> None:
bases = [video("h2160", Video.Range.HDR10), video("h1080", Video.Range.HDR10, 1080)]
assert missing_hybrid_error(bases, 1) is None
assert missing_hybrid_error([], 0) == "No HEVC HDR10 or HDR10+ track to make a hybrid from"
assert missing_hybrid_error(bases, 0) == ("Every hybrid failed (2160p, 1080p), so the title has no hybrid file")
def test_only_hevc_tracks_are_hybrid_bases() -> None:
av1 = SimpleNamespace(id="av1", height=2160, range=Video.Range.HDR10P, codec=Video.Codec.AV1)
hevc = SimpleNamespace(id="hevc", height=1080, range=Video.Range.HDR10P, codec=Video.Codec.HEVC)
assert [v.id for v in select_hybrid_bases([av1, hevc])] == ["hevc"] # type: ignore[list-item]
@pytest.mark.parametrize(
"dv_available, to_dv, hdr10_gives, fetched, kept",
[
(True, True, ["HDR10"], ["HDR10", "DV"], ["DV", "HDR10"]),
(False, False, ["HDR10"], ["HDR10", "DV"], ["HDR10"]),
(False, True, ["HDR10"], ["HDR10", "DV", "HDR10P"], ["HDR10", "HDR10P"]),
(False, True, ["HDR10", "HDR10P"], ["HDR10", "DV"], ["HDR10", "HDR10P"]),
],
)
def test_hybrid_asks_for_hdr10plus_only_for_a_conversion_without_dv(
dv_available: bool, to_dv: bool, hdr10_gives: list[str], fetched: list[str], kept: list[str]
) -> None:
"""The HDR10 request may not return HDR10+, and the conversion needs it as its base.
When that request already returned HDR10+, there is nothing more to ask for.
"""
calls: list[str] = []
def fetch(title: Any, codec: Any, range_: Video.Range) -> Tracks:
calls.append(range_.name)
if range_ == Video.Range.DV and not dv_available:
raise ValueError("no DV")
tracks = Tracks()
for name in hdr10_gives if range_ == Video.Range.HDR10 else [range_.name]:
tracks.add(Video(url="https://example.invalid/v", language="en", id_=name, range_=Video.Range[name]))
return tracks
service = SimpleNamespace(
track_request=TrackRequest(ranges=[Video.Range.HYBRID], generate_dv=to_dv),
log=logging.getLogger("variants-test"),
)
tracks = Service.get_tracks_for_variants(service, None, fetch) # type: ignore[arg-type]
assert calls == fetched
assert sorted(v.range.name for v in tracks.videos) == kept
def test_the_hybrid_step_gets_the_base_alone_when_there_is_no_dv(tmp_path: Path) -> None:
base = SimpleNamespace(id="base", path=tmp_path / "base.mp4", height=2160, range=Video.Range.HDR10P)
tracks = hybrid_source_tracks(base, None) # type: ignore[arg-type]
assert len(tracks) == 1 and tracks[0].range == Video.Range.HDR10P and tracks[0].needs_duration_fix
def test_the_api_needs_hybrid_for_generate_dv() -> None:
assert "HYBRID" in (validate_download_parameters({"generate_dv": True}) or "")
assert "HYBRID" in (validate_download_parameters({"generate_dv": True, "range": ["HDR10P"]}) or "")
assert validate_download_parameters({"generate_dv": True, "range": ["HYBRID"]}) is None
assert "boolean" in (validate_download_parameters({"generate_dv": "yes", "range": ["HYBRID"]}) or "")
def test_the_api_takes_hybrid_from_the_serve_default_range(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "serve", {"range": ["HYBRID"]})
assert validate_download_parameters({"generate_dv": True}) is None
assert "HYBRID" in (validate_download_parameters({"generate_dv": True, "range": ["HDR10P"]}) or "")
class _Captured(Exception):
pass
@pytest.mark.parametrize("to_dv", [False, True])
def test_a_rest_job_gives_the_flag_to_the_service_ctx_and_to_dl(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, to_dv: bool
) -> None:
"""The REST manager calls dl.result() directly, so the flag must take both of the CLI's paths."""
import click
class FakeDl:
cli = click.Command("dl")
def __init__(self, ctx: click.Context, **kwargs: object) -> None:
self.ctx = ctx
def result(self, **kwargs: object) -> None:
raise _Captured(self.ctx.params["generate_dv"], kwargs["generate_dv"])
class FakeService:
def __init__(self, ctx: click.Context) -> None:
pass
monkeypatch.setattr(dl_module, "dl", FakeDl)
monkeypatch.setattr(Services, "get_path", staticmethod(lambda s: tmp_path))
monkeypatch.setattr(Services, "load", staticmethod(lambda s: FakeService))
monkeypatch.setattr(handlers, "load_full_cdm", lambda *a: None)
params = {"generate_dv": True} if to_dv else {}
with pytest.raises(_Captured) as exc:
download_manager.perform_download("job-1", "EXAMPLE", "t1", params)
assert exc.value.args == (to_dv, to_dv)
@pytest.mark.parametrize("to_dv", [False, True])
def test_the_remote_client_forwards_the_flag_only_when_set(to_dv: bool) -> None:
data = _sent_create_data({"generate_dv": to_dv}, {})
assert data.get("generate_dv", False) is to_dv
@pytest.mark.parametrize("to_dv", [False, True])
def test_a_remote_session_puts_the_flag_in_the_service_ctx(monkeypatch: pytest.MonkeyPatch, to_dv: bool) -> None:
seen: dict[str, Any] = {}
def fake_instantiate(parent_ctx: Any, *args: Any) -> object:
seen["ctx"] = parent_ctx
return object()
monkeypatch.setattr(handlers, "load_service_yaml", lambda service: {})
monkeypatch.setattr(handlers, "load_full_cdm", lambda *args: None)
monkeypatch.setattr(handlers.Services, "load", lambda service: None)
monkeypatch.setattr(handlers, "instantiate_service", fake_instantiate)
data = {"generate_dv": True} if to_dv else {}
handlers.create_service_instance("EXAMPLE", "title", data, None, [], None)
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 test_hybrid_fails_with_neither_a_base_nor_dv() -> None:
assert hybrid_gate_failure([video("sdr", Video.Range.SDR)], True) == (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available",
"Available ranges: SDR",
)
assert hybrid_gate_failure([], True) == (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available",
"Available ranges: none",
)
def test_hybrid_fails_with_dv_only() -> None:
assert hybrid_gate_failure([video("dv", Video.Range.DV)], True) == (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available",
"Available ranges: DV",
)
def test_hybrid_goes_on_without_dv_when_the_flag_has_an_hdr10plus_base() -> None:
assert hybrid_gate_failure([video("p", Video.Range.HDR10P), video("h", Video.Range.HDR10)], True) is None
def test_hybrid_goes_on_with_a_base_and_dv_whatever_the_flag() -> None:
videos = [video("h", Video.Range.HDR10), video("dv", Video.Range.DV)]
assert hybrid_gate_failure(videos, False) is None
assert hybrid_gate_failure(videos, True) is None
def test_hybrid_fails_up_front_when_no_base_is_hevc() -> None:
"""dovi_tool injects into HEVC, so an all-AV1 title fails before it downloads anything."""
videos = [video("av1", Video.Range.HDR10P, codec=Video.Codec.AV1), video("dv", Video.Range.DV)]
assert hybrid_gate_failure(videos, False) == (
"HYBRID mode requires an HEVC HDR10/HDR10+ base, but none is available",
"Available HDR10/HDR10+ codecs: AV1",
)
assert hybrid_gate_failure(videos + [video("h", Video.Range.HDR10, codec=None)], False) is None
@pytest.mark.parametrize(
"ranges, to_dv, detail",
[
(
[Video.Range.HDR10P],
False,
"Available ranges: HDR10P. Add --generate-dv to generate Dolby Vision from HDR10+.",
),
([Video.Range.HDR10], False, "Available ranges: HDR10"),
(
[Video.Range.HDR10],
True,
"Available ranges: HDR10. --generate-dv needs an HEVC HDR10+ track, and the service returned none.",
),
],
)
def test_hybrid_fails_without_dv_and_hints_at_the_flag_only_when_it_is_off(
ranges: list[Video.Range], to_dv: bool, detail: str
) -> None:
"""A service that fetches HYBRID by hand may never return HDR10+; the hint must not blame the user then."""
videos = [video(r.name, r) for r in ranges]
assert hybrid_gate_failure(videos, to_dv) == (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available",
detail,
)
@pytest.mark.parametrize("to_dv", [False, True])
def test_real_dv_takes_every_base_and_the_lowest_dv(to_dv: bool) -> None:
videos = [
video("h2160", Video.Range.HDR10),
video("p1080", Video.Range.HDR10P, 1080),
video("dv2160", Video.Range.DV),
video("dv1080", Video.Range.DV, 1080),
]
bases, dv = hybrid_plan(videos, to_dv)
assert [v.id for v in bases] == ["h2160", "p1080"]
assert dv is not None and dv.id == "dv1080"
def test_the_flag_without_dv_takes_only_hdr10plus_bases() -> None:
videos = [video("h2160", Video.Range.HDR10), video("p1080", Video.Range.HDR10P, 1080)]
bases, dv = hybrid_plan(videos, True)
assert [v.id for v in bases] == ["p1080"] and dv is 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)
+5 -8
View File
@@ -66,14 +66,11 @@ def patch_probe_run(monkeypatch: pytest.MonkeyPatch, result: FakeCompleted) -> l
return calls
def test_level_5_skips_a_stream_with_no_duration(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
def test_crop_detection_skips_a_stream_with_no_duration(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""FFprobe gives a raw stream an empty format object with exit code 0."""
calls = patch_probe_run(monkeypatch, FakeCompleted(0, '{"format": {}}'))
hybrid = make_hybrid()
hybrid.level_5(tmp_path / "base.dv.hevc")
assert hybrid.rpu_file == "RPU.bin"
assert make_hybrid().detect_active_area(tmp_path / "base.dv.hevc") is None
assert len(calls) == 1
@@ -207,11 +204,11 @@ def test_level_5_returns_early_when_the_cropped_rpu_exists(monkeypatch: pytest.M
(tmp_path / "RPU_L5.bin").write_bytes(b"")
def boom(*args: Any, **kwargs: Any) -> None:
raise AssertionError("level_5 must not run ffprobe when RPU_L5.bin is already there")
raise AssertionError("level_5 must not run dovi_tool when RPU_L5.bin is already there")
monkeypatch.setattr(subprocess, "run", boom)
monkeypatch.setattr(hybrid_mod.dovi, "editor", boom)
obj = make_hybrid()
obj.level_5(tmp_path / "HDR10.hevc")
obj.level_5((0, 276, 0, 276))
assert obj.rpu_file == "RPU_L5.bin"
+13
View File
@@ -95,6 +95,19 @@ def test_select_hybrid_base_picks_highest_bitrate_then_worst_flips() -> None:
assert ids(worst) == {"hdr10p-2160-lo", "dv-360"}
def test_select_hybrid_skips_a_base_that_is_not_hevc() -> None:
"""dovi_tool injects into HEVC, so a higher-bitrate AV1 base is not a hybrid ingredient."""
tracks = [
make_video(
"hdr10p-2160-av1", range_=Video.Range.HDR10P, height=2160, bitrate=30_000_000, codec=Video.Codec.AV1
),
make_video("hdr10p-2160", range_=Video.Range.HDR10P, height=2160, bitrate=20_000_000, codec=Video.Codec.HEVC),
make_video("dv-360", range_=Video.Range.DV, height=360, bitrate=1_000_000, codec=Video.Codec.HEVC),
]
chosen = list(filter(Tracks().select_hybrid(tracks, [2160]), tracks))
assert ids(chosen) == {"hdr10p-2160", "dv-360"}
def test_select_hybrid_no_dv_selects_only_base() -> None:
H = Video.Codec.HEVC
tracks = [make_video("hdr10p-2160", range_=Video.Range.HDR10P, height=2160, bitrate=20_000_000, codec=H)]
+110 -33
View File
@@ -214,10 +214,17 @@ def group_videos_by_variant(videos: list[Video], *, merge: bool) -> list[list[Vi
def select_hybrid_bases(videos: Sequence[Video]) -> list[Video]:
"""The HDR10 or HDR10+ track that is the hybrid base for each height, tallest first."""
"""The HDR10 or HDR10+ track that is the hybrid base for each height, tallest first.
Only HEVC tracks qualify, because dovi_tool injects the RPU into an HEVC stream.
"""
bases: dict[int, Video] = {}
for video in sorted(
(v for v in videos if v.range in (Video.Range.HDR10P, Video.Range.HDR10)),
(
v
for v in videos
if v.range in (Video.Range.HDR10P, Video.Range.HDR10) and v.codec in (Video.Codec.HEVC, None)
),
key=lambda v: v.height or 0,
reverse=True,
):
@@ -225,19 +232,85 @@ def select_hybrid_bases(videos: Sequence[Video]) -> list[Video]:
return list(bases.values())
def hybrid_source_tracks(base: Video, dv: Video, base_container: Optional[Path] = None) -> 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."""
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]
has_hdr10p = any(v.range == Video.Range.HDR10P for v in base_tracks)
available_ranges = sorted(set(v.range.name for v in videos))
if not hdr_tracks and not dv_tracks:
return (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available",
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}",
)
if not hdr_tracks:
return (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available",
f"Available ranges: {', '.join(available_ranges)}",
)
if not base_tracks:
codecs = sorted(set(v.codec.name for v in hdr_tracks if v.codec))
return (
"HYBRID mode requires an HEVC HDR10/HDR10+ base, but none is available",
f"Available HDR10/HDR10+ codecs: {', '.join(codecs)}",
)
if not dv_tracks and not (generate_dv and has_hdr10p):
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
if generate_dv:
msg_detail += ". --generate-dv needs an HEVC HDR10+ track, and the service returned none."
elif has_hdr10p:
msg_detail += ". Add --generate-dv to generate Dolby Vision from HDR10+."
return "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available", msg_detail
return None
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``.
"""
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)]
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
def hybrid_source_tracks(base: Video, dv: Optional[Video], base_container: Optional[Path] = None) -> list[Video]:
"""Copies of the base and DV tracks for the hybrid step, with the base on its container file.
The hybrid step reads the duration and seeks, and the raw stream from the DV fixup
permits neither.
permits neither. With no DV track, the hybrid step makes Dolby Vision from the base's HDR10+.
"""
tracks = [deepcopy(base), deepcopy(dv)]
tracks = [deepcopy(track) for track in (base, dv) if track]
tracks[0].path = base_container or base.path
for track in tracks:
track.needs_duration_fix = True
return tracks
def hybrid_step_error(tracks: list[Video], source: str, output_name: str) -> Optional[str]:
"""Run the hybrid step for one resolution; the error that failed it, or None.
A failure here is this resolution's alone: the title goes on with its other resolutions.
"""
try:
Hybrid(tracks, source, output_name=output_name)
except (ValueError, KeyError, OSError, RuntimeError) as e:
return str(e) or type(e).__name__
return None
def missing_hybrid_error(bases: Sequence[Video], made: int) -> Optional[str]:
"""Why the title has no hybrid file, or None when at least one was made."""
if made:
return None
if not bases:
return "No HEVC HDR10 or HDR10+ track to make a hybrid from"
return f"Every hybrid failed ({', '.join(f'{v.height or 0}p' for v in bases)}), so the title has no hybrid file"
def title_wanted(candidate: Any, wanted: Collection[str]) -> bool:
"""Whether ``-w`` selects this title. A title type without selection keys is always kept.
@@ -1122,6 +1195,12 @@ class dl:
default=False,
help="Warn instead of failing when a requested resolution, range, or language is missing, and continue with what is available.",
)
@click.option(
"--generate-dv",
is_flag=True,
default=False,
help="With -r HYBRID, make Dolby Vision from the HDR10+ metadata when a title has no Dolby Vision track.",
)
@click.option(
"--remote",
is_flag=True,
@@ -1647,6 +1726,7 @@ class dl:
postscript: Sequence[str] = (),
no_postscript: bool = False,
all_drm: bool = False,
generate_dv: bool = False,
*_: Any,
**__: Any,
) -> None:
@@ -1843,6 +1923,13 @@ class dl:
self.log.error("Unable to run hybrid mode: dovi_tool not detected")
self.log.error("Please install dovi_tool from https://github.com/quietvoid/dovi_tool")
sys.exit(1)
if generate_dv:
from unshackle.core.binaries import HDR10PlusTool
if not HDR10PlusTool:
self.log.error("Unable to run --generate-dv: hdr10plus_tool not detected")
self.log.error("Please install hdr10plus_tool from https://github.com/quietvoid/hdr10plus_tool")
sys.exit(1)
if cdm_only is None:
vaults_only = None
@@ -2733,31 +2820,12 @@ class dl:
title.tracks.videos = selected_videos
if keep_videos and any(r == Video.Range.HYBRID for r in range_):
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]
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):
self.log.info("No Dolby Vision track: Dolby Vision will be generated from HDR10+")
hybrid_failed = False
if not base_tracks and not dv_tracks:
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available"
msg_detail = (
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}"
)
hybrid_failed = True
elif not base_tracks:
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available"
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
hybrid_failed = True
elif not dv_tracks:
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available"
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
hybrid_failed = True
if hybrid_failed:
if gate_failure:
msg, msg_detail = gate_failure
other_ranges = [r for r in range_ if r != Video.Range.HYBRID]
if best_available and other_ranges:
self.log.warning(msg)
@@ -3583,16 +3651,15 @@ class dl:
standalone_groups: list[list[Optional[Video]]] = [
list(g) for g in group_videos_by_variant(standalone_videos, merge=merge_video)
]
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
hybrid_bases = select_hybrid_bases(title.tracks.videos) if dv_tracks else []
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 group in sorted(standalone_groups, key=group_file_size, reverse=True):
mux_video_group(group)
for hdr10_track in hybrid_bases:
resolution = hdr10_track.height or 0
matching_dv = min(dv_tracks, key=lambda v: v.height)
container = container_paths.get(hdr10_track.id)
resolution_tracks = hybrid_source_tracks(hdr10_track, matching_dv, container)
@@ -3605,7 +3672,12 @@ class dl:
"--sample cut the HDR10 and Dolby Vision tracks at different "
"points, so a hybrid of them would not line up."
)
Hybrid(resolution_tracks, self.service, output_name=hybrid_filename)
step_error = hybrid_step_error(resolution_tracks, self.service, hybrid_filename)
if step_error:
# the other files of the title are already queued for muxing
self.log.error(f"Hybrid {resolution}p failed, skipping it: {step_error}")
continue
hybrids_made += 1
if container:
try:
@@ -3628,6 +3700,10 @@ class dl:
enqueue_mux_tasks(task_description, task_tracks, hybrid=True)
no_hybrid = missing_hybrid_error(hybrid_bases, hybrids_made)
if no_hybrid:
raise click.ClickException(no_hybrid)
console.print()
else:
groups: list[list[Optional[Video]]] = [
@@ -3731,6 +3807,7 @@ class dl:
"RPU_L5.bin",
"RPU_L6.bin",
"L5.json",
"HDR10Plus.json",
):
leftover_path = config.directories.temp / leftover
try:
+2
View File
@@ -495,6 +495,7 @@ def perform_download(
"vcodec": params.get("vcodec", []),
"range_": params.get("range", [Video.Range.SDR]),
"best_available": params.get("best_available", False),
"generate_dv": params.get("generate_dv", False),
# services read these here, not from dl.result(), to pick the manifests they fetch
"acodec": params.get("acodec") or [],
"lang": params.get("lang", ["orig"]),
@@ -659,6 +660,7 @@ def perform_download(
downloads=params.get("downloads", 1),
worst=params.get("worst", False),
best_available=params.get("best_available", False),
generate_dv=params.get("generate_dv", False),
split_audio=params.get("split_audio"),
progress_sink=progress_callback,
)
+17 -8
View File
@@ -108,6 +108,7 @@ DEFAULT_DOWNLOAD_PARAMS = {
"downloads": 1,
"worst": False,
"best_available": False,
"generate_dv": False,
"repack": False,
"tag": None,
"tmdb_id": None,
@@ -2289,14 +2290,20 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
if value is not None and (not isinstance(value, list) or not all(isinstance(v, str) for v in value)):
return f"{key} must be an array of language strings"
if "range" in data and data["range"]:
# "HDR10P" is the canonical range value ("+" is awkward in scripts); "HDR10+" stays valid.
valid_ranges = ["SDR", "HDR10", "HDR10P", "DV", "HLG", "HYBRID"]
accepted = {*valid_ranges, "HDR10+"}
values = data["range"] if isinstance(data["range"], list) else [data["range"]]
for r in values:
if r.upper() not in accepted:
return f"Invalid range value: {r}. Must be one of: {', '.join(valid_ranges)}"
ranges = data.get("range") or []
ranges = ranges if isinstance(ranges, list) else [ranges]
# "HDR10P" is the canonical range value ("+" is awkward in scripts); "HDR10+" stays valid.
valid_ranges = ["SDR", "HDR10", "HDR10P", "DV", "HLG", "HYBRID"]
accepted = {*valid_ranges, "HDR10+"}
for r in ranges:
if r.upper() not in accepted:
return f"Invalid range value: {r}. Must be one of: {', '.join(valid_ranges)}"
if data.get("generate_dv"):
# a request without a range runs with the serve: default range
job_ranges = ranges or (config.serve or {}).get("range") or []
job_ranges = job_ranges if isinstance(job_ranges, list) else [job_ranges]
if not any(str(r).upper() == "HYBRID" for r in job_ranges):
return "generate_dv needs HYBRID in range."
return None
@@ -3574,6 +3581,7 @@ SESSION_TRANSPORT_KEYS = {
"vcodec",
"quality",
"best_available",
"generate_dv",
"dl_params",
}
@@ -3758,6 +3766,7 @@ def create_service_instance(
"vcodec": vcodec_values,
"quality": data.get("quality"),
"best_available": data.get("best_available", False),
"generate_dv": data.get("generate_dv", False),
**forwarded_dl_params(data),
}
+5
View File
@@ -1083,6 +1083,11 @@ async def download(request: web.Request) -> web.Response:
description: >-
Continue with best available if requested quality unavailable (default - false).
An API key with a server_cdm_max_height for the service cannot set it.
generate_dv:
type: boolean
description: >-
With HYBRID in range, make Dolby Vision from the HDR10+ metadata when a title has no
Dolby Vision track (default - false). Needs hdr10plus_tool on the server.
worst:
type: boolean
description: Select the lowest bitrate track within the specified quality. Requires `quality` (default - false)
+1
View File
@@ -199,6 +199,7 @@ class Config:
self.language_tags: dict = kwargs.get("language_tags") or {}
self.tag_rules: list = kwargs.get("tag_rules") or []
self.dual_multi_mode: str = (kwargs.get("dual_multi_mode") or "strict").lower()
self.generate_dv_mode: str = str(kwargs.get("generate_dv_mode") or "v29").lower()
self.output_template: dict = kwargs.get("output_template") or {}
folder_cfg = self.output_template.pop("folder", "")
self.folder_template: str = ""
+2
View File
@@ -1213,6 +1213,8 @@ class RemoteService:
create_data["quality"] = list(quality)
if self.ctx.parent.params.get("best_available"):
create_data["best_available"] = True
if self.ctx.parent.params.get("generate_dv"):
create_data["generate_dv"] = True
# Services pick the manifests to fetch from these. They go in their own
# object: flat, a service option with the same name would take them (or override them).
# An empty list is sent as-is, because only an absent key falls back to the dl default.
+16 -4
View File
@@ -126,6 +126,7 @@ class TrackRequest:
codecs: list[Video.Codec] = field(default_factory=list)
ranges: list[Video.Range] = field(default_factory=lambda: [Video.Range.SDR])
best_available: bool = False
generate_dv: bool = False
def sanitize_proxy_for_log(uri: Optional[str], mask_host: bool = False) -> Optional[str]:
@@ -194,10 +195,12 @@ class Service(metaclass=ABCMeta):
vcodec = ctx.parent.params.get("vcodec") if ctx.parent else None
range_ = ctx.parent.params.get("range_") if ctx.parent else None
best_available = ctx.parent.params.get("best_available", False) if ctx.parent else False
generate_dv = ctx.parent.params.get("generate_dv", False) if ctx.parent else False
self.track_request = TrackRequest(
codecs=list(vcodec) if vcodec else [],
ranges=list(range_) if range_ else [Video.Range.SDR],
best_available=bool(best_available),
generate_dv=bool(generate_dv),
)
whose = "the server's" if ctx.parent and ctx.parent.params.get("served") else "your"
@@ -353,7 +356,8 @@ class Service(metaclass=ABCMeta):
The fetch_fn signature should be: (title, codec, range_) -> Tracks
For HYBRID range, this helper calls fetch_fn with HDR10 and DV separately,
then merges the DV video tracks into the HDR10 result.
then merges the DV video tracks into the HDR10 result. With no DV and
``generate_dv`` requested, it merges the HDR10P video tracks instead.
Args:
title: The title to process.
@@ -386,11 +390,19 @@ class Service(metaclass=ABCMeta):
all_tracks.add(video, warn_only=True)
try:
dv_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.DV)
for video in dv_tracks.videos:
all_tracks.add(video, warn_only=True)
dv_videos = fetch_fn(title, codec=codec_val, range_=Video.Range.DV).videos
except (ValueError, SystemExit):
self.log.info(" - No DolbyVision manifest available for HYBRID")
dv_videos = []
has_hdr10p = any(v.range == Video.Range.HDR10P for v in all_tracks.videos)
if not dv_videos and self.track_request.generate_dv and not has_hdr10p:
# the HDR10 request may not return HDR10+, which the conversion needs as its base
try:
dv_videos = fetch_fn(title, codec=codec_val, range_=Video.Range.HDR10P).videos
except (ValueError, SystemExit):
self.log.info(" - No HDR10+ manifest available for HYBRID")
for video in dv_videos:
all_tracks.add(video, warn_only=True)
else:
for codec_val in codecs:
try:
+364 -213
View File
@@ -1,10 +1,9 @@
import json
import logging
import os
import random
import re
import subprocess
import sys
from collections import Counter
from concurrent import futures
from pathlib import Path
from typing import Optional
@@ -19,6 +18,97 @@ from unshackle.core.utilities import log_event
from unshackle.core.utils import dovi
from unshackle.core.utils.subprocess import run_step
# HDR10+ histogram slots that form the maxRGB distribution, as (DistributionIndex, fraction of pixels).
# Index 99 holds the 99.98% percentile. Index 5 (a luminance-Y value) and index 10 (the share of pixels
# at or under 100 nits) are not part of the distribution.
# config.generate_dv_mode values: unshackle's L1 with the CM v2.9 or the CM v4.0 avg floor, or dovi_tool's own L1.
GENERATE_DV_MODES = ("v29", "v40", "legacy")
HDR10PLUS_PERCENTILES = ((1, 0.01), (25, 0.25), (50, 0.5), (75, 0.75), (90, 0.9), (95, 0.95), (99, 0.9998))
def nits_to_pq(nits: float) -> float:
"""SMPTE ST 2084 inverse EOTF, as a 12-bit code value (0-4095)."""
m1, m2, c1, c2, c3 = 0.1593017578125, 78.84375, 0.8359375, 18.8515625, 18.6875
y = (max(nits, 0.0) / 10000) ** m1
return 4095 * ((c1 + c2 * y) / (1 + c3 * y)) ** m2
def hdr10plus_mean_pq(values: dict[int, float], active_fraction: float = 1.0) -> Optional[float]:
"""Mean PQ code of the maxRGB distribution in one HDR10+ histogram, or None if a slot is missing.
Dolby Vision L1 avg is a mean of PQ-coded maxRGB over the active area. HDR10+ AverageMaxRGB is a
linear mean over the whole frame, so the mean comes from the percentiles instead. The darkest
``1 - active_fraction`` of the pixels are the letterbox bars, and the mean leaves them out. A scene
whose 1st percentile is above black has no bars, so its whole frame counts.
"""
if any(index not in values for index, _ in HDR10PLUS_PERCENTILES):
return None
if values[1] > 0:
active_fraction = 1.0
knots = [(0.0, nits_to_pq(values[1]))]
knots += [(fraction, nits_to_pq(values[index])) for index, fraction in HDR10PLUS_PERCENTILES]
knots.append((1.0, knots[-1][1]))
low = 1.0 - active_fraction
area = 0.0
for (x1, y1), (x2, y2) in zip(knots, knots[1:]):
if x2 <= low:
continue
if x1 < low:
y1 += (y2 - y1) * (low - x1) / (x2 - x1)
x1 = low
area += (x2 - x1) * (y1 + y2) / 2
return area / active_fraction
def hdr10plus_shots(metadata: dict, active_fraction: float = 1.0) -> list[dict]:
"""One dovi_tool generator shot with L1 metadata per HDR10+ scene.
Each scene keeps its own cut. max_pq is the histogram peak, as in dovi_tool's default peak source,
without index 10: that slot is the share of pixels at or under 100 nits, not a brightness, and would
put a near-black scene at 10 nits. Index 5, the 99.99% luminance, stays: against decoded pixels it
tracked the true peak better than index 99 alone. MaxScl is the peak when the histogram is empty.
A scene whose histogram has no complete distribution falls back to dovi_tool's average,
PQ(AverageMaxRGB), capped at max_pq.
"""
scenes = metadata["SceneInfo"]
summary = metadata["SceneInfoSummary"]
first_frames = summary["SceneFirstFrameIndex"]
offset = first_frames[0]
shots = []
fallbacks = 0
for first_frame, length in zip(first_frames, summary["SceneFrameNumbers"]):
start = first_frame - offset
luminance = scenes[start]["LuminanceParameters"]
distribution = luminance["LuminanceDistributions"]
values = {
index: value / 10
for index, value in zip(distribution["DistributionIndex"], distribution["DistributionValues"])
}
peaks = [value for index, value in values.items() if index != 10]
max_pq = nits_to_pq(max(peaks or [v / 10 for v in luminance["MaxScl"]]))
avg_pq = hdr10plus_mean_pq(values, active_fraction)
if avg_pq is None:
fallbacks += 1
avg_pq = min(nits_to_pq(luminance["AverageRGB"] / 10), max_pq)
shots.append(
{
"start": start,
"duration": length,
"metadata_blocks": [{"Level1": {"min_pq": 0, "max_pq": round(max_pq), "avg_pq": round(avg_pq)}}],
}
)
if fallbacks:
log_event(
"hybrid_convert_hdr10plus",
level="DEBUG",
message="HDR10+ scenes without a full histogram use PQ(AverageMaxRGB) for L1 avg",
context={"scenes": fallbacks, "total": len(shots)},
)
return shots
class Hybrid:
def __init__(self, videos, source, output_name: Optional[str] = None) -> None:
@@ -80,40 +170,55 @@ class Hybrid:
console.status("Already Injected")
return
for video in videos:
save_path = video.path
if not save_path or not os.path.exists(save_path):
raise ValueError(f"Video track {video.id} was not downloaded or path not found: {save_path}")
try:
for video in videos:
save_path = video.path
if not save_path or not os.path.exists(save_path):
raise ValueError(f"Video track {video.id} was not downloaded or path not found: {save_path}")
if video.range == Video.Range.HDR10:
self.extract_stream(save_path, "HDR10")
elif video.range == Video.Range.HDR10P:
self.extract_stream(save_path, "HDR10")
self.hdr_type = "HDR10+"
elif video.range == Video.Range.DV:
if not os.path.isfile(config.directories.temp / "DV.hevc"):
self.extract_stream(save_path, "DV")
if video.range == Video.Range.HDR10:
self.extract_stream(save_path, "HDR10")
elif video.range == Video.Range.HDR10P:
self.extract_stream(save_path, "HDR10")
self.hdr_type = "HDR10+"
elif video.range == Video.Range.DV:
if not os.path.isfile(config.directories.temp / "DV.hevc"):
self.extract_stream(save_path, "DV")
if self.hdr10plus_to_dv:
hdr10p_video = next(v for v in videos if v.range == Video.Range.HDR10P)
self.extract_hdr10plus(hdr10p_video)
self.convert_hdr10plus_to_dv()
else:
dv_video = next(v for v in videos if v.range == Video.Range.DV)
self.extract_rpu(dv_video)
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
self.rpu_file = "RPU_UNT.bin"
# Mode 3 conversion already done during extraction when not untouched
elif os.path.isfile(config.directories.temp / "RPU.bin"):
# RPU already extracted with mode 3
pass
base_video = next((v for v in videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)), None)
area = self.detect_active_area(base_video.path) if base_video and base_video.path else None
self.level_6()
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)
if self.hdr10plus_to_dv:
# a base that failed earlier in this title can leave its edited RPUs, which level_5/level_6 reuse
for stale in ("RPU_L5.bin", "RPU_L6.bin"):
Path.unlink(config.directories.temp / stale, missing_ok=True)
hdr10p_video = next(v for v in videos if v.range == Video.Range.HDR10P)
self.extract_hdr10plus(hdr10p_video)
self.convert_hdr10plus_to_dv(area[1] if area else 1.0)
else:
dv_video = next(v for v in videos if v.range == Video.Range.DV)
self.extract_rpu(dv_video)
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
self.rpu_file = "RPU_UNT.bin"
# Mode 3 conversion already done during extraction when not untouched
elif os.path.isfile(config.directories.temp / "RPU.bin"):
# RPU already extracted with mode 3
pass
self.injecting()
self.level_6()
if area:
self.level_5(area[0])
self.injecting()
finally:
# The title goes on after a failed resolution, and level_5 reuses an RPU_L5.bin it finds,
# so the files of this resolution's crop must not outlive it.
for own in ("HDR10.hevc", "RPU_L5.bin", "L5.json", "L6.json"):
Path.unlink(config.directories.temp / own, missing_ok=True)
if self.hdr10plus_to_dv:
# The RPU comes from this base's own HDR10+, so the next resolution must not reuse it.
Path.unlink(config.directories.temp / "RPU.bin", missing_ok=True)
Path.unlink(config.directories.temp / "RPU_L6.bin", missing_ok=True)
log_event(
"hybrid_complete",
@@ -131,10 +236,6 @@ class Hybrid:
if self.source == ("itunes" or "appletvplus"):
Path.unlink(config.directories.temp / "hdr10.mkv")
Path.unlink(config.directories.temp / "dv.mkv")
Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True)
Path.unlink(config.directories.temp / "RPU_L5.bin", missing_ok=True)
Path.unlink(config.directories.temp / "L5.json", missing_ok=True)
Path.unlink(config.directories.temp / "L6.json", missing_ok=True)
def extract_stream(self, save_path, type_):
output = Path(config.directories.temp / f"{type_}.hevc")
@@ -152,8 +253,7 @@ class Hybrid:
message=f"Failed extracting {type_} stream",
context={"type": type_, "input": str(save_path), "output": str(output), "error": str(e)},
)
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
raise ValueError(f"Failed extracting {type_} stream")
log_event(
"hybrid_extract_stream",
@@ -199,8 +299,8 @@ class Hybrid:
success=True,
)
def level_5(self, input_video):
"""Make Level 5 active area metadata through crop detection on the HDR10 track.
def level_5(self, crop: tuple[int, int, int, int]) -> None:
"""Write the active area of the HDR10 track, as (left, top, right, bottom), to Level 5.
This resolves mismatches where DV has no black bars but HDR10 does (or vice versa)
by telling the display the correct active area.
@@ -209,136 +309,7 @@ class Hybrid:
self.rpu_file = "RPU_L5.bin"
return
ffprobe_bin = str(FFProbe) if FFProbe else "ffprobe"
ffmpeg_bin = str(FFMPEG) if FFMPEG else "ffmpeg"
with console.status("Detecting active area (crop detection)...", spinner="dots"):
result_duration = subprocess.run(
[ffprobe_bin, "-v", "error", "-show_entries", "format=duration", "-of", "json", str(input_video)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
if result_duration.returncode != 0:
log_event(
"hybrid_level5",
level="WARNING",
message="Could not probe video duration",
context={"returncode": result_duration.returncode, "stderr": (result_duration.stderr or "")},
)
self.log.warning("Could not probe video duration, skipping L5 crop detection")
return
duration_value = json.loads(result_duration.stdout).get("format", {}).get("duration")
if duration_value is None:
log_event(
"hybrid_level5",
level="WARNING",
message="Video has no container duration; RPU keeps the active area of the DV track",
context={"input": str(input_video)},
)
self.log.warning(
"Video has no container duration, skipping L5 crop detection. "
"The RPU keeps the active area of the DV track."
)
return
duration = float(duration_value)
result_streams = subprocess.run(
[
ffprobe_bin,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"json",
str(input_video),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
if result_streams.returncode != 0:
log_event(
"hybrid_level5",
level="WARNING",
message="Could not probe video resolution",
context={"returncode": result_streams.returncode, "stderr": (result_streams.stderr or "")},
)
self.log.warning("Could not probe video resolution, skipping L5 crop detection")
return
stream_info = json.loads(result_streams.stdout)
original_width = int(stream_info["streams"][0]["width"])
original_height = int(stream_info["streams"][0]["height"])
random_times = sorted(random.uniform(0, duration) for _ in range(10))
def sample_crop(t: float) -> Optional[tuple[int, int, int, int]]:
result_cropdetect = subprocess.run(
[
ffmpeg_bin,
"-y",
"-nostdin",
"-loglevel",
"info",
"-ss",
f"{t:.2f}",
"-i",
str(input_video),
"-vf",
"cropdetect=round=2",
"-vframes",
"4",
"-f",
"null",
"-",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
# cropdetect outputs crop=w:h:x:y
crop_match = re.search(
r"crop=(\d+):(\d+):(\d+):(\d+)",
(result_cropdetect.stdout or "") + (result_cropdetect.stderr or ""),
)
if not crop_match:
return None
w, h = int(crop_match.group(1)), int(crop_match.group(2))
x, y = int(crop_match.group(3)), int(crop_match.group(4))
return (x, y, original_width - w - x, original_height - h - y)
with futures.ThreadPoolExecutor(max_workers=len(random_times)) as pool:
crop_results = [crop for crop in pool.map(sample_crop, random_times) if crop]
if not crop_results:
log_event(
"hybrid_level5",
level="WARNING",
message="No crop data detected, skipping L5",
context={"samples": len(random_times)},
)
self.log.warning("No crop data detected, skipping L5")
return
crop_counts = {}
for crop in crop_results:
crop_counts[crop] = crop_counts.get(crop, 0) + 1
most_common = max(crop_counts, key=crop_counts.get)
left, top, right, bottom = most_common
left, top, right, bottom = crop
l5_json = {
"active_area": {
@@ -373,13 +344,160 @@ class Hybrid:
"hybrid_level5",
level="DEBUG",
message="Edited RPU Level 5 active area",
context={"crop": {"left": left, "right": right, "top": top, "bottom": bottom}},
success=True,
)
self.rpu_file = "RPU_L5.bin"
def detect_active_area(self, input_video: Path) -> Optional[tuple[tuple[int, int, int, int], float]]:
"""The picture's crop as (left, top, right, bottom) and its share of the frame, or None.
None also when the picture is under 40% of the frame: cropdetect on dark samples can report a tiny
picture, which would be wrong in L5 and would divide the HDR10+ conversion's mean by a tiny share.
"""
ffprobe_bin = str(FFProbe) if FFProbe else "ffprobe"
ffmpeg_bin = str(FFMPEG) if FFMPEG else "ffmpeg"
with console.status("Detecting active area (crop detection)...", spinner="dots"):
result_duration = subprocess.run(
[ffprobe_bin, "-v", "error", "-show_entries", "format=duration", "-of", "json", str(input_video)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
if result_duration.returncode != 0:
log_event(
"hybrid_level5",
level="WARNING",
message="Could not probe video duration",
context={"returncode": result_duration.returncode, "stderr": (result_duration.stderr or "")},
)
self.log.warning("Could not probe video duration, skipping crop detection")
return None
duration_value = json.loads(result_duration.stdout).get("format", {}).get("duration")
if duration_value is None:
log_event(
"hybrid_level5",
level="WARNING",
message="Video has no container duration; RPU keeps the active area of the DV track",
context={"input": str(input_video)},
)
self.log.warning(
"Video has no container duration, skipping crop detection. "
"The RPU keeps the active area of the DV track."
)
return None
duration = float(duration_value)
result_streams = subprocess.run(
[
ffprobe_bin,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"json",
str(input_video),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
if result_streams.returncode != 0:
log_event(
"hybrid_level5",
level="WARNING",
message="Could not probe video resolution",
context={"returncode": result_streams.returncode, "stderr": (result_streams.stderr or "")},
)
self.log.warning("Could not probe video resolution, skipping crop detection")
return None
stream_info = json.loads(result_streams.stdout)
original_width = int(stream_info["streams"][0]["width"])
original_height = int(stream_info["streams"][0]["height"])
sample_times = [duration * (i + 0.5) / 10 for i in range(10)]
def sample_crop(t: float) -> Optional[tuple[int, int, int, int]]:
result_cropdetect = subprocess.run(
[
ffmpeg_bin,
"-y",
"-nostdin",
"-loglevel",
"info",
"-ss",
f"{t:.2f}",
"-i",
str(input_video),
"-vf",
"cropdetect=round=2",
# cropdetect skips its first 2 frames and only ever grows the picture, so 12 frames
# give each sample 10 evaluated ones
"-vframes",
"12",
"-f",
"null",
"-",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
# cropdetect prints crop=w:h:x:y per frame and settles over the frames, so the last line counts
crop_matches = re.findall(
r"crop=(\d+):(\d+):(\d+):(\d+)",
(result_cropdetect.stdout or "") + (result_cropdetect.stderr or ""),
)
if not crop_matches:
return None
w, h, x, y = map(int, crop_matches[-1])
return (x, y, original_width - w - x, original_height - h - y)
with futures.ThreadPoolExecutor(max_workers=len(sample_times)) as pool:
crop_results = [crop for crop in pool.map(sample_crop, sample_times) if crop]
if not crop_results:
log_event(
"hybrid_level5",
level="WARNING",
message="No crop data detected",
context={"samples": len(sample_times)},
)
self.log.warning("No crop data detected, skipping the active area")
return None
left, top, right, bottom = Counter(crop_results).most_common(1)[0][0]
fraction = (
(original_width - left - right) * (original_height - top - bottom) / (original_width * original_height)
)
if fraction < 0.4:
self.log.warning("The detected picture is under 40% of the frame, skipping the active area")
return None
log_event(
"hybrid_level5",
level="DEBUG",
message="Detected active area",
context={
"crop": {"left": left, "right": right, "top": top, "bottom": bottom},
"samples": len(crop_results),
},
success=True,
)
self.rpu_file = "RPU_L5.bin"
return (left, top, right, bottom), fraction
@staticmethod
def sanitize_l6(
@@ -537,7 +655,6 @@ class Hybrid:
"hdr_type": self.hdr_type,
"rpu_file": self.rpu_file,
"output": self.hevc_file,
"drop_hdr10plus": self.hdr10plus_to_dv,
},
success=True,
)
@@ -574,6 +691,8 @@ class Hybrid:
file_size = os.path.getsize(config.directories.temp / self.hdr10plus_file)
if file_size == 0:
# left in place, the next resolution would take the empty file for its metadata
Path.unlink(config.directories.temp / self.hdr10plus_file, missing_ok=True)
log_event(
"hybrid_extract_hdr10plus",
level="ERROR",
@@ -655,51 +774,83 @@ class Hybrid:
return max_mdl, min_mdl, max_cll, max_fall
def convert_hdr10plus_to_dv(self):
"""Convert HDR10+ metadata to Dolby Vision RPU"""
if os.path.isfile(config.directories.temp / "RPU.bin"):
return
with console.status("Converting HDR10+ metadata to Dolby Vision...", spinner="dots"):
max_mdl, min_mdl, max_cll, max_fall = self.probe_hdr_metadata()
max_mdl, min_mdl, max_cll, max_fall = self.sanitize_l6(max_mdl, min_mdl, max_cll, max_fall)
extra_metadata = {
"cm_version": "V29",
"length": 0, # dovi_tool will figure this out
"level6": {
"max_display_mastering_luminance": max_mdl,
"min_display_mastering_luminance": min_mdl,
"max_content_light_level": max_cll,
"max_frame_average_light_level": max_fall,
},
}
with open(config.directories.temp / "extra.json", "w") as f:
json.dump(extra_metadata, f, indent=2)
def convert_hdr10plus_to_dv(self, active_fraction: float = 1.0) -> None:
"""Generate a Dolby Vision RPU with one shot per HDR10+ scene.
By default unshackle builds the L1 of each shot itself (see hdr10plus_shots), because dovi_tool's
--hdr10plus-json sets L1 avg from the linear AverageMaxRGB and so makes most scenes too bright.
config.generate_dv_mode picks the L1 avg floor: v29 (about 2.4 nits, closest to the pixels) or v40
(about 10 nits, as in studio CM v4.0 RPUs). legacy hands the HDR10+ JSON to dovi_tool instead.
"""
mode = config.generate_dv_mode
if mode not in GENERATE_DV_MODES:
raise ValueError(f"generate_dv_mode must be one of {', '.join(GENERATE_DV_MODES)}, not {mode!r}")
hdr10plus_path = config.directories.temp / self.hdr10plus_file
config_path = config.directories.temp / "generate.json"
try:
dovi.generate_from_hdr10plus(
config.directories.temp / "extra.json",
config.directories.temp / self.hdr10plus_file,
config.directories.temp / "RPU.bin",
label="dovi_tool generate",
)
except RuntimeError as e:
log_event(
"hybrid_convert_hdr10plus",
level="ERROR",
message="Failed converting HDR10+ to Dolby Vision",
context={"error": str(e)},
)
raise ValueError("Failed converting HDR10+ to Dolby Vision")
with console.status("Converting HDR10+ metadata to Dolby Vision...", spinner="dots"):
with open(hdr10plus_path, encoding="utf-8") as f:
metadata = json.load(f)
profile = metadata.get("JSONInfo", {}).get("HDR10plusProfile")
shots = hdr10plus_shots(metadata, active_fraction)
# one entry per frame: a feature film is hundreds of MB of objects, freed before dovi_tool runs
del metadata
max_mdl, min_mdl, max_cll, max_fall = self.probe_hdr_metadata()
max_mdl, min_mdl, max_cll, max_fall = self.sanitize_l6(max_mdl, min_mdl, max_cll, max_fall)
generator_config = {
"cm_version": "V29",
"level6": {
"max_display_mastering_luminance": max_mdl,
"min_display_mastering_luminance": min_mdl,
"max_content_light_level": max_cll,
"max_frame_average_light_level": max_fall,
},
}
if mode == "legacy":
generator_config["length"] = 0 # dovi_tool takes the length from the HDR10+ JSON
else:
generator_config["shots"] = shots
if mode == "v40":
generator_config["l1_avg_pq_cm_version"] = "V40"
with open(config_path, "w", encoding="utf-8") as f:
json.dump(generator_config, f)
if profile == "A":
self.log.warning(
"The HDR10+ metadata is Profile A, which is often less reliable than Profile B. "
"Check the Dolby Vision result before you keep it."
)
try:
dovi.generate(
config_path,
config.directories.temp / "RPU.bin",
hdr10plus_json=hdr10plus_path if mode == "legacy" else None,
label="dovi_tool generate",
)
except RuntimeError as e:
log_event(
"hybrid_convert_hdr10plus",
level="ERROR",
message="Failed converting HDR10+ to Dolby Vision",
context={"error": str(e)},
)
raise ValueError("Failed converting HDR10+ to Dolby Vision")
finally:
config_path.unlink(missing_ok=True)
hdr10plus_path.unlink(missing_ok=True)
log_event(
"hybrid_convert_hdr10plus",
level="DEBUG",
message="Converted HDR10+ metadata to Dolby Vision Profile 8",
message="Converted HDR10+ metadata to Dolby Vision Profile 8.1",
context={
"shots": len(shots),
"mode": mode,
"hdr10plus_profile": profile,
"active_fraction": round(active_fraction, 3),
},
success=True,
)
Path.unlink(config.directories.temp / "extra.json")
Path.unlink(config.directories.temp / self.hdr10plus_file)
self.log.info(f"Dolby Vision generated from HDR10+ metadata ({len(shots)} scenes, {mode})")
+5 -2
View File
@@ -440,14 +440,17 @@ class Tracks:
video.hybrid_base_only = True
def select_hybrid(self, tracks, quality, worst: bool = False):
# 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.
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 (v.width and int(v.width * 9 / 16) in quality))
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))
]
if base_tracks:
break
+8 -7
View File
@@ -127,18 +127,19 @@ def info_frame(rpu: Path, frame: int = 0) -> dict:
raise RuntimeError(f"dovi_tool info returned invalid JSON: {e}")
def generate_from_hdr10plus(
extra_json: Path,
hdr10plus_json: Path,
def generate(
config_json: Path,
output: Path,
*,
status: Optional[str] = "Generating DV RPU from HDR10+ metadata...",
hdr10plus_json: Optional[Path] = None,
status: Optional[str] = "Generating DV RPU...",
label: str = "dovi_tool generate",
) -> bytes:
"""Assemble a DV RPU from extracted HDR10+ metadata + an extra JSON descriptor."""
"""Build a DV RPU from a dovi_tool generator config, and from hdr10plus_tool JSON when given."""
tool = require_dovi_tool()
extra = ["--hdr10plus-json", hdr10plus_json] if hdr10plus_json else []
return run_step(
[tool, "generate", "-j", extra_json, "--hdr10plus-json", hdr10plus_json, "-o", output],
[tool, "generate", "-j", config_json, *extra, "-o", output],
status=status,
output=output,
label=label,
@@ -166,5 +167,5 @@ __all__ = (
"editor",
"info_summary",
"info_frame",
"generate_from_hdr10plus",
"generate",
)