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.
108 lines
4.6 KiB
C++
108 lines
4.6 KiB
C++
#ifndef PLEZY_LINUX_MPV_VIDEO_PARAMS_H_
|
|
#define PLEZY_LINUX_MPV_VIDEO_PARAMS_H_
|
|
|
|
#include <mpv/client.h>
|
|
|
|
#include <cstring>
|
|
#include <string>
|
|
|
|
// What mpv's `video-params` says about the current source's colour space and
|
|
// HDR10 static metadata.
|
|
//
|
|
// This is a pure function of one mpv_node, and deliberately so: it is the sole
|
|
// input to the whole HDR decision, the absent-versus-zero rules below are what
|
|
// keeps a source that stated nothing from being described as if it stated zero,
|
|
// and getting either wrong describes the plane in a colour space the pixels are
|
|
// not in. None of that needs a running mpv core to test. Header-only for the
|
|
// same reasons as the other pure headers here: one function over plain structs,
|
|
// no dependency beyond libmpv's own type.
|
|
|
|
namespace mpv {
|
|
|
|
// The source's colour space under mpv's own names, plus its HDR10 static
|
|
// metadata. A zero luminance means the source did not state it — mpv omits the
|
|
// field rather than reporting a zero — and the strings are empty when nothing
|
|
// is loaded.
|
|
struct SourceHdrMetadata {
|
|
std::string transfer; ///< mpv trc name, e.g. "pq", "hlg", "bt.1886"
|
|
std::string primaries; ///< mpv primaries name, e.g. "bt.2020"
|
|
double max_cll = 0.0; ///< nits, maximum content light level
|
|
double max_fall = 0.0; ///< nits, maximum frame-average light level
|
|
double max_luminance = 0.0; ///< nits, mastering display maximum
|
|
double min_luminance = 0.0; ///< nits, mastering display minimum
|
|
};
|
|
|
|
// Reads the fields the HDR decision needs out of a `video-params` node.
|
|
//
|
|
// Anything that is not the map mpv documents — a node of another type, a null
|
|
// list, a key of an unexpected format — yields the default, which reads as "no
|
|
// stream" everywhere downstream and describes no plane. Unrecognised names are
|
|
// carried through verbatim: what counts as an HDR curve is the caller's
|
|
// judgement, not this parse's.
|
|
inline SourceHdrMetadata ParseSourceHdrMetadata(const mpv_node* params) {
|
|
SourceHdrMetadata metadata;
|
|
if (params == nullptr || params->format != MPV_FORMAT_NODE_MAP || params->u.list == nullptr) return metadata;
|
|
const mpv_node_list& entries = *params->u.list;
|
|
if (entries.keys == nullptr || entries.values == nullptr) return metadata;
|
|
|
|
auto name = [](const mpv_node& value, std::string* out) {
|
|
if (value.format != MPV_FORMAT_STRING || value.u.string == nullptr) return;
|
|
out->assign(value.u.string);
|
|
};
|
|
|
|
// mpv writes every luminance as a double. Integers are accepted as well
|
|
// because the sub-property read this replaced asked for MPV_FORMAT_DOUBLE,
|
|
// which libmpv would have converted for us.
|
|
auto number = [](const mpv_node& value, double* out) {
|
|
if (value.format == MPV_FORMAT_DOUBLE) {
|
|
*out = value.u.double_;
|
|
return true;
|
|
}
|
|
if (value.format == MPV_FORMAT_INT64) {
|
|
*out = static_cast<double>(value.u.int64);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// A luminance the source did not state stays absent rather than becoming a
|
|
// zero-valued claim, and zero is exactly how the rest of the pipeline spells
|
|
// "not stated" — so a zero here would be indistinguishable anyway.
|
|
auto positive = [&number](const mpv_node& value, double* out) {
|
|
double parsed = 0.0;
|
|
if (!number(value, &parsed) || !(parsed > 0.0)) return;
|
|
*out = parsed;
|
|
};
|
|
|
|
for (int i = 0; i < entries.num; ++i) {
|
|
const char* key = entries.keys[i];
|
|
if (key == nullptr) continue;
|
|
const mpv_node& value = entries.values[i];
|
|
// The two names decide whether the plane may be described as HDR at all, so
|
|
// they are taken whether or not any luminance came with them: plenty of
|
|
// HDR10 carries a PQ curve and no static metadata whatsoever.
|
|
if (std::strcmp(key, "gamma") == 0) {
|
|
name(value, &metadata.transfer);
|
|
} else if (std::strcmp(key, "primaries") == 0) {
|
|
name(value, &metadata.primaries);
|
|
} else if (std::strcmp(key, "max-cll") == 0) {
|
|
positive(value, &metadata.max_cll);
|
|
} else if (std::strcmp(key, "max-fall") == 0) {
|
|
positive(value, &metadata.max_fall);
|
|
} else if (std::strcmp(key, "max-luma") == 0) {
|
|
positive(value, &metadata.max_luminance);
|
|
} else if (std::strcmp(key, "min-luma") == 0) {
|
|
// The mastering floor is the one luminance a source may legitimately
|
|
// state as zero — a display whose black is unmeasurably low — so it is
|
|
// taken on its own terms and only a negative is refused.
|
|
double parsed = 0.0;
|
|
if (number(value, &parsed) && parsed >= 0.0) metadata.min_luminance = parsed;
|
|
}
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
} // namespace mpv
|
|
|
|
#endif // PLEZY_LINUX_MPV_VIDEO_PARAMS_H_
|