diff --git a/data/darktableconfig.xml.in b/data/darktableconfig.xml.in
index b2298bbd16f7..d9dc94cc14f6 100644
--- a/data/darktableconfig.xml.in
+++ b/data/darktableconfig.xml.in
@@ -1597,6 +1597,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 external viewer
+ forward float pixel data to the external HDR viewer app over a Unix domain socket before the gamma module clips to 8-bit. 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 efda5704a7c9..450abf1080b6 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -28,6 +28,7 @@ FILE(GLOB SOURCE_FILES
"common/calculator.c"
"common/collection.c"
"common/color_harmony.c"
+ "common/hdr_viewer.c"
"common/color_picker.c"
"common/color_vocabulary.c"
"common/colorlabels.c"
diff --git a/src/common/hdr_viewer.c b/src/common/hdr_viewer.c
new file mode 100644
index 000000000000..9f8a554286b5
--- /dev/null
+++ b/src/common/hdr_viewer.c
@@ -0,0 +1,193 @@
+/*
+ * dt_hdr_client.c
+ *
+ * Minimal POSIX-only C client for the darktable HDR Viewer.
+ * No external dependencies; compiles cleanly on macOS 10.13+ and Linux.
+ *
+ * See dt_hdr_client.h for API documentation.
+ */
+
+#include "hdr_viewer.h"
+
+#include
+#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
+
+/* --------------------------------------------------------------------------
+ * Internal helpers
+ * -------------------------------------------------------------------------- */
+
+/**
+ * Write exactly `len` bytes from `buf` to `fd`, restarting on EINTR.
+ * Returns 0 on success, -1 on error.
+ */
+static int write_exact(int fd, const void *buf, size_t len)
+{
+ const char *p = (const char *)buf;
+ size_t left = len;
+
+ while (left > 0) {
+#ifdef __linux__
+ ssize_t n = send(fd, p, left, MSG_NOSIGNAL);
+#else
+ 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], 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);
+}
+
+/* --------------------------------------------------------------------------
+ * Public API
+ * -------------------------------------------------------------------------- */
+
+int dt_hdr_viewer_connect(void)
+{
+ int fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (fd < 0) return -1;
+
+ /* Prevent SIGPIPE from killing the calling process if the viewer
+ * crashes or disconnects while we are writing a frame. */
+#ifdef SO_NOSIGPIPE
+ {
+ 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);
+
+ /*
+ * Set a non-blocking connect with a short timeout so darktable does not
+ * stall when the viewer is not running.
+ */
+#if defined(__APPLE__) || defined(__linux__)
+ {
+ /* Set socket to non-blocking */
+ int flags = 0;
+# if defined(O_NONBLOCK)
+ {
+ /* POSIX fcntl path */
+ flags = fcntl(fd, F_GETFL, 0);
+ if (flags < 0) flags = 0;
+ fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+ }
+# endif
+
+ int rc = connect(fd,
+ (const struct sockaddr *)&addr,
+ (socklen_t)sizeof(addr));
+
+ if (rc == 0) {
+ /* Connected immediately (unlikely for Unix sockets but possible) */
+# if defined(O_NONBLOCK)
+ fcntl(fd, F_SETFL, flags); /* restore blocking */
+# endif
+ return fd;
+ }
+
+ if (errno != EINPROGRESS && errno != EAGAIN) {
+ close(fd);
+ return -1;
+ }
+
+ /* Wait for the socket to become writable (= connected) */
+ 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) {
+ /* Timeout or error */
+ close(fd);
+ return -1;
+ }
+
+ /* Check that 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 subsequent writes */
+# if defined(O_NONBLOCK)
+ fcntl(fd, F_SETFL, flags);
+# endif
+ return fd;
+ }
+#else
+ /* Fallback: plain blocking connect (may hang briefly if viewer is absent) */
+ if (connect(fd, (const struct sockaddr *)&addr,
+ (socklen_t)sizeof(addr)) < 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+#endif
+}
+
+void dt_hdr_viewer_send_frame(int fd,
+ uint32_t w,
+ uint32_t h,
+ const float *rgb_linear_bt2020)
+{
+ if (fd < 0 || w == 0 || h == 0 || rgb_linear_bt2020 == NULL) return;
+
+ /* Send 8-byte header: width and height as little-endian uint32 */
+ uint8_t header[8];
+ encode_le32(header + 0, w);
+ encode_le32(header + 4, h);
+
+ if (write_exact(fd, header, sizeof(header)) != 0) return;
+
+ /* Send pixel data – already in host byte order (float32).
+ * On all modern Macs (and x86/ARM64 Linux) the host is little-endian,
+ * which matches what the Swift receiver expects.
+ * If big-endian support is ever needed, swap bytes here. */
+ size_t pixel_bytes = (size_t)w * (size_t)h * 3u * sizeof(float);
+ write_exact(fd, rgb_linear_bt2020, pixel_bytes);
+ /* Errors are silently ignored; caller should reconnect if needed. */
+}
+
+void dt_hdr_viewer_disconnect(int fd)
+{
+ if (fd >= 0) close(fd);
+}
diff --git a/src/common/hdr_viewer.h b/src/common/hdr_viewer.h
new file mode 100644
index 000000000000..08885789ec8f
--- /dev/null
+++ b/src/common/hdr_viewer.h
@@ -0,0 +1,73 @@
+/*
+ * dt_hdr_client.h
+ *
+ * Minimal POSIX-only C client library for sending HDR pixel frames to the
+ * darktable HDR Viewer app (https://github.com/MaykThewessen/darktable-hdr-viewer).
+ *
+ * Protocol (little-endian):
+ * [4 bytes] width – uint32_t
+ * [4 bytes] height – uint32_t
+ * [width * height * 3 * sizeof(float)] – RGB float32, linear BT.2020,
+ * row-major, top-to-bottom
+ *
+ * Typical usage from darktable:
+ *
+ * int fd = dt_hdr_viewer_connect();
+ * if (fd >= 0) {
+ * dt_hdr_viewer_send_frame(fd, width, height, rgb_linear_bt2020);
+ * dt_hdr_viewer_disconnect(fd);
+ * }
+ *
+ * Or keep `fd` open across frames for lower overhead (the server handles
+ * multiple frames per 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"
+
+/**
+ * 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.
+ */
+int dt_hdr_viewer_connect(void);
+
+/**
+ * Send one frame of linear BT.2020 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_bt2020 Row-major, top-to-bottom, interleaved RGB float32
+ * buffer of size w * h * 3 floats.
+ *
+ * The call blocks until all data has been written. On write error the
+ * function returns silently; the caller should call dt_hdr_viewer_disconnect()
+ * and reconnect on the next frame if reliable delivery is required.
+ */
+void dt_hdr_viewer_send_frame(int fd,
+ uint32_t w,
+ uint32_t h,
+ const float *rgb_linear_bt2020);
+
+/**
+ * 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
diff --git a/src/develop/pixelpipe_hb.c b/src/develop/pixelpipe_hb.c
index 8d46100bf354..71fc4585a6d3 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"
@@ -3009,6 +3010,50 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe,
roi_in.width, roi_in.height,
display_profile,
dt_ioppr_get_histogram_profile_info(dev));
+
+ // HDR viewer: forward float pixels to the external HDR preview app (if running).
+ // input is float RGBA in the display profile colorspace; values above 1.0 represent
+ // HDR signal that GTK would otherwise clip to uint8.
+ // NOTE: the socket calls below must remain outside any OMP parallel section.
+ if(dt_conf_get_bool("plugins/darkroom/hdr_viewer_enabled"))
+ {
+ // Cooldown: skip connect attempts for 2 seconds after a failed connect
+ // to avoid 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 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;
+ int viewer_fd = dt_hdr_viewer_connect();
+ if(viewer_fd >= 0)
+ {
+ // Strip alpha channel: RGBA float -> RGB float (packed, row-major)
+ const size_t npixels = w * h;
+ float *rgb = dt_alloc_align_float(npixels * 3);
+ 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);
+ 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 dt_pipe_shutdown(pipe);
}