Files
plezy/linux/runner/mpv/hdr_metadata_test.cc
T
edde746 bcd6fe9906 feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry
HDR at all, and which forced a whole-window Flutter recomposite for every video
frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with
mpv rendering into an EGL window surface on it through the libmpv render API. The
subsurface is desynchronized, so video and UI now present independently.

With the plane in place HDR follows: the surface is described to the compositor
through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG,
BT.2020 - carrying whatever HDR10 static metadata the stream actually declares.
The description and the buffer it describes land on the same commit, staged and
validated before mpv is switched, so a PQ frame is never presented labelled sRGB.
A five-second watchdog bounds the one wait a compositor could otherwise leave
hanging. A session that cannot host the plane - X11, or a compositor without
wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the
reason: the texture path is gone, and refusing by name beats degrading to
something the user cannot see. An SDR output, a missing capability or an 8-bit
config keep the plane and simply leave it undescribed.

The output's colour state is trusted only when it has been earned. Every landed
property step records itself as it lands; a reset or sequence that cannot
finish downgrades its result to unknown and marks the applied-output cache
untrusted until a clean apply earns it back. A plane whose output state cannot
be named is quarantined - hidden, its description withdrawn - and the
quarantine is recorded state: an unrelated visibility change cannot put a
mislabelled plane back on screen, and only a commit that resolves to a nameable
outcome lifts it. A rect collapsing to zero detaches the buffer exactly as
hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the
next layout pass retries for free, and a refused tone-mapping pick tells the
user instead of dying in a log.

NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window
configs, so the plane takes half-float as the tier between 10-bit unorm and
8-bit, declares the whole surface opaque so the compositor never reads the
alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie.
Whether the output is in HDR is read from luminance headroom above its own
reference white rather than from the preferred transfer function, which current
KWin no longer answers PQ for; the margin is half a stop, because KWin reports
an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090
(driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs.

Who tone-maps is a user choice. The default is the compositor: photographed on a
400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and
separated where the player leg flattens them, because the player path drives
mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the
renderer, not the wiring.

The decision itself - what the source carries, what the output supports, what to
tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of
Wayland and GTK so its luminance validation can be tested without a display
server. Sending an incoherent luminance set is a protocol error that disconnects
the client, so the rules are worth a unit test.

The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL:
the plane links them directly and bundle-libs.sh deliberately never bundles them,
since they are coupled to the running compositor and GPU driver.

lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware -
it drives one clip with scripted mpv properties and reports the colour state mpv
actually settled on. Nothing imports it, so it is tree-shaken out of the app.

Verified on a Steam Deck against an external 400-nit HDR display: the compositor
reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv
vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall -
closer to the reference HDR player than to its own SDR fallback.
2026-08-10 08:48:13 +02:00

811 lines
34 KiB
C++

#include "hdr_metadata.h"
#include <iostream>
#include <limits>
namespace {
int failures = 0;
void Expect(bool condition, const char* expression, int line) {
if (condition) return;
std::cerr << "line " << line << ": check failed: " << expression << '\n';
++failures;
}
#define EXPECT(condition) Expect(static_cast<bool>(condition), #condition, __LINE__)
// Defaults to PQ / BT.2020, since that is what the luminance rules are usually
// exercised against. HLG cases override the transfer explicitly.
mpv::HdrMetadata Metadata(
uint32_t max_cll, uint32_t max_fall, uint32_t max_luminance, double min_luminance,
mpv::SourceTransfer transfer = mpv::SourceTransfer::kPq) {
mpv::HdrMetadata metadata;
metadata.transfer = transfer;
metadata.primaries = mpv::SourcePrimaries::kBt2020;
metadata.max_cll = max_cll;
metadata.max_fall = max_fall;
metadata.max_luminance = max_luminance;
metadata.min_luminance = min_luminance;
return metadata;
}
mpv::CompositorLuminanceSupport Support(
bool mastering, uint32_t interface_version, bool extended_target_volume = false) {
mpv::CompositorLuminanceSupport support;
support.mastering = mastering;
support.interface_version = interface_version;
support.extended_target_volume = extended_target_volume;
return support;
}
// Well-formed HDR10 keeps every field: this is the common case and it must not
// be degraded by the validation.
void TestWellFormedMetadataSurvives() {
const auto plan = mpv::PlanHdrLuminance(Metadata(1000, 400, 1000, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 1000);
// 0.0001 nits sits below the primary colour volume's 0.005 floor, so it is
// clamped up to stay contained; see TestMasteringFloorClampedIntoPrimaryVolume.
EXPECT(plan.mastering_min_scaled == 50u);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 1000);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 400);
}
// The 10000-nit synthetic clip. The volume cap drops anything strictly above
// the curve's maximum, so max_cll sitting exactly on PQ's 10000 is the boundary
// case that must survive it.
void TestMaxCllAtPqCeilingIsKept() {
const auto plan = mpv::PlanHdrLuminance(Metadata(10000, 600, 10000, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 10000);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 10000);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 600);
}
// A MaxCLL above the mastering display's own peak is common in badly authored
// files and is a fatal invalid_luminance on version 1. The light level goes,
// not the mastering range.
void TestMaxCllAboveMasteringMaxIsDroppedOnV1() {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000, 400, 1000, 0.005), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 1000);
EXPECT(!plan.send_max_cll);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 400);
}
// Version 2 dropped that requirement, so the same metadata keeps max_cll.
void TestMaxCllAboveMasteringMaxIsKeptOnV2() {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000, 400, 1000, 0.005), Support(true, 2));
EXPECT(plan.send_mastering);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 4000);
EXPECT(plan.send_max_fall);
}
// The same version-1 range rule applies to MaxFALL independently, and this is
// the case where nothing else would catch a regression: MaxCLL is unset, so the
// pair rule cannot fire and drop MaxFALL for the wrong reason. Getting it wrong
// sends a request set that is a *fatal* invalid_luminance, which disconnects the
// whole client rather than just failing the description.
void TestMaxFallAboveMasteringMaxIsDroppedOnV1() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 1001, 1000, 0.005), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 1000);
EXPECT(!plan.send_max_cll);
EXPECT(!plan.send_max_fall);
}
// max_fall > max_cll is a protocol error in *every* version. max_fall is the
// one dropped.
void TestMaxFallAboveMaxCllIsDropped() {
for (uint32_t version = 1; version <= 3; ++version) {
const auto plan = mpv::PlanHdrLuminance(Metadata(600, 900, 1000, 0.0001), Support(true, version));
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 600);
EXPECT(!plan.send_max_fall);
}
}
// When max_cll is dropped for being outside the range, the pair rule no longer
// applies and a legal max_fall survives on its own.
void TestMaxFallSurvivesWhenMaxCllIsDropped() {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000, 900, 1000, 0.0001), Support(true, 1));
EXPECT(!plan.send_max_cll);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 900);
}
// Without the advertised feature the mastering request would be
// unsupported_feature, so it is never sent. The light levels are then bounded by
// PQ's ceiling instead of the stream's mastering range.
void TestMasteringSuppressedWithoutCompositorSupport() {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000, 400, 1000, 0.0001), Support(false, 1));
EXPECT(!plan.send_mastering);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 4000);
EXPECT(plan.send_max_fall);
}
// max L <= min L is invalid_luminance on set_mastering_luminance itself.
void TestInvertedMasteringRangeIsSuppressed() {
const auto plan = mpv::PlanHdrLuminance(Metadata(500, 100, 1, 5.0), Support(true, 1));
EXPECT(!plan.send_mastering);
// With no mastering range the bound is PQ's ceiling, so both survive.
EXPECT(plan.send_max_cll);
EXPECT(plan.send_max_fall);
}
// Equal min and max is also rejected: the protocol wants strictly greater.
void TestEqualMasteringRangeIsSuppressed() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 1, 1.0), Support(true, 1));
EXPECT(!plan.send_mastering);
}
// A corrupt mastering maximum must not overflow the scaled comparison, and must
// not describe a display brighter than PQ can encode.
void TestMasteringMaxIsCappedAtPqCeiling() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 4000000000u, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 10000u);
}
// A light level PQ has no code point for is dropped on every version.
void TestLightLevelsAbovePqCeilingAreDropped() {
for (uint32_t version = 1; version <= 3; ++version) {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000000000u, 3000000000u, 0, 0.0), Support(true, version));
EXPECT(!plan.send_max_cll);
EXPECT(!plan.send_max_fall);
}
}
// A source that carried nothing sends nothing, leaving the compositor on its
// own defaults.
void TestEmptyMetadataSendsNothing() {
const auto plan = mpv::PlanHdrLuminance(mpv::HdrMetadata(), Support(true, 1));
EXPECT(!plan.send_mastering);
EXPECT(!plan.send_max_cll);
EXPECT(!plan.send_max_fall);
}
// A mastering minimum coarser than one scaled unit must still round to a
// non-zero floor rather than silently becoming "unset".
void TestMinLuminanceScaling() {
EXPECT(mpv::ScaleMinLuminance(0.0001) == 1);
// Half a scaled unit. Without the rounding term this truncates to 0, i.e. the
// floor silently becomes "unset" instead of the smallest expressible value.
EXPECT(mpv::ScaleMinLuminance(0.00005) == 1);
EXPECT(mpv::ScaleMinLuminance(0.005) == 50);
EXPECT(mpv::ScaleMinLuminance(1.0) == 10000);
EXPECT(mpv::ScaleMinLuminance(0.0) == 0);
EXPECT(mpv::ScaleMinLuminance(-1.0) == 0);
// An out-of-range float-to-uint32 conversion is undefined behaviour rather than
// a wrap, and mpv's video-params is untrusted input, so saturating is part of
// the contract rather than an implementation detail.
EXPECT(mpv::ScaleMinLuminance(1e30) == 4294967295u);
}
// The range predicate itself: strictly above min L, at or below max L.
void TestRangePredicateBoundaries() {
// min L = 0.0001 nits, so any whole nit clears it.
EXPECT(mpv::LuminanceInMasteringRange(1, 1, 1000));
EXPECT(mpv::LuminanceInMasteringRange(1000, 1, 1000));
EXPECT(!mpv::LuminanceInMasteringRange(1001, 1, 1000));
// min L = 5 nits: 5 is not strictly greater, 6 is.
EXPECT(!mpv::LuminanceInMasteringRange(5, 50000, 1000));
EXPECT(mpv::LuminanceInMasteringRange(6, 50000, 1000));
// An absurd value is rejected by the max bound, before the scaled multiply.
EXPECT(!mpv::LuminanceInMasteringRange(4000000000u, 1, 1000));
}
// The mastering floor is a bound in its own right: on version 1 a light level at
// or below min L is invalid_luminance just as surely as one above max L. This
// drives it through PlanHdrLuminance rather than the predicate alone, so it
// covers the wiring of mastering_min_scaled into the range test.
void TestLightLevelsBelowTheMasteringFloorAreDropped() {
const auto plan = mpv::PlanHdrLuminance(Metadata(3, 2, 1000, 5.0), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_min_scaled == 50000);
EXPECT(!plan.send_max_cll);
EXPECT(!plan.send_max_fall);
}
// HLG's primary colour volume tops out at 1000 nits, not PQ's 10000. A 4000-nit
// MaxCLL with no mastering range is inside PQ's volume but outside HLG's, and on
// version 1 that is a fatal invalid_luminance, so it must be dropped.
void TestHlgLightLevelsBoundedAtThousand() {
const auto hlg = mpv::PlanHdrLuminance(Metadata(4000, 400, 0, 0.0, mpv::SourceTransfer::kHlg), Support(false, 1));
EXPECT(!hlg.send_mastering);
EXPECT(!hlg.send_max_cll);
EXPECT(hlg.send_max_fall);
EXPECT(hlg.max_fall == 400);
// The identical numbers are legal under PQ, which is the whole point.
const auto pq = mpv::PlanHdrLuminance(Metadata(4000, 400, 0, 0.0, mpv::SourceTransfer::kPq), Support(false, 1));
EXPECT(pq.send_max_cll);
EXPECT(pq.max_cll == 4000);
}
// Both interface versions must agree about the same stream. extended_target_volume
// lets the mastering range reach 10000 even for HLG, so a version-1 range check
// alone would accept a 2000-nit HLG light level that version 2 refuses — the
// curve's own volume bound has to apply regardless of version.
void TestVolumeCapIsVersionIndependent() {
const auto metadata = Metadata(2000, 1500, 4000, 0.01, mpv::SourceTransfer::kHlg);
const auto v1 = mpv::PlanHdrLuminance(metadata, Support(true, 1, true));
const auto v2 = mpv::PlanHdrLuminance(metadata, Support(true, 2, true));
EXPECT(!v1.send_max_cll);
EXPECT(!v2.send_max_cll);
EXPECT(v1.send_max_cll == v2.send_max_cll);
EXPECT(v1.send_max_fall == v2.send_max_fall);
}
// Exactly 1000 is inside HLG's volume; 1001 is not.
void TestHlgVolumeBoundary() {
const auto inside = mpv::PlanHdrLuminance(Metadata(1000, 0, 0, 0.0, mpv::SourceTransfer::kHlg), Support(false, 1));
EXPECT(inside.send_max_cll);
const auto outside = mpv::PlanHdrLuminance(Metadata(1001, 0, 0, 0.0, mpv::SourceTransfer::kHlg), Support(false, 1));
EXPECT(!outside.send_max_cll);
EXPECT(mpv::PrimaryVolumeMaxNits(mpv::SourceTransfer::kHlg) == 1000);
EXPECT(mpv::PrimaryVolumeMaxNits(mpv::SourceTransfer::kPq) == 10000);
}
// An HLG mastering display brighter than 1000 nits exceeds the primary colour
// volume, which needs extended_target_volume. Without it the value is clamped
// down rather than sent as-is.
void TestHlgMasteringClampedWithoutExtendedVolume() {
const auto clamped = mpv::PlanHdrLuminance(Metadata(0, 0, 4000, 0.005, mpv::SourceTransfer::kHlg), Support(true, 1));
EXPECT(clamped.send_mastering);
EXPECT(clamped.mastering_max == 1000u);
// With the feature advertised the source's own figure is honoured.
const auto extended =
mpv::PlanHdrLuminance(Metadata(0, 0, 4000, 0.005, mpv::SourceTransfer::kHlg), Support(true, 1, true));
EXPECT(extended.send_mastering);
EXPECT(extended.mastering_max == 4000);
}
// PQ mastering is never clamped by the extended-volume gate, because 10000 is
// already its primary colour volume maximum.
void TestPqMasteringUnaffectedByExtendedVolumeGate() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 10000, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 10000);
}
// Player-side tone mapping: the description must claim the peak we produced, not
// the source's original range, or the compositor compresses levels that are no
// longer present.
void TestDescribeTonemappedTo() {
const auto source = Metadata(10000, 600, 10000, 0.0001);
const auto described = mpv::DescribeTonemappedTo(source, 600);
EXPECT(described.transfer == mpv::SourceTransfer::kPq);
EXPECT(described.primaries == mpv::SourcePrimaries::kBt2020);
EXPECT(described.max_cll == 600);
EXPECT(described.max_luminance == 600);
// The source's 600-nit MaxFALL still fits, so it survives.
EXPECT(described.max_fall == 600);
// The floor is untouched.
EXPECT(described.min_luminance == source.min_luminance);
// A MaxFALL above the produced peak would violate max_fall <= max_cll, so it
// is dropped rather than clamped to a figure we never measured.
const auto dropped = mpv::DescribeTonemappedTo(Metadata(10000, 900, 10000, 0.0001), 600);
EXPECT(dropped.max_fall == 0);
// Zero peak means "not known"; nothing is rewritten.
const auto untouched = mpv::DescribeTonemappedTo(source, 0);
EXPECT(untouched.max_cll == 10000);
// HLG is clamped to its own volume, not PQ's.
const auto hlg = mpv::DescribeTonemappedTo(Metadata(0, 0, 0, 0.005, mpv::SourceTransfer::kHlg), 4000);
EXPECT(hlg.max_cll == 1000u);
}
// The whole reason the rewrite exists: what it produces must itself survive the
// planner, on version 1, with no mastering support.
void TestTonemappedDescriptionIsSendable() {
const auto described = mpv::DescribeTonemappedTo(Metadata(10000, 600, 10000, 0.0001), 600);
const auto plan = mpv::PlanHdrLuminance(described, Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 600);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 600);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 600);
}
// Containment is two-sided. A 0.0001-nit mastering floor is below the primary
// colour volume's 0.005, so without extended_target_volume it is clamped up
// rather than sent as-is — and the maximum, which is the compositor's fallback
// peak, is preserved instead of dropping the whole request.
void TestMasteringFloorClampedIntoPrimaryVolume() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 1000, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_min_scaled == 50u);
EXPECT(plan.mastering_max == 1000);
// An absent floor reads as zero and is clamped the same way.
const auto absent = mpv::PlanHdrLuminance(Metadata(0, 0, 1000, 0.0), Support(true, 1));
EXPECT(absent.send_mastering);
EXPECT(absent.mastering_min_scaled == 50u);
// With extended_target_volume the source's true floor goes out untouched.
const auto extended = mpv::PlanHdrLuminance(Metadata(0, 0, 1000, 0.0001), Support(true, 1, true));
EXPECT(extended.send_mastering);
EXPECT(extended.mastering_min_scaled == 1);
}
// A floor already inside the volume is left exactly as the source stated it.
void TestMasteringFloorInsideVolumeIsUntouched() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 1000, 0.05), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_min_scaled == 500);
}
// Both HDR curves, so every peak-clamping case is exercised against each.
const mpv::SourceTransfer kHdrTransfers[] = {mpv::SourceTransfer::kPq, mpv::SourceTransfer::kHlg};
// Every gate passing, in compositor mode with no peak or reference reported.
// Each case starts here and mutates only the field it is about.
mpv::HdrInputs AllGatesPass() {
mpv::HdrInputs inputs;
inputs.allowed = true;
inputs.client_can_describe = true;
inputs.output_is_hdr = true;
inputs.source_describable = true;
return inputs;
}
// Each of the four gates must be able to veto on its own, and an SDR source
// vetoes regardless of the rest.
void TestEachGateCanVeto() {
const auto pq = Metadata(1000, 400, 1000, 0.0001);
EXPECT(mpv::DecideHdr(AllGatesPass(), pq).describe);
auto not_allowed = AllGatesPass();
not_allowed.allowed = false;
EXPECT(!mpv::DecideHdr(not_allowed, pq).describe);
auto client_cannot_describe = AllGatesPass();
client_cannot_describe.client_can_describe = false;
EXPECT(!mpv::DecideHdr(client_cannot_describe, pq).describe);
auto output_is_sdr = AllGatesPass();
output_is_sdr.output_is_hdr = false;
EXPECT(!mpv::DecideHdr(output_is_sdr, pq).describe);
auto source_not_describable = AllGatesPass();
source_not_describable.source_describable = false;
EXPECT(!mpv::DecideHdr(source_not_describable, pq).describe);
const auto sdr = Metadata(0, 0, 0, 0.0, mpv::SourceTransfer::kSdr);
EXPECT(!mpv::DecideHdr(AllGatesPass(), sdr).describe);
}
// Compositor mode never sets a target peak, whatever the display reports.
void TestCompositorModeLeavesPeakAuto() {
auto inputs = AllGatesPass();
inputs.display_peak_nits = 600;
const auto decision = mpv::DecideHdr(inputs, Metadata(10000, 600, 10000, 0.0001));
EXPECT(decision.describe);
EXPECT(!decision.tone_map_in_player);
EXPECT(decision.target_peak_nits == 0);
}
void TestPlayerModeAdoptsDisplayPeak() {
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
inputs.display_peak_nits = 600;
const auto decision = mpv::DecideHdr(inputs, Metadata(10000, 600, 10000, 0.0001));
EXPECT(decision.describe);
EXPECT(decision.tone_map_in_player);
EXPECT(decision.target_peak_nits == 600);
}
// An HDR source on an SDR output is the fallback path, and mpv still has to be
// told what it is mapping to. Left on auto with no window it does not tone-map at
// all, so the peak has to come from the output's diffuse white - not from the
// HDR-mode peak, which an SDR signal cannot reach.
void TestUndescribedHdrSourceAdoptsSdrReference() {
for (const mpv::SourceTransfer transfer : kHdrTransfers) {
const auto source = Metadata(1000, 400, 1000, 0.0001, transfer);
auto inputs = AllGatesPass();
// The one gate that puts us on this path on an SDR panel.
inputs.output_is_hdr = false;
inputs.display_peak_nits = 600;
inputs.sdr_reference_nits = 200;
const auto decision = mpv::DecideHdr(inputs, source);
EXPECT(!decision.describe);
// mpv reduces the range here, so the flag says so; `describe` is what keeps
// metadata off the surface.
EXPECT(decision.tone_map_in_player);
EXPECT(decision.target_peak_nits == 200);
// Player mode changes nothing on this path: the undescribed branch never
// reads `requested` and never reads the HDR-mode peak, so the 600-nit peak
// does not displace the 200-nit reference.
auto player = inputs;
player.requested = mpv::HdrToneMapping::kPlayer;
const auto player_decision = mpv::DecideHdr(player, source);
EXPECT(!player_decision.describe);
EXPECT(player_decision.tone_map_in_player);
EXPECT(player_decision.target_peak_nits == 200);
}
}
// Without a reference white there is nothing to aim at, and inventing one would
// be worse than mpv's own default.
void TestUndescribedWithoutSdrReferenceStaysAuto() {
const auto source = Metadata(1000, 400, 1000, 0.0001);
auto inputs = AllGatesPass();
inputs.output_is_hdr = false;
inputs.display_peak_nits = 600;
EXPECT(mpv::DecideHdr(inputs, source).target_peak_nits == 0);
// Below the option's floor is the same as unknown.
inputs.sdr_reference_nits = 9;
EXPECT(mpv::DecideHdr(inputs, source).target_peak_nits == 0);
inputs.sdr_reference_nits = 10;
EXPECT(mpv::DecideHdr(inputs, source).target_peak_nits == 10);
}
// The regression this guard exists for: an ordinary BT.709 file has nothing to
// map down, so naming a peak would change plain SDR playback.
void TestUndescribedSdrSourceKeepsPeakAuto() {
const auto sdr = Metadata(0, 0, 0, 0.0, mpv::SourceTransfer::kSdr);
auto inputs = AllGatesPass();
inputs.output_is_hdr = false;
inputs.display_peak_nits = 600;
inputs.sdr_reference_nits = 200;
const auto decision = mpv::DecideHdr(inputs, sdr);
EXPECT(!decision.describe);
EXPECT(decision.target_peak_nits == 0);
// Also true when every other gate would have passed.
auto every_gate = AllGatesPass();
every_gate.requested = mpv::HdrToneMapping::kPlayer;
every_gate.display_peak_nits = 600;
every_gate.sdr_reference_nits = 200;
EXPECT(mpv::DecideHdr(every_gate, sdr).target_peak_nits == 0);
}
// On an HDR output the described peak still comes from the HDR-mode peak; the SDR
// reference must not displace it.
void TestDescribedPlayerModeIgnoresSdrReference() {
const auto source = Metadata(10000, 600, 10000, 0.0001);
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
inputs.display_peak_nits = 600;
inputs.sdr_reference_nits = 200;
const auto decision = mpv::DecideHdr(inputs, source);
EXPECT(decision.describe);
EXPECT(decision.tone_map_in_player);
EXPECT(decision.target_peak_nits == 600);
}
// Without a usable peak there is nothing to aim at, so player mode degrades to
// passthrough rather than inventing a target.
void TestPlayerModeWithoutPeakFallsBack() {
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
const auto absent = mpv::DecideHdr(inputs, Metadata(10000, 600, 10000, 0.0001));
EXPECT(absent.describe);
EXPECT(!absent.tone_map_in_player);
EXPECT(absent.target_peak_nits == 0);
// mpv's target-peak option starts at 10.
inputs.display_peak_nits = 5;
const auto tiny = mpv::DecideHdr(inputs, Metadata(10000, 600, 10000, 0.0001));
EXPECT(!tiny.tone_map_in_player);
}
// The invariant that keeps mpv's target equal to the declared peak: whatever
// DecideHdr returns must survive DescribeTonemappedTo unchanged.
void TestDecidedPeakMatchesDescribedPeak() {
const uint32_t reported[] = {600, 1000, 1500, 4000, 12000};
for (const uint32_t peak : reported) {
for (const mpv::SourceTransfer transfer : kHdrTransfers) {
const auto source = Metadata(0, 0, 0, 0.0001, transfer);
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
inputs.display_peak_nits = peak;
const auto decision = mpv::DecideHdr(inputs, source);
EXPECT(decision.tone_map_in_player);
EXPECT(decision.target_peak_nits <= mpv::PrimaryVolumeMaxNits(transfer));
const auto described = mpv::DescribeTonemappedTo(source, decision.target_peak_nits);
EXPECT(described.max_luminance == decision.target_peak_nits);
EXPECT(described.max_cll == decision.target_peak_nits);
}
}
// Both curves specifically, at a peak above their own ceiling. An inequality
// alone would accept a clamp to any lower value: this is simultaneously mpv's
// target-peak and the peak declared to the compositor, so the exact number is
// the contract, not merely "not too big".
auto clamped = AllGatesPass();
clamped.requested = mpv::HdrToneMapping::kPlayer;
clamped.display_peak_nits = 12000;
const auto pq = mpv::DecideHdr(clamped, Metadata(0, 0, 0, 0.0001, mpv::SourceTransfer::kPq));
EXPECT(pq.target_peak_nits == 10000u);
clamped.display_peak_nits = 1500;
const auto hlg = mpv::DecideHdr(clamped, Metadata(0, 0, 0, 0.0001, mpv::SourceTransfer::kHlg));
EXPECT(hlg.target_peak_nits == 1000u);
// The undescribed fallback takes its peak from the same clamp, so an absurd
// compositor reference white cannot reach mpv's target-peak either.
auto undescribed = AllGatesPass();
undescribed.output_is_hdr = false;
undescribed.sdr_reference_nits = 99999;
const auto fallback = mpv::DecideHdr(undescribed, Metadata(0, 0, 0, 0.0001, mpv::SourceTransfer::kPq));
EXPECT(!fallback.describe);
EXPECT(fallback.target_peak_nits == 10000u);
}
// And the decided peak, once described, must still be legal to send.
void TestDecidedPeakIsSendable() {
for (const mpv::SourceTransfer transfer : kHdrTransfers) {
const auto source = Metadata(4000, 2000, 4000, 0.0001, transfer);
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
inputs.display_peak_nits = 700;
const auto decision = mpv::DecideHdr(inputs, source);
const auto plan =
mpv::PlanHdrLuminance(mpv::DescribeTonemappedTo(source, decision.target_peak_nits), Support(true, 1));
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == decision.target_peak_nits);
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == decision.target_peak_nits);
EXPECT(!plan.send_max_fall || plan.max_fall <= plan.max_cll);
}
}
// The numbers here are what KWin actually reports, because the risk this guards
// against is a plausible-looking rule that mistakes one state for another.
void TestHdrOutputsAreRecognisedByHeadroom() {
// A 400-nit HDR panel over 203-nit reference white, measured on hardware.
EXPECT(mpv::OutputHasHdrHeadroom(400, 203));
// KWin's own default HDR peak when the EDID declares none.
EXPECT(mpv::OutputHasHdrHeadroom(800, 200));
// An HDR output whose reference white was raised by the brightness slider
// still clears the margin.
EXPECT(mpv::OutputHasHdrHeadroom(465, 208));
}
void TestSdrOutputsAreRejectedEvenWhenDimmed() {
// Undimmed SDR: the compositor reports its reference white as the maximum.
EXPECT(!mpv::OutputHasHdrHeadroom(200, 200));
// Dimmed SDR is the trap. KWin scales reference white in software and keeps
// reporting the undimmed maximum, so a bare `max > reference` reads as HDR
// on an output that is not: at 80% brightness reference white is
// 5 + (200 - 5) * 0.8 = 161.
EXPECT(!mpv::OutputHasHdrHeadroom(200, 161));
// The same at 70%, which is 1.46x and still under the margin.
EXPECT(!mpv::OutputHasHdrHeadroom(200, 137));
}
void TestUnknownLuminancesAreNotHdr() {
// Nothing reported at all, and a maximum without a reference to measure it
// against: neither is evidence of headroom.
EXPECT(!mpv::OutputHasHdrHeadroom(0, 0));
EXPECT(!mpv::OutputHasHdrHeadroom(800, 0));
// A reference white above the maximum is incoherent, not headroom.
EXPECT(!mpv::OutputHasHdrHeadroom(100, 203));
}
// The margin is exactly 1.5x, so both sides of it are worth pinning: truncating
// integer arithmetic would put the boundary half a nit low and let a 304-nit
// peak over 203-nit white read as headroom.
void TestHeadroomBoundaryIsExact() {
EXPECT(!mpv::OutputHasHdrHeadroom(304, 203)); // 1.4975x - just under
EXPECT(mpv::OutputHasHdrHeadroom(305, 203)); // 1.5025x - just over
EXPECT(mpv::OutputHasHdrHeadroom(300, 200)); // exactly 1.5x counts
EXPECT(!mpv::OutputHasHdrHeadroom(299, 200));
// Small values must not round their way into headroom either.
EXPECT(!mpv::OutputHasHdrHeadroom(1, 1));
EXPECT(!mpv::OutputHasHdrHeadroom(2, 2));
}
// Naming a curve the compositor never advertised is a *fatal* invalid_tf on
// create(), which disconnects the client. So each arm is pinned separately: a
// swap between the two, or one standing in for the other, would otherwise pass.
void TestOnlyAdvertisedCurvesAreDescribable() {
const mpv::CompositorColorSupport pq_only{true, true, false};
const mpv::CompositorColorSupport hlg_only{true, false, true};
const mpv::CompositorColorSupport both{true, true, true};
auto pq = Metadata(1000, 400, 1000, 0.005);
pq.transfer = mpv::SourceTransfer::kPq;
pq.primaries = mpv::SourcePrimaries::kBt2020;
auto hlg = pq;
hlg.transfer = mpv::SourceTransfer::kHlg;
EXPECT(mpv::SourceIsDescribable(pq, pq_only));
EXPECT(!mpv::SourceIsDescribable(hlg, pq_only));
EXPECT(mpv::SourceIsDescribable(hlg, hlg_only));
EXPECT(!mpv::SourceIsDescribable(pq, hlg_only));
EXPECT(mpv::SourceIsDescribable(pq, both));
EXPECT(mpv::SourceIsDescribable(hlg, both));
}
void TestSdrAndNarrowGamutSourcesAreNotDescribable() {
const mpv::CompositorColorSupport all{true, true, true};
// An SDR source has nothing to describe, whatever the compositor accepts.
auto sdr = Metadata(0, 0, 0, 0.0);
sdr.transfer = mpv::SourceTransfer::kSdr;
sdr.primaries = mpv::SourcePrimaries::kBt2020;
EXPECT(!mpv::SourceIsDescribable(sdr, all));
// An HDR curve in a non-BT.2020 container is not worth the switch, and the
// container primaries would be a claim we cannot make.
auto narrow = Metadata(1000, 400, 1000, 0.005);
narrow.transfer = mpv::SourceTransfer::kPq;
narrow.primaries = mpv::SourcePrimaries::kOther;
EXPECT(!mpv::SourceIsDescribable(narrow, all));
// And a compositor that never advertised BT.2020 cannot be told about it,
// however describable the curve is.
auto pq = Metadata(1000, 400, 1000, 0.005);
pq.transfer = mpv::SourceTransfer::kPq;
pq.primaries = mpv::SourcePrimaries::kBt2020;
EXPECT(!mpv::SourceIsDescribable(pq, mpv::CompositorColorSupport{false, true, true}));
}
// These arrive unvalidated from the compositor, so the comparison has to hold
// at the top of the range rather than wrapping into the wrong answer.
void TestHeadroomSurvivesExtremeLuminances() {
const uint32_t huge = 0xFFFFFFFFu;
EXPECT(!mpv::OutputHasHdrHeadroom(huge, huge));
EXPECT(mpv::OutputHasHdrHeadroom(huge, 1));
// A reference white so large that reference + reference/2 would overflow:
// the answer is still "no headroom", not an accidental yes.
EXPECT(!mpv::OutputHasHdrHeadroom(1000, huge));
}
// min_luminance is copied straight off mpv's video-params with no sanitising,
// so the guard has to be NaN-safe by construction. It is only safe because the
// comparison is negated - rewriting it as `nits <= 0` would let NaN through into
// an undefined double-to-uint32 conversion, with nothing else to catch it.
void TestNonFiniteMasteringMinimumIsRejected() {
const double nan = std::numeric_limits<double>::quiet_NaN();
const double infinity = std::numeric_limits<double>::infinity();
EXPECT(mpv::ScaleMinLuminance(nan) == 0);
EXPECT(mpv::ScaleMinLuminance(-infinity) == 0);
// Infinity is finite-clamped rather than wrapped: the scaled value saturates.
EXPECT(mpv::ScaleMinLuminance(infinity) == UINT32_MAX);
// And it reaches the plan as the primary volume's floor rather than as a
// nonsense minimum: a NaN scales to 0, which the floor then raises to 50.
auto metadata = Metadata(1000, 400, 1000, 0.0);
metadata.min_luminance = nan;
const auto plan = mpv::PlanHdrLuminance(metadata, Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_min_scaled == 50u);
EXPECT(plan.mastering_max == 1000);
}
// Version 2 drops the mastering-range rule for *both* light levels. Only MaxCLL
// was proven; a regression that kept rejecting an out-of-range MaxFALL on v2
// would otherwise pass, silently dropping metadata the compositor would accept.
void TestVersionTwoKeepsBothLightLevelsOutsideTheMasteringRange() {
const auto plan = mpv::PlanHdrLuminance(Metadata(5000, 4000, 1000, 0.005), Support(true, 2));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 1000);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 5000);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 4000);
}
// The undescribed-HDR fallback belongs to *any* veto, not just an SDR output.
// With the gate tests all leaving sdr_reference_nits at zero, a regression that
// applied it only when the output vetoed would leave mpv's target peak on auto
// whenever permission, client capability or describability was the reason -
// which is an HDR source rendered against no known white point.
void TestEveryVetoStillAdoptsTheSdrReference() {
for (int gate = 0; gate < 3; ++gate) {
mpv::HdrInputs inputs = AllGatesPass();
inputs.sdr_reference_nits = 203;
if (gate == 0) inputs.allowed = false;
if (gate == 1) inputs.client_can_describe = false;
if (gate == 2) inputs.source_describable = false;
const auto decision = mpv::DecideHdr(inputs, Metadata(1000, 400, 1000, 0.005));
EXPECT(!decision.describe);
EXPECT(decision.target_peak_nits == 203);
EXPECT(decision.tone_map_in_player);
}
}
// Every field, one at a time. This operator decides whether a colour transition
// is staged at all: the plane treats an equal snapshot as "nothing to do" and
// never re-describes, so a field dropped from the conjunction leaves the old
// image description attached to pixels it no longer describes. Dropping one, or
// replacing the whole body with `return true`, passes every other test here.
void TestMetadataEqualityComparesEveryField() {
const auto base = Metadata(1000, 400, 4000, 0.005);
EXPECT(base == Metadata(1000, 400, 4000, 0.005));
EXPECT(!(base != Metadata(1000, 400, 4000, 0.005)));
auto transfer = base;
transfer.transfer = mpv::SourceTransfer::kHlg;
EXPECT(base != transfer);
auto primaries = base;
primaries.primaries = mpv::SourcePrimaries::kOther;
EXPECT(base != primaries);
auto max_cll = base;
max_cll.max_cll = 999;
EXPECT(base != max_cll);
auto max_fall = base;
max_fall.max_fall = 399;
EXPECT(base != max_fall);
auto max_luminance = base;
max_luminance.max_luminance = 3999;
EXPECT(base != max_luminance);
auto min_luminance = base;
min_luminance.min_luminance = 0.0051;
EXPECT(base != min_luminance);
// != must stay the negation of ==, not a second opinion.
EXPECT(!(base == transfer) && (base != transfer));
}
} // namespace
int main() {
TestWellFormedMetadataSurvives();
TestMaxCllAtPqCeilingIsKept();
TestMaxCllAboveMasteringMaxIsDroppedOnV1();
TestMaxCllAboveMasteringMaxIsKeptOnV2();
TestMaxFallAboveMasteringMaxIsDroppedOnV1();
TestMaxFallAboveMaxCllIsDropped();
TestMaxFallSurvivesWhenMaxCllIsDropped();
TestMasteringSuppressedWithoutCompositorSupport();
TestInvertedMasteringRangeIsSuppressed();
TestEqualMasteringRangeIsSuppressed();
TestMasteringMaxIsCappedAtPqCeiling();
TestLightLevelsAbovePqCeilingAreDropped();
TestEmptyMetadataSendsNothing();
TestMinLuminanceScaling();
TestRangePredicateBoundaries();
TestLightLevelsBelowTheMasteringFloorAreDropped();
TestHlgLightLevelsBoundedAtThousand();
TestHlgVolumeBoundary();
TestVolumeCapIsVersionIndependent();
TestHlgMasteringClampedWithoutExtendedVolume();
TestPqMasteringUnaffectedByExtendedVolumeGate();
TestDescribeTonemappedTo();
TestTonemappedDescriptionIsSendable();
TestMasteringFloorClampedIntoPrimaryVolume();
TestMasteringFloorInsideVolumeIsUntouched();
TestEachGateCanVeto();
TestCompositorModeLeavesPeakAuto();
TestPlayerModeAdoptsDisplayPeak();
TestUndescribedHdrSourceAdoptsSdrReference();
TestUndescribedWithoutSdrReferenceStaysAuto();
TestUndescribedSdrSourceKeepsPeakAuto();
TestDescribedPlayerModeIgnoresSdrReference();
TestPlayerModeWithoutPeakFallsBack();
TestDecidedPeakMatchesDescribedPeak();
TestDecidedPeakIsSendable();
TestHdrOutputsAreRecognisedByHeadroom();
TestSdrOutputsAreRejectedEvenWhenDimmed();
TestUnknownLuminancesAreNotHdr();
TestHeadroomBoundaryIsExact();
TestHeadroomSurvivesExtremeLuminances();
TestOnlyAdvertisedCurvesAreDescribable();
TestSdrAndNarrowGamutSourcesAreNotDescribable();
TestNonFiniteMasteringMinimumIsRejected();
TestVersionTwoKeepsBothLightLevelsOutsideTheMasteringRange();
TestEveryVetoStillAdoptsTheSdrReference();
TestMetadataEqualityComparesEveryField();
return failures == 0 ? 0 : 1;
}