From d12bf31b6b8405e8543987b858fc07402c85445f Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 10 Nov 2026 05:30:03 -0700 Subject: [PATCH] feat(hybrid): generate Dolby Vision from the decoded video --generate-dv now takes an HDR10 base and measures L1 with FFmpeg when there is no usable HDR10+ metadata. -r HDR10 or -r HDR10P with the flag runs as a hybrid of that base alone, --sample shifts the DV RPU to match the base, and the REST API accepts generate_dv with any range. --- docs/dev/creating-a-service.md | 4 +- docs/dev/rest-api/endpoints.md | 2 +- docs/dev/service-architecture.md | 2 +- docs/guide/cli-reference.md | 2 +- docs/guide/downloading.md | 54 +++- docs/reference/configuration/download.md | 9 +- tests/core/test_hybrid_hdr10plus.py | 266 ++++++++++++++-- unshackle/commands/dl.py | 80 ++--- unshackle/core/api/handlers.py | 9 - unshackle/core/api/routes.py | 6 +- unshackle/core/tracks/hybrid.py | 379 +++++++++++++++++++++-- 11 files changed, 682 insertions(+), 131 deletions(-) diff --git a/docs/dev/creating-a-service.md b/docs/dev/creating-a-service.md index 020bf4a9..27ad55dc 100644 --- a/docs/dev/creating-a-service.md +++ b/docs/dev/creating-a-service.md @@ -156,13 +156,13 @@ 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+ + generate_dv: bool = False # --generate-dv: make Dolby Vision when a title has no DV track ``` !!! 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. + unshackle prefers an HEVC HDR10+ track as the base, and measures an HDR10 base from the decoded video. `get_tracks_for_variants` does this for you. !!! note "Read the request, don't second-guess the user" diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index a2b5c3b1..3b56abee 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -480,7 +480,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`. | +| `generate_dv` | boolean | `false` | With `HYBRID` in `range`, make Dolby Vision from the HDR10+ metadata or the decoded video when a title has no DV track. With `HDR10` or `HDR10P` in `range` and no `HYBRID`, make it for that base and ignore a DV track. With none of the three in `range`, the field has no effect. | | `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/service-architecture.md b/docs/dev/service-architecture.md index e1591b13..8f670844 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -130,7 +130,7 @@ See [Proxies and VPN](../guide/proxies-and-vpn.md). `[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. + the title has no DV track, because unshackle prefers an HEVC HDR10+ base. ## Titles and the `Title` hierarchy diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 7fc7f0f2..2de93ec9 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -80,7 +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). | +| `--generate-dv` | off | With `-r HYBRID`, make Dolby Vision from the HDR10+ metadata or the decoded video when a title has no Dolby Vision track. With `-r HDR10` or `-r HDR10P`, make it for that base and ignore a Dolby Vision track. See [Dolby Vision without a DV track](downloading.md#dolby-vision-without-a-dv-track). | | `-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 4a94c6ee..d7a690a6 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -185,18 +185,36 @@ The output file of a track with a Dolby Vision profile 8 RPU plays as Dolby Visi 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+ +#### Dolby Vision without a DV track -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. A track that the service labels HDR10 but -that carries HDR10+ metadata also qualifies (see [`hdr10plus_probe`](../reference/configuration/download.md#hdr10plus_probe)). +When a title has no DV track, `-r HYBRID --generate-dv` makes a Dolby Vision Profile 8.1 track +for its HDR10 or HDR10+ base. 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" +| Base | Source of the Dolby Vision brightness values | +| --- | --- | +| HDR10+, with `hdr10plus_tool` installed | The HDR10+ metadata | +| HDR10, or HDR10+ without `hdr10plus_tool`, or an HDR10+ label with no metadata in the track | The decoded video | + +A track that the service labels HDR10 but that carries HDR10+ metadata counts as HDR10+ +(see [`hdr10plus_probe`](../reference/configuration/download.md#hdr10plus_probe)). + +```shell title="Generate Dolby Vision, and keep the HDR10+ file too" unshackle dl -q 2160 -r HYBRID,HDR10P --generate-dv EXAMPLE '...' ``` +To generate Dolby Vision for one base when the title has a DV track, name the base and not `HYBRID`: +`-r HDR10 --generate-dv` or `-r HDR10P --generate-dv`. unshackle then ignores the DV track and writes +only the hybrid of that base. The DV track of the service is better than generated Dolby Vision, +so prefer `-r DV` or `-r HYBRID` when the title has one. With `DV` also in `-r`, unshackle shows a +warning, ignores the flag and downloads the ranges as usual. + +```shell title="Generate Dolby Vision from the HDR10 track alone" +unshackle dl -q 2160 -r HDR10 --generate-dv EXAMPLE '...' +``` + +##### From HDR10+ metadata + 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 @@ -207,6 +225,21 @@ The [`generate_dv_mode`](../reference/configuration/download.md#generate_dv_mode `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. +##### From the decoded video + +For a base with no HDR10+ metadata, FFmpeg decodes the whole HDR10 track and unshackle measures +each frame inside the letterbox bars. Each scene cut that FFmpeg detects starts one Dolby Vision +shot, and a shot is at least half a second long. The Level 1 values of a shot are the minimum, the +peak and the mean brightness of its frames. The peak is the brightest small area of the picture +(1/32 of its width), not one pixel, which is how studio Dolby Vision reads. `dovi_tool` keeps the +minimum at 0.0003 nits or below and the peak at 100 nits or above. When the track states no MaxCLL +or MaxFALL, the Level 6 values come from the same measurement, so they are lower than values +measured for each pixel. + +!!! note "The measurement decodes the whole title" + A 2160p film takes about 10 minutes on a fast CPU, because every frame is decoded once. The + download and the HDR10 track are not changed. + !!! 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. @@ -214,15 +247,12 @@ Dolby Vision, or `legacy` for the `dovi_tool` average. `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. + - Dolby Vision from HDR10+ 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 80d0e856..71573df3 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -52,7 +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, video language, or audio 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). | +| `generate_dv` | bool | `false` | With `-r HYBRID`, make Dolby Vision from the HDR10+ metadata or the decoded video when a title has no DV track. With `-r HDR10` or `-r HDR10P`, make it for that base and ignore a DV track. See [Dolby Vision without a DV track](../../guide/downloading.md#dolby-vision-without-a-dv-track). | | `proxy` | str | *(unset)* | Default proxy URI or 2-letter country. | | `no_folder` | bool | `false` | Do not create a per-title folder. | @@ -407,7 +407,7 @@ dual_multi_mode: strict Find the HDR10+ tracks that a service labels as HDR10. Before unshackle lists or selects tracks, it reads the first fragment of HEVC HDR10 tracks from DASH, HLS fMP4 and one-file sources. A track whose first frame carries HDR10+ (SMPTE ST 2094-40) metadata becomes `HDR10P`. Then `-r HDR10P`, -`--list` and [`--generate-dv`](../../guide/downloading.md#dolby-vision-from-hdr10) use it. +`--list` and [`--generate-dv`](../../guide/downloading.md#dolby-vision-without-a-dv-track) use it. unshackle also probes HEVC Dolby Vision tracks. A DV track that carries HDR10+ keeps its `DV` range, so `-r DV` selects it, and `--list` shows it as `DV+HDR10P`. `-r HYBRID` uses it as the @@ -440,7 +440,7 @@ remote downloads and to the REST API. - **Type:** `str`  ·  **Default:** `v29` -How [`--generate-dv`](../../guide/downloading.md#dolby-vision-from-hdr10) sets the Level 1 brightness +How [`--generate-dv`](../../guide/downloading.md#dolby-vision-without-a-dv-track) 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. @@ -454,5 +454,8 @@ same peak and letterbox area. They differ in the scene average, which the TV use generate_dv_mode: v29 ``` +Dolby Vision that unshackle measures from the decoded video uses its measured average in all modes. +`v40` raises the lowest average to about 10 nits, and `legacy` is the same as `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/tests/core/test_hybrid_hdr10plus.py b/tests/core/test_hybrid_hdr10plus.py index aafcb633..916df041 100644 --- a/tests/core/test_hybrid_hdr10plus.py +++ b/tests/core/test_hybrid_hdr10plus.py @@ -19,10 +19,12 @@ 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 ( + generated_dv_ranges, hybrid_gate_failure, hybrid_plan, hybrid_source_tracks, hybrid_step_error, + keep_generated_dv_bases, missing_hybrid_error, select_hybrid_bases, ) @@ -33,7 +35,15 @@ from unshackle.core.service import Service, TrackRequest from unshackle.core.services import Services from unshackle.core.tracks import Audio, Chapter, Subtitle, 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 +from unshackle.core.tracks.hybrid import ( + Hybrid, + frame_stats, + hdr10plus_mean_pq, + hdr10plus_shots, + measured_shots, + nits_to_pq, + pq_to_nits, +) pytestmark = pytest.mark.unit @@ -211,6 +221,26 @@ def make_hybrid() -> Hybrid: return obj +def stub_hybrid_steps(monkeypatch: pytest.MonkeyPatch, *steps: str, tool: Optional[str] = "hdr10plus_tool") -> None: + """Make the stream, Level 5 and inject steps (and those named) do nothing, with a fixed active area.""" + for name in ("extract_stream", "level_5", "injecting", *steps): + 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_mod.console, "print", lambda *a, **k: None) + monkeypatch.setattr(hybrid_mod, "HDR10PlusTool", tool) + + +def capture_generate(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + """The config that dovi.generate gets, filled in when it runs.""" + written: dict[str, Any] = {} + monkeypatch.setattr( + hybrid_mod.dovi, + "generate", + lambda config_path, output, **k: written.update(json.loads(config_path.read_text())), + ) + return written + + @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. @@ -411,12 +441,9 @@ def test_a_conversion_run_removes_its_rpus_so_the_next_base_makes_its_own( 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)) + stub_hybrid_steps(monkeypatch, "extract_hdr10plus") 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, width=None, height=2160, range=Video.Range.HDR10P)], "TEST") @@ -424,6 +451,191 @@ def test_a_conversion_run_removes_its_rpus_so_the_next_base_makes_its_own( assert not (tmp_path / "RPU.bin").exists() and not (tmp_path / "RPU_L6.bin").exists() +@pytest.mark.parametrize("range_, tool", [(Video.Range.HDR10, "hdr10plus_tool"), (Video.Range.HDR10P, None)]) +def test_a_base_without_usable_hdr10plus_is_measured_inside_its_crop( + range_: Video.Range, tool: Optional[str], quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + base = tmp_path / "base.mkv" + base.write_bytes(b"") + (tmp_path / "RPU_L6.bin").write_bytes(b"stale") + seen: dict[str, Any] = {} + + def measure(self: Hybrid, crop: Any = None) -> None: + seen.update(crop=crop, stale_l6=(tmp_path / "RPU_L6.bin").exists()) + (tmp_path / "RPU.bin").write_bytes(b"rpu") + + stub_hybrid_steps(monkeypatch, "level_6", tool=tool) + monkeypatch.setattr(Hybrid, "measure_dv", measure) + + Hybrid([SimpleNamespace(id="b", path=base, width=None, height=2160, range=range_)], "TEST") + + assert seen == {"crop": (0, 100, 0, 100), "stale_l6": False} + assert not (tmp_path / "RPU.bin").exists() + + +def test_an_hdr10plus_label_without_metadata_is_measured( + quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + base = tmp_path / "base.mkv" + base.write_bytes(b"") + measured: list[Any] = [] + + def no_metadata(*args: Any, **kwargs: Any) -> bytes: + raise RuntimeError("hdr10plus_tool extract failed: Error: File doesn't contain dynamic metadata") + + stub_hybrid_steps(monkeypatch, "level_6") + monkeypatch.setattr(Hybrid, "measure_dv", lambda self, crop=None: measured.append(crop)) + monkeypatch.setattr(hybrid_mod, "run_step", no_metadata) + + hybrid = Hybrid([SimpleNamespace(id="b", path=base, width=None, height=2160, range=Video.Range.HDR10P)], "TEST") + + assert measured == [(0, 100, 0, 100)] and hybrid.hdr10plus_to_dv is False + + +MEASURED = """frame:0 pts:0 pts_time:0 +lavfi.scd.score=0.000 +lavfi.signalstats.YMIN=1600 +lavfi.signalstats.YAVG=16000.5 +lavfi.signalstats.YMAX=32000 +frame:1 pts:42 pts_time:0.042 +lavfi.scd.score=0.100 +lavfi.signalstats.YMIN=800 +lavfi.signalstats.YAVG=32000.5 +lavfi.signalstats.YMAX=48000 +frame:2 pts:83 pts_time:0.083 +lavfi.scd.score=55.000 +lavfi.scd.time=0.083 +lavfi.signalstats.YMIN=160 +lavfi.signalstats.YAVG=1600 +lavfi.signalstats.YMAX=3200 +""" + + +def test_measured_frames_become_one_shot_per_scene_cut() -> None: + frames = frame_stats(MEASURED.splitlines(keepends=True)) + assert frames == [(1600, 16000.5, 32000, False), (800, 32000.5, 48000, False), (160, 1600.0, 3200, True)] + + shots = measured_shots(frames[:2] * 6 + frames[2:]) + + assert [(s["start"], s["duration"]) for s in shots] == [(0, 12), (12, 1)] + assert shots[0]["metadata_blocks"] == [{"Level1": {"min_pq": 50, "max_pq": 2999, "avg_pq": 1500}}] + assert shots[1]["metadata_blocks"] == [{"Level1": {"min_pq": 10, "max_pq": 200, "avg_pq": 100}}] + + +def test_a_cut_on_the_first_frame_or_inside_a_flash_burst_opens_no_shot() -> None: + still, cut = (0, 1.0, 2, False), (0, 1.0, 2, True) + shots = measured_shots([cut] + [still] * 11 + [cut, still, cut] + [still] * 9 + [cut]) + assert [(s["start"], s["duration"]) for s in shots] == [(0, 12), (12, 12), (24, 1)] + + +def test_pq_to_nits_is_the_inverse_of_nits_to_pq() -> None: + for nits in (0.005, 1.0, 100.0, 1000.0, 10000.0): + assert pq_to_nits(nits_to_pq(nits)) == pytest.approx(nits, rel=1e-6) + + +@pytest.mark.parametrize("stream, expected", [((0, 0), (1000, 400)), ((800, 200), (800, 200))]) +def test_measured_light_levels_fill_only_what_the_stream_does_not_state( + stream: tuple[int, int], expected: tuple[int, int], quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + peak = round(nits_to_pq(1000.0) * 65535 / 4095) + frames = [(0, 100.0, peak, False)] + written = capture_generate(monkeypatch) + hybrid = make_hybrid() + monkeypatch.setattr(Hybrid, "measure_frames", lambda self, path, crop: (frames, 400 * 65535 / 10000)) + monkeypatch.setattr(Hybrid, "dropped_leading_frames", lambda self, path: 0) + monkeypatch.setattr(Hybrid, "probe_hdr_metadata", lambda self: (1000, 1, *stream)) + + hybrid.measure_dv() + + level6 = written["level6"] + assert (level6["max_content_light_level"], level6["max_frame_average_light_level"]) == expected + assert written["shots"] == measured_shots(frames) and "l1_avg_pq_cm_version" not in written + assert not (tmp_path / "generate.json").exists() + + +def test_leading_frames_the_decoder_dropped_go_to_the_first_shot( + quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """dovi_tool pads a short RPU at its end, which would put every cut early.""" + frames = [(10, 100.0, 1000, False)] * 12 + [(20, 200.0, 2000, True)] + written = capture_generate(monkeypatch) + monkeypatch.setattr(Hybrid, "measure_frames", lambda self, path, crop: (frames, 0.0)) + monkeypatch.setattr(Hybrid, "dropped_leading_frames", lambda self, path: 2) + monkeypatch.setattr(Hybrid, "probe_hdr_metadata", lambda self: (1000, 1, 800, 200)) + + make_hybrid().measure_dv() + + assert [(s["start"], s["duration"]) for s in written["shots"]] == [(0, 14), (14, 1)] + + +@pytest.mark.parametrize("stream, expected", [((0, 0), (600, 30)), ((650, 120), (650, 120))]) +def test_an_hdr10plus_conversion_fills_light_levels_the_stream_does_not_state( + stream: tuple[int, int], expected: tuple[int, int], quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + meta = metadata([(2, LETTERBOXED)]) + meta["SceneInfo"][1]["LuminanceParameters"]["MaxScl"] = [4000, 6000, 5000] + (tmp_path / "HDR10Plus.json").write_text(json.dumps(meta)) + hybrid = make_hybrid() + monkeypatch.setattr(hybrid, "probe_hdr_metadata", lambda: (1000, 1, *stream)) + seen = capture_generate(monkeypatch) + + hybrid.convert_hdr10plus_to_dv() + + level6 = seen["level6"] + assert (level6["max_content_light_level"], level6["max_frame_average_light_level"]) == expected + + +@pytest.mark.parametrize( + "dv_start, expected", + [ + (122.0, {"duplicate": [{"source": 0, "offset": 0, "length": 48}]}), + (119.0, {"remove": ["0-23"]}), + (120.0, None), + (119.99, None), + (120.01, None), + ], +) +def test_a_sample_rpu_is_shifted_to_the_first_frame_of_the_base( + dv_start: float, expected: Optional[dict[str, Any]], quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """--sample cuts each track on its own segment grid, so the RPU can start before or after the base.""" + seen: dict[str, Any] = {} + monkeypatch.setattr( + hybrid_mod.dovi, "editor", lambda source, spec, output, **k: seen.update(json.loads(spec.read_text())) + ) + hybrid = make_hybrid() + + hybrid.align_rpu(SimpleNamespace(sample_start=120.0, fps=None), SimpleNamespace(sample_start=dv_start, fps=24.0)) # type: ignore[arg-type] + + assert (seen or None) == expected + assert hybrid.rpu_file == ("RPU_ALIGN.bin" if expected else "RPU.bin") + + +def test_a_base_range_with_the_flag_runs_as_a_hybrid_of_that_base_alone() -> None: + R = Video.Range + assert generated_dv_ranges([R.HDR10], True) == ([R.HYBRID], [R.HDR10]) + assert generated_dv_ranges([R.HDR10P, R.SDR], True) == ([R.HYBRID, R.SDR], [R.HDR10P]) + for unchanged in ([R.HDR10], False), ([R.HYBRID, R.HDR10P], True), ([R.DV], True), ([R.HDR10, R.DV], True): + assert generated_dv_ranges(*unchanged) == (unchanged[0], []) + + videos = [video("dv", R.DV), video("h", R.HDR10), video("p", R.HDR10P), video("s", R.SDR)] + assert [v.id for v in keep_generated_dv_bases(videos, [R.HDR10])] == ["h", "s"] + assert hybrid_gate_failure(keep_generated_dv_bases(videos, [R.HDR10]), True) is None + + +def test_hdr10plus_is_the_base_before_hdr10_at_one_height() -> None: + videos = [video("h", Video.Range.HDR10), video("p", Video.Range.HDR10P), video("h1080", Video.Range.HDR10, 1080)] + assert [v.id for v in hybrid_plan(videos, True)[0]] == ["p", "h1080"] + assert [v.id for v in select_hybrid_bases(videos[::-1])] == ["p", "h1080"] + + +@pytest.mark.parametrize("to_dv", [False, True]) +def test_an_hdr10_base_without_dv_is_planned_only_with_the_flag(to_dv: bool) -> None: + videos = [video("h", Video.Range.HDR10)] + assert (hybrid_gate_failure(videos, to_dv) is None) is to_dv + assert [v.id for v in hybrid_plan(videos, to_dv)[0]] == (["h"] if to_dv else []) + + def test_a_failed_resolution_removes_its_crop_edits_and_keeps_the_dv_files( quiet: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -446,8 +658,12 @@ def test_a_failed_resolution_removes_its_crop_edits_and_keeps_the_dv_files( 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", width=None, height=2160, range=Video.Range.HDR10), - SimpleNamespace(id="d", path=tmp_path / "dv.mkv", width=None, height=1080, range=Video.Range.DV), + SimpleNamespace( + id="b", path=tmp_path / "base.mkv", width=None, height=2160, range=Video.Range.HDR10, sample_start=0.0 + ), + SimpleNamespace( + id="d", path=tmp_path / "dv.mkv", width=None, height=1080, range=Video.Range.DV, sample_start=0.0 + ), ] with pytest.raises(ValueError): @@ -566,19 +782,12 @@ def test_the_hybrid_step_gets_the_base_alone_when_there_is_no_dv(tmp_path: Path) 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 +def test_the_api_takes_generate_dv_with_any_range_as_dl_does() -> None: + for data in ({}, {"range": ["SDR"]}, {"range": ["DV"]}, {"range": ["HYBRID"]}, {"range": ["HDR10"]}): + assert validate_download_parameters({"generate_dv": True, **data}) 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 @@ -680,20 +889,11 @@ def test_hybrid_fails_up_front_when_no_base_is_hevc() -> None: 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, "HDR10P; add --generate-dv to make DV from HDR10+"), - ([Video.Range.HDR10], False, "HDR10"), - ([Video.Range.HDR10], True, "HDR10; --generate-dv found no HEVC HDR10+ track"), - ], -) -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) == f"HYBRID needs HDR10/HDR10+ and DV tracks, available ranges: {detail}" +@pytest.mark.parametrize("range_", [Video.Range.HDR10P, Video.Range.HDR10]) +def test_hybrid_fails_without_dv_and_hints_at_the_flag(range_: Video.Range) -> None: + assert hybrid_gate_failure([video("v", range_)], False) == ( + f"HYBRID needs HDR10/HDR10+ and DV tracks, available ranges: {range_.name}; add --generate-dv to make DV" + ) @pytest.mark.parametrize("to_dv", [False, True]) @@ -711,12 +911,12 @@ def test_real_dv_takes_every_base_and_the_lowest_dv(to_dv: bool) -> None: assert dv is not None and dv.id == "dv1080" -def test_the_flag_without_dv_takes_only_hdr10plus_bases() -> None: +def test_the_flag_without_dv_takes_hdr10_and_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 + assert [v.id for v in bases] == ["h2160", "p1080"] and dv is None def test_no_dv_and_no_flag_gives_no_bases() -> None: diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 46c63f54..6540859f 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -237,33 +237,52 @@ def group_videos_by_variant(videos: list[Video], *, merge: bool) -> list[list[Vi return list(groups.values()) +HYBRID_BASE_RANGES = (Video.Range.HDR10P, Video.Range.HDR10) + + def select_hybrid_bases(videos: Sequence[Video]) -> list[Video]: """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. + Only HEVC tracks qualify, because dovi_tool injects the RPU into an HEVC stream. HDR10+ comes before + HDR10 at one height. """ bases: dict[int, Video] = {} for video in sorted( - ( - 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, + (v for v in videos if v.range in HYBRID_BASE_RANGES and v.codec in (Video.Codec.HEVC, None)), + key=lambda v: (v.height or 0, v.range == Video.Range.HDR10P), reverse=True, ): bases.setdefault(video.height or 0, video) return list(bases.values()) +def generated_dv_ranges( + range_: Sequence[Video.Range], generate_dv: bool +) -> tuple[list[Video.Range], list[Video.Range]]: + """Turn ``-r HDR10 --generate-dv`` (or HDR10P) into a HYBRID run on that base alone. + + Returns the ranges to run with, and the base ranges the user named. The second list is empty when + the flag is off, HYBRID or DV is in the ranges, or no base range is. The service still gets the + ranges the user gave, so it fetches no DV track. + """ + bases = [r for r in range_ if r in HYBRID_BASE_RANGES] + if not generate_dv or not bases or Video.Range.HYBRID in range_ or Video.Range.DV in range_: + return list(range_), [] + return [Video.Range.HYBRID, *(r for r in range_ if r not in bases)], bases + + +def keep_generated_dv_bases(videos: Sequence[Video], bases: Sequence[Video.Range]) -> list[Video]: + """The videos without the DV tracks and without a hybrid base range the user did not name.""" + return [v for v in videos if v.range != Video.Range.DV and (v.range not in HYBRID_BASE_RANGES or v.range in bases)] + + def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[str]: """The message that stops HYBRID for these selected videos, or None when it can go on.""" if any(v.native_hybrid for v in videos): return None - hdr_tracks = [v for v in videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)] + hdr_tracks = [v for v in videos if v.range in HYBRID_BASE_RANGES] 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) ranges = ", ".join(sorted(set(v.range.name for v in videos))) or "none" missing = f"HYBRID needs HDR10/HDR10+ and DV tracks, available ranges: {ranges}" @@ -272,28 +291,20 @@ def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[ if not base_tracks: codecs = sorted(set(v.codec.name for v in hdr_tracks if v.codec)) return f"HYBRID needs an HEVC HDR10/HDR10+ base, available codecs: {', '.join(codecs)}" - if not dv_tracks and not (generate_dv and has_hdr10p): - if generate_dv: - return f"{missing}; --generate-dv found no HEVC HDR10+ track" - if has_hdr10p: - return f"{missing}; add --generate-dv to make DV from HDR10+" - return missing + if not dv_tracks and not generate_dv: + return f"{missing}; add --generate-dv to make DV" 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``. A resolution with a + With no DV track, a base qualifies only with ``--generate-dv``. A resolution with a finished hybrid (``native_hybrid``) gets no base: that track is its hybrid. """ dv_tracks = [v for v in videos if v.range == Video.Range.DV] native_heights = {v.height for v in videos if v.native_hybrid} - base_pool = [ - v - for v in videos - if v.height not in native_heights and (dv_tracks or (generate_dv and v.range == Video.Range.HDR10P)) - ] + base_pool = [v for v in videos if v.height not in native_heights and (dv_tracks or generate_dv)] 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 @@ -302,7 +313,7 @@ def hybrid_source_tracks(base: Video, dv: Optional[Video], base_container: Optio """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. With no DV track, the hybrid step makes Dolby Vision from the base's HDR10+. + permits neither. With no DV track, the hybrid step makes Dolby Vision for the base. """ tracks = [deepcopy(track) for track in (base, dv) if track] tracks[0].path = base_container or base.path @@ -1231,7 +1242,8 @@ class dl: "--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.", + help="Make Dolby Vision from HDR10+ or the decoded video: with -r HYBRID when a title has no DV track, " + "with -r HDR10 or HDR10P for that base alone.", ) @click.option( "--remote/--local", @@ -1904,18 +1916,15 @@ class dl: self.log.error("--select-titles and -w/--wanted cannot be used together") sys.exit(1) + if generate_dv and Video.Range.DV in range_ and Video.Range.HYBRID not in range_: + self.log.warning("--generate-dv ignored: DV is in the selected ranges") + range_, generated_dv_bases = generated_dv_ranges(range_, generate_dv) if any(r == Video.Range.HYBRID for r in range_): from unshackle.core.binaries import DoviTool if not DoviTool: self.log.error("Unable to run hybrid mode: dovi_tool not found") 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 found") - sys.exit(1) if cdm_only is None: vaults_only = None @@ -2585,6 +2594,12 @@ class dl: elif keep_videos: title.tracks.select_video(lambda x: x.codec != Video.Codec.LCEVC) + if keep_videos and generated_dv_bases: + title.tracks.videos = keep_generated_dv_bases(title.tracks.videos, generated_dv_bases) + if not any(v.range in generated_dv_bases for v in title.tracks.videos): + self.log.error(f"There's no {', '.join(r.name for r in generated_dv_bases)} Video Track...") + sys.exit(1) + if keep_videos and range_: # Special handling for HYBRID - don't filter, keep all HDR10 and DV tracks if Video.Range.HYBRID not in range_: @@ -2819,7 +2834,7 @@ class dl: if not gate_failure and not any( v.range == Video.Range.DV or v.native_hybrid for v in title.tracks.videos ): - self.log.info("No Dolby Vision track: Dolby Vision will be generated from HDR10+") + self.log.info("No Dolby Vision track: Dolby Vision will be generated") if gate_failure: other_ranges: list[Video.Range] = [r for r in range_ if r != Video.Range.HYBRID] @@ -3643,11 +3658,6 @@ class dl: hybrid_output_path = config.directories.temp / hybrid_filename hybrid_temp_paths.append(hybrid_output_path) - if len({track.sample_start for track in resolution_tracks}) > 1: - raise click.ClickException( - "--sample cut the HDR10 and Dolby Vision tracks at different " - "points, so a hybrid of them would not line up." - ) 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 diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index f99bf4f2..42a8fef2 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -2252,15 +2252,6 @@ 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" - ranges = data.get("range") or [] - ranges = ranges if isinstance(ranges, list) else [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." - from unshackle.commands.dl import dl try: diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 718892b5..38b0f9a0 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -1164,8 +1164,10 @@ async def download(request: web.Request) -> web.Response: 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. + With HYBRID in range, make Dolby Vision from the HDR10+ metadata or the decoded video + when a title has no Dolby Vision track (default - false). With HDR10 or HDR10P in range + and no HYBRID, make it for that base and ignore a Dolby Vision track. With none of the + three in range, the field has no effect. worst: type: boolean description: Select the lowest bitrate track within the specified quality. Requires `quality` (default - false) diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index a5c16880..7e41fce4 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -3,10 +3,11 @@ import logging import os import re import subprocess +import tempfile from collections import Counter from concurrent import futures from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Iterable, Optional from rich.padding import Padding from rich.rule import Rule @@ -27,16 +28,102 @@ if TYPE_CHECKING: # 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") + +def generate_dv_mode() -> str: + """config.generate_dv_mode, checked against GENERATE_DV_MODES.""" + 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}") + return mode + + HDR10PLUS_PERCENTILES = ((1, 0.01), (25, 0.25), (50, 0.5), (75, 0.75), (90, 0.9), (95, 0.95), (99, 0.9998)) +ST2084 = (0.1593017578125, 78.84375, 0.8359375, 18.8515625, 18.6875) + + 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 + m1, m2, c1, c2, c3 = ST2084 y = (max(nits, 0.0) / 10000) ** m1 return 4095 * ((c1 + c2 * y) / (1 + c3 * y)) ** m2 +def pq_to_nits(code: float, peak: int = 4095) -> float: + """SMPTE ST 2084 EOTF of a PQ code value on a 0-``peak`` scale.""" + m1, m2, c1, c2, c3 = ST2084 + v = (max(code, 0.0) / peak) ** (1 / m2) + return 10000 * (max(v - c1, 0.0) / (c2 - c3 * v)) ** (1 / m1) + + +# maxRGB as one PQ-coded 16-bit plane, with scene cuts marked. The picture is first averaged over cells of +# 1/32 of its width: against a studio RPU the L1 max of a shot is the peak of such cells (close to the +# 99.98% percentile of HDR10+), not of single pixels, and a finer cell reads up to 600 PQ codes too high. +# scdet scores PQ-coded video low, so its threshold is 4: the default of 10 found 6 of 18 studio cuts. The second branch applies +# the ST 2084 EOTF (65535 = 10000 nits), because MaxFALL is a mean of linear light. It prints to a file +# of its own: two filters that print to stdout mix their lines. +PQ_TO_LINEAR_LUT = "65535*pow(max(pow(val/65535\\,1/{1})-{2}\\,0)/({3}-{4}*pow(val/65535\\,1/{1}))\\,1/{0})".format( + *ST2084 +) +MEASURE_GRAPH = ( + "[0:v]{crop}scale=iw/32:ih/32:flags=area,scdet=threshold=4," + "scale=in_color_matrix=bt2020:in_range=tv:out_range=pc,format=gbrp16le," + "extractplanes=g+b+r[g][b][r];[g][b]blend=all_mode=lighten[m];[m][r]blend=all_mode=lighten,split[p][l];" + "[p]signalstats,metadata=print:file=-[pq];" + "[l]lut=y='" + PQ_TO_LINEAR_LUT + "'," + "signalstats,metadata=print:key=lavfi.signalstats.YAVG:file={linear}[linear]" +) + + +def frame_stats(lines: Iterable[str]) -> list[tuple[int, float, int, bool]]: + """(min, avg, max, scene cut) of the PQ maxRGB of each frame as 16-bit codes, from MEASURE_GRAPH's stdout.""" + frames: list[tuple[int, float, int, bool]] = [] + block: dict[str, str] = {} + + def flush() -> None: + if "YMIN" in block: + frames.append((int(block["YMIN"]), float(block["YAVG"]), int(block["YMAX"]), "time" in block)) + + for line in lines: + if line.startswith("frame:"): + flush() + block = {} + elif line.startswith("lavfi.") and "=" in line: + key, _, value = line.strip().partition("=") + block[key.rsplit(".", 1)[-1]] = value + flush() + return frames + + +# Half a second at 24 fps. A burst of flashes scores a scene cut on each flash. +MIN_SHOT_FRAMES = 12 + + +def measured_shots(frames: list[tuple[int, float, int, bool]]) -> list[dict]: + """One dovi_tool generator shot per scene, with L1 from the frame_stats values of its frames. + + The shot takes the lowest min, the highest max and the mean of the frame averages. A cut that + comes less than MIN_SHOT_FRAMES after the last one is ignored. + """ + cuts = [0] + for i, frame in enumerate(frames): + if frame[3] and i - cuts[-1] >= MIN_SHOT_FRAMES: + cuts.append(i) + cuts.append(len(frames)) + to_12_bit = 4095 / 65535 + shots = [] + for start, end in zip(cuts, cuts[1:]): + shot = frames[start:end] + level1 = { + "min_pq": round(min(f[0] for f in shot) * to_12_bit), + "max_pq": round(max(f[2] for f in shot) * to_12_bit), + "avg_pq": round(sum(f[1] for f in shot) / len(shot) * to_12_bit), + } + shots.append({"start": start, "duration": end - start, "metadata_blocks": [{"Level1": level1}]}) + return shots + + 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. @@ -113,6 +200,10 @@ def hdr10plus_shots(metadata: dict, active_fraction: float = 1.0) -> list[dict]: return shots +class NoHDR10PlusMetadata(ValueError): + """A track labelled HDR10+ carries no HDR10+ metadata.""" + + class Hybrid: def __init__(self, videos: list["Video"], source: str, output_name: Optional[str] = None) -> None: self.log = logging.getLogger("hybrid") @@ -120,7 +211,8 @@ class Hybrid: """ Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks. It will then attempt to inject the Dolby Vision metadata layer to the HDR10(+) stream. - If no DV track is available but HDR10+ is present, it will convert HDR10+ to DV. + If no DV track is available, it makes the DV from the HDR10+ metadata, or from the + decoded video when there is no HDR10+ or no hdr10plus_tool. """ global directories from unshackle.core.tracks import Video @@ -167,11 +259,10 @@ class Hybrid: if not has_hdr10 and not has_hdr10p: raise ValueError("No HDR10 or HDR10+ track available for hybrid processing.") - if not has_dv and has_hdr10p: - console.status("No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.") - self.hdr10plus_to_dv = True - elif not has_dv: - raise ValueError("No DV track available and no HDR10+ to convert.") + if not has_dv: + self.hdr10plus_to_dv = has_hdr10p and bool(HDR10PlusTool) + if has_hdr10p and not self.hdr10plus_to_dv: + self.log.warning("hdr10plus_tool not found, measuring the decoded video instead of HDR10+") if os.path.isfile(config.directories.temp / self.hevc_file): console.status("Already Injected") @@ -195,14 +286,20 @@ class Hybrid: 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 - if self.hdr10plus_to_dv: + if not has_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) + 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(area[1] if area else 1.0) - else: + try: + self.extract_hdr10plus(hdr10p_video) + except NoHDR10PlusMetadata: + self.log.warning("No HDR10+ metadata in the track, measuring the decoded video instead") + self.hdr10plus_to_dv = False + else: + self.convert_hdr10plus_to_dv(area[1] if area else 1.0) + if has_dv: 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"): @@ -211,8 +308,12 @@ class Hybrid: elif os.path.isfile(config.directories.temp / "RPU.bin"): # RPU already extracted with mode 3 pass + elif not self.hdr10plus_to_dv: + self.measure_dv(area[0] if area else None) self.level_6() + if base_video and has_dv: + self.align_rpu(base_video, dv_video) if area: self.level_5(area[0]) @@ -220,10 +321,10 @@ class Hybrid: 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"): + for own in ("HDR10.hevc", "RPU_L5.bin", "L5.json", "L6.json", "RPU_ALIGN.bin", "ALIGN.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. + if not has_dv: + # The RPU comes from this base's own metadata or frames, 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) @@ -235,6 +336,7 @@ class Hybrid: "hdr_type": self.hdr_type, "resolution": self.resolution, "hdr10plus_to_dv": self.hdr10plus_to_dv, + "measured_dv": not has_dv and not self.hdr10plus_to_dv, "rpu_file": self.rpu_file, "output_file": self.hevc_file, }, @@ -306,6 +408,43 @@ class Hybrid: success=True, ) + def align_rpu(self, base: "Video", dv: "Video") -> None: + """Shift the RPU so that it starts on the first frame of the base. + + --sample cuts each track on its own segment grid, so the DV track can start before or after the + base. An RPU that starts early loses its first frames; one that starts late repeats its first frame. + """ + seconds = dv.sample_start - base.sample_start + if not seconds: + return + fps = float(base.fps or dv.fps or 0) + if not fps: + raise ValueError("--sample cut the HDR10 and Dolby Vision tracks at different points") + frames = round(seconds * fps) + if not frames: + return + edit: dict[str, Any] = ( + {"duplicate": [{"source": 0, "offset": 0, "length": frames}]} + if frames > 0 + else {"remove": [f"0-{-frames - 1}"]} + ) + align_path = config.directories.temp / "ALIGN.json" + with open(align_path, "w", encoding="utf-8") as f: + json.dump(edit, f) + try: + dovi.editor( + config.directories.temp / self.rpu_file, + align_path, + config.directories.temp / "RPU_ALIGN.bin", + status="Aligning the RPU with the sample...", + label="dovi_tool editor (align)", + ) + except RuntimeError as e: + log_event("hybrid_align", level="ERROR", message="Failed aligning the RPU", context={"error": str(e)}) + raise ValueError("Failed aligning the Dolby Vision RPU with the sample") + log_event("hybrid_align", level="DEBUG", message="Aligned the RPU", context={"frames": frames}, success=True) + self.rpu_file = "RPU_ALIGN.bin" + 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. @@ -691,6 +830,8 @@ class Hybrid: message="Failed extracting HDR10+ metadata", context={"error": str(e)}, ) + if "dynamic metadata" in str(e): + raise NoHDR10PlusMetadata("No HDR10+ metadata found in the stream") raise ValueError("Failed extracting HDR10+ metadata") file_size = os.path.getsize(config.directories.temp / self.hdr10plus_file) @@ -703,7 +844,7 @@ class Hybrid: message="No HDR10+ metadata found in the stream", context={"file_size": 0}, ) - raise ValueError("No HDR10+ metadata found in the stream") + raise NoHDR10PlusMetadata("No HDR10+ metadata found in the stream") log_event( "hybrid_extract_hdr10plus", @@ -786,9 +927,7 @@ class Hybrid: 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}") + mode = generate_dv_mode() hdr10plus_path = config.directories.temp / self.hdr10plus_file config_path = config.directories.temp / "generate.json" try: @@ -797,25 +936,18 @@ class Hybrid: metadata = json.load(f) profile = metadata.get("JSONInfo", {}).get("HDR10plusProfile") shots = hdr10plus_shots(metadata, active_fraction) + light = [frame["LuminanceParameters"] for frame in metadata["SceneInfo"]] + generator_config = self.generator_config( + round(max(max(frame["MaxScl"]) for frame in light) / 10), + round(max(frame["AverageRGB"] for frame in light) / 10), + ) # one entry per frame: a feature film is hundreds of MB of objects, freed before dovi_tool runs - del metadata + del metadata, light - max_mdl, min_mdl, max_cll, max_fall = self.sanitize_l6(*self.probe_hdr_metadata()) - generator_config: dict[str, Any] = { - "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) @@ -854,3 +986,186 @@ class Hybrid: success=True, ) self.log.info(f"Dolby Vision generated from HDR10+ metadata ({len(shots)} scenes, {mode})") + + def measure_frames( + self, input_video: Path, crop: Optional[tuple[int, int, int, int]] + ) -> tuple[list[tuple[int, float, int, bool]], float]: + """Decode the whole video through MEASURE_GRAPH. + + Returns its frame_stats and the highest linear frame average (65535 = 10000 nits). + """ + crop_filter = "" + if crop: + left, top, right, bottom = crop + crop_filter = f"crop=iw-{left + right}:ih-{top + bottom}:{left}:{top}," + linear_name = "measure_linear.txt" + linear_path = config.directories.temp / linear_name + args = [ + str(FFMPEG) if FFMPEG else "ffmpeg", + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + # one filter thread: after the downscale a frame is tiny, and handing its slices to a thread + # pool costs more than the filters do. The pass then runs close to the speed of the decoder. + "-filter_complex_threads", + "1", + "-i", + str(Path(input_video).resolve()), + "-filter_complex", + MEASURE_GRAPH.format(crop=crop_filter, linear=linear_name), + "-map", + "[pq]", + "-f", + "null", + "-", + "-map", + "[linear]", + "-f", + "null", + "-", + ] + max_linear = 0.0 + try: + with console.status("Measuring Dolby Vision brightness from the decoded video...", spinner="dots"): + # the linear file is named relative to cwd, because a Windows path needs escapes in a filter graph + with ( + tempfile.TemporaryFile() as stderr, + subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=stderr, + cwd=config.directories.temp, + text=True, + encoding="utf-8", + errors="replace", + ) as process, + ): + assert process.stdout + try: + frames = frame_stats(process.stdout) + except BaseException: + # a cancelled run must not leave FFmpeg decoding the rest of the title + process.kill() + raise + process.wait() + stderr.seek(0) + error_tail = stderr.read()[-2000:].decode("utf-8", "replace") + if linear_path.is_file(): + with open(linear_path, encoding="utf-8", errors="replace") as f: + max_linear = max((float(line.partition("=")[2]) for line in f if "YAVG=" in line), default=0.0) + finally: + linear_path.unlink(missing_ok=True) + if process.returncode != 0 or not frames: + log_event( + "hybrid_measure_dv", + level="ERROR", + message="Failed measuring the decoded video", + context={"returncode": process.returncode, "frames": len(frames), "stderr": error_tail}, + ) + raise ValueError("Failed measuring the decoded video for Dolby Vision") + return frames, max_linear + + def dropped_leading_frames(self, stream: Path) -> int: + """How many frames at the head of a raw HEVC file the decoder gives no picture for. + + A file that starts on an open GOP holds frames that need the GOP before it. + """ + result = subprocess.run( + [ + str(FFProbe) if FFProbe else "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-count_packets", + "-count_frames", + "-read_intervals", + "%+#64", + "-show_entries", + "stream=nb_read_packets,nb_read_frames", + "-of", + "default=nw=1", + str(stream), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + counts = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + try: + return max(int(counts["nb_read_packets"]) - int(counts["nb_read_frames"]), 0) + except (KeyError, ValueError): + return 0 + + def generator_config(self, measured_cll: int, measured_fall: int) -> dict[str, Any]: + """The dovi_tool generator config without its shots, for config.generate_dv_mode. + + Level 6 comes from the HDR10 track. The measured light levels fill only what the track does not state. + """ + stream_mdl, stream_min_mdl, stream_cll, stream_fall = self.probe_hdr_metadata() + max_mdl, min_mdl, max_cll, max_fall = self.sanitize_l6( + stream_mdl, stream_min_mdl, stream_cll or measured_cll, stream_fall or measured_fall + ) + generator_config: dict[str, Any] = { + "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 config.generate_dv_mode == "v40": + generator_config["l1_avg_pq_cm_version"] = "V40" + return generator_config + + def measure_dv(self, crop: Optional[tuple[int, int, int, int]] = None) -> None: + """Generate a Dolby Vision RPU with L1 measured from the decoded video, one shot per scene cut. + + It measures HDR10.hevc, the file that gets the RPU, so the shots count the same frames. The first + shot takes the leading frames that the decoder drops. + """ + mode = generate_dv_mode() + stream = config.directories.temp / "HDR10.hevc" + frames, max_linear = self.measure_frames(stream, crop) + dropped = self.dropped_leading_frames(stream) + frames = [(*frames[0][:3], False)] * dropped + frames + shots = measured_shots(frames) + generator_config = self.generator_config( + round(pq_to_nits(max(f[2] for f in frames), 65535)), round(max_linear * 10000 / 65535) + ) + generator_config["shots"] = shots + + config_path = config.directories.temp / "generate.json" + try: + with open(config_path, "w", encoding="utf-8") as f: + json.dump(generator_config, f) + dovi.generate(config_path, config.directories.temp / "RPU.bin", label="dovi_tool generate") + except RuntimeError as e: + log_event( + "hybrid_measure_dv", + level="ERROR", + message="Failed generating Dolby Vision from the decoded video", + context={"error": str(e)}, + ) + raise ValueError("Failed generating Dolby Vision from the decoded video") + finally: + config_path.unlink(missing_ok=True) + + log_event( + "hybrid_measure_dv", + level="DEBUG", + message="Generated Dolby Vision Profile 8.1 from the decoded video", + context={ + "frames": len(frames), + "dropped_leading_frames": dropped, + "shots": len(shots), + "mode": mode, + "level6": generator_config["level6"], + }, + success=True, + ) + self.log.info(f"Dolby Vision generated from the decoded video ({len(shots)} scenes, {mode})")