Files
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

112 lines
6.0 KiB
C++

#ifndef PLEZY_LINUX_MPV_PLANE_GEOMETRY_H_
#define PLEZY_LINUX_MPV_PLANE_GEOMETRY_H_
#include <cstdint>
#include <limits>
// How large the video plane's buffer is and where its subsurface sits, given
// the rect Flutter cut out for it and the output's buffer scale.
//
// This header is deliberately free of Wayland and GTK: both rules bias the
// plane *outward* on purpose, the penalty for getting either wrong is severe —
// an undersized plane shows the desktop through the seam, and a buffer size
// that is not a whole multiple of the buffer scale is a fatal protocol error
// that disconnects the client — and neither deserves a display server to test.
// Header-only is deliberate as well: pure functions over int32, no
// dependencies, every one of them inline.
namespace mpv {
// The buffer scale to actually divide and round by. Scale arrives as an int32
// cast of an unvalidated channel argument, and anything below 1 is not a scale:
// 0 would divide by zero and a negative would inflate the plane instead of
// shrinking it. One physical pixel per logical one is the identity, so it is
// also the safe floor.
inline int32_t NormalizePlaneScale(int32_t scale) { return scale < 1 ? 1 : scale; }
// Where one axis of the plane starts, in whole surface-local units.
//
// Floor, not truncate. C integer division rounds toward zero, which for a
// negative origin - a video rect scrolled partly off the left or top - would
// bias the plane *inward*, while the extent below deliberately rounds outward.
// Flooring makes both ends bias the same way.
inline int32_t PlaneOriginUnits(int32_t position, int32_t scale) {
const int32_t divisor = NormalizePlaneScale(scale);
const int32_t quotient = position / divisor;
return (position % divisor != 0 && position < 0) ? quotient - 1 : quotient;
}
// One dimension of the plane's buffer, in physical pixels, for the rect
// [position, position + extent).
//
// Measured from the floored origin rather than from the extent alone, and this
// is the whole point: the two roundings have to compose. Flooring the origin
// moves the plane's left/top edge outward but does nothing for its right/bottom
// edge, so sizing from the extent on its own leaves the far edge short by
// whatever the floor gave away - at scale 2 a rect at x=1 of width 100 rounds to
// a 100-pixel buffer placed at 0, covering [0,100) while the hole is [1,101).
// The toplevel is an RGBA visual cleared to transparent, so that strip is not
// black: the desktop shows through it. Taking the far edge to the next whole
// unit and subtracting the floored origin covers the rect on both sides by
// construction, for every scale and either sign.
//
// The buffer size must also be an integer multiple of the buffer scale, or
// wl_surface.commit raises the fatal invalid_size error and the compositor
// disconnects us - the process dies with nothing in our own logs. A whole
// number of units times the scale is one by construction.
//
// Arithmetic in 64 bits because position and extent are int32 casts of
// unvalidated channel arguments: their sum, and the rounding added to it, both
// overflow int32 near the ends of the range, and a negative product would reach
// wl_egl_window_resize.
inline int32_t PlaneBufferExtent(int32_t position, int32_t extent, int32_t scale) {
const int64_t block = NormalizePlaneScale(scale);
const int64_t start = PlaneOriginUnits(position, scale);
const int64_t far = static_cast<int64_t>(position) + extent;
// Ceiling division that is correct for negatives too.
const int64_t end = far >= 0 ? (far + block - 1) / block : -((-far) / block);
int64_t span = (end - start) * block;
// The floor of one whole block is what keeps a degenerate rect legal: a zero
// or sub-scale extent would otherwise round to zero, which is not a multiple
// the compositor accepts either. Callers that care whether the rect is worth
// showing must ask before rounding, not after.
if (span < block) span = block;
// The largest multiple of the block that still fits in an int32. Rounding the
// far edge up can carry the span past INT32_MAX, and the result has to remain
// both representable and a whole multiple - taking the cap from the ceiling
// rather than from INT32_MAX would throw away a whole block at odd scales.
const int64_t cap = (static_cast<int64_t>(std::numeric_limits<int32_t>::max()) / block) * block;
if (span > cap) span = cap;
return static_cast<int32_t>(span);
}
// One axis of the subsurface's position, in the toplevel's surface-local frame.
//
// Positions are surface-local, i.e. logical units in the parent's frame.
// Floor, not truncate. C integer division rounds toward zero, which for a
// negative origin - a video rect scrolled partly off the left or top - would
// bias the plane *inward* by up to scale-1 physical pixels while the size
// above deliberately rounds outward. Flooring makes both ends bias the same
// way, so the plane always covers at least the rect Flutter cut out for it.
//
// `view_offset` is where the FlView sits inside the toplevel, and is added
// after the divide because GTK widget coordinates are already logical units,
// the same frame wl_subsurface_set_position expects.
//
// Summed in 64 bits and clamped, for the same reason PlaneBufferExtent is: the
// position is an int32 cast of an unvalidated channel argument, which setVideoRect
// clamps to INT32_MAX rather than rejecting. At scale 1 the floored origin is
// then INT32_MAX, and adding a non-zero offset - which is exactly what a
// client-side-decorated window supplies - is signed overflow. That is undefined
// behaviour, and the reliability builds run under -fsanitize=undefined.
inline int32_t PlaneSurfacePosition(int32_t position, int32_t scale, int32_t view_offset) {
const int64_t sum = static_cast<int64_t>(PlaneOriginUnits(position, scale)) + view_offset;
constexpr int64_t kMin = std::numeric_limits<int32_t>::min();
constexpr int64_t kMax = std::numeric_limits<int32_t>::max();
return static_cast<int32_t>(sum < kMin ? kMin : (sum > kMax ? kMax : sum));
}
} // namespace mpv
#endif // PLEZY_LINUX_MPV_PLANE_GEOMETRY_H_