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..31448e15b2bd 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"
@@ -1705,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)
{
@@ -1997,6 +2016,80 @@ 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
+ // 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")
+ // 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;
@@ -2767,7 +2860,9 @@ 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);
+ }
}
else
{
@@ -2943,6 +3038,7 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe,
display_profile,
dt_ioppr_get_histogram_profile_info(dev));
}
+
return _pipe_has_shutdown(pipe);
}