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.
This commit is contained in:
imSp4rky
2026-11-10 05:30:03 -07:00
parent b4418fa731
commit d12bf31b6b
11 changed files with 682 additions and 131 deletions
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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. |
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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`.** |
+42 -12
View File
@@ -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
+6 -3
View File
@@ -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.
+233 -33
View File
@@ -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:
+45 -35
View File
@@ -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
-9
View File
@@ -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:
+4 -2
View File
@@ -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)
+347 -32
View File
@@ -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})")