mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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})")
|
||||
|
||||
Reference in New Issue
Block a user