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); }