From 35871f3a526263465bb66184ee75e8c1fb7c2216 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sat, 17 Oct 2026 16:40:14 -0600 Subject: [PATCH] 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. --- docs/dev/creating-a-service.md | 7 + docs/dev/rest-api/endpoints.md | 1 + docs/dev/rest-api/remote-sessions.md | 6 +- docs/dev/service-architecture.md | 6 +- docs/guide/cli-reference.md | 1 + docs/guide/downloading.md | 48 +- docs/reference/configuration/download.md | 22 + docs/reference/configuration/index.md | 2 +- tests/core/test_hybrid_dv_fixup.py | 4 +- tests/core/test_hybrid_hdr10plus.py | 736 +++++++++++++++++++++++ tests/core/test_hybrid_l6.py | 13 +- tests/tracks/test_hybrid_selection.py | 13 + unshackle/commands/dl.py | 143 ++++- unshackle/core/api/download_manager.py | 2 + unshackle/core/api/handlers.py | 25 +- unshackle/core/api/routes.py | 5 + unshackle/core/config.py | 1 + unshackle/core/remote_service.py | 2 + unshackle/core/service.py | 20 +- unshackle/core/tracks/hybrid.py | 577 +++++++++++------- unshackle/core/tracks/tracks.py | 7 +- unshackle/core/utils/dovi.py | 15 +- 22 files changed, 1370 insertions(+), 286 deletions(-) create mode 100644 tests/core/test_hybrid_hdr10plus.py diff --git a/docs/dev/creating-a-service.md b/docs/dev/creating-a-service.md index a0e92ddd..db24d192 100644 --- a/docs/dev/creating-a-service.md +++ b/docs/dev/creating-a-service.md @@ -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 diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 9625805d..3d0d8037 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -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. | diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index 22e1e37a..d57d3fb2 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -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` diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index 4b3fdd8b..0d8f4693 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -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 diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 3e79d1eb..257eedbd 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -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`.** | diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 0edca1e2..8d214157 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -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 diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index a7275a70..88887209 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -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. diff --git a/docs/reference/configuration/index.md b/docs/reference/configuration/index.md index 041d0a60..e8026089 100644 --- a/docs/reference/configuration/index.md +++ b/docs/reference/configuration/index.md @@ -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` | diff --git a/tests/core/test_hybrid_dv_fixup.py b/tests/core/test_hybrid_dv_fixup.py index cbda8dd4..a1dd594d 100644 --- a/tests/core/test_hybrid_dv_fixup.py +++ b/tests/core/test_hybrid_dv_fixup.py @@ -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 diff --git a/tests/core/test_hybrid_hdr10plus.py b/tests/core/test_hybrid_hdr10plus.py new file mode 100644 index 00000000..2d46e6d7 --- /dev/null +++ b/tests/core/test_hybrid_hdr10plus.py @@ -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) diff --git a/tests/core/test_hybrid_l6.py b/tests/core/test_hybrid_l6.py index 0f5c6a05..20048fca 100644 --- a/tests/core/test_hybrid_l6.py +++ b/tests/core/test_hybrid_l6.py @@ -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" diff --git a/tests/tracks/test_hybrid_selection.py b/tests/tracks/test_hybrid_selection.py index 515fbafd..85db2dc1 100644 --- a/tests/tracks/test_hybrid_selection.py +++ b/tests/tracks/test_hybrid_selection.py @@ -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)] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index ed2cc492..8a0bb36e 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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: diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 92c9a135..0bc6a89c 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -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, ) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 004e43f9..1133e524 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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), } diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index a3a9e4c7..d605be85 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -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) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 9ddcef4d..fc96173c 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -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 = "" diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 3ebce1bf..202d7b52 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -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. diff --git a/unshackle/core/service.py b/unshackle/core/service.py index 6dd50828..97fb353d 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -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: diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index abde324f..bbfca667 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -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})") diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index 5b17c889..145ba1fb 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -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 diff --git a/unshackle/core/utils/dovi.py b/unshackle/core/utils/dovi.py index a97ff0f7..9b16f346 100644 --- a/unshackle/core/utils/dovi.py +++ b/unshackle/core/utils/dovi.py @@ -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", )