mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 03:55:05 +00:00
Builds per-scene L1 in unshackle (PQ mean, letterbox-aware) with a generate_dv_mode config (v29/v40/legacy); a failed hybrid no longer exits 0 with no video.
215 lines
7.2 KiB
Python
215 lines
7.2 KiB
Python
"""Unit tests for the Hybrid Level 6 path: the FFprobe HDR10 metadata probe and the
|
|
rule that decides when the RPU L6 block gets overwritten. One test covers the Level 5
|
|
duration probe. Nothing runs FFprobe or dovi_tool; the tests mock subprocess.run and
|
|
the dovi wrappers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any, Iterator, Optional
|
|
|
|
import pytest
|
|
|
|
from unshackle.core.config import config
|
|
from unshackle.core.tracks import hybrid as hybrid_mod
|
|
from unshackle.core.tracks.hybrid import Hybrid
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
MASTERING = {
|
|
"side_data_type": "Mastering display metadata",
|
|
"min_luminance": "50/10000",
|
|
"max_luminance": "10000000/10000",
|
|
}
|
|
CLL = {"side_data_type": "Content light level metadata", "max_content": 588, "max_average": 148}
|
|
|
|
|
|
class FakeCompleted:
|
|
def __init__(self, returncode: int, stdout: str = "", stderr: str = "") -> None:
|
|
self.returncode = returncode
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def quiet(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setattr(config.directories, "temp", tmp_path)
|
|
monkeypatch.setattr(hybrid_mod, "log_event", lambda *a, **k: None)
|
|
|
|
@contextlib.contextmanager
|
|
def no_status(*args: Any, **kwargs: Any) -> Iterator[None]:
|
|
yield
|
|
|
|
monkeypatch.setattr(hybrid_mod.console, "status", no_status)
|
|
|
|
|
|
def make_hybrid() -> Hybrid:
|
|
"""Build a Hybrid without __init__, which would demux real video tracks."""
|
|
obj = Hybrid.__new__(Hybrid)
|
|
obj.log = logging.getLogger("hybrid-test")
|
|
obj.rpu_file = "RPU.bin"
|
|
return obj
|
|
|
|
|
|
def patch_probe_run(monkeypatch: pytest.MonkeyPatch, result: FakeCompleted) -> list[list[str]]:
|
|
calls: list[list[str]] = []
|
|
|
|
def fake_run(args: list[str], **kwargs: Any) -> FakeCompleted:
|
|
calls.append([str(a) for a in args])
|
|
return result
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
return calls
|
|
|
|
|
|
def test_crop_detection_skips_a_stream_with_no_duration(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
"""FFprobe gives a raw stream an empty format object with exit code 0."""
|
|
calls = patch_probe_run(monkeypatch, FakeCompleted(0, '{"format": {}}'))
|
|
|
|
assert make_hybrid().detect_active_area(tmp_path / "base.dv.hevc") is None
|
|
assert len(calls) == 1
|
|
|
|
|
|
def probe_output(*side_data: dict) -> str:
|
|
return json.dumps({"frames": [{"side_data_list": list(side_data)}]})
|
|
|
|
|
|
def test_probe_reads_mastering_display_and_light_level(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls = patch_probe_run(monkeypatch, FakeCompleted(0, probe_output(MASTERING, CLL)))
|
|
|
|
assert make_hybrid().probe_hdr_metadata() == (1000, 50, 588, 148)
|
|
assert "-show_frames" in calls[0]
|
|
assert "frame_side_data_list" in calls[0]
|
|
|
|
|
|
def test_probe_min_luminance_uses_integer_math(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Float rounding would turn 0.0003 nits into 2 units instead of 3."""
|
|
mastering = dict(MASTERING, min_luminance="3/10000")
|
|
patch_probe_run(monkeypatch, FakeCompleted(0, probe_output(mastering, CLL)))
|
|
|
|
assert make_hybrid().probe_hdr_metadata()[1] == 3
|
|
|
|
|
|
def test_probe_falls_back_on_failed_ffprobe(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
patch_probe_run(monkeypatch, FakeCompleted(1, "", "boom"))
|
|
|
|
assert make_hybrid().probe_hdr_metadata() == (1000, 1, 0, 0)
|
|
|
|
|
|
def test_probe_falls_back_on_garbage_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
patch_probe_run(monkeypatch, FakeCompleted(0, "not json at all"))
|
|
|
|
assert make_hybrid().probe_hdr_metadata() == (1000, 1, 0, 0)
|
|
|
|
|
|
def rpu_info(max_cll: int, max_fall: int, max_mdl: int, min_mdl: int) -> dict:
|
|
return {
|
|
"vdr_dm_data": {
|
|
"cmv29_metadata": {
|
|
"ext_metadata_blocks": [
|
|
{
|
|
"Level6": {
|
|
"max_content_light_level": max_cll,
|
|
"max_frame_average_light_level": max_fall,
|
|
"max_display_mastering_luminance": max_mdl,
|
|
"min_display_mastering_luminance": min_mdl,
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def patch_level6(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
info: dict,
|
|
probe: Optional[tuple[int, int, int, int]],
|
|
) -> list[Path]:
|
|
"""Mock the RPU read, the HDR10 probe, and the editor. Returns the editor call log."""
|
|
edits: list[Path] = []
|
|
|
|
monkeypatch.setattr(hybrid_mod.dovi, "info_frame", lambda *a, **k: info)
|
|
|
|
def fake_probe(self: Hybrid) -> tuple[int, int, int, int]:
|
|
if probe is None:
|
|
raise AssertionError("probe_hdr_metadata must not run when the RPU L6 block is populated")
|
|
return probe
|
|
|
|
monkeypatch.setattr(Hybrid, "probe_hdr_metadata", fake_probe)
|
|
|
|
def fake_editor(source: Path, json_spec: Path, output: Path, **kwargs: Any) -> bytes:
|
|
edits.append(json_spec)
|
|
return b""
|
|
|
|
monkeypatch.setattr(hybrid_mod.dovi, "editor", fake_editor)
|
|
return edits
|
|
|
|
|
|
def test_zero_light_levels_are_filled_from_the_probe(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
edits = patch_level6(monkeypatch, rpu_info(0, 0, 1000, 1), (1000, 50, 588, 148))
|
|
|
|
obj = make_hybrid()
|
|
obj.level_6()
|
|
|
|
assert len(edits) == 1
|
|
written = json.loads(edits[0].read_text())["level6"]
|
|
assert written["max_content_light_level"] == 588
|
|
assert written["max_frame_average_light_level"] == 148
|
|
assert written["max_display_mastering_luminance"] == 1000
|
|
# The RPU mastering display wins over the probed 50; only unset values come from the probe.
|
|
assert written["min_display_mastering_luminance"] == 1
|
|
assert obj.rpu_file == "RPU_L6.bin"
|
|
|
|
|
|
def test_populated_light_levels_skip_the_probe_and_the_edit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
edits = patch_level6(monkeypatch, rpu_info(580, 538, 1000, 1), None)
|
|
|
|
obj = make_hybrid()
|
|
obj.level_6()
|
|
|
|
assert edits == []
|
|
assert obj.rpu_file == "RPU.bin"
|
|
|
|
|
|
def test_probe_without_light_levels_leaves_the_rpu_alone(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
edits = patch_level6(monkeypatch, rpu_info(0, 0, 1000, 1), (1000, 1, 0, 0))
|
|
|
|
obj = make_hybrid()
|
|
obj.level_6()
|
|
|
|
assert edits == []
|
|
assert obj.rpu_file == "RPU.bin"
|
|
|
|
|
|
def test_level_6_returns_early_when_the_edited_rpu_exists(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
(tmp_path / "RPU_L6.bin").write_bytes(b"")
|
|
|
|
def boom(*args: Any, **kwargs: Any) -> None:
|
|
raise AssertionError("level_6 must not read the RPU when RPU_L6.bin is already there")
|
|
|
|
monkeypatch.setattr(hybrid_mod.dovi, "info_frame", boom)
|
|
|
|
obj = make_hybrid()
|
|
obj.level_6()
|
|
|
|
assert obj.rpu_file == "RPU_L6.bin"
|
|
|
|
|
|
def test_level_5_returns_early_when_the_cropped_rpu_exists(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
(tmp_path / "RPU_L5.bin").write_bytes(b"")
|
|
|
|
def boom(*args: Any, **kwargs: Any) -> None:
|
|
raise AssertionError("level_5 must not run dovi_tool when RPU_L5.bin is already there")
|
|
|
|
monkeypatch.setattr(hybrid_mod.dovi, "editor", boom)
|
|
|
|
obj = make_hybrid()
|
|
obj.level_5((0, 276, 0, 276))
|
|
|
|
assert obj.rpu_file == "RPU_L5.bin"
|