From 5db14c3247a48b74909ea48d42e202ae49a97ac4 Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Fri, 19 Jun 2026 13:23:11 +0200 Subject: [PATCH 1/3] HDR/EDR darkroom preview via an external viewer darktable's pixelpipe already computes a scene-referred, super-white (>1.0) float image, but GTK3/Cairo can only display it clipped to 8-bit SDR, so those HDR highlights are never visible while editing. Migrating the canvas to a toolkit with HDR support is a large, long-term effort (GTK4). This adds an optional, low-cost path to see the real HDR image now, without any platform-specific code in the darktable tree: when the feature is enabled and a companion viewer app is running, the darkroom preview pipe forwards its working-space linear RGB image to that app over a Unix domain socket. The app (separate, platform-specific, e.g. a macOS Metal/EDR window) does the HDR display. When the viewer is not running, connect() fails fast and darktable is unaffected. - common/hdr_viewer.c/.h: a ~200-line POSIX socket client. Protocol v2 sends a versioned header (magic, dims, channels, transfer) plus the working profile's RGB->XYZ(D50) matrix, then the linear float pixels, so the viewer can color-manage any working profile. On non-POSIX platforms (Windows) the client compiles to inert stubs, so no platform-specific build logic is required. - develop/pixelpipe_hb.c: tap the input to the output color profile module ("colorout") on the preview pipe -- the fully edited image still in the working profile's linear primaries with HDR signal intact, before the display TRC and 8-bit clip. Guarded by gui-attached, host-resident input, and the conf flag; a short cooldown avoids repeated connect timeouts when no viewer is present. - registered behind plugins/darkroom/hdr_viewer_enabled (default off). Companion viewer: https://github.com/MaykThewessen/darktable-hdr-viewer Addresses #17710, #18078, #20477. Co-Authored-By: Claude Opus 4.8 --- data/darktableconfig.xml.in | 7 ++ src/CMakeLists.txt | 1 + src/common/hdr_viewer.c | 214 ++++++++++++++++++++++++++++++++++++ src/common/hdr_viewer.h | 117 ++++++++++++++++++++ src/develop/pixelpipe_hb.c | 73 ++++++++++++ 5 files changed, 412 insertions(+) create mode 100644 src/common/hdr_viewer.c create mode 100644 src/common/hdr_viewer.h diff --git a/data/darktableconfig.xml.in b/data/darktableconfig.xml.in index e85ea9c18c09..23d08fec906c 100644 --- a/data/darktableconfig.xml.in +++ b/data/darktableconfig.xml.in @@ -1654,6 +1654,13 @@ show the guides widget in modules UI show the guides widget in modules UI + + plugins/darkroom/hdr_viewer_enabled + bool + false + send HDR preview to an external viewer + forward the working-space linear preview image to the external HDR viewer application over a Unix domain socket, so it can be shown on an HDR/EDR display. requires the standalone darktable-hdr-viewer app to be running. + plugins/lighttable/hide_default_presets bool diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 40edc6397a3c..bc0885784ba1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -54,6 +54,7 @@ FILE(GLOB SOURCE_FILES "common/gpx.c" "common/grouping.c" "common/guided_filter.c" + "common/hdr_viewer.c" "common/heal.c" "common/histogram.c" "common/history.c" diff --git a/src/common/hdr_viewer.c b/src/common/hdr_viewer.c new file mode 100644 index 000000000000..efd6d6d2d902 --- /dev/null +++ b/src/common/hdr_viewer.c @@ -0,0 +1,214 @@ +/* + This file is part of darktable, + Copyright (C) 2026 darktable developers. + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with darktable. If not, see . +*/ + +#include "common/hdr_viewer.h" + +// The viewer protocol uses POSIX (Berkeley) sockets. Build the real client on +// the POSIX platforms darktable supports and fall back to no-op stubs elsewhere +// (e.g. Windows), so no platform-specific build logic is needed: the feature is +// simply inert where the transport is unavailable. +#if defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) \ + || defined(__NetBSD__) || defined(__OpenBSD__) + +#include +#include +#include +#include +#include +#include +#include +#include + +// Timeout when the viewer is not yet running (milliseconds). +#ifndef DT_HDR_VIEWER_CONNECT_TIMEOUT_MS +#define DT_HDR_VIEWER_CONNECT_TIMEOUT_MS 200 +#endif + +// Write exactly `len` bytes from `buf` to `fd`, restarting on EINTR. +// Returns 0 on success, -1 on error. +static int _write_exact(const int fd, const void *buf, const size_t len) +{ + const char *p = (const char *)buf; + size_t left = len; + + while(left > 0) + { +#ifdef __linux__ + // Linux has no SO_NOSIGPIPE; suppress SIGPIPE per write instead. + const ssize_t n = send(fd, p, left, MSG_NOSIGNAL); +#else + const ssize_t n = write(fd, p, left); +#endif + if(n < 0) + { + if(errno == EINTR) continue; + return -1; + } + p += (size_t)n; + left -= (size_t)n; + } + return 0; +} + +// Encode a uint32_t as 4 little-endian bytes into `out`. +static void _encode_le32(uint8_t out[4], const uint32_t v) +{ + out[0] = (uint8_t)(v & 0xFFu); + out[1] = (uint8_t)((v >> 8) & 0xFFu); + out[2] = (uint8_t)((v >> 16) & 0xFFu); + out[3] = (uint8_t)((v >> 24) & 0xFFu); +} + +int dt_hdr_viewer_connect(void) +{ + const int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if(fd < 0) return -1; + + // Prevent SIGPIPE from killing darktable if the viewer crashes or disconnects + // while we are writing a frame (BSD/macOS; Linux uses MSG_NOSIGNAL above). +#ifdef SO_NOSIGPIPE + { + const int yes = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &yes, sizeof(yes)); + } +#endif + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, DT_HDR_VIEWER_SOCKET_PATH, sizeof(addr.sun_path) - 1); + + // Non-blocking connect with a short timeout so darktable does not stall when + // the viewer is not running. + const int flags = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, (flags < 0 ? 0 : flags) | O_NONBLOCK); + + int rc = connect(fd, (const struct sockaddr *)&addr, (socklen_t)sizeof(addr)); + if(rc == 0) + { + // Connected immediately (unlikely for a Unix socket, but possible). + fcntl(fd, F_SETFL, (flags < 0 ? 0 : flags)); + return fd; + } + + if(errno != EINPROGRESS && errno != EAGAIN) + { + close(fd); + return -1; + } + + // Wait for the socket to become writable (= connected) or time out. + fd_set wfds; + FD_ZERO(&wfds); + FD_SET(fd, &wfds); + + struct timeval tv; + tv.tv_sec = DT_HDR_VIEWER_CONNECT_TIMEOUT_MS / 1000; + tv.tv_usec = (DT_HDR_VIEWER_CONNECT_TIMEOUT_MS % 1000) * 1000; + + rc = select(fd + 1, NULL, &wfds, NULL, &tv); + if(rc <= 0) + { + close(fd); + return -1; + } + + // Confirm the connection actually succeeded. + int err = 0; + socklen_t errlen = (socklen_t)sizeof(err); + getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &errlen); + if(err != 0) + { + close(fd); + return -1; + } + + // Restore blocking mode for the subsequent frame writes. + fcntl(fd, F_SETFL, (flags < 0 ? 0 : flags)); + return fd; +} + +void dt_hdr_viewer_send_frame(const int fd, + const uint32_t w, + const uint32_t h, + const float *rgb_linear, + const float rgb_to_xyz[9]) +{ + if(fd < 0 || w == 0 || h == 0 || rgb_linear == NULL || rgb_to_xyz == NULL) + return; + + // Header (protocol v2, 60 bytes), see hdr_viewer.h for the layout. The magic + // is written as literal bytes so it is endian-independent; integers are + // little-endian; floats are copied in host byte order (little-endian on every + // supported platform). + uint8_t header[60]; + header[0] = 'D'; + header[1] = 'T'; + header[2] = 'H'; + header[3] = 'V'; + _encode_le32(header + 4, DT_HDR_VIEWER_VERSION); + _encode_le32(header + 8, w); + _encode_le32(header + 12, h); + _encode_le32(header + 16, 3u); // channels + _encode_le32(header + 20, DT_HDR_VIEWER_XFER_LINEAR); + memcpy(header + 24, rgb_to_xyz, 9u * sizeof(float)); // 36 bytes + + if(_write_exact(fd, header, sizeof(header)) != 0) return; + + const size_t pixel_bytes = (size_t)w * (size_t)h * 3u * sizeof(float); + _write_exact(fd, rgb_linear, pixel_bytes); + // Errors are silently ignored; the caller reconnects on the next frame. +} + +void dt_hdr_viewer_disconnect(const int fd) +{ + if(fd >= 0) close(fd); +} + +#else // no POSIX sockets (e.g. Windows): inert stubs + +int dt_hdr_viewer_connect(void) +{ + return -1; +} + +void dt_hdr_viewer_send_frame(const int fd, + const uint32_t w, + const uint32_t h, + const float *rgb_linear, + const float rgb_to_xyz[9]) +{ + (void)fd; + (void)w; + (void)h; + (void)rgb_linear; + (void)rgb_to_xyz; +} + +void dt_hdr_viewer_disconnect(const int fd) +{ + (void)fd; +} + +#endif + +// clang-format off +// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py +// vim: shiftwidth=2 expandtab tabstop=2 cindent +// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified; +// clang-format on diff --git a/src/common/hdr_viewer.h b/src/common/hdr_viewer.h new file mode 100644 index 000000000000..75c741406fd9 --- /dev/null +++ b/src/common/hdr_viewer.h @@ -0,0 +1,117 @@ +/* + This file is part of darktable, + Copyright (C) 2026 darktable developers. + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with darktable. If not, see . +*/ + +/* + Minimal POSIX-only client for streaming the darkroom preview to an external + HDR viewer over a Unix domain socket. The viewer is a separate, platform + specific application (https://github.com/MaykThewessen/darktable-hdr-viewer); + nothing platform specific is built into darktable: this client is plain + POSIX C and does nothing unless the viewer is running and the feature is + enabled via plugins/darkroom/hdr_viewer_enabled. + + The frame buffer carries the working-space linear RGB image (the input to + the output color profile module, "colorout"), so the receiver can do its own + accurate, display-referred color management. The working profile's + RGB -> XYZ(D50) matrix travels in the header so the viewer is correct for any + working profile, not just the linear Rec.2020 default. + + Wire format (protocol version 2, all multi-byte values little-endian, which + matches the host byte order on every platform darktable supports): + + offset size field + 0 4 magic : bytes 'D','T','H','V' + 4 4 version : uint32, currently DT_HDR_VIEWER_VERSION (2) + 8 4 width : uint32, pixels + 12 4 height : uint32, pixels + 16 4 channels : uint32, currently 3 (interleaved RGB) + 20 4 transfer : uint32, DT_HDR_VIEWER_XFER_LINEAR (0) = linear light + 24 36 rgb_to_xyz: 9 x float32, row-major working RGB -> XYZ (D50 PCS) + 60 w*h*channels*4 : float32 pixels, row-major, top-to-bottom + + The server also accepts multiple frames on a single connection. +*/ + +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Default Unix-domain socket path used by the HDR viewer app. */ +#define DT_HDR_VIEWER_SOCKET_PATH "/tmp/dt_hdr_viewer.sock" + +/** Wire protocol version understood by both ends. */ +#define DT_HDR_VIEWER_VERSION 2u + +/** Transfer function tag: the pixel data is linear light. */ +#define DT_HDR_VIEWER_XFER_LINEAR 0u + +/** + * Connect to the HDR viewer Unix socket. + * + * Returns a connected socket file descriptor on success, or -1 on failure + * (check errno for details). The connection attempt times out after + * DT_HDR_VIEWER_CONNECT_TIMEOUT_MS milliseconds so it is safe to call + * unconditionally in a hot path: when the viewer is not running it returns + * -1 quickly rather than blocking. + */ +int dt_hdr_viewer_connect(void); + +/** + * Send one frame of working-space linear RGB pixels to the HDR viewer. + * + * @param fd file descriptor returned by dt_hdr_viewer_connect(). + * @param w image width in pixels. + * @param h image height in pixels. + * @param rgb_linear row-major, top-to-bottom, interleaved RGB float32 buffer + * of size w * h * 3 floats, linear light in the working + * profile's primaries. Values may exceed 1.0 (HDR signal). + * @param rgb_to_xyz 9 floats, row-major 3x3 matrix converting the working + * profile's linear RGB to XYZ (ICC D50 PCS). This is + * darktable's work-profile matrix_in with the SIMD padding + * column removed. + * + * The call blocks until all data has been written. On write error the function + * returns silently; the caller should disconnect and reconnect on the next + * frame. + */ +void dt_hdr_viewer_send_frame(int fd, + uint32_t w, + uint32_t h, + const float *rgb_linear, + const float rgb_to_xyz[9]); + +/** + * Close the connection to the HDR viewer. + * + * @param fd file descriptor returned by dt_hdr_viewer_connect(), or -1 + * (no-op in that case). + */ +void dt_hdr_viewer_disconnect(int fd); + +#ifdef __cplusplus +} +#endif + +// clang-format off +// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py +// vim: shiftwidth=2 expandtab tabstop=2 cindent +// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified; +// clang-format on diff --git a/src/develop/pixelpipe_hb.c b/src/develop/pixelpipe_hb.c index d6f1d1bb3348..48c0fccb5a1e 100644 --- a/src/develop/pixelpipe_hb.c +++ b/src/develop/pixelpipe_hb.c @@ -18,6 +18,7 @@ #include "common/color_picker.h" #include "common/colorspaces.h" +#include "common/hdr_viewer.h" #include "common/histogram.h" #include "common/opencl.h" #include "common/iop_order.h" @@ -1744,6 +1745,10 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe, dt_iop_module_t *module = NULL; dt_dev_pixelpipe_iop_t *piece = NULL; + // tracks whether the host `input` buffer holds valid pixels (it may be left + // GPU-only after an OpenCL run); used by the HDR viewer tap below. + gboolean input_host_valid = TRUE; + const dt_iop_module_t *gui_module = dt_dev_gui_module(); // if a module is active, check if this module allow a fast pipe run if(gui_module @@ -2767,7 +2772,10 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe, /* input is still only on GPU? Let's invalidate CPU input buffer then */ if(valid_input_on_gpu_only) + { dt_dev_pixelpipe_invalidate_cacheline(pipe, input); + input_host_valid = FALSE; + } } else { @@ -2943,6 +2951,71 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe, display_profile, dt_ioppr_get_histogram_profile_info(dev)); } + + // HDR viewer: forward the working-space linear image to the external HDR + // preview app (if running). We tap the *input* to the output color profile + // module ("colorout"), which is the fully-edited image still in the working + // profile's linear primaries (Rec.2020 by default) with super-white (>1.0) + // signal intact -- exactly what an EDR/HDR display needs, and before colorout + // bakes in the display TRC and gamma clips to 8-bit. The work-profile + // RGB->XYZ(D50) matrix is sent alongside so the viewer color-manages + // correctly for any working profile. + // NOTE: the socket calls below must remain outside any OMP parallel section. + if(dev->gui_attached && !dev->gui_leaving + && pipe == dev->preview_pipe + && input_host_valid + && dt_iop_module_is(module, "colorout") + && input_format->datatype == TYPE_FLOAT && input_format->channels == 4 + && dt_conf_get_bool("plugins/darkroom/hdr_viewer_enabled")) + { + // Cooldown: skip connect attempts for 2 seconds after a failed connect + // to avoid a 200ms timeout on every frame when the viewer is not running. + // The preview pipe runs on a single thread, so no synchronisation needed. + static int64_t _hdr_viewer_next_attempt_us = 0; + const int64_t now_us = g_get_monotonic_time(); + if(now_us >= _hdr_viewer_next_attempt_us) + { + const int viewer_fd = dt_hdr_viewer_connect(); + if(viewer_fd >= 0) + { + const size_t w = (size_t)roi_in.width; + const size_t h = (size_t)roi_in.height; + const float *const rgba = (const float *const)input; + + // working RGB -> XYZ(D50): darktable stores this as a [4][4] + // dt_colormatrix_t (rows padded to 4 for SIMD); copy the 3x3 core. + const dt_iop_order_iccprofile_info_t *const work_profile + = dt_ioppr_get_pipe_work_profile_info(pipe); + float rgb_to_xyz[9]; + if(work_profile) + for(int i = 0; i < 3; i++) + for(int j = 0; j < 3; j++) + rgb_to_xyz[i * 3 + j] = work_profile->matrix_in[i][j]; + + // Strip alpha channel: RGBA float -> RGB float (packed, row-major) + const size_t npixels = w * h; + float *rgb = work_profile ? dt_alloc_align_float(npixels * 3) : NULL; + if(rgb) + { + DT_OMP_FOR() + for(size_t k = 0; k < npixels; k++) + { + rgb[k * 3 + 0] = rgba[k * 4 + 0]; + rgb[k * 3 + 1] = rgba[k * 4 + 1]; + rgb[k * 3 + 2] = rgba[k * 4 + 2]; + } + dt_hdr_viewer_send_frame(viewer_fd, (uint32_t)w, (uint32_t)h, rgb, rgb_to_xyz); + dt_free_align(rgb); + } + dt_hdr_viewer_disconnect(viewer_fd); + } + else + { + // Viewer not reachable -- back off for 2 seconds before retrying + _hdr_viewer_next_attempt_us = now_us + 2 * G_USEC_PER_SEC; + } + } + } return _pipe_has_shutdown(pipe); } From 54505d3e39839823290f3e3df8dd87802e7fdb4c Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Sat, 20 Jun 2026 23:28:19 +0200 Subject: [PATCH 2/3] HDR viewer: capture colorout input before it processes colorout converts to the display profile in place, overwriting/recycling its own input buffer. The tap read that buffer at the end of the recursion, AFTER colorout had run, so it captured post-process scratch (large positive and negative working-space values) rather than the tone-mapped image; the EDR preview then showed a blown-out, hue-shifted result regardless of filmic/sigmoid being enabled. Move the tap to immediately after the recursion fills `input` and before colorout's process(), where the buffer still holds the fully-edited, display-referred working image (filmic/sigmoid output, super-white intact). Also gate on _hdr_viewer_pipe_incomplete(): the basic preview pipe skips enabled modules downstream of a focused tag-filtering module (dt_iop_module_is_skipped), which can drop filmic/sigmoid and leave a scene-linear buffer at colorout's input; forward only complete frames. Drop the now-unused input_host_valid. Co-Authored-By: Claude Opus 4.8 --- src/develop/pixelpipe_hb.c | 156 +++++++++++++++++++++---------------- 1 file changed, 87 insertions(+), 69 deletions(-) diff --git a/src/develop/pixelpipe_hb.c b/src/develop/pixelpipe_hb.c index 48c0fccb5a1e..e6857b8a41cb 100644 --- a/src/develop/pixelpipe_hb.c +++ b/src/develop/pixelpipe_hb.c @@ -1706,6 +1706,24 @@ static inline gboolean _skip_piece_on_tags(const dt_dev_pixelpipe_iop_t *piece) && dt_pipe_is_basic(piece->pipe); } +// The HDR viewer must only receive the fully-processed, display-referred image. +// The basic darkroom pipes skip enabled modules located downstream of a focused +// module that shares its operation tags (see dt_iop_module_is_skipped), so the +// focused module's effect is shown in context. That can drop filmic/sigmoid and +// leave an unbounded scene-linear buffer at colorout's input. Detect it so we +// forward only complete frames and otherwise keep the viewer's last good frame. +static gboolean _hdr_viewer_pipe_incomplete(const dt_dev_pixelpipe_t *pipe, + const dt_develop_t *dev) +{ + for(const GList *n = pipe->nodes; n; n = g_list_next(n)) + { + const dt_dev_pixelpipe_iop_t *p = (const dt_dev_pixelpipe_iop_t *)n->data; + if(p && p->module && p->enabled && dt_iop_module_is_skipped(dev, p->module)) + return TRUE; + } + return FALSE; +} + static inline gboolean _dev_pixelpipe_early_exit(const dt_develop_t *dev, const dt_dev_pixelpipe_t *pipe) { @@ -1745,10 +1763,6 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe, dt_iop_module_t *module = NULL; dt_dev_pixelpipe_iop_t *piece = NULL; - // tracks whether the host `input` buffer holds valid pixels (it may be left - // GPU-only after an OpenCL run); used by the HDR viewer tap below. - gboolean input_host_valid = TRUE; - const dt_iop_module_t *gui_module = dt_dev_gui_module(); // if a module is active, check if this module allow a fast pipe run if(gui_module @@ -2002,6 +2016,75 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe, g_list_previous(pieces), pos - 1)) return TRUE; + // HDR viewer: forward the working-space linear image (colorout's input) to the + // external HDR preview app. We capture it HERE -- right after the recursion + // fills `input` and BEFORE colorout processes -- because colorout overwrites/ + // recycles its input buffer in place while converting to the display profile; + // reading it afterwards yields scratch values, not the tone-mapped image. This + // buffer is the fully-edited image still in the working profile's linear + // primaries (Rec.2020 by default) with super-white (>1.0) intact, before + // colorout bakes in the display TRC -- exactly what an EDR/HDR display needs. + // The work-profile RGB->XYZ(D50) matrix is sent alongside for color management. + // NOTE: the socket calls below must remain outside any OMP parallel section. + if(dev->gui_attached && !dev->gui_leaving + && pipe == dev->preview_pipe + && dt_iop_module_is(module, "colorout") + && input_format->datatype == TYPE_FLOAT && input_format->channels == 4 + && dt_conf_get_bool("plugins/darkroom/hdr_viewer_enabled") + // only forward fully-processed frames: when a tag-filtering module is + // focused, the basic pipe skips downstream modules (e.g. filmic/sigmoid) + // and colorout's input would be incomplete (scene-linear). + && !_hdr_viewer_pipe_incomplete(pipe, dev)) + { + // Cooldown: skip connect attempts for 2 seconds after a failed connect to + // avoid a 200ms timeout on every frame when the viewer is not running. The + // preview pipe runs on a single thread, so no synchronisation is needed. + static int64_t _hdr_viewer_next_attempt_us = 0; + const int64_t now_us = g_get_monotonic_time(); + if(now_us >= _hdr_viewer_next_attempt_us) + { + const int viewer_fd = dt_hdr_viewer_connect(); + if(viewer_fd >= 0) + { + const size_t w = (size_t)roi_in.width; + const size_t h = (size_t)roi_in.height; + const float *const rgba = (const float *const)input; + + // working RGB -> XYZ(D50): darktable stores this as a [4][4] + // dt_colormatrix_t (rows padded to 4 for SIMD); copy the 3x3 core. + const dt_iop_order_iccprofile_info_t *const work_profile + = dt_ioppr_get_pipe_work_profile_info(pipe); + float rgb_to_xyz[9]; + if(work_profile) + for(int i = 0; i < 3; i++) + for(int j = 0; j < 3; j++) + rgb_to_xyz[i * 3 + j] = work_profile->matrix_in[i][j]; + + // Strip alpha channel: RGBA float -> RGB float (packed, row-major) + const size_t npixels = w * h; + float *rgb = work_profile ? dt_alloc_align_float(npixels * 3) : NULL; + if(rgb) + { + DT_OMP_FOR() + for(size_t k = 0; k < npixels; k++) + { + rgb[k * 3 + 0] = rgba[k * 4 + 0]; + rgb[k * 3 + 1] = rgba[k * 4 + 1]; + rgb[k * 3 + 2] = rgba[k * 4 + 2]; + } + dt_hdr_viewer_send_frame(viewer_fd, (uint32_t)w, (uint32_t)h, rgb, rgb_to_xyz); + dt_free_align(rgb); + } + dt_hdr_viewer_disconnect(viewer_fd); + } + else + { + // Viewer not reachable -- back off for 2 seconds before retrying. + _hdr_viewer_next_attempt_us = now_us + 2 * G_USEC_PER_SEC; + } + } + } + const size_t in_bpp = dt_iop_buffer_dsc_to_bpp(input_format); piece->dsc_out = piece->dsc_in = *input_format; @@ -2774,7 +2857,6 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe, if(valid_input_on_gpu_only) { dt_dev_pixelpipe_invalidate_cacheline(pipe, input); - input_host_valid = FALSE; } } else @@ -2952,70 +3034,6 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe, dt_ioppr_get_histogram_profile_info(dev)); } - // HDR viewer: forward the working-space linear image to the external HDR - // preview app (if running). We tap the *input* to the output color profile - // module ("colorout"), which is the fully-edited image still in the working - // profile's linear primaries (Rec.2020 by default) with super-white (>1.0) - // signal intact -- exactly what an EDR/HDR display needs, and before colorout - // bakes in the display TRC and gamma clips to 8-bit. The work-profile - // RGB->XYZ(D50) matrix is sent alongside so the viewer color-manages - // correctly for any working profile. - // NOTE: the socket calls below must remain outside any OMP parallel section. - if(dev->gui_attached && !dev->gui_leaving - && pipe == dev->preview_pipe - && input_host_valid - && dt_iop_module_is(module, "colorout") - && input_format->datatype == TYPE_FLOAT && input_format->channels == 4 - && dt_conf_get_bool("plugins/darkroom/hdr_viewer_enabled")) - { - // Cooldown: skip connect attempts for 2 seconds after a failed connect - // to avoid a 200ms timeout on every frame when the viewer is not running. - // The preview pipe runs on a single thread, so no synchronisation needed. - static int64_t _hdr_viewer_next_attempt_us = 0; - const int64_t now_us = g_get_monotonic_time(); - if(now_us >= _hdr_viewer_next_attempt_us) - { - const int viewer_fd = dt_hdr_viewer_connect(); - if(viewer_fd >= 0) - { - const size_t w = (size_t)roi_in.width; - const size_t h = (size_t)roi_in.height; - const float *const rgba = (const float *const)input; - - // working RGB -> XYZ(D50): darktable stores this as a [4][4] - // dt_colormatrix_t (rows padded to 4 for SIMD); copy the 3x3 core. - const dt_iop_order_iccprofile_info_t *const work_profile - = dt_ioppr_get_pipe_work_profile_info(pipe); - float rgb_to_xyz[9]; - if(work_profile) - for(int i = 0; i < 3; i++) - for(int j = 0; j < 3; j++) - rgb_to_xyz[i * 3 + j] = work_profile->matrix_in[i][j]; - - // Strip alpha channel: RGBA float -> RGB float (packed, row-major) - const size_t npixels = w * h; - float *rgb = work_profile ? dt_alloc_align_float(npixels * 3) : NULL; - if(rgb) - { - DT_OMP_FOR() - for(size_t k = 0; k < npixels; k++) - { - rgb[k * 3 + 0] = rgba[k * 4 + 0]; - rgb[k * 3 + 1] = rgba[k * 4 + 1]; - rgb[k * 3 + 2] = rgba[k * 4 + 2]; - } - dt_hdr_viewer_send_frame(viewer_fd, (uint32_t)w, (uint32_t)h, rgb, rgb_to_xyz); - dt_free_align(rgb); - } - dt_hdr_viewer_disconnect(viewer_fd); - } - else - { - // Viewer not reachable -- back off for 2 seconds before retrying - _hdr_viewer_next_attempt_us = now_us + 2 * G_USEC_PER_SEC; - } - } - } return _pipe_has_shutdown(pipe); } From a50ecc613205e1c4b5155e6a9bfc26dd71c7a4cf Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Sun, 21 Jun 2026 23:21:18 +0200 Subject: [PATCH 3/3] HDR viewer: skip frames whose input is GPU-only (fix stale-buffer regression) Relocating the tap before colorout's process() (and dropping input_host_valid) left the tap reading the host `input` buffer unconditionally. When OpenCL is enabled and the upstream module ran on GPU, its result is GPU-resident (cl_mem_input != NULL) and the host buffer is stale, so the viewer received garbage pixels. Gate the tap on cl_mem_input == NULL (always NULL on non-OpenCL builds), forwarding only frames whose host buffer actually holds this frame. Co-Authored-By: Claude Opus 4.8 --- src/develop/pixelpipe_hb.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/develop/pixelpipe_hb.c b/src/develop/pixelpipe_hb.c index e6857b8a41cb..31448e15b2bd 100644 --- a/src/develop/pixelpipe_hb.c +++ b/src/develop/pixelpipe_hb.c @@ -2028,6 +2028,11 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe, // NOTE: the socket calls below must remain outside any OMP parallel section. if(dev->gui_attached && !dev->gui_leaving && pipe == dev->preview_pipe + // host `input` must actually hold this frame's pixels. If the upstream + // module ran on OpenCL its result is left GPU-resident (cl_mem_input set) + // and the host buffer is stale, so skip this frame rather than forward + // garbage to the viewer (it keeps its last good frame). + && cl_mem_input == NULL && dt_iop_module_is(module, "colorout") && input_format->datatype == TYPE_FLOAT && input_format->channels == 4 && dt_conf_get_bool("plugins/darkroom/hdr_viewer_enabled")