From 3a07ed7e1169f87797eb1b179744c94a87b4001c Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Thu, 21 May 2026 21:41:18 -0700 Subject: [PATCH 1/3] screenshot: Capture HDR-aware PNGs on HDR outputs clutter_stage_paint_to_buffer() already accepts a target ClutterColorState and Cogl supports COGL_PIXEL_FORMAT_RGBA_FP_16161616, so the missing piece in the shell is detecting HDR views, requesting an FP16 BT.2020/PQ capture, and writing the result as a 16-bit RGBA PNG with a cICP chunk. This adds an hdr_screenshots meson option (default on) that pulls in libpng 1.6+ and links it from shell. shell-screenshot.c gains: * view_is_hdr() / pick_screenshot_color_state() walking the views that intersect the captured rect and returning a fresh BT.2020/PQ color state when any of them is HDR. The output color state (not the blending one, which is sRGB/Gamma2.2 even on PQ outputs) is what we have to consult. * do_grab_hdr_stage_content() mirroring clutter_stage_paint_to_content() but allocating an FP16 RGBA destination texture, so the interactive screenshot path retains HDR data instead of having it tone-mapped down by the default 8-bit texture allocation. * write_hdr_png() driving libpng directly to emit 16-bit RGBA with a cICP chunk of {9, 16, 0, 1} (BT.2020 / PQ / identity / full range). Probes png_set_cICP at configure time and falls back to a hand-rolled unknown-chunk payload on libpng older than 1.6.46. * Software cursor composite in PQ-encoded BT.2020 space for the interactive composite_to_stream() path (cursor texture is small; Cogl-side compositing into an FP16 offscreen can come later). * A Hable tone-mapped 8-bit sRGB thumbnail for the notification icon on the HDR path, and a tone-mapped pixel for pick_color(). SDR captures continue through the existing GdkPixbuf-based code path unchanged. JPEG is left as a future extension point; the saved file keeps its .png extension and image/png MIME type. Assisted-By: Claude Opus 4.7 --- config.h.meson | 6 + .../org.gnome.Shell.Screenshot.xml | 12 + meson.build | 19 + meson.options | 6 + src/meson.build | 1 + src/shell-screenshot.c | 935 +++++++++++++++++- 6 files changed, 962 insertions(+), 17 deletions(-) diff --git a/config.h.meson b/config.h.meson index b0be35d25e..4539e34261 100644 --- a/config.h.meson +++ b/config.h.meson @@ -42,3 +42,9 @@ /* Whether GNOME Shell is built with XWayland support */ #mesondefine HAVE_XWAYLAND + +/* Whether GNOME Shell is built with HDR screenshot support */ +#mesondefine HAVE_HDR_SCREENSHOTS + +/* Whether libpng exports png_set_cICP (1.6.46+) */ +#mesondefine HAVE_PNG_SET_CICP diff --git a/data/dbus-interfaces/org.gnome.Shell.Screenshot.xml b/data/dbus-interfaces/org.gnome.Shell.Screenshot.xml index 8e16a30a62..2f32db91e2 100644 --- a/data/dbus-interfaces/org.gnome.Shell.Screenshot.xml +++ b/data/dbus-interfaces/org.gnome.Shell.Screenshot.xml @@ -42,6 +42,10 @@ which case the screenshot will be saved in the $XDG_PICTURES_DIR or the home directory if it doesn't exist. The filename used to save the screenshot will be returned in @filename_used. + When the captured area intersects an HDR-enabled monitor, + the PNG is emitted as 16-bit RGBA with a cICP chunk + (BT.2020 primaries, PQ transfer); otherwise the existing + 8-bit sRGB PNG is produced. --> @@ -67,6 +71,10 @@ which case the screenshot will be saved in the $XDG_PICTURES_DIR or the home directory if it doesn't exist. The filename used to save the screenshot will be returned in @filename_used. + When the captured area intersects an HDR-enabled monitor, + the PNG is emitted as 16-bit RGBA with a cICP chunk + (BT.2020 primaries, PQ transfer); otherwise the existing + 8-bit sRGB PNG is produced. --> @@ -95,6 +103,10 @@ which case the screenshot will be saved in the $XDG_PICTURES_DIR or the home directory if it doesn't exist. The filename used to save the screenshot will be returned in @filename_used. + When the captured area intersects an HDR-enabled monitor, + the PNG is emitted as 16-bit RGBA with a cICP chunk + (BT.2020 primaries, PQ transfer); otherwise the existing + 8-bit sRGB PNG is produced. --> diff --git a/meson.build b/meson.build index a13c322c82..cab6c467e3 100644 --- a/meson.build +++ b/meson.build @@ -87,6 +87,13 @@ schemas_dep = dependency('gsettings-desktop-schemas', version: schemas_req) gnome_desktop_dep = dependency('gnome-desktop-4', version: gnome_desktop_req) pango_dep = dependency('pango', version: pango_req) +have_hdr_screenshots = get_option('hdr_screenshots') +if have_hdr_screenshots + libpng_dep = dependency('libpng', version: '>= 1.6.0') +else + libpng_dep = dependency('', required: false) +endif + have_fonts = mutter_dep.get_variable('have_fonts') == 'true' have_xwayland = mutter_dep.get_variable('have_xwayland') == 'true' if have_xwayland @@ -160,6 +167,15 @@ cc = meson.get_compiler('c') m_dep = cc.find_library('m', required: false) +if have_hdr_screenshots + have_png_set_cicp = cc.has_function('png_set_cICP', + dependencies: libpng_dep, + prefix: '#include ' + ) +else + have_png_set_cicp = false +endif + cdata = configuration_data() cdata.set_quoted('GETTEXT_PACKAGE', meson.project_name()) cdata.set_quoted('VERSION', meson.project_version()) @@ -169,6 +185,8 @@ cdata.set('HAVE_NETWORKMANAGER', have_networkmanager) cdata.set('HAVE_PIPEWIRE', have_pipewire) cdata.set('HAVE_SYSTEMD', have_systemd) cdata.set('HAVE_XWAYLAND', have_xwayland) +cdata.set('HAVE_HDR_SCREENSHOTS', have_hdr_screenshots) +cdata.set('HAVE_PNG_SET_CICP', have_png_set_cicp) cdata.set('HAVE_FDWALK', cc.has_function('fdwalk')) cdata.set('HAVE_MALLINFO', cc.has_function('mallinfo')) @@ -349,6 +367,7 @@ summary_options = { 'camera_monitor': get_option('camera_monitor'), 'networkmanager': get_option('networkmanager'), 'systemd': get_option('systemd'), + 'hdr_screenshots': have_hdr_screenshots, 'extensions_app': get_option('extensions_app'), 'extensions_tool': get_option('extensions_tool'), 'man': get_option('man'), diff --git a/meson.options b/meson.options index 01e0d5803b..be69ab0669 100644 --- a/meson.options +++ b/meson.options @@ -51,3 +51,9 @@ option('systemd', value: true, description: 'Enable systemd integration' ) + +option('hdr_screenshots', + type: 'boolean', + value: true, + description: 'Enable HDR screenshot capture (requires libpng >= 1.6)' +) diff --git a/src/meson.build b/src/meson.build index 128f9606e4..a0a3421b1a 100644 --- a/src/meson.build +++ b/src/meson.build @@ -75,6 +75,7 @@ gnome_shell_deps = [ libsystemd_dep, libpipewire_dep, pango_dep, + libpng_dep, ] if have_xwayland diff --git a/src/shell-screenshot.c b/src/shell-screenshot.c index 076eb2c9f2..41a6dbc2dd 100644 --- a/src/shell-screenshot.c +++ b/src/shell-screenshot.c @@ -1,5 +1,7 @@ /* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ +#include "config.h" + #include #include #include @@ -9,6 +11,13 @@ #include #include +#ifdef HAVE_HDR_SCREENSHOTS +#include +#include +#include +#include +#endif + #include "shell-global.h" #include "shell-screenshot.h" #include "shell-util.h" @@ -52,6 +61,13 @@ typedef struct _ShellScreenshot gboolean include_frame; + /* HDR capture (RGBA half-float, BT.2020 / PQ); image is NULL when this is set. */ + gboolean is_hdr; + uint16_t *hdr_pixels; + int hdr_width; + int hdr_height; + int hdr_stride; + float scale; ClutterContent *cursor_content; graphene_point_t cursor_point; @@ -92,8 +108,11 @@ on_screenshot_written (GObject *source, g_object_unref (result); g_clear_pointer (&screenshot->image, cairo_surface_destroy); + g_clear_pointer (&screenshot->hdr_pixels, g_free); g_clear_object (&screenshot->stream); g_clear_pointer (&screenshot->datetime, g_date_time_unref); + screenshot->is_hdr = FALSE; + screenshot->hdr_width = screenshot->hdr_height = screenshot->hdr_stride = 0; } static cairo_format_t @@ -272,6 +291,465 @@ util_pixbuf_from_surface (cairo_surface_t *surface, return dest; } +#ifdef HAVE_HDR_SCREENSHOTS + +static gboolean +view_is_hdr (ClutterStageView *view) +{ + /* + * Mutter's per-view "color state" is the blending color state (sRGB / + * Gamma 2.2 even on HDR PQ outputs, see + * clutter_color_state_params_get_blending in mutter). The actual destination + * color state is on the output, so check that one. + */ + ClutterColorState *color_state; + ClutterEncodingRequiredFormat fmt; + + color_state = clutter_stage_view_get_output_color_state (view); + if (!color_state) + color_state = clutter_stage_view_get_color_state (view); + if (!color_state) + return FALSE; + + fmt = clutter_color_state_required_format (color_state); + if (fmt != CLUTTER_ENCODING_REQUIRED_FORMAT_UINT8) + return TRUE; + + if (CLUTTER_IS_COLOR_STATE_PARAMS (color_state)) + { + ClutterColorStateParams *params = CLUTTER_COLOR_STATE_PARAMS (color_state); + const ClutterEOTF *eotf = clutter_color_state_params_get_eotf (params); + + if (eotf->type == CLUTTER_EOTF_TYPE_NAMED && + (eotf->tf_name == CLUTTER_TRANSFER_FUNCTION_PQ || + eotf->tf_name == CLUTTER_TRANSFER_FUNCTION_LINEAR)) + return TRUE; + + if (CLUTTER_IS_COLOR_STATE_PARAMS (color_state)) + { + const ClutterColorimetry *cm = + clutter_color_state_params_get_colorimetry (params); + + if (cm->type == CLUTTER_COLORIMETRY_TYPE_COLORSPACE && + cm->colorspace == CLUTTER_COLORSPACE_BT2020) + return TRUE; + } + } + + return FALSE; +} + +static ClutterColorState * +pick_screenshot_color_state (ClutterStage *stage, + const MtkRectangle *rect) +{ + ClutterActor *stage_actor = CLUTTER_ACTOR (stage); + ClutterContext *ctx; + GList *views; + gboolean any_hdr = FALSE; + + views = clutter_actor_peek_stage_views (stage_actor); + for (GList *l = views; l; l = l->next) + { + ClutterStageView *view = l->data; + MtkRectangle layout; + MtkRectangle intersection; + + clutter_stage_view_get_layout (view, &layout); + if (!mtk_rectangle_intersect (rect, &layout, &intersection)) + continue; + + if (view_is_hdr (view)) + { + any_hdr = TRUE; + break; + } + } + + if (!any_hdr) + return NULL; + + ctx = clutter_actor_get_context (stage_actor); + return clutter_color_state_params_new_full (ctx, + CLUTTER_COLORSPACE_BT2020, + CLUTTER_TRANSFER_FUNCTION_PQ, + NULL, + 0.f, + 0.005f, + 10000.f, + 203.f, + 10000.f); +} + +/* PQ inverse OETF: linear (normalized to 1.0 = 10000 nits) -> PQ-encoded [0,1] */ +static inline float +pq_encode (float linear) +{ + const float c1 = 0.8359375f; + const float c2 = 18.8515625f; + const float c3 = 18.6875f; + const float m1 = 0.1593017578125f; + const float m2 = 78.84375f; + float xm; + + if (!(linear > 0.f)) + return 0.f; + if (linear > 1.f) + linear = 1.f; + + xm = powf (linear, m1); + return powf ((c1 + c2 * xm) / (1.f + c3 * xm), m2); +} + +/* PQ EOTF: PQ-encoded [0,1] -> linear (normalized to 1.0 = 10000 nits) */ +static inline float +pq_decode (float pq) +{ + const float c1 = 0.8359375f; + const float c2 = 18.8515625f; + const float c3 = 18.6875f; + const float m1 = 0.1593017578125f; + const float m2 = 78.84375f; + float p, num, den; + + if (!(pq > 0.f)) + return 0.f; + if (pq > 1.f) + pq = 1.f; + + p = powf (pq, 1.f / m2); + num = p - c1; + if (num < 0.f) + num = 0.f; + den = c2 - c3 * p; + if (den <= 0.f) + return 1.f; + + return powf (num / den, 1.f / m1); +} + +static inline float +srgb_decode (float c) +{ + if (c <= 0.04045f) + return c / 12.92f; + return powf ((c + 0.055f) / 1.055f, 2.4f); +} + +static inline float +srgb_encode (float c) +{ + if (!(c > 0.f)) + return 0.f; + if (c >= 1.f) + return 1.f; + if (c <= 0.0031308f) + return c * 12.92f; + return 1.055f * powf (c, 1.f / 2.4f) - 0.055f; +} + +/* sRGB linear primaries -> BT.2020 linear primaries (D65). */ +static inline void +srgb_to_bt2020_linear (float r, float g, float b, + float *out_r, float *out_g, float *out_b) +{ + *out_r = 0.6274040f * r + 0.3292820f * g + 0.0433136f * b; + *out_g = 0.0690970f * r + 0.9195400f * g + 0.0113612f * b; + *out_b = 0.0163916f * r + 0.0880132f * g + 0.8955950f * b; +} + +/* IEEE 754 binary16 -> binary32. Handles normals, subnormals, inf, NaN. */ +static inline float +half_to_float (uint16_t h) +{ + uint32_t sign = (h >> 15) & 0x1u; + uint32_t exp = (h >> 10) & 0x1fu; + uint32_t mant = h & 0x3ffu; + union + { + uint32_t bits; + float f; + } u; + + if (exp == 0) + { + if (mant == 0) + { + u.bits = sign << 31; + } + else + { + /* Subnormal: normalize. */ + while ((mant & 0x400u) == 0) + { + mant <<= 1; + exp -= 1; + } + exp += 1; + mant &= 0x3ffu; + u.bits = (sign << 31) | ((exp + 112) << 23) | (mant << 13); + } + } + else if (exp == 31) + { + u.bits = (sign << 31) | 0x7f800000u | (mant << 13); + } + else + { + u.bits = (sign << 31) | ((exp + 112) << 23) | (mant << 13); + } + + return u.f; +} + +static uint16_t * +convert_fp16_pq_to_uint16_rgba (const uint16_t *src, + int width, + int height, + int src_stride_bytes) +{ + uint16_t *dst; + size_t dst_stride = (size_t) width * 4 * sizeof (uint16_t); + + dst = g_malloc (dst_stride * (size_t) height); + + for (int y = 0; y < height; y++) + { + const uint16_t *row = (const uint16_t *) ((const uint8_t *) src + (size_t) y * src_stride_bytes); + uint16_t *out = dst + (size_t) y * width * 4; + + for (int x = 0; x < width; x++) + { + for (int c = 0; c < 4; c++) + { + float v = half_to_float (row[x * 4 + c]); + + if (!(v > 0.f)) + v = 0.f; + else if (v > 1.f) + v = 1.f; + + out[x * 4 + c] = (uint16_t) (v * 65535.f + 0.5f); + } + } + } + + return dst; +} + +typedef struct +{ + GOutputStream *stream; + GError *error; +} HdrPngWriteCtx; + +static void +hdr_png_write_cb (png_structp png_ptr, + png_bytep data, + size_t length) +{ + HdrPngWriteCtx *ctx = png_get_io_ptr (png_ptr); + + if (ctx->error) + return; + + if (!g_output_stream_write_all (ctx->stream, data, length, NULL, NULL, &ctx->error)) + png_error (png_ptr, ctx->error ? ctx->error->message : "Output stream write failed"); +} + +static void +hdr_png_flush_cb (png_structp png_ptr) +{ +} + +static void +hdr_png_error_cb (png_structp png_ptr, + png_const_charp msg) +{ + HdrPngWriteCtx *ctx = png_get_error_ptr (png_ptr); + + if (ctx && !ctx->error) + { + g_set_error (&ctx->error, G_IO_ERROR, G_IO_ERROR_FAILED, + "libpng: %s", msg); + } + + png_longjmp (png_ptr, 1); +} + +static void +hdr_png_warning_cb (png_structp png_ptr, + png_const_charp msg) +{ + g_warning ("libpng (HDR screenshot): %s", msg); +} + +static gboolean +write_hdr_png (GOutputStream *stream, + const uint16_t *rgba16, + int width, + int height, + GDateTime *datetime, + GError **error) +{ + HdrPngWriteCtx ctx = { stream, NULL }; + png_structp png = NULL; + png_infop info = NULL; + g_autofree char *creation_time = NULL; + gboolean success = FALSE; + png_text text_chunks[2]; + png_bytep *rows = NULL; + + png = png_create_write_struct (PNG_LIBPNG_VER_STRING, &ctx, + hdr_png_error_cb, + hdr_png_warning_cb); + if (!png) + { + g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Failed to allocate libpng write struct"); + return FALSE; + } + + info = png_create_info_struct (png); + if (!info) + { + png_destroy_write_struct (&png, NULL); + g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Failed to allocate libpng info struct"); + return FALSE; + } + + if (setjmp (png_jmpbuf (png))) + goto done; + + png_set_write_fn (png, &ctx, hdr_png_write_cb, hdr_png_flush_cb); + + png_set_IHDR (png, info, + (png_uint_32) width, (png_uint_32) height, + 16, + PNG_COLOR_TYPE_RGB_ALPHA, + PNG_INTERLACE_NONE, + PNG_COMPRESSION_TYPE_DEFAULT, + PNG_FILTER_TYPE_DEFAULT); + +#ifdef HAVE_PNG_SET_CICP + png_set_cICP (png, info, + 9, /* colour primaries: BT.2020 */ + 16, /* transfer characteristics: SMPTE ST 2084 (PQ) */ + 0, /* matrix coefficients: identity */ + 1); /* video full range flag: full range */ +#else + { + png_unknown_chunk cicp_chunk; + static png_byte cicp_data[4] = { 9, 16, 0, 1 }; + + memset (&cicp_chunk, 0, sizeof (cicp_chunk)); + memcpy (cicp_chunk.name, "cICP", 5); + cicp_chunk.data = cicp_data; + cicp_chunk.size = sizeof (cicp_data); + cicp_chunk.location = PNG_HAVE_IHDR; + + png_set_keep_unknown_chunks (png, PNG_HANDLE_CHUNK_ALWAYS, + (png_const_bytep) "cICP", 1); + png_set_unknown_chunks (png, info, &cicp_chunk, 1); + } +#endif + + creation_time = g_date_time_format (datetime, "%c"); + if (!creation_time) + creation_time = g_date_time_format (datetime, "%FT%T%z"); + + memset (text_chunks, 0, sizeof (text_chunks)); + text_chunks[0].compression = PNG_TEXT_COMPRESSION_NONE; + text_chunks[0].key = (png_charp) "Software"; + text_chunks[0].text = (png_charp) "gnome-screenshot"; + text_chunks[1].compression = PNG_TEXT_COMPRESSION_NONE; + text_chunks[1].key = (png_charp) "Creation Time"; + text_chunks[1].text = creation_time ? creation_time : (png_charp) ""; + png_set_text (png, info, text_chunks, 2); + + png_write_info (png, info); + +#if G_BYTE_ORDER == G_LITTLE_ENDIAN + png_set_swap (png); +#endif + + rows = g_new (png_bytep, height); + for (int y = 0; y < height; y++) + rows[y] = (png_bytep) (rgba16 + (size_t) y * width * 4); + + png_write_image (png, rows); + png_write_end (png, info); + + success = TRUE; + +done: + g_free (rows); + png_destroy_write_struct (&png, &info); + + if (!success) + { + if (ctx.error) + g_propagate_error (error, ctx.error); + else + g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Failed to write HDR PNG"); + } + + return success; +} + +/* + * Like clutter_stage_paint_to_content() in mutter, but allocates the + * destination CoglTexture in COGL_PIXEL_FORMAT_RGBA_FP_16161616 so the HDR + * pixel data survives. Used by the interactive screenshot path. + */ +static ClutterContent * +do_grab_hdr_stage_content (ClutterStage *stage, + const MtkRectangle *rect, + float scale, + ClutterColorState *color_state, + ClutterPaintFlag paint_flags, + GError **error) +{ + ClutterContext *clutter_context = + clutter_actor_get_context (CLUTTER_ACTOR (stage)); + ClutterBackend *backend = clutter_context_get_backend (clutter_context); + CoglContext *cogl_context = clutter_backend_get_cogl_context (backend); + int tex_w, tex_h; + CoglTexture *texture; + CoglOffscreen *offscreen; + g_autoptr (CoglFramebuffer) framebuffer = NULL; + + tex_w = (int) roundf (rect->width * scale); + tex_h = (int) roundf (rect->height * scale); + + texture = cogl_texture_2d_new_with_format (cogl_context, + tex_w, tex_h, + COGL_PIXEL_FORMAT_RGBA_FP_16161616); + if (!texture) + { + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Failed to create HDR FP16 %dx%d texture", tex_w, tex_h); + return NULL; + } + + offscreen = cogl_offscreen_new_with_texture (texture); + framebuffer = COGL_FRAMEBUFFER (offscreen); + g_object_unref (texture); + + if (!cogl_framebuffer_allocate (framebuffer, error)) + return NULL; + + clutter_stage_paint_to_framebuffer (stage, framebuffer, rect, scale, + color_state, paint_flags); + + return clutter_texture_content_new_from_texture (cogl_offscreen_get_texture (offscreen), + NULL); +} + +#endif /* HAVE_HDR_SCREENSHOTS */ + static void write_screenshot_thread (GTask *result, gpointer object, @@ -288,6 +766,29 @@ write_screenshot_thread (GTask *result, stream = g_object_ref (screenshot->stream); +#ifdef HAVE_HDR_SCREENSHOTS + if (screenshot->is_hdr) + { + g_autofree uint16_t *rgba16 = NULL; + + rgba16 = convert_fp16_pq_to_uint16_rgba (screenshot->hdr_pixels, + screenshot->hdr_width, + screenshot->hdr_height, + screenshot->hdr_stride); + + if (!write_hdr_png (stream, rgba16, + screenshot->hdr_width, + screenshot->hdr_height, + screenshot->datetime, + &error)) + g_task_return_error (result, error); + else + g_task_return_boolean (result, TRUE); + + return; + } +#endif + pixbuf = util_pixbuf_from_surface (screenshot->image, 0, 0, cairo_image_surface_get_width (screenshot->image), @@ -321,35 +822,73 @@ do_grab_screenshot (ShellScreenshot *screenshot, int image_width; int image_height; float scale; - cairo_surface_t *image; ClutterPaintFlag paint_flags = CLUTTER_PAINT_FLAG_NONE; g_autoptr (GError) error = NULL; +#ifdef HAVE_HDR_SCREENSHOTS + g_autoptr (ClutterColorState) hdr_color_state = NULL; +#endif clutter_stage_get_capture_final_size (stage, &screenshot_rect, &image_width, &image_height, &scale); - image = cairo_image_surface_create (CAIRO_FORMAT_ARGB32, - image_width, image_height); if (flags & SHELL_SCREENSHOT_FLAG_INCLUDE_CURSOR) paint_flags |= CLUTTER_PAINT_FLAG_FORCE_CURSORS; else paint_flags |= CLUTTER_PAINT_FLAG_NO_CURSORS; - if (!clutter_stage_paint_to_buffer (stage, &screenshot_rect, scale, - cairo_image_surface_get_data (image), - cairo_image_surface_get_stride (image), - COGL_PIXEL_FORMAT_CAIRO_ARGB32_COMPAT, - NULL, - paint_flags, - &error)) + +#ifdef HAVE_HDR_SCREENSHOTS + hdr_color_state = pick_screenshot_color_state (stage, &screenshot_rect); + if (hdr_color_state) { - cairo_surface_destroy (image); - g_warning ("Failed to take screenshot: %s", error->message); + int stride = image_width * 4 * (int) sizeof (uint16_t); + uint16_t *pixels = g_malloc ((size_t) stride * (size_t) image_height); + + if (!clutter_stage_paint_to_buffer (stage, &screenshot_rect, scale, + (uint8_t *) pixels, + stride, + COGL_PIXEL_FORMAT_RGBA_FP_16161616, + hdr_color_state, + paint_flags, + &error)) + { + g_free (pixels); + g_warning ("Failed to take HDR screenshot: %s", error->message); + return; + } + + screenshot->hdr_pixels = pixels; + screenshot->hdr_width = image_width; + screenshot->hdr_height = image_height; + screenshot->hdr_stride = stride; + screenshot->is_hdr = TRUE; + screenshot->datetime = g_date_time_new_now_local (); return; } +#endif - screenshot->image = image; + { + cairo_surface_t *image; + + image = cairo_image_surface_create (CAIRO_FORMAT_ARGB32, + image_width, image_height); + + if (!clutter_stage_paint_to_buffer (stage, &screenshot_rect, scale, + cairo_image_surface_get_data (image), + cairo_image_surface_get_stride (image), + COGL_PIXEL_FORMAT_CAIRO_ARGB32_COMPAT, + NULL, + paint_flags, + &error)) + { + cairo_surface_destroy (image); + g_warning ("Failed to take screenshot: %s", error->message); + return; + } + + screenshot->image = image; + } screenshot->datetime = g_date_time_new_now_local (); } @@ -473,6 +1012,10 @@ grab_screenshot_content (ShellScreenshot *screenshot, MetaCursorTracker *tracker; CoglTexture *cursor_texture; int cursor_hot_x, cursor_hot_y; + ClutterColorState *capture_color_state = NULL; +#ifdef HAVE_HDR_SCREENSHOTS + g_autoptr (ClutterColorState) hdr_color_state = NULL; +#endif display = shell_global_get_display (screenshot->global); backend = shell_global_get_backend (screenshot->global); @@ -493,10 +1036,25 @@ grab_screenshot_content (ShellScreenshot *screenshot, screenshot->scale = scale; - content = clutter_stage_paint_to_content (stage, &screenshot_rect, scale, - NULL, - CLUTTER_PAINT_FLAG_NO_CURSORS, - &error); +#ifdef HAVE_HDR_SCREENSHOTS + hdr_color_state = pick_screenshot_color_state (stage, &screenshot_rect); + capture_color_state = hdr_color_state; + + if (hdr_color_state) + { + content = do_grab_hdr_stage_content (stage, &screenshot_rect, scale, + hdr_color_state, + CLUTTER_PAINT_FLAG_NO_CURSORS, + &error); + } + else +#endif + { + content = clutter_stage_paint_to_content (stage, &screenshot_rect, scale, + capture_color_state, + CLUTTER_PAINT_FLAG_NO_CURSORS, + &error); + } if (!content) { g_task_return_error (result, g_steal_pointer (&error)); @@ -1072,6 +1630,33 @@ shell_screenshot_pick_color_finish (ShellScreenshot *screenshot, if (!g_task_propagate_boolean (G_TASK (result), error)) return FALSE; +#ifdef HAVE_HDR_SCREENSHOTS + if (screenshot->is_hdr) + { + /* PQ-encoded BT.2020 FP16 -> tone-mapped sRGB 8-bit so the existing + * sRGB-only color picker UI gets a usable result. */ + const float sdr_white_nits = 203.f; + const float pq_peak_nits = 10000.f; + const float pq_to_sdr = pq_peak_nits / sdr_white_nits; + const uint16_t *src = screenshot->hdr_pixels; + float r = pq_decode (half_to_float (src[0])) * pq_to_sdr; + float g = pq_decode (half_to_float (src[1])) * pq_to_sdr; + float b = pq_decode (half_to_float (src[2])) * pq_to_sdr; + + if (r > 1.f) r = 1.f; + if (g > 1.f) g = 1.f; + if (b > 1.f) b = 1.f; + if (color) + { + color->red = (uint8_t) (srgb_encode (r) * 255.f + 0.5f); + color->green = (uint8_t) (srgb_encode (g) * 255.f + 0.5f); + color->blue = (uint8_t) (srgb_encode (b) * 255.f + 0.5f); + color->alpha = 255; + } + return TRUE; + } +#endif + /* protect against mutter changing the format used for stage captures */ g_assert (cairo_image_surface_get_format (screenshot->image) == CAIRO_FORMAT_ARGB32); @@ -1109,6 +1694,256 @@ composite_to_stream_on_png_saved (GObject *pixbuf, g_object_unref (task); } +#ifdef HAVE_HDR_SCREENSHOTS + +#if G_BYTE_ORDER == G_LITTLE_ENDIAN +# define HDR_CURSOR_A 3 +# define HDR_CURSOR_R 2 +# define HDR_CURSOR_G 1 +# define HDR_CURSOR_B 0 +#else +# define HDR_CURSOR_A 0 +# define HDR_CURSOR_R 1 +# define HDR_CURSOR_G 2 +# define HDR_CURSOR_B 3 +#endif + +/* + * Software-composite the (premultiplied sRGB ARGB32) cursor pixels on top of + * the (PQ-encoded BT.2020 FP16) HDR buffer. SDR white maps to 203 nits per + * ITU-R BT.2408. + */ +static void +hdr_composite_cursor (uint16_t *hdr_pixels, + int hdr_width, + int hdr_height, + const uint8_t *cursor_argb, + int cursor_width, + int cursor_height, + int cursor_stride, + int dst_x, + int dst_y, + float cursor_pixel_scale) +{ + const float sdr_white_nits = 203.f; + const float pq_peak_nits = 10000.f; + const float sdr_to_pq = sdr_white_nits / pq_peak_nits; + int scaled_w, scaled_h; + + scaled_w = (int) (cursor_width / cursor_pixel_scale + 0.5f); + scaled_h = (int) (cursor_height / cursor_pixel_scale + 0.5f); + + for (int j = 0; j < scaled_h; j++) + { + int dy = dst_y + j; + if (dy < 0 || dy >= hdr_height) + continue; + + for (int i = 0; i < scaled_w; i++) + { + int dx = dst_x + i; + int sx, sy; + const uint8_t *src; + float a, r_lin, g_lin, b_lin; + float br, bg, bb; + uint16_t *dst_pixel; + float dst_r, dst_g, dst_b; + + if (dx < 0 || dx >= hdr_width) + continue; + + sx = (int) (i * cursor_pixel_scale); + sy = (int) (j * cursor_pixel_scale); + if (sx >= cursor_width || sy >= cursor_height) + continue; + + src = cursor_argb + (size_t) sy * cursor_stride + (size_t) sx * 4; + + /* CAIRO_FORMAT_ARGB32 byte layout (little-endian): B,G,R,A. + * Cogl writes COGL_PIXEL_FORMAT_CAIRO_ARGB32_COMPAT which matches. */ + a = src[HDR_CURSOR_A] / 255.f; + if (a <= 0.f) + continue; + + /* Unpremultiply, sRGB-decode, then scale into PQ-linear space. */ + r_lin = srgb_decode (src[HDR_CURSOR_R] / 255.f / (a > 0.f ? a : 1.f)); + g_lin = srgb_decode (src[HDR_CURSOR_G] / 255.f / (a > 0.f ? a : 1.f)); + b_lin = srgb_decode (src[HDR_CURSOR_B] / 255.f / (a > 0.f ? a : 1.f)); + + srgb_to_bt2020_linear (r_lin, g_lin, b_lin, &br, &bg, &bb); + + br *= sdr_to_pq; + bg *= sdr_to_pq; + bb *= sdr_to_pq; + + dst_pixel = hdr_pixels + ((size_t) dy * hdr_width + dx) * 4; + + dst_r = pq_decode (dst_pixel[0] / 65535.f); + dst_g = pq_decode (dst_pixel[1] / 65535.f); + dst_b = pq_decode (dst_pixel[2] / 65535.f); + + dst_r = br + (1.f - a) * dst_r; + dst_g = bg + (1.f - a) * dst_g; + dst_b = bb + (1.f - a) * dst_b; + + dst_pixel[0] = (uint16_t) (pq_encode (dst_r) * 65535.f + 0.5f); + dst_pixel[1] = (uint16_t) (pq_encode (dst_g) * 65535.f + 0.5f); + dst_pixel[2] = (uint16_t) (pq_encode (dst_b) * 65535.f + 0.5f); + /* Alpha left at full (cursor opaque region paints onto opaque bg). */ + } + } +} + +/* + * Build a small (~thumb_max-pixel-wide) tone-mapped sRGB GdkPixbuf from the + * full-size HDR PQ buffer for the screenshot notification icon. + */ +static GdkPixbuf * +hdr_make_sdr_thumbnail (const uint16_t *hdr_pixels, + int width, + int height, + int thumb_max) +{ + const float sdr_white_nits = 203.f; + const float pq_peak_nits = 10000.f; + const float pq_to_sdr = pq_peak_nits / sdr_white_nits; + int thumb_w, thumb_h; + GdkPixbuf *pixbuf; + guchar *pixels; + int stride; + + if (width <= 0 || height <= 0) + return NULL; + + if (width > height) + { + thumb_w = MIN (thumb_max, width); + thumb_h = MAX (1, (int) ((int64_t) thumb_w * height / width)); + } + else + { + thumb_h = MIN (thumb_max, height); + thumb_w = MAX (1, (int) ((int64_t) thumb_h * width / height)); + } + + pixbuf = gdk_pixbuf_new (GDK_COLORSPACE_RGB, TRUE, 8, thumb_w, thumb_h); + if (!pixbuf) + return NULL; + + pixels = gdk_pixbuf_get_pixels (pixbuf); + stride = gdk_pixbuf_get_rowstride (pixbuf); + + for (int y = 0; y < thumb_h; y++) + { + int sy = (int) ((int64_t) y * height / thumb_h); + guchar *row = pixels + (size_t) y * stride; + for (int x = 0; x < thumb_w; x++) + { + int sx = (int) ((int64_t) x * width / thumb_w); + const uint16_t *src = hdr_pixels + ((size_t) sy * width + sx) * 4; + float r_pq = src[0] / 65535.f; + float g_pq = src[1] / 65535.f; + float b_pq = src[2] / 65535.f; + float r = pq_decode (r_pq) * pq_to_sdr; + float g = pq_decode (g_pq) * pq_to_sdr; + float b = pq_decode (b_pq) * pq_to_sdr; + /* Hable filmic tonemap. */ + float A = 0.15f, B = 0.50f, C = 0.10f, D = 0.20f, E = 0.02f, F = 0.30f; + float W = 11.2f; + float white_scale = 1.f / (((W*(A*W+C*B)+D*E)/(W*(A*W+B)+D*F))-E/F); +#define HABLE(x) ((((x)*(A*(x)+C*B)+D*E)/((x)*(A*(x)+B)+D*F))-E/F) + r = HABLE (r) * white_scale; + g = HABLE (g) * white_scale; + b = HABLE (b) * white_scale; +#undef HABLE + r = srgb_encode (r); + g = srgb_encode (g); + b = srgb_encode (b); + + row[x * 4 + 0] = (guchar) (r * 255.f + 0.5f); + row[x * 4 + 1] = (guchar) (g * 255.f + 0.5f); + row[x * 4 + 2] = (guchar) (b * 255.f + 0.5f); + row[x * 4 + 3] = 255; + } + } + + return pixbuf; +} + +typedef struct +{ + GOutputStream *stream; + uint16_t *hdr_pixels; /* owned, FP16 host-endian RGBA */ + int width; + int height; + int stride; /* bytes */ + GDateTime *date_time; + /* Optional cursor overlay (CAIRO_FORMAT_ARGB32 premultiplied sRGB). */ + uint8_t *cursor_argb; + int cursor_w; + int cursor_h; + int cursor_stride; + int cursor_dst_x; + int cursor_dst_y; + float cursor_pixel_scale; + gboolean has_cursor; +} HdrCompositeData; + +static void +hdr_composite_data_free (gpointer data) +{ + HdrCompositeData *d = data; + + g_clear_object (&d->stream); + g_clear_pointer (&d->hdr_pixels, g_free); + g_clear_pointer (&d->date_time, g_date_time_unref); + g_clear_pointer (&d->cursor_argb, g_free); + g_free (d); +} + +static void +hdr_composite_thread (GTask *task, + gpointer source_object, + gpointer task_data, + GCancellable *cancellable) +{ + HdrCompositeData *d = task_data; + g_autofree uint16_t *rgba16 = NULL; + GdkPixbuf *thumbnail; + GError *error = NULL; + + rgba16 = convert_fp16_pq_to_uint16_rgba (d->hdr_pixels, d->width, d->height, + d->stride); + + if (d->has_cursor && d->cursor_argb) + { + hdr_composite_cursor (rgba16, d->width, d->height, + d->cursor_argb, d->cursor_w, d->cursor_h, + d->cursor_stride, + d->cursor_dst_x, d->cursor_dst_y, + d->cursor_pixel_scale); + } + + if (!write_hdr_png (d->stream, rgba16, d->width, d->height, d->date_time, + &error)) + { + g_task_return_error (task, error); + return; + } + + thumbnail = hdr_make_sdr_thumbnail (rgba16, d->width, d->height, 512); + if (!thumbnail) + { + g_task_return_new_error (task, G_IO_ERROR, G_IO_ERROR_FAILED, + "Failed to build HDR thumbnail"); + return; + } + + g_task_return_pointer (task, thumbnail, g_object_unref); +} + +#endif /* HAVE_HDR_SCREENSHOTS */ + /** * shell_screenshot_composite_to_stream: * @texture: the source texture @@ -1170,6 +2005,72 @@ shell_screenshot_composite_to_stream (CoglTexture *texture, ctx = cogl_texture_get_context (texture); sub_texture = cogl_sub_texture_new (ctx, texture, x, y, width, height); +#ifdef HAVE_HDR_SCREENSHOTS + { + CoglPixelFormat src_format = cogl_texture_get_format (texture); + gboolean is_hdr_source = + src_format == COGL_PIXEL_FORMAT_RGBA_FP_16161616 || + src_format == COGL_PIXEL_FORMAT_RGBA_FP_16161616_PRE || + src_format == COGL_PIXEL_FORMAT_RGBX_FP_16161616; + + if (is_hdr_source) + { + HdrCompositeData *data = g_new0 (HdrCompositeData, 1); + int w = cogl_texture_get_width (sub_texture); + int h = cogl_texture_get_height (sub_texture); + int stride = w * 4 * (int) sizeof (uint16_t); + + data->stream = g_object_ref (stream); + data->width = w; + data->height = h; + data->stride = stride; + data->date_time = g_date_time_new_now_local (); + data->hdr_pixels = g_malloc ((size_t) stride * (size_t) h); + + cogl_texture_get_data (sub_texture, + COGL_PIXEL_FORMAT_RGBA_FP_16161616, + stride, + (uint8_t *) data->hdr_pixels); + + g_object_unref (sub_texture); + + if (cursor != NULL) + { + int cw = cogl_texture_get_width (cursor); + int ch = cogl_texture_get_height (cursor); + int cstride = cw * 4; + + data->cursor_w = cw; + data->cursor_h = ch; + data->cursor_stride = cstride; + data->cursor_argb = g_malloc ((size_t) cstride * (size_t) ch); + cogl_texture_get_data (cursor, + COGL_PIXEL_FORMAT_CAIRO_ARGB32_COMPAT, + cstride, data->cursor_argb); + /* Cursor position in destination buffer coordinates. + * The source texture pixels are at (x, y); cursor_x/y are also in + * source-texture coordinates, so the destination offset is + * (cursor_x - x) / scale * cursor_pixel_scale_inverse... actually + * the destination buffer is the sub_texture at native size; the + * cursor texture is rendered at 1/cursor_scale device pixels per + * cursor pixel. */ + data->cursor_dst_x = + (int) ((cursor_x - x) + 0.5f); + data->cursor_dst_y = + (int) ((cursor_y - y) + 0.5f); + data->cursor_pixel_scale = cursor_scale > 0.f ? cursor_scale : 1.f; + data->has_cursor = TRUE; + } + + g_task_set_task_data (task, data, hdr_composite_data_free); + g_task_run_in_thread (task, hdr_composite_thread); + /* task is consumed by g_task_run_in_thread (it takes a ref). */ + g_steal_pointer (&task); + return; + } + } +#endif + surface = cairo_image_surface_create (CAIRO_FORMAT_ARGB32, cogl_texture_get_width (sub_texture), cogl_texture_get_height (sub_texture)); -- GitLab From 08c5be3ef2aa249707cccd8db704153b14828b4d Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Thu, 21 May 2026 21:53:02 -0700 Subject: [PATCH 2/3] screenshot: Capture HDR window screenshots when mutter supports it grab_window_screenshot() routes through meta_window_actor_get_image(), which is hardcoded to flatten the window's layers into an 8-bit CAIRO_FORMAT_ARGB32 surface, so ScreenshotWindow emits an 8-bit sRGB PNG even on HDR PQ outputs. Probe for meta_window_actor_paint_to_content_full() at configure time. When available, ask for a BT.2020/PQ ClutterColorState in FP16 RGBA, read the resulting ClutterContent's texture into the existing HDR pixel buffer, and let write_screenshot_thread() emit a 16-bit RGBA PNG with a cICP chunk through the path added in the previous commit. The HDR cursor overlay reuses the software hdr_composite_cursor() helper already used by the interactive composite_to_stream() path. SDR captures and builds against a pre-patch mutter take the original cairo path unchanged. Assisted-By: Claude Opus 4.7 --- config.h.meson | 3 + meson.build | 8 +++ src/shell-screenshot.c | 148 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) diff --git a/config.h.meson b/config.h.meson index 4539e34261..9a8d934a6d 100644 --- a/config.h.meson +++ b/config.h.meson @@ -48,3 +48,6 @@ /* Whether libpng exports png_set_cICP (1.6.46+) */ #mesondefine HAVE_PNG_SET_CICP + +/* Whether mutter exports meta_window_actor_paint_to_content_full */ +#mesondefine HAVE_META_WINDOW_ACTOR_PAINT_TO_CONTENT_FULL diff --git a/meson.build b/meson.build index cab6c467e3..fb76e24825 100644 --- a/meson.build +++ b/meson.build @@ -172,8 +172,14 @@ if have_hdr_screenshots dependencies: libpng_dep, prefix: '#include ' ) + have_meta_window_actor_paint_to_content_full = cc.has_function( + 'meta_window_actor_paint_to_content_full', + dependencies: mutter_dep, + prefix: '#include ' + ) else have_png_set_cicp = false + have_meta_window_actor_paint_to_content_full = false endif cdata = configuration_data() @@ -187,6 +193,8 @@ cdata.set('HAVE_SYSTEMD', have_systemd) cdata.set('HAVE_XWAYLAND', have_xwayland) cdata.set('HAVE_HDR_SCREENSHOTS', have_hdr_screenshots) cdata.set('HAVE_PNG_SET_CICP', have_png_set_cicp) +cdata.set('HAVE_META_WINDOW_ACTOR_PAINT_TO_CONTENT_FULL', + have_meta_window_actor_paint_to_content_full) cdata.set('HAVE_FDWALK', cc.has_function('fdwalk')) cdata.set('HAVE_MALLINFO', cc.has_function('mallinfo')) diff --git a/src/shell-screenshot.c b/src/shell-screenshot.c index 41a6dbc2dd..ea7b4268e5 100644 --- a/src/shell-screenshot.c +++ b/src/shell-screenshot.c @@ -748,6 +748,19 @@ do_grab_hdr_stage_content (ClutterStage *stage, NULL); } +/* Defined in the second HDR block below; forward-declared here so the window + * screenshot path can use it. */ +static void hdr_composite_cursor (uint16_t *hdr_pixels, + int hdr_width, + int hdr_height, + const uint8_t *cursor_argb, + int cursor_width, + int cursor_height, + int cursor_stride, + int dst_x, + int dst_y, + float cursor_pixel_scale); + #endif /* HAVE_HDR_SCREENSHOTS */ static void @@ -1137,6 +1150,126 @@ grab_screenshot_content (ShellScreenshot *screenshot, g_task_return_pointer (result, g_steal_pointer (&content), g_object_unref); } +#if defined(HAVE_HDR_SCREENSHOTS) && \ + defined(HAVE_META_WINDOW_ACTOR_PAINT_TO_CONTENT_FULL) + +/* + * Composite the cursor onto an HDR window screenshot. Mirrors draw_cursor_image + * but writes into the FP16 PQ-encoded buffer via the software hdr_composite_cursor + * helper instead of cairo. + */ +static void +hdr_window_draw_cursor (ShellScreenshot *screenshot, + MtkRectangle area, + float resource_scale) +{ + MetaBackend *backend = shell_global_get_backend (shell_global_get ()); + MetaCursorTracker *tracker = meta_backend_get_cursor_tracker (backend); + CoglTexture *cursor_texture = meta_cursor_tracker_get_sprite (tracker); + graphene_point_t point; + int xhot, yhot, cw, ch, cstride; + g_autofree uint8_t *cursor_argb = NULL; + float cursor_scale_inv; + int dst_x, dst_y; + + if (!cursor_texture) + return; + + meta_cursor_tracker_get_pointer (tracker, &point, NULL); + if (!mtk_rectangle_contains_point (&area, (int) point.x, (int) point.y)) + return; + + meta_cursor_tracker_get_hot (tracker, &xhot, &yhot); + cw = cogl_texture_get_width (cursor_texture); + ch = cogl_texture_get_height (cursor_texture); + cstride = cw * 4; + cursor_argb = g_malloc ((size_t) cstride * (size_t) ch); + cogl_texture_get_data (cursor_texture, COGL_PIXEL_FORMAT_CAIRO_ARGB32_COMPAT, + cstride, cursor_argb); + + /* + * Cursor sprite is in "monitor pixels"; the FP16 buffer is in window + * resource-scale pixels. hdr_composite_cursor scales the sprite back to + * destination pixels using cursor_pixel_scale = scale (sprite->dst is + * 1 / scale). We provide that ratio as cursor_pixel_scale. + */ + cursor_scale_inv = meta_cursor_tracker_get_scale (tracker); + if (cursor_scale_inv <= 0.f) + cursor_scale_inv = 1.f; + + dst_x = (int) (((point.x - xhot) - area.x) * resource_scale + 0.5f); + dst_y = (int) (((point.y - yhot) - area.y) * resource_scale + 0.5f); + + hdr_composite_cursor (screenshot->hdr_pixels, + screenshot->hdr_width, + screenshot->hdr_height, + cursor_argb, cw, ch, cstride, + dst_x, dst_y, + cursor_scale_inv / resource_scale); +} + +static gboolean +try_grab_hdr_window_screenshot (ShellScreenshot *screenshot, + MetaWindow *window, + ClutterActor *window_actor, + ShellScreenshotFlag flags) +{ + ClutterStage *stage = shell_global_get_stage (screenshot->global); + g_autoptr (ClutterColorState) hdr_color_state = NULL; + g_autoptr (ClutterContent) content = NULL; + g_autoptr (GError) error = NULL; + CoglTexture *texture; + int tex_w, tex_h, stride; + + hdr_color_state = + pick_screenshot_color_state (stage, &screenshot->screenshot_area); + if (!hdr_color_state) + return FALSE; + + content = meta_window_actor_paint_to_content_full ( + META_WINDOW_ACTOR (window_actor), + NULL, + COGL_PIXEL_FORMAT_RGBA_FP_16161616, + hdr_color_state, + &error); + if (!content) + { + g_warning ("Failed to capture HDR window: %s", + error ? error->message : "(unknown)"); + return FALSE; + } + + texture = clutter_texture_content_get_texture (CLUTTER_TEXTURE_CONTENT (content)); + if (!texture) + return FALSE; + + tex_w = cogl_texture_get_width (texture); + tex_h = cogl_texture_get_height (texture); + stride = tex_w * 4 * (int) sizeof (uint16_t); + + screenshot->hdr_pixels = g_malloc ((size_t) stride * (size_t) tex_h); + screenshot->hdr_width = tex_w; + screenshot->hdr_height = tex_h; + screenshot->hdr_stride = stride; + screenshot->is_hdr = TRUE; + + cogl_texture_get_data (texture, COGL_PIXEL_FORMAT_RGBA_FP_16161616, stride, + (uint8_t *) screenshot->hdr_pixels); + + if (flags & SHELL_SCREENSHOT_FLAG_INCLUDE_CURSOR) + { + float resource_scale = 1.f; + if (meta_window_get_client_type (window) == META_WINDOW_CLIENT_TYPE_WAYLAND) + resource_scale = clutter_actor_get_resource_scale (window_actor); + hdr_window_draw_cursor (screenshot, screenshot->screenshot_area, + resource_scale); + } + + return TRUE; +} + +#endif /* HDR + format-aware meta_window_actor */ + static void grab_window_screenshot (ShellScreenshot *screenshot, ShellScreenshotFlag flags, @@ -1159,6 +1292,21 @@ grab_window_screenshot (ShellScreenshot *screenshot, screenshot->screenshot_area = rect; +#if defined(HAVE_HDR_SCREENSHOTS) && \ + defined(HAVE_META_WINDOW_ACTOR_PAINT_TO_CONTENT_FULL) + if (try_grab_hdr_window_screenshot (screenshot, window, window_actor, flags)) + { + screenshot->datetime = g_date_time_new_now_local (); + g_signal_emit (screenshot, signals[SCREENSHOT_TAKEN], 0, &rect); + + task = g_task_new (screenshot, NULL, on_screenshot_written, result); + g_task_set_source_tag (task, grab_window_screenshot); + g_task_run_in_thread (task, write_screenshot_thread); + g_object_unref (task); + return; + } +#endif + screenshot->image = meta_window_actor_get_image (META_WINDOW_ACTOR (window_actor), NULL); -- GitLab From 8a6439a9782ea2ad57e7b82e484921b29d1fb9b2 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Thu, 21 May 2026 22:48:30 -0700 Subject: [PATCH 3/3] screenshot: HDR-aware window paint for the interactive UI The interactive screenshot UI in window-selection mode captures each candidate window via meta_window_actor_paint_to_content(), which allocates an 8-bit RGBA texture, then hands that texture to shell_screenshot_composite_to_stream() at save time. On HDR outputs the PQ data is tone-mapped away by the time composite_to_stream() looks at the texture format, so the saved PNG always lands as 8-bit. Add shell_screenshot_paint_window_actor_to_content(), a GObject- introspectable helper that: * checks the views the window actor is painted on (via clutter_actor_peek_stage_views) and walks their output color states; * when any view is HDR, builds a BT.2020 / PQ ClutterColorState and calls meta_window_actor_paint_to_content_full() with COGL_PIXEL_FORMAT_RGBA_FP_16161616 so the texture preserves HDR data; * otherwise falls back to meta_window_actor_paint_to_content() so the existing 8-bit code path is unchanged. Use the helper from both the interactive UIWindowSelectorWindowContent constructor and the screenshot-window keybinding. composite_to_stream() already routes FP16 textures into write_hdr_png(), so the saved PNG is now 16-bit BT.2020/PQ with a cICP chunk on HDR monitors. When mutter doesn't ship the format-aware paint API, the helper still returns the 8-bit content and behaviour is identical to before. Assisted-By: Claude Opus 4.7 --- js/ui/screenshot.js | 4 +-- src/shell-screenshot.c | 60 ++++++++++++++++++++++++++++++++++++++++++ src/shell-screenshot.h | 3 +++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/js/ui/screenshot.js b/js/ui/screenshot.js index 20bfb43a69..283e60cff9 100644 --- a/js/ui/screenshot.js +++ b/js/ui/screenshot.js @@ -838,7 +838,7 @@ class UIWindowSelectorWindowContent extends Clutter.Actor { this._bufferRect = window.get_buffer_rect(); this._bufferScale = actor.get_resource_scale(); this._actor = new Clutter.Actor({ - content: actor.paint_to_content(null), + content: Shell.Screenshot.paint_window_actor_to_content(actor), }); this.add_child(this._actor); @@ -1483,7 +1483,7 @@ export const ScreenshotUI = GObject.registerClass({ async (_display, window, _event, _binding) => { try { const actor = window.get_compositor_private(); - const content = actor.paint_to_content(null); + const content = Shell.Screenshot.paint_window_actor_to_content(actor); const texture = content.get_texture(); await captureScreenshot(texture, null, 1, null); diff --git a/src/shell-screenshot.c b/src/shell-screenshot.c index ea7b4268e5..b2f5312615 100644 --- a/src/shell-screenshot.c +++ b/src/shell-screenshot.c @@ -2303,6 +2303,66 @@ shell_screenshot_composite_to_stream_finish (GAsyncResult *result, return g_task_propagate_pointer (G_TASK (result), error); } +/** + * shell_screenshot_paint_window_actor_to_content: + * @actor: the #MetaWindowActor to paint + * + * Like meta_window_actor_paint_to_content(), but paints into an FP16 + * BT.2020/PQ texture when the actor is on an HDR output. Falls back to + * the standard 8-bit paint when no HDR view is involved or when the + * format-aware Mutter API is not available. + * + * Returns: (transfer full) (nullable): a new #ClutterContent, or %NULL on error. + */ +ClutterContent * +shell_screenshot_paint_window_actor_to_content (MetaWindowActor *actor) +{ + g_return_val_if_fail (META_IS_WINDOW_ACTOR (actor), NULL); + +#if defined(HAVE_HDR_SCREENSHOTS) && \ + defined(HAVE_META_WINDOW_ACTOR_PAINT_TO_CONTENT_FULL) + { + GList *views = clutter_actor_peek_stage_views (CLUTTER_ACTOR (actor)); + gboolean any_hdr = FALSE; + + for (GList *l = views; l; l = l->next) + { + if (view_is_hdr (l->data)) + { + any_hdr = TRUE; + break; + } + } + + if (any_hdr) + { + ClutterContext *ctx = + clutter_actor_get_context (CLUTTER_ACTOR (actor)); + g_autoptr (ClutterColorState) cs = NULL; + g_autoptr (GError) error = NULL; + ClutterContent *content; + + cs = clutter_color_state_params_new_full (ctx, + CLUTTER_COLORSPACE_BT2020, + CLUTTER_TRANSFER_FUNCTION_PQ, + NULL, + 0.f, 0.005f, 10000.f, + 203.f, 10000.f); + content = meta_window_actor_paint_to_content_full ( + actor, NULL, COGL_PIXEL_FORMAT_RGBA_FP_16161616, cs, &error); + if (content) + return content; + + g_warning ("Failed to paint HDR window content: %s", + error ? error->message : "(unknown)"); + } + } +#endif + + return meta_window_actor_paint_to_content (META_WINDOW_ACTOR (actor), + NULL, NULL); +} + ShellScreenshot * shell_screenshot_new (void) { diff --git a/src/shell-screenshot.h b/src/shell-screenshot.h index c67587917b..b387d2b983 100644 --- a/src/shell-screenshot.h +++ b/src/shell-screenshot.h @@ -1,6 +1,7 @@ /* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ #pragma once +#include #include /** @@ -88,3 +89,5 @@ void shell_screenshot_composite_to_stream (CoglTexture *texture, gpointer user_data); GdkPixbuf *shell_screenshot_composite_to_stream_finish (GAsyncResult *result, GError **error); + +ClutterContent *shell_screenshot_paint_window_actor_to_content (MetaWindowActor *actor); -- GitLab