From 598149aa22ad731a054604843ef23f854aab0ad1 Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Tue, 17 Mar 2026 22:13:22 +0100 Subject: [PATCH 1/4] macOS: add HDR/EDR preview viewer for M1 displays (fixes #17710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone Swift Metal HDR preview window that displays darktable's float pixel data with Extended Dynamic Range on Apple M1/M2 displays (up to ~1600 nits on XDR), working around the GTK3 limitation that clips all output to 8-bit SDR. ## Architecture Two components wired together via a Unix domain socket: **tools/hdr-viewer/** — standalone macOS app (Swift Package, macOS 12+) - CAMetalLayer with wantsExtendedDynamicRangeContent=YES and rgba16Float - Fragment shader converts linear BT.2020 → Display-P3 with soft gamut compression and a Reinhard knee tone map that preserves HDR signal - IPCServer listens on /tmp/dt_hdr_viewer.sock for frames from darktable - Reads EDR headroom from NSScreen to adapt to ambient light conditions **src/common/hdr_viewer.c/.h** — POSIX C client (no dependencies) - dt_hdr_viewer_connect(): non-blocking connect with 200ms timeout - dt_hdr_viewer_send_frame(): sends width/height header + RGB float32 pixels - Returns -1 immediately when viewer is not running (no darktable stall) **src/develop/pixelpipe_hb.c** — pipeline tap - Taps the float RGBA input to the gamma module (last step before uint8 downconversion), preserving HDR values above 1.0 that GTK would clip - Guarded by dt_conf bool "plugins/darkroom/hdr_viewer_enabled" - Strips alpha, sends RGB to viewer on each pipeline redraw ## Usage # terminal 1 cd tools/hdr-viewer && swift build -c release .build/release/HDRViewer # terminal 2 darktable --conf plugins/darkroom/hdr_viewer_enabled=true The HDR Preview window updates live with each darkroom pipeline redraw. HDR highlight values above diffuse white render with true EDR brightness. Closes #17710 Co-Authored-By: Claude Sonnet 4.6 --- src/CMakeLists.txt | 1 + src/common/hdr_viewer.c | 180 +++++++++++++ src/common/hdr_viewer.h | 73 ++++++ src/develop/pixelpipe_hb.c | 32 +++ tools/hdr-viewer/.gitignore | 3 + tools/hdr-viewer/Package.swift | 22 ++ tools/hdr-viewer/README.md | 124 +++++++++ .../Sources/HDRViewer/AppDelegate.swift | 89 +++++++ .../Sources/HDRViewer/HDRMetalView.swift | 247 ++++++++++++++++++ .../Sources/HDRViewer/HDRViewController.swift | 110 ++++++++ .../Sources/HDRViewer/IPCServer.swift | 200 ++++++++++++++ .../Sources/HDRViewer/ShaderSource.swift | 85 ++++++ .../Sources/HDRViewer/Shaders.metal | 136 ++++++++++ tools/hdr-viewer/Sources/HDRViewer/main.swift | 11 + tools/hdr-viewer/dt_hdr_client.c | 180 +++++++++++++ tools/hdr-viewer/dt_hdr_client.h | 73 ++++++ 16 files changed, 1566 insertions(+) create mode 100644 src/common/hdr_viewer.c create mode 100644 src/common/hdr_viewer.h create mode 100644 tools/hdr-viewer/.gitignore create mode 100644 tools/hdr-viewer/Package.swift create mode 100644 tools/hdr-viewer/README.md create mode 100644 tools/hdr-viewer/Sources/HDRViewer/AppDelegate.swift create mode 100644 tools/hdr-viewer/Sources/HDRViewer/HDRMetalView.swift create mode 100644 tools/hdr-viewer/Sources/HDRViewer/HDRViewController.swift create mode 100644 tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift create mode 100644 tools/hdr-viewer/Sources/HDRViewer/ShaderSource.swift create mode 100644 tools/hdr-viewer/Sources/HDRViewer/Shaders.metal create mode 100644 tools/hdr-viewer/Sources/HDRViewer/main.swift create mode 100644 tools/hdr-viewer/dt_hdr_client.c create mode 100644 tools/hdr-viewer/dt_hdr_client.h 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..106ce34f2896 --- /dev/null +++ b/src/common/hdr_viewer.c @@ -0,0 +1,180 @@ +/* + * 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) { + ssize_t n = write(fd, p, left); + 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; + + 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..1b78091bfd25 --- /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 (tools/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..b86e31112650 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,37 @@ 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. + // Only attempt this when the preference is enabled (or always try; the connect() + // call returns immediately with -1 when the viewer is not running). + if(dt_conf_get_bool("plugins/darkroom/hdr_viewer_enabled")) + { + const int w = roi_in.width; + const int h = roi_in.height; + const float *const rgba = (const float *const)input; + // Strip alpha channel: RGBA float → RGB float (packed, row-major) + float *rgb = dt_alloc_align_float((size_t)w * h * 3); + if(rgb) + { + DT_OMP_FOR() + for(int k = 0; k < w * h; 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]; + } + int viewer_fd = dt_hdr_viewer_connect(); + if(viewer_fd >= 0) + { + dt_hdr_viewer_send_frame(viewer_fd, (uint32_t)w, (uint32_t)h, rgb); + dt_hdr_viewer_disconnect(viewer_fd); + } + dt_free_align(rgb); + } + } } return dt_pipe_shutdown(pipe); } diff --git a/tools/hdr-viewer/.gitignore b/tools/hdr-viewer/.gitignore new file mode 100644 index 000000000000..e3697905784d --- /dev/null +++ b/tools/hdr-viewer/.gitignore @@ -0,0 +1,3 @@ +.build/ +*.o +*.d diff --git a/tools/hdr-viewer/Package.swift b/tools/hdr-viewer/Package.swift new file mode 100644 index 000000000000..2368217a027c --- /dev/null +++ b/tools/hdr-viewer/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "HDRViewer", + platforms: [ + .macOS(.v12) + ], + targets: [ + .executableTarget( + name: "HDRViewer", + path: "Sources/HDRViewer", + exclude: ["Shaders.metal"], + linkerSettings: [ + .linkedFramework("Metal"), + .linkedFramework("AppKit"), + .linkedFramework("CoreGraphics"), + .linkedFramework("QuartzCore") + ] + ) + ] +) diff --git a/tools/hdr-viewer/README.md b/tools/hdr-viewer/README.md new file mode 100644 index 000000000000..cce6c6bed372 --- /dev/null +++ b/tools/hdr-viewer/README.md @@ -0,0 +1,124 @@ +# darktable HDR Viewer + +A standalone macOS application that displays HDR pixel data sent from darktable +over a Unix domain socket, using Metal with Extended Dynamic Range (EDR) output. + +## Requirements + +- macOS 12 Monterey or later +- Xcode Command Line Tools (provides `swift build`) +- An HDR-capable display (Pro Display XDR, MacBook Pro/Air with Liquid Retina XDR, + or an external HDR monitor). On SDR displays the app still works but applies a + Reinhard tone map to keep values in [0, 1]. + +## Building + +```sh +cd /Users/mayk/darktable/tools/hdr-viewer +swift build -c release +``` + +The binary is placed at `.build/release/HDRViewer`. + +For development / debugging: + +```sh +swift build # debug build +swift run # build + launch immediately +``` + +## Running + +Launch the app **before** triggering a preview export in darktable: + +```sh +.build/release/HDRViewer +``` + +A window titled **"darktable HDR Preview"** appears and waits on +`/tmp/dt_hdr_viewer.sock`. Once darktable sends a frame the status text +disappears and the image is displayed. + +## Protocol + +darktable communicates via a Unix domain socket at `/tmp/dt_hdr_viewer.sock`. +Each frame is a simple binary message (all integers little-endian): + +| Offset | Size | Type | Description | +|--------|------|---------|--------------------| +| 0 | 4 | uint32 | Image width | +| 4 | 4 | uint32 | Image height | +| 8 | w×h×3×4 | float32[] | RGB pixels, linear BT.2020, row-major top-to-bottom | + +A new TCP connection is established per frame (or the connection may be kept +open for multiple frames; the server handles both). + +## Integrating with darktable (C side) + +Copy `dt_hdr_client.h` and `dt_hdr_client.c` into the darktable source tree +and add them to the relevant CMakeLists. Then call from a soft-proofing or +export code path: + +```c +#include "dt_hdr_client.h" + +// On every preview update: +int fd = dt_hdr_viewer_connect(); +if (fd >= 0) { + // buf is float*, width * height * 3 floats, linear BT.2020 + dt_hdr_viewer_send_frame(fd, width, height, buf); + dt_hdr_viewer_disconnect(fd); +} +``` + +`dt_hdr_viewer_connect()` returns -1 (and does **not** block) when the viewer +is not running, so it is safe to call unconditionally in a hot path. + +## Architecture + +``` +darktable process HDRViewer.app +───────────────── ───────────────────────────────────── +dt_hdr_client.c TCP/Unix IPCServer.swift + connect() ──────────────▶ accept() + send_frame() ──────frame──▶ decode → [Float] + disconnect() │ + HDRViewController.swift + │ DispatchQueue.main + HDRMetalView.swift + │ MTLTexture (RGBA32Float) + Shaders.metal + │ BT.2020 → Display-P3 + │ tone map to [0, EDR headroom] + CAMetalLayer (RGBA16Float, EDR) + │ + Display hardware (HDR) +``` + +### Shader pipeline + +1. **Sample** the source RGBA32Float texture (linear BT.2020). +2. **Matrix multiply** with the BT.2020 → Linear Display-P3 3×3 matrix. +3. **Tone map** using a smooth knee: + - Values ≤ 1.0 pass through unchanged (SDR range). + - Values in (1.0, EDR headroom] are kept as HDR signal. + - Values above `headroom` are soft-compressed toward `headroom`. +4. **Output** as `half4` into the `RGBA16Float` CAMetalLayer drawable, whose + colorspace is set to `extendedLinearDisplayP3` so the OS compositor + interprets the values correctly without additional color conversion. + +### EDR headroom + +The shader receives `screen.maximumExtendedDynamicRangeColorComponentValue` +each frame. This value is typically 2.0–4.0 on an XDR display at full +brightness, and 1.0 on SDR displays. + +## Known limitations + +- Only one connected client at a time is fully supported (the accept loop is + serial). darktable sends one frame per connection, so this is not a + practical limitation. +- The Metal library is compiled at build time from `Shaders.metal`. If you + modify the shader, re-run `swift build`. +- Aspect ratio locking triggers only when the image dimensions change; it does + not prevent arbitrary window resizing. diff --git a/tools/hdr-viewer/Sources/HDRViewer/AppDelegate.swift b/tools/hdr-viewer/Sources/HDRViewer/AppDelegate.swift new file mode 100644 index 000000000000..c02f974e1771 --- /dev/null +++ b/tools/hdr-viewer/Sources/HDRViewer/AppDelegate.swift @@ -0,0 +1,89 @@ +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + + private var window: NSWindow? + private var viewController: HDRViewController? + + func applicationDidFinishLaunching(_ notification: Notification) { + setupMenu() + setupWindow() + NSApp.activate(ignoringOtherApps: true) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + // MARK: - Private + + private func setupWindow() { + let contentRect = NSRect(x: 0, y: 0, width: 800, height: 600) + let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable] + + let window = NSWindow( + contentRect: contentRect, + styleMask: styleMask, + backing: .buffered, + defer: false + ) + window.title = "darktable HDR Preview" + window.center() + window.isReleasedWhenClosed = false + + // Allow the window to display HDR content on HDR-capable displays + // This is set on the view/layer level, but the window must also allow it. + if #available(macOS 12.0, *) { + // Nothing extra required at window level; EDR is opt-in per layer. + } + + let vc = HDRViewController() + window.contentViewController = vc + window.makeKeyAndOrderFront(nil) + + self.window = window + self.viewController = vc + } + + private func setupMenu() { + let mainMenu = NSMenu() + + // App menu + let appMenuItem = NSMenuItem() + mainMenu.addItem(appMenuItem) + let appMenu = NSMenu() + appMenuItem.submenu = appMenu + + let appName = ProcessInfo.processInfo.processName + appMenu.addItem( + withTitle: "About \(appName)", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + keyEquivalent: "" + ) + appMenu.addItem(.separator()) + appMenu.addItem( + withTitle: "Quit \(appName)", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + + // Window menu + let windowMenuItem = NSMenuItem() + mainMenu.addItem(windowMenuItem) + let windowMenu = NSMenu(title: "Window") + windowMenuItem.submenu = windowMenu + windowMenu.addItem( + withTitle: "Minimize", + action: #selector(NSWindow.miniaturize(_:)), + keyEquivalent: "m" + ) + windowMenu.addItem( + withTitle: "Zoom", + action: #selector(NSWindow.zoom(_:)), + keyEquivalent: "" + ) + + NSApp.mainMenu = mainMenu + NSApp.windowsMenu = windowMenu + } +} diff --git a/tools/hdr-viewer/Sources/HDRViewer/HDRMetalView.swift b/tools/hdr-viewer/Sources/HDRViewer/HDRMetalView.swift new file mode 100644 index 000000000000..eff0e04622f9 --- /dev/null +++ b/tools/hdr-viewer/Sources/HDRViewer/HDRMetalView.swift @@ -0,0 +1,247 @@ +import AppKit +import Metal +import QuartzCore + +/// An NSView subclass that hosts a CAMetalLayer configured for EDR (Extended Dynamic Range). +/// Receives linear BT.2020 float data, uploads it to a texture, and renders it via a +/// Metal render pipeline that converts to Display-P3 linear and outputs RGBA16Float for EDR. +final class HDRMetalView: NSView { + + // MARK: - Metal objects + + private var device: MTLDevice! + private var commandQueue: MTLCommandQueue! + private var renderPipeline: MTLRenderPipelineState! + private var samplerState: MTLSamplerState! + + /// The source texture in RGBA32Float (linear BT.2020, padded to RGBA) + private var sourceTexture: MTLTexture? + + /// Uniform buffer carrying the EDR headroom value passed to the shader + private var uniformBuffer: MTLBuffer! + + private struct Uniforms { + var edrHeadroom: Float + var _pad0: Float = 0 + var _pad1: Float = 0 + var _pad2: Float = 0 + } + + // MARK: - Metal layer + + override var wantsUpdateLayer: Bool { true } + + private var metalLayer: CAMetalLayer { + return layer as! CAMetalLayer + } + + // MARK: - Init + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + wantsLayer = true + + guard let device = MTLCreateSystemDefaultDevice() else { + fatalError("No Metal-capable GPU found.") + } + self.device = device + + setupLayer() + setupMetal() + } + + // MARK: - Layer setup + + override func makeBackingLayer() -> CALayer { + return CAMetalLayer() + } + + private func setupLayer() { + let ml = metalLayer + ml.device = device + // RGBA16Float is required for EDR values above 1.0 + ml.pixelFormat = .rgba16Float + ml.framebufferOnly = true + + // Extended Dynamic Range: allow values above 1.0 to reach the display + ml.wantsExtendedDynamicRangeContent = true + + // Use the extended linear Display-P3 colorspace so Metal values map correctly + // to physical display output without OS-level tone mapping. + if let cs = CGColorSpace(name: CGColorSpace.extendedLinearDisplayP3) { + ml.colorspace = cs + } + + ml.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 + ml.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + } + + // MARK: - Metal setup + + private func setupMetal() { + commandQueue = device.makeCommandQueue()! + + // Compile shaders from the embedded source string at runtime. + // This avoids SPM resource bundle complexities and works in all contexts. + let library: MTLLibrary + do { + library = try device.makeLibrary(source: metalShaderSource, options: nil) + } catch { + fatalError("Failed to compile Metal shaders: \(error)") + } + + guard + let vertexFn = library.makeFunction(name: "vertexPassthrough"), + let fragmentFn = library.makeFunction(name: "fragmentHDR") + else { + fatalError("Metal shader functions not found. Ensure Shaders.metal is included in the target.") + } + + let pipelineDesc = MTLRenderPipelineDescriptor() + pipelineDesc.vertexFunction = vertexFn + pipelineDesc.fragmentFunction = fragmentFn + // Output pixel format must match the CAMetalLayer + pipelineDesc.colorAttachments[0].pixelFormat = .rgba16Float + + do { + renderPipeline = try device.makeRenderPipelineState(descriptor: pipelineDesc) + } catch { + fatalError("Failed to create render pipeline: \(error)") + } + + // Bilinear sampler – good quality for scaling the image to window size + let samplerDesc = MTLSamplerDescriptor() + samplerDesc.minFilter = .linear + samplerDesc.magFilter = .linear + samplerDesc.mipFilter = .notMipmapped + samplerDesc.sAddressMode = .clampToEdge + samplerDesc.tAddressMode = .clampToEdge + samplerState = device.makeSamplerState(descriptor: samplerDesc)! + + // Uniform buffer (single struct, reused every frame) + uniformBuffer = device.makeBuffer( + length: MemoryLayout.stride, + options: .storageModeShared + )! + } + + // MARK: - Texture upload + + /// Called from the main thread with new pixel data from darktable. + /// `pixels` is interleaved RGB float32 in linear BT.2020, row-major, top-to-bottom. + func updateTexture(width: Int, height: Int, pixels: [Float]) { + guard width > 0, height > 0 else { return } + + // (Re)create the texture if dimensions changed + if sourceTexture == nil + || sourceTexture!.width != width + || sourceTexture!.height != height + { + let desc = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rgba32Float, // Metal does not support RGB32Float natively + width: width, + height: height, + mipmapped: false + ) + desc.usage = [.shaderRead] + desc.storageMode = .shared + sourceTexture = device.makeTexture(descriptor: desc)! + } + + guard let tex = sourceTexture else { return } + + // Expand RGB → RGBA (Metal has no native RGB32Float texture format) + let pixelCount = width * height + var rgba = [Float](repeating: 1.0, count: pixelCount * 4) + for i in 0 ..< pixelCount { + rgba[i * 4 + 0] = pixels[i * 3 + 0] + rgba[i * 4 + 1] = pixels[i * 3 + 1] + rgba[i * 4 + 2] = pixels[i * 3 + 2] + rgba[i * 4 + 3] = 1.0 + } + + rgba.withUnsafeBytes { ptr in + tex.replace( + region: MTLRegionMake2D(0, 0, width, height), + mipmapLevel: 0, + withBytes: ptr.baseAddress!, + bytesPerRow: width * 4 * MemoryLayout.size + ) + } + + render() + } + + // MARK: - Rendering + + private func render() { + guard + let drawable = metalLayer.nextDrawable(), + let texture = sourceTexture + else { return } + + // Read current EDR headroom from the screen + let headroom = Float( + window?.screen?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0 + ) + + // Write uniforms + var uniforms = Uniforms(edrHeadroom: headroom) + memcpy(uniformBuffer.contents(), &uniforms, MemoryLayout.stride) + + let rpDesc = MTLRenderPassDescriptor() + rpDesc.colorAttachments[0].texture = drawable.texture + rpDesc.colorAttachments[0].loadAction = .clear + rpDesc.colorAttachments[0].storeAction = .store + rpDesc.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1) + + guard + let cmdBuf = commandQueue.makeCommandBuffer(), + let encoder = cmdBuf.makeRenderCommandEncoder(descriptor: rpDesc) + else { return } + + encoder.setRenderPipelineState(renderPipeline) + encoder.setFragmentTexture(texture, index: 0) + encoder.setFragmentSamplerState(samplerState, index: 0) + encoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 0) + + // Full-screen triangle (no vertex buffer needed; positions generated in vertex shader) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) + + encoder.endEncoding() + cmdBuf.present(drawable) + cmdBuf.commit() + } + + // MARK: - Layout + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + metalLayer.drawableSize = CGSize( + width: newSize.width * scale, + height: newSize.height * scale + ) + // Defer render until the next run loop pass so the CAMetalLayer drawable + // pool has time to resize before we request a new drawable. + if sourceTexture != nil { + DispatchQueue.main.async { [weak self] in self?.render() } + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let scale = window?.backingScaleFactor { + metalLayer.contentsScale = scale + } + } +} diff --git a/tools/hdr-viewer/Sources/HDRViewer/HDRViewController.swift b/tools/hdr-viewer/Sources/HDRViewer/HDRViewController.swift new file mode 100644 index 000000000000..cc00ef0b09be --- /dev/null +++ b/tools/hdr-viewer/Sources/HDRViewer/HDRViewController.swift @@ -0,0 +1,110 @@ +import AppKit +import Metal + +/// Ties together the Metal view and the IPC server. +/// Receives frames from darktable via Unix socket and forwards them to the Metal view. +final class HDRViewController: NSViewController { + + private var hdrView: HDRMetalView! + private var ipcServer: IPCServer! + + // Track the current image aspect ratio for window resize constraints + private var imageAspectRatio: CGFloat = 4.0 / 3.0 + + // Status label shown while waiting for the first frame + private var statusLabel: NSTextField! + + override func loadView() { + // Create a plain backing view; the HDRMetalView will fill it. + view = NSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.black.cgColor + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupHDRView() + setupStatusLabel() + startIPCServer() + } + + // MARK: - Setup + + private func setupHDRView() { + hdrView = HDRMetalView(frame: view.bounds) + hdrView.autoresizingMask = [.width, .height] + view.addSubview(hdrView) + } + + private func setupStatusLabel() { + statusLabel = NSTextField(labelWithString: "Waiting for darktable…\nSocket: /tmp/dt_hdr_viewer.sock") + statusLabel.alignment = .center + statusLabel.textColor = NSColor.secondaryLabelColor + statusLabel.font = NSFont.systemFont(ofSize: 16, weight: .light) + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusLabel.maximumNumberOfLines = 0 + + view.addSubview(statusLabel) + NSLayoutConstraint.activate([ + statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + statusLabel.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, constant: -40) + ]) + } + + private func startIPCServer() { + ipcServer = IPCServer(socketPath: IPCServer.defaultSocketPath) + ipcServer.onFrame = { [weak self] width, height, pixels in + self?.handleFrame(width: width, height: height, pixels: pixels) + } + ipcServer.start() + } + + // MARK: - Frame handling + + private func handleFrame(width: UInt32, height: UInt32, pixels: [Float]) { + // Update the Metal view on the main thread + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Hide the status label once we have a real frame + if !self.statusLabel.isHidden { + self.statusLabel.isHidden = true + } + + let w = Int(width) + let h = Int(height) + + // Update aspect ratio and resize window if this is the first frame + // or the image dimensions changed. + let newAspect = CGFloat(w) / CGFloat(h) + if abs(newAspect - self.imageAspectRatio) > 0.001 { + self.imageAspectRatio = newAspect + self.adjustWindowForAspectRatio() + } + + self.hdrView.updateTexture(width: w, height: h, pixels: pixels) + } + } + + private func adjustWindowForAspectRatio() { + guard let window = view.window else { return } + // Keep current width, adjust height to match aspect ratio + let currentWidth = window.frame.width + let newHeight = currentWidth / imageAspectRatio + var frame = window.frame + frame.size.height = newHeight + window.titlebarHeight + window.setFrame(frame, display: true, animate: false) + + // Set content aspect ratio so dragging corners maintains it + window.contentAspectRatio = NSSize(width: imageAspectRatio, height: 1.0) + } +} + +// MARK: - NSWindow titlebar height helper +private extension NSWindow { + var titlebarHeight: CGFloat { + frame.height - contentLayoutRect.height + } +} diff --git a/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift b/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift new file mode 100644 index 000000000000..de051863cadc --- /dev/null +++ b/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift @@ -0,0 +1,200 @@ +import Foundation +import Darwin + +/// Listens on a Unix domain socket and decodes pixel frames sent by darktable. +/// +/// Packet format (little-endian): +/// [4 bytes] width – UInt32 +/// [4 bytes] height – UInt32 +/// [width * height * 3 * 4 bytes] – Float32 RGB, linear BT.2020, row-major top-to-bottom +final class IPCServer { + + static let defaultSocketPath = "/tmp/dt_hdr_viewer.sock" + + /// Called on a background thread with each decoded frame. + var onFrame: ((_ width: UInt32, _ height: UInt32, _ pixels: [Float]) -> Void)? + + private let socketPath: String + private var serverFD: Int32 = -1 + private var isRunning = false + private let queue = DispatchQueue(label: "com.darktable.hdr-viewer.ipc", qos: .userInteractive) + + init(socketPath: String = IPCServer.defaultSocketPath) { + self.socketPath = socketPath + } + + deinit { + stop() + } + + // MARK: - Start / Stop + + func start() { + guard !isRunning else { return } + isRunning = true + queue.async { [weak self] in + self?.runAcceptLoop() + } + } + + func stop() { + isRunning = false + if serverFD >= 0 { + Darwin.close(serverFD) + serverFD = -1 + } + unlink(socketPath) + } + + // MARK: - Accept loop + + private func runAcceptLoop() { + // Remove stale socket file + unlink(socketPath) + + // Create UNIX domain socket + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + printErr("IPCServer: socket() failed: \(String(cString: strerror(errno)))") + return + } + serverFD = fd + + // Set SO_REUSEADDR + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + // Bind + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &addr.sun_path) { ptr in + socketPath.withCString { cstr in + _ = strncpy(ptr.baseAddress!.assumingMemoryBound(to: CChar.self), + cstr, + ptr.count - 1) + } + } + + let bindResult = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + printErr("IPCServer: bind() failed: \(String(cString: strerror(errno)))") + Darwin.close(fd) + return + } + + // Listen (backlog = 4; darktable typically sends one frame at a time) + guard listen(fd, 4) == 0 else { + printErr("IPCServer: listen() failed: \(String(cString: strerror(errno)))") + Darwin.close(fd) + return + } + + print("IPCServer: listening on \(socketPath)") + + while isRunning { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + accept(fd, $0, &clientAddrLen) + } + } + + guard clientFD >= 0 else { + if errno == EINTR || errno == EBADF { break } + printErr("IPCServer: accept() failed: \(String(cString: strerror(errno)))") + continue + } + + // Handle each client on the same serial queue (darktable sends one frame per connection) + handleClient(clientFD) + } + + Darwin.close(fd) + unlink(socketPath) + print("IPCServer: stopped.") + } + + // MARK: - Client handling + + private func handleClient(_ fd: Int32) { + defer { Darwin.close(fd) } + + // Read header: 4+4 bytes + var width: UInt32 = 0 + var height: UInt32 = 0 + + guard readExact(fd: fd, into: &width, count: 4), + readExact(fd: fd, into: &height, count: 4) + else { + printErr("IPCServer: failed to read header") + return + } + + // Ensure little-endian (darktable sends LE) + width = UInt32(littleEndian: width) + height = UInt32(littleEndian: height) + + guard width > 0, height > 0, width <= 32768, height <= 32768 else { + printErr("IPCServer: invalid dimensions \(width)x\(height)") + return + } + + let floatCount = Int(width) * Int(height) * 3 + let byteCount = floatCount * MemoryLayout.size + + var pixels = [Float](repeating: 0, count: floatCount) + guard readExactBytes(fd: fd, buffer: &pixels, byteCount: byteCount) else { + printErr("IPCServer: failed to read pixel data (\(byteCount) bytes)") + return + } + + // On little-endian hosts (all modern Macs) Float byte order is native, + // so no byte-swapping is needed. + + onFrame?(width, height, pixels) + } + + // MARK: - Low-level I/O helpers + + /// Read exactly `count` bytes into a value via its raw pointer. + private func readExact(fd: Int32, into value: inout T, count: Int) -> Bool { + return withUnsafeMutableBytes(of: &value) { ptr in + readExactBytes(fd: fd, buffer: ptr.baseAddress!, byteCount: count) + } + } + + private func readExactBytes(fd: Int32, buffer: UnsafeMutableRawPointer, byteCount: Int) -> Bool { + var remaining = byteCount + var offset = 0 + while remaining > 0 { + let n = recv(fd, buffer.advanced(by: offset), remaining, 0) + if n <= 0 { + if n == 0 { return false } // connection closed + if errno == EINTR { continue } + return false + } + offset += n + remaining -= n + } + return true + } + + private func readExactBytes(fd: Int32, buffer: inout [Float], byteCount: Int) -> Bool { + buffer.withUnsafeMutableBytes { ptr in + readExactBytes(fd: fd, buffer: ptr.baseAddress!, byteCount: byteCount) + } + } +} + +// MARK: - Helpers + +private func printErr(_ msg: String) { + let data = ((msg + "\n").data(using: .utf8)) ?? Data() + FileHandle.standardError.write(data) +} diff --git a/tools/hdr-viewer/Sources/HDRViewer/ShaderSource.swift b/tools/hdr-viewer/Sources/HDRViewer/ShaderSource.swift new file mode 100644 index 000000000000..ac76475ec061 --- /dev/null +++ b/tools/hdr-viewer/Sources/HDRViewer/ShaderSource.swift @@ -0,0 +1,85 @@ +/// Metal shader source embedded as a string so no SPM resource bundle is needed. +/// Compiled at runtime via `device.makeLibrary(source:options:)`. +let metalShaderSource = """ +#include +using namespace metal; + +struct VertexOut { + float4 position [[position]]; + float2 texcoord; +}; + +vertex VertexOut vertexPassthrough(uint vid [[vertex_id]]) +{ + const float2 positions[3] = { + float2(-1.0f, -1.0f), + float2( 3.0f, -1.0f), + float2(-1.0f, 3.0f) + }; + const float2 texcoords[3] = { + float2(0.0f, 1.0f), + float2(2.0f, 1.0f), + float2(0.0f, -1.0f) + }; + VertexOut out; + out.position = float4(positions[vid], 0.0f, 1.0f); + out.texcoord = texcoords[vid]; + return out; +} + +struct Uniforms { + float edrHeadroom; + float _pad0; + float _pad1; + float _pad2; +}; + +constant float3x3 BT2020_TO_DISPLAY_P3 = float3x3( + float3( 1.3441f, -0.1145f, -0.2298f), + float3(-0.2817f, 1.2095f, 0.0723f), + float3( 0.0053f, -0.0358f, 1.0305f) +); + +float3 toneMap(float3 c, float headroom) +{ + if (headroom <= 1.0f) { + float lum = dot(c, float3(0.2126f, 0.7152f, 0.0722f)); + float lumOut = lum / (1.0f + lum); + float scale = (lum > 0.0f) ? (lumOut / lum) : 0.0f; + return c * scale; + } + float3 out; + for (int i = 0; i < 3; ++i) { + float v = c[i]; + if (v <= headroom) { + out[i] = v; + } else { + out[i] = headroom * (v / (v + headroom - 1.0f)); + } + } + return out; +} + +fragment half4 fragmentHDR( + VertexOut in [[stage_in]], + texture2d srcTex [[texture(0)]], + sampler smp [[sampler(0)]], + constant Uniforms& uni [[buffer(0)]]) +{ + float4 rgba = srcTex.sample(smp, in.texcoord); + float3 p3 = BT2020_TO_DISPLAY_P3 * rgba.rgb; + + // Soft gamut compression: redistribute out-of-gamut negative energy toward + // the achromatic axis instead of hard-clamping (which creates a visible line). + float minVal = min(min(p3.r, p3.g), p3.b); + if (minVal < 0.0f) { + float lum = dot(p3, float3(0.2126f, 0.7152f, 0.0722f)); + float t = minVal / (minVal - lum + 1e-6f); + p3 = mix(p3, float3(lum), saturate(t)); + p3 = max(p3, float3(0.0f)); + } + + float3 mapped = toneMap(p3, uni.edrHeadroom); + return half4(half3(mapped), 1.0h); +} +""" diff --git a/tools/hdr-viewer/Sources/HDRViewer/Shaders.metal b/tools/hdr-viewer/Sources/HDRViewer/Shaders.metal new file mode 100644 index 000000000000..05a9f9efa075 --- /dev/null +++ b/tools/hdr-viewer/Sources/HDRViewer/Shaders.metal @@ -0,0 +1,136 @@ +#include +using namespace metal; + +// --------------------------------------------------------------------------- +// Vertex shader +// --------------------------------------------------------------------------- +// Generates a single full-screen triangle from 3 vertices with no vertex +// buffer. The triangle is large enough to cover the entire clip-space quad +// [-1,+1] x [-1,+1]. +// +// Vertex 0: (-1, -1) → UV (0, 1) bottom-left +// Vertex 1: ( 3, -1) → UV (2, 1) far right +// Vertex 2: (-1, 3) → UV (0,-1) far top +// +// The texture coordinate convention in Metal is (0,0) = top-left, which +// matches the row-major top-to-bottom pixel data sent by darktable. + +struct VertexOut { + float4 position [[position]]; + float2 texcoord; +}; + +vertex VertexOut vertexPassthrough(uint vid [[vertex_id]]) +{ + // Full-screen triangle trick – no vertex buffer needed + const float2 positions[3] = { + float2(-1.0f, -1.0f), + float2( 3.0f, -1.0f), + float2(-1.0f, 3.0f) + }; + // Map clip-space [-1,1] → UV [0,1]. Note: Metal UV origin is top-left, + // clip-space Y is bottom=−1, top=+1, so we flip Y. + const float2 texcoords[3] = { + float2(0.0f, 1.0f), + float2(2.0f, 1.0f), + float2(0.0f, -1.0f) + }; + + VertexOut out; + out.position = float4(positions[vid], 0.0f, 1.0f); + out.texcoord = texcoords[vid]; + return out; +} + +// --------------------------------------------------------------------------- +// Uniforms +// --------------------------------------------------------------------------- +struct Uniforms { + float edrHeadroom; // maximumExtendedDynamicRangeColorComponentValue + float _pad0; + float _pad1; + float _pad2; +}; + +// --------------------------------------------------------------------------- +// Color matrix: BT.2020 linear D65 → Linear Display-P3 D65 +// --------------------------------------------------------------------------- +// Derived from: BT.2020 → XYZ(D65) → Display-P3 +// Column vectors are BT.2020 primaries expressed in Display-P3. +// +// Each column of a float3x3 in MSL is a column vector. +constant float3x3 BT2020_TO_DISPLAY_P3 = float3x3( + // col 0 (R) col 1 (G) col 2 (B) + float3( 1.3441f, -0.1145f, -0.2298f), // row 0 → P3 R + float3(-0.2817f, 1.2095f, 0.0723f), // row 1 → P3 G + float3( 0.0053f, -0.0358f, 1.0305f) // row 2 → P3 B +); + +// --------------------------------------------------------------------------- +// Tone mapping +// --------------------------------------------------------------------------- +// We use a simple "knee" function: +// - Values ≤ SDR_WHITE pass through unchanged (pure linear) +// - Values above SDR_WHITE are compressed toward edrHeadroom using a +// smooth Reinhard-style knee so that specular highlights are visible +// as HDR signal rather than clipping. +// +// If the display does not support EDR (headroom == 1.0), a standard +// Reinhard curve is applied to keep everything in [0, 1]. +// +// This is intentionally minimal. Replace with your preferred operator. + +constant float SDR_WHITE = 1.0f; // in linear Display-P3 + +float3 toneMap(float3 c, float headroom) +{ + if (headroom <= 1.0f) { + // SDR display: simple Reinhard on luminance to avoid hue shifts + float lum = dot(c, float3(0.2126f, 0.7152f, 0.0722f)); + float lumOut = lum / (1.0f + lum); + float scale = (lum > 0.0f) ? (lumOut / lum) : 0.0f; + return c * scale; + } + + // HDR display path: pass through values that are already in [0, headroom]. + // Apply a soft knee only above headroom to handle extreme values. + float3 out; + for (int i = 0; i < 3; ++i) { + float v = c[i]; + if (v <= headroom) { + out[i] = v; // preserve HDR signal intact + } else { + // Soft compress above headroom using Reinhard scaled to headroom + out[i] = headroom * (v / (v + headroom - SDR_WHITE)); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Fragment shader +// --------------------------------------------------------------------------- +fragment half4 fragmentHDR( + VertexOut in [[stage_in]], + texture2d srcTex [[texture(0)]], + sampler smp [[sampler(0)]], + constant Uniforms& uni [[buffer(0)]]) +{ + // Sample the source texture (linear BT.2020, RGBA32Float) + float4 rgba = srcTex.sample(smp, in.texcoord); + float3 rgb = rgba.rgb; + + // 1. Convert BT.2020 linear → Linear Display-P3 + // The CAMetalLayer colorspace is extendedLinearDisplayP3, so values we + // write here are interpreted as linear Display-P3 by the compositor. + float3 p3 = BT2020_TO_DISPLAY_P3 * rgb; + + // 2. Clamp negative values (out-of-gamut colors below 0 – rare in practice) + p3 = max(p3, float3(0.0f)); + + // 3. Tone map to [0, edrHeadroom] + float3 mapped = toneMap(p3, uni.edrHeadroom); + + // Output as half4; Metal will store this as RGBA16Float in the drawable. + return half4(half3(mapped), 1.0h); +} diff --git a/tools/hdr-viewer/Sources/HDRViewer/main.swift b/tools/hdr-viewer/Sources/HDRViewer/main.swift new file mode 100644 index 000000000000..a8d0312f34c4 --- /dev/null +++ b/tools/hdr-viewer/Sources/HDRViewer/main.swift @@ -0,0 +1,11 @@ +import AppKit + +// Ensure we run on the main thread +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate + +// Set activation policy before running +app.setActivationPolicy(.regular) + +app.run() diff --git a/tools/hdr-viewer/dt_hdr_client.c b/tools/hdr-viewer/dt_hdr_client.c new file mode 100644 index 000000000000..643a8858dee0 --- /dev/null +++ b/tools/hdr-viewer/dt_hdr_client.c @@ -0,0 +1,180 @@ +/* + * 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 "dt_hdr_client.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) { + ssize_t n = write(fd, p, left); + 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; + + 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/tools/hdr-viewer/dt_hdr_client.h b/tools/hdr-viewer/dt_hdr_client.h new file mode 100644 index 000000000000..1b78091bfd25 --- /dev/null +++ b/tools/hdr-viewer/dt_hdr_client.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 (tools/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 From 62684ae598df8fb7271b9c97e1cb8ea00494ebe9 Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Wed, 18 Mar 2026 12:53:42 +0100 Subject: [PATCH 2/4] Fix SIGPIPE crash and add receive timeout for HDR viewer IPC - hdr_viewer.c: set SO_NOSIGPIPE on the socket so darktable does not get killed by SIGPIPE if the HDR viewer crashes while we are writing a frame - IPCServer.swift: set SO_RCVTIMEO (5s) on accepted client sockets so the viewer does not block forever if darktable dies mid-frame Co-Authored-By: Claude Opus 4.6 (1M context) --- src/common/hdr_viewer.c | 9 +++++++++ tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/common/hdr_viewer.c b/src/common/hdr_viewer.c index 106ce34f2896..e6b3838aa668 100644 --- a/src/common/hdr_viewer.c +++ b/src/common/hdr_viewer.c @@ -69,6 +69,15 @@ 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; diff --git a/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift b/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift index de051863cadc..1de7dcbad4cf 100644 --- a/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift +++ b/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift @@ -111,7 +111,11 @@ final class IPCServer { continue } - // Handle each client on the same serial queue (darktable sends one frame per connection) + // Set a receive timeout so we don't block forever if darktable + // crashes mid-frame. 5 seconds is generous for any single frame. + var tv = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(clientFD, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + handleClient(clientFD) } From 361f80fef6d45860276d8e9c0017ae960df3dcc4 Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Thu, 19 Mar 2026 11:44:16 +0100 Subject: [PATCH 3/4] Move Swift HDR viewer to separate repo, keep C client in-tree The Swift Metal HDR viewer app has been moved to its own repository at https://github.com/MaykThewessen/darktable-hdr-viewer to address feature creep concerns. Only the lightweight C client (~180 lines) and the pipeline tap remain in the darktable tree. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/common/hdr_viewer.h | 2 +- tools/hdr-viewer/.gitignore | 3 - tools/hdr-viewer/Package.swift | 22 -- tools/hdr-viewer/README.md | 124 --------- .../Sources/HDRViewer/AppDelegate.swift | 89 ------- .../Sources/HDRViewer/HDRMetalView.swift | 247 ------------------ .../Sources/HDRViewer/HDRViewController.swift | 110 -------- .../Sources/HDRViewer/IPCServer.swift | 204 --------------- .../Sources/HDRViewer/ShaderSource.swift | 85 ------ .../Sources/HDRViewer/Shaders.metal | 136 ---------- tools/hdr-viewer/Sources/HDRViewer/main.swift | 11 - tools/hdr-viewer/dt_hdr_client.c | 180 ------------- tools/hdr-viewer/dt_hdr_client.h | 73 ------ 13 files changed, 1 insertion(+), 1285 deletions(-) delete mode 100644 tools/hdr-viewer/.gitignore delete mode 100644 tools/hdr-viewer/Package.swift delete mode 100644 tools/hdr-viewer/README.md delete mode 100644 tools/hdr-viewer/Sources/HDRViewer/AppDelegate.swift delete mode 100644 tools/hdr-viewer/Sources/HDRViewer/HDRMetalView.swift delete mode 100644 tools/hdr-viewer/Sources/HDRViewer/HDRViewController.swift delete mode 100644 tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift delete mode 100644 tools/hdr-viewer/Sources/HDRViewer/ShaderSource.swift delete mode 100644 tools/hdr-viewer/Sources/HDRViewer/Shaders.metal delete mode 100644 tools/hdr-viewer/Sources/HDRViewer/main.swift delete mode 100644 tools/hdr-viewer/dt_hdr_client.c delete mode 100644 tools/hdr-viewer/dt_hdr_client.h diff --git a/src/common/hdr_viewer.h b/src/common/hdr_viewer.h index 1b78091bfd25..08885789ec8f 100644 --- a/src/common/hdr_viewer.h +++ b/src/common/hdr_viewer.h @@ -2,7 +2,7 @@ * dt_hdr_client.h * * Minimal POSIX-only C client library for sending HDR pixel frames to the - * darktable HDR Viewer app (tools/hdr-viewer). + * darktable HDR Viewer app (https://github.com/MaykThewessen/darktable-hdr-viewer). * * Protocol (little-endian): * [4 bytes] width – uint32_t diff --git a/tools/hdr-viewer/.gitignore b/tools/hdr-viewer/.gitignore deleted file mode 100644 index e3697905784d..000000000000 --- a/tools/hdr-viewer/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.build/ -*.o -*.d diff --git a/tools/hdr-viewer/Package.swift b/tools/hdr-viewer/Package.swift deleted file mode 100644 index 2368217a027c..000000000000 --- a/tools/hdr-viewer/Package.swift +++ /dev/null @@ -1,22 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "HDRViewer", - platforms: [ - .macOS(.v12) - ], - targets: [ - .executableTarget( - name: "HDRViewer", - path: "Sources/HDRViewer", - exclude: ["Shaders.metal"], - linkerSettings: [ - .linkedFramework("Metal"), - .linkedFramework("AppKit"), - .linkedFramework("CoreGraphics"), - .linkedFramework("QuartzCore") - ] - ) - ] -) diff --git a/tools/hdr-viewer/README.md b/tools/hdr-viewer/README.md deleted file mode 100644 index cce6c6bed372..000000000000 --- a/tools/hdr-viewer/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# darktable HDR Viewer - -A standalone macOS application that displays HDR pixel data sent from darktable -over a Unix domain socket, using Metal with Extended Dynamic Range (EDR) output. - -## Requirements - -- macOS 12 Monterey or later -- Xcode Command Line Tools (provides `swift build`) -- An HDR-capable display (Pro Display XDR, MacBook Pro/Air with Liquid Retina XDR, - or an external HDR monitor). On SDR displays the app still works but applies a - Reinhard tone map to keep values in [0, 1]. - -## Building - -```sh -cd /Users/mayk/darktable/tools/hdr-viewer -swift build -c release -``` - -The binary is placed at `.build/release/HDRViewer`. - -For development / debugging: - -```sh -swift build # debug build -swift run # build + launch immediately -``` - -## Running - -Launch the app **before** triggering a preview export in darktable: - -```sh -.build/release/HDRViewer -``` - -A window titled **"darktable HDR Preview"** appears and waits on -`/tmp/dt_hdr_viewer.sock`. Once darktable sends a frame the status text -disappears and the image is displayed. - -## Protocol - -darktable communicates via a Unix domain socket at `/tmp/dt_hdr_viewer.sock`. -Each frame is a simple binary message (all integers little-endian): - -| Offset | Size | Type | Description | -|--------|------|---------|--------------------| -| 0 | 4 | uint32 | Image width | -| 4 | 4 | uint32 | Image height | -| 8 | w×h×3×4 | float32[] | RGB pixels, linear BT.2020, row-major top-to-bottom | - -A new TCP connection is established per frame (or the connection may be kept -open for multiple frames; the server handles both). - -## Integrating with darktable (C side) - -Copy `dt_hdr_client.h` and `dt_hdr_client.c` into the darktable source tree -and add them to the relevant CMakeLists. Then call from a soft-proofing or -export code path: - -```c -#include "dt_hdr_client.h" - -// On every preview update: -int fd = dt_hdr_viewer_connect(); -if (fd >= 0) { - // buf is float*, width * height * 3 floats, linear BT.2020 - dt_hdr_viewer_send_frame(fd, width, height, buf); - dt_hdr_viewer_disconnect(fd); -} -``` - -`dt_hdr_viewer_connect()` returns -1 (and does **not** block) when the viewer -is not running, so it is safe to call unconditionally in a hot path. - -## Architecture - -``` -darktable process HDRViewer.app -───────────────── ───────────────────────────────────── -dt_hdr_client.c TCP/Unix IPCServer.swift - connect() ──────────────▶ accept() - send_frame() ──────frame──▶ decode → [Float] - disconnect() │ - HDRViewController.swift - │ DispatchQueue.main - HDRMetalView.swift - │ MTLTexture (RGBA32Float) - Shaders.metal - │ BT.2020 → Display-P3 - │ tone map to [0, EDR headroom] - CAMetalLayer (RGBA16Float, EDR) - │ - Display hardware (HDR) -``` - -### Shader pipeline - -1. **Sample** the source RGBA32Float texture (linear BT.2020). -2. **Matrix multiply** with the BT.2020 → Linear Display-P3 3×3 matrix. -3. **Tone map** using a smooth knee: - - Values ≤ 1.0 pass through unchanged (SDR range). - - Values in (1.0, EDR headroom] are kept as HDR signal. - - Values above `headroom` are soft-compressed toward `headroom`. -4. **Output** as `half4` into the `RGBA16Float` CAMetalLayer drawable, whose - colorspace is set to `extendedLinearDisplayP3` so the OS compositor - interprets the values correctly without additional color conversion. - -### EDR headroom - -The shader receives `screen.maximumExtendedDynamicRangeColorComponentValue` -each frame. This value is typically 2.0–4.0 on an XDR display at full -brightness, and 1.0 on SDR displays. - -## Known limitations - -- Only one connected client at a time is fully supported (the accept loop is - serial). darktable sends one frame per connection, so this is not a - practical limitation. -- The Metal library is compiled at build time from `Shaders.metal`. If you - modify the shader, re-run `swift build`. -- Aspect ratio locking triggers only when the image dimensions change; it does - not prevent arbitrary window resizing. diff --git a/tools/hdr-viewer/Sources/HDRViewer/AppDelegate.swift b/tools/hdr-viewer/Sources/HDRViewer/AppDelegate.swift deleted file mode 100644 index c02f974e1771..000000000000 --- a/tools/hdr-viewer/Sources/HDRViewer/AppDelegate.swift +++ /dev/null @@ -1,89 +0,0 @@ -import AppKit - -final class AppDelegate: NSObject, NSApplicationDelegate { - - private var window: NSWindow? - private var viewController: HDRViewController? - - func applicationDidFinishLaunching(_ notification: Notification) { - setupMenu() - setupWindow() - NSApp.activate(ignoringOtherApps: true) - } - - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - // MARK: - Private - - private func setupWindow() { - let contentRect = NSRect(x: 0, y: 0, width: 800, height: 600) - let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable] - - let window = NSWindow( - contentRect: contentRect, - styleMask: styleMask, - backing: .buffered, - defer: false - ) - window.title = "darktable HDR Preview" - window.center() - window.isReleasedWhenClosed = false - - // Allow the window to display HDR content on HDR-capable displays - // This is set on the view/layer level, but the window must also allow it. - if #available(macOS 12.0, *) { - // Nothing extra required at window level; EDR is opt-in per layer. - } - - let vc = HDRViewController() - window.contentViewController = vc - window.makeKeyAndOrderFront(nil) - - self.window = window - self.viewController = vc - } - - private func setupMenu() { - let mainMenu = NSMenu() - - // App menu - let appMenuItem = NSMenuItem() - mainMenu.addItem(appMenuItem) - let appMenu = NSMenu() - appMenuItem.submenu = appMenu - - let appName = ProcessInfo.processInfo.processName - appMenu.addItem( - withTitle: "About \(appName)", - action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), - keyEquivalent: "" - ) - appMenu.addItem(.separator()) - appMenu.addItem( - withTitle: "Quit \(appName)", - action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q" - ) - - // Window menu - let windowMenuItem = NSMenuItem() - mainMenu.addItem(windowMenuItem) - let windowMenu = NSMenu(title: "Window") - windowMenuItem.submenu = windowMenu - windowMenu.addItem( - withTitle: "Minimize", - action: #selector(NSWindow.miniaturize(_:)), - keyEquivalent: "m" - ) - windowMenu.addItem( - withTitle: "Zoom", - action: #selector(NSWindow.zoom(_:)), - keyEquivalent: "" - ) - - NSApp.mainMenu = mainMenu - NSApp.windowsMenu = windowMenu - } -} diff --git a/tools/hdr-viewer/Sources/HDRViewer/HDRMetalView.swift b/tools/hdr-viewer/Sources/HDRViewer/HDRMetalView.swift deleted file mode 100644 index eff0e04622f9..000000000000 --- a/tools/hdr-viewer/Sources/HDRViewer/HDRMetalView.swift +++ /dev/null @@ -1,247 +0,0 @@ -import AppKit -import Metal -import QuartzCore - -/// An NSView subclass that hosts a CAMetalLayer configured for EDR (Extended Dynamic Range). -/// Receives linear BT.2020 float data, uploads it to a texture, and renders it via a -/// Metal render pipeline that converts to Display-P3 linear and outputs RGBA16Float for EDR. -final class HDRMetalView: NSView { - - // MARK: - Metal objects - - private var device: MTLDevice! - private var commandQueue: MTLCommandQueue! - private var renderPipeline: MTLRenderPipelineState! - private var samplerState: MTLSamplerState! - - /// The source texture in RGBA32Float (linear BT.2020, padded to RGBA) - private var sourceTexture: MTLTexture? - - /// Uniform buffer carrying the EDR headroom value passed to the shader - private var uniformBuffer: MTLBuffer! - - private struct Uniforms { - var edrHeadroom: Float - var _pad0: Float = 0 - var _pad1: Float = 0 - var _pad2: Float = 0 - } - - // MARK: - Metal layer - - override var wantsUpdateLayer: Bool { true } - - private var metalLayer: CAMetalLayer { - return layer as! CAMetalLayer - } - - // MARK: - Init - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - commonInit() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - commonInit() - } - - private func commonInit() { - wantsLayer = true - - guard let device = MTLCreateSystemDefaultDevice() else { - fatalError("No Metal-capable GPU found.") - } - self.device = device - - setupLayer() - setupMetal() - } - - // MARK: - Layer setup - - override func makeBackingLayer() -> CALayer { - return CAMetalLayer() - } - - private func setupLayer() { - let ml = metalLayer - ml.device = device - // RGBA16Float is required for EDR values above 1.0 - ml.pixelFormat = .rgba16Float - ml.framebufferOnly = true - - // Extended Dynamic Range: allow values above 1.0 to reach the display - ml.wantsExtendedDynamicRangeContent = true - - // Use the extended linear Display-P3 colorspace so Metal values map correctly - // to physical display output without OS-level tone mapping. - if let cs = CGColorSpace(name: CGColorSpace.extendedLinearDisplayP3) { - ml.colorspace = cs - } - - ml.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 - ml.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] - } - - // MARK: - Metal setup - - private func setupMetal() { - commandQueue = device.makeCommandQueue()! - - // Compile shaders from the embedded source string at runtime. - // This avoids SPM resource bundle complexities and works in all contexts. - let library: MTLLibrary - do { - library = try device.makeLibrary(source: metalShaderSource, options: nil) - } catch { - fatalError("Failed to compile Metal shaders: \(error)") - } - - guard - let vertexFn = library.makeFunction(name: "vertexPassthrough"), - let fragmentFn = library.makeFunction(name: "fragmentHDR") - else { - fatalError("Metal shader functions not found. Ensure Shaders.metal is included in the target.") - } - - let pipelineDesc = MTLRenderPipelineDescriptor() - pipelineDesc.vertexFunction = vertexFn - pipelineDesc.fragmentFunction = fragmentFn - // Output pixel format must match the CAMetalLayer - pipelineDesc.colorAttachments[0].pixelFormat = .rgba16Float - - do { - renderPipeline = try device.makeRenderPipelineState(descriptor: pipelineDesc) - } catch { - fatalError("Failed to create render pipeline: \(error)") - } - - // Bilinear sampler – good quality for scaling the image to window size - let samplerDesc = MTLSamplerDescriptor() - samplerDesc.minFilter = .linear - samplerDesc.magFilter = .linear - samplerDesc.mipFilter = .notMipmapped - samplerDesc.sAddressMode = .clampToEdge - samplerDesc.tAddressMode = .clampToEdge - samplerState = device.makeSamplerState(descriptor: samplerDesc)! - - // Uniform buffer (single struct, reused every frame) - uniformBuffer = device.makeBuffer( - length: MemoryLayout.stride, - options: .storageModeShared - )! - } - - // MARK: - Texture upload - - /// Called from the main thread with new pixel data from darktable. - /// `pixels` is interleaved RGB float32 in linear BT.2020, row-major, top-to-bottom. - func updateTexture(width: Int, height: Int, pixels: [Float]) { - guard width > 0, height > 0 else { return } - - // (Re)create the texture if dimensions changed - if sourceTexture == nil - || sourceTexture!.width != width - || sourceTexture!.height != height - { - let desc = MTLTextureDescriptor.texture2DDescriptor( - pixelFormat: .rgba32Float, // Metal does not support RGB32Float natively - width: width, - height: height, - mipmapped: false - ) - desc.usage = [.shaderRead] - desc.storageMode = .shared - sourceTexture = device.makeTexture(descriptor: desc)! - } - - guard let tex = sourceTexture else { return } - - // Expand RGB → RGBA (Metal has no native RGB32Float texture format) - let pixelCount = width * height - var rgba = [Float](repeating: 1.0, count: pixelCount * 4) - for i in 0 ..< pixelCount { - rgba[i * 4 + 0] = pixels[i * 3 + 0] - rgba[i * 4 + 1] = pixels[i * 3 + 1] - rgba[i * 4 + 2] = pixels[i * 3 + 2] - rgba[i * 4 + 3] = 1.0 - } - - rgba.withUnsafeBytes { ptr in - tex.replace( - region: MTLRegionMake2D(0, 0, width, height), - mipmapLevel: 0, - withBytes: ptr.baseAddress!, - bytesPerRow: width * 4 * MemoryLayout.size - ) - } - - render() - } - - // MARK: - Rendering - - private func render() { - guard - let drawable = metalLayer.nextDrawable(), - let texture = sourceTexture - else { return } - - // Read current EDR headroom from the screen - let headroom = Float( - window?.screen?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0 - ) - - // Write uniforms - var uniforms = Uniforms(edrHeadroom: headroom) - memcpy(uniformBuffer.contents(), &uniforms, MemoryLayout.stride) - - let rpDesc = MTLRenderPassDescriptor() - rpDesc.colorAttachments[0].texture = drawable.texture - rpDesc.colorAttachments[0].loadAction = .clear - rpDesc.colorAttachments[0].storeAction = .store - rpDesc.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1) - - guard - let cmdBuf = commandQueue.makeCommandBuffer(), - let encoder = cmdBuf.makeRenderCommandEncoder(descriptor: rpDesc) - else { return } - - encoder.setRenderPipelineState(renderPipeline) - encoder.setFragmentTexture(texture, index: 0) - encoder.setFragmentSamplerState(samplerState, index: 0) - encoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 0) - - // Full-screen triangle (no vertex buffer needed; positions generated in vertex shader) - encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) - - encoder.endEncoding() - cmdBuf.present(drawable) - cmdBuf.commit() - } - - // MARK: - Layout - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 - metalLayer.drawableSize = CGSize( - width: newSize.width * scale, - height: newSize.height * scale - ) - // Defer render until the next run loop pass so the CAMetalLayer drawable - // pool has time to resize before we request a new drawable. - if sourceTexture != nil { - DispatchQueue.main.async { [weak self] in self?.render() } - } - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - if let scale = window?.backingScaleFactor { - metalLayer.contentsScale = scale - } - } -} diff --git a/tools/hdr-viewer/Sources/HDRViewer/HDRViewController.swift b/tools/hdr-viewer/Sources/HDRViewer/HDRViewController.swift deleted file mode 100644 index cc00ef0b09be..000000000000 --- a/tools/hdr-viewer/Sources/HDRViewer/HDRViewController.swift +++ /dev/null @@ -1,110 +0,0 @@ -import AppKit -import Metal - -/// Ties together the Metal view and the IPC server. -/// Receives frames from darktable via Unix socket and forwards them to the Metal view. -final class HDRViewController: NSViewController { - - private var hdrView: HDRMetalView! - private var ipcServer: IPCServer! - - // Track the current image aspect ratio for window resize constraints - private var imageAspectRatio: CGFloat = 4.0 / 3.0 - - // Status label shown while waiting for the first frame - private var statusLabel: NSTextField! - - override func loadView() { - // Create a plain backing view; the HDRMetalView will fill it. - view = NSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) - view.wantsLayer = true - view.layer?.backgroundColor = NSColor.black.cgColor - } - - override func viewDidLoad() { - super.viewDidLoad() - - setupHDRView() - setupStatusLabel() - startIPCServer() - } - - // MARK: - Setup - - private func setupHDRView() { - hdrView = HDRMetalView(frame: view.bounds) - hdrView.autoresizingMask = [.width, .height] - view.addSubview(hdrView) - } - - private func setupStatusLabel() { - statusLabel = NSTextField(labelWithString: "Waiting for darktable…\nSocket: /tmp/dt_hdr_viewer.sock") - statusLabel.alignment = .center - statusLabel.textColor = NSColor.secondaryLabelColor - statusLabel.font = NSFont.systemFont(ofSize: 16, weight: .light) - statusLabel.translatesAutoresizingMaskIntoConstraints = false - statusLabel.maximumNumberOfLines = 0 - - view.addSubview(statusLabel) - NSLayoutConstraint.activate([ - statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), - statusLabel.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, constant: -40) - ]) - } - - private func startIPCServer() { - ipcServer = IPCServer(socketPath: IPCServer.defaultSocketPath) - ipcServer.onFrame = { [weak self] width, height, pixels in - self?.handleFrame(width: width, height: height, pixels: pixels) - } - ipcServer.start() - } - - // MARK: - Frame handling - - private func handleFrame(width: UInt32, height: UInt32, pixels: [Float]) { - // Update the Metal view on the main thread - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // Hide the status label once we have a real frame - if !self.statusLabel.isHidden { - self.statusLabel.isHidden = true - } - - let w = Int(width) - let h = Int(height) - - // Update aspect ratio and resize window if this is the first frame - // or the image dimensions changed. - let newAspect = CGFloat(w) / CGFloat(h) - if abs(newAspect - self.imageAspectRatio) > 0.001 { - self.imageAspectRatio = newAspect - self.adjustWindowForAspectRatio() - } - - self.hdrView.updateTexture(width: w, height: h, pixels: pixels) - } - } - - private func adjustWindowForAspectRatio() { - guard let window = view.window else { return } - // Keep current width, adjust height to match aspect ratio - let currentWidth = window.frame.width - let newHeight = currentWidth / imageAspectRatio - var frame = window.frame - frame.size.height = newHeight + window.titlebarHeight - window.setFrame(frame, display: true, animate: false) - - // Set content aspect ratio so dragging corners maintains it - window.contentAspectRatio = NSSize(width: imageAspectRatio, height: 1.0) - } -} - -// MARK: - NSWindow titlebar height helper -private extension NSWindow { - var titlebarHeight: CGFloat { - frame.height - contentLayoutRect.height - } -} diff --git a/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift b/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift deleted file mode 100644 index 1de7dcbad4cf..000000000000 --- a/tools/hdr-viewer/Sources/HDRViewer/IPCServer.swift +++ /dev/null @@ -1,204 +0,0 @@ -import Foundation -import Darwin - -/// Listens on a Unix domain socket and decodes pixel frames sent by darktable. -/// -/// Packet format (little-endian): -/// [4 bytes] width – UInt32 -/// [4 bytes] height – UInt32 -/// [width * height * 3 * 4 bytes] – Float32 RGB, linear BT.2020, row-major top-to-bottom -final class IPCServer { - - static let defaultSocketPath = "/tmp/dt_hdr_viewer.sock" - - /// Called on a background thread with each decoded frame. - var onFrame: ((_ width: UInt32, _ height: UInt32, _ pixels: [Float]) -> Void)? - - private let socketPath: String - private var serverFD: Int32 = -1 - private var isRunning = false - private let queue = DispatchQueue(label: "com.darktable.hdr-viewer.ipc", qos: .userInteractive) - - init(socketPath: String = IPCServer.defaultSocketPath) { - self.socketPath = socketPath - } - - deinit { - stop() - } - - // MARK: - Start / Stop - - func start() { - guard !isRunning else { return } - isRunning = true - queue.async { [weak self] in - self?.runAcceptLoop() - } - } - - func stop() { - isRunning = false - if serverFD >= 0 { - Darwin.close(serverFD) - serverFD = -1 - } - unlink(socketPath) - } - - // MARK: - Accept loop - - private func runAcceptLoop() { - // Remove stale socket file - unlink(socketPath) - - // Create UNIX domain socket - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - printErr("IPCServer: socket() failed: \(String(cString: strerror(errno)))") - return - } - serverFD = fd - - // Set SO_REUSEADDR - var yes: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) - - // Bind - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - withUnsafeMutableBytes(of: &addr.sun_path) { ptr in - socketPath.withCString { cstr in - _ = strncpy(ptr.baseAddress!.assumingMemoryBound(to: CChar.self), - cstr, - ptr.count - 1) - } - } - - let bindResult = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - bind(fd, $0, socklen_t(MemoryLayout.size)) - } - } - guard bindResult == 0 else { - printErr("IPCServer: bind() failed: \(String(cString: strerror(errno)))") - Darwin.close(fd) - return - } - - // Listen (backlog = 4; darktable typically sends one frame at a time) - guard listen(fd, 4) == 0 else { - printErr("IPCServer: listen() failed: \(String(cString: strerror(errno)))") - Darwin.close(fd) - return - } - - print("IPCServer: listening on \(socketPath)") - - while isRunning { - var clientAddr = sockaddr_un() - var clientAddrLen = socklen_t(MemoryLayout.size) - - let clientFD = withUnsafeMutablePointer(to: &clientAddr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - accept(fd, $0, &clientAddrLen) - } - } - - guard clientFD >= 0 else { - if errno == EINTR || errno == EBADF { break } - printErr("IPCServer: accept() failed: \(String(cString: strerror(errno)))") - continue - } - - // Set a receive timeout so we don't block forever if darktable - // crashes mid-frame. 5 seconds is generous for any single frame. - var tv = timeval(tv_sec: 5, tv_usec: 0) - setsockopt(clientFD, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) - - handleClient(clientFD) - } - - Darwin.close(fd) - unlink(socketPath) - print("IPCServer: stopped.") - } - - // MARK: - Client handling - - private func handleClient(_ fd: Int32) { - defer { Darwin.close(fd) } - - // Read header: 4+4 bytes - var width: UInt32 = 0 - var height: UInt32 = 0 - - guard readExact(fd: fd, into: &width, count: 4), - readExact(fd: fd, into: &height, count: 4) - else { - printErr("IPCServer: failed to read header") - return - } - - // Ensure little-endian (darktable sends LE) - width = UInt32(littleEndian: width) - height = UInt32(littleEndian: height) - - guard width > 0, height > 0, width <= 32768, height <= 32768 else { - printErr("IPCServer: invalid dimensions \(width)x\(height)") - return - } - - let floatCount = Int(width) * Int(height) * 3 - let byteCount = floatCount * MemoryLayout.size - - var pixels = [Float](repeating: 0, count: floatCount) - guard readExactBytes(fd: fd, buffer: &pixels, byteCount: byteCount) else { - printErr("IPCServer: failed to read pixel data (\(byteCount) bytes)") - return - } - - // On little-endian hosts (all modern Macs) Float byte order is native, - // so no byte-swapping is needed. - - onFrame?(width, height, pixels) - } - - // MARK: - Low-level I/O helpers - - /// Read exactly `count` bytes into a value via its raw pointer. - private func readExact(fd: Int32, into value: inout T, count: Int) -> Bool { - return withUnsafeMutableBytes(of: &value) { ptr in - readExactBytes(fd: fd, buffer: ptr.baseAddress!, byteCount: count) - } - } - - private func readExactBytes(fd: Int32, buffer: UnsafeMutableRawPointer, byteCount: Int) -> Bool { - var remaining = byteCount - var offset = 0 - while remaining > 0 { - let n = recv(fd, buffer.advanced(by: offset), remaining, 0) - if n <= 0 { - if n == 0 { return false } // connection closed - if errno == EINTR { continue } - return false - } - offset += n - remaining -= n - } - return true - } - - private func readExactBytes(fd: Int32, buffer: inout [Float], byteCount: Int) -> Bool { - buffer.withUnsafeMutableBytes { ptr in - readExactBytes(fd: fd, buffer: ptr.baseAddress!, byteCount: byteCount) - } - } -} - -// MARK: - Helpers - -private func printErr(_ msg: String) { - let data = ((msg + "\n").data(using: .utf8)) ?? Data() - FileHandle.standardError.write(data) -} diff --git a/tools/hdr-viewer/Sources/HDRViewer/ShaderSource.swift b/tools/hdr-viewer/Sources/HDRViewer/ShaderSource.swift deleted file mode 100644 index ac76475ec061..000000000000 --- a/tools/hdr-viewer/Sources/HDRViewer/ShaderSource.swift +++ /dev/null @@ -1,85 +0,0 @@ -/// Metal shader source embedded as a string so no SPM resource bundle is needed. -/// Compiled at runtime via `device.makeLibrary(source:options:)`. -let metalShaderSource = """ -#include -using namespace metal; - -struct VertexOut { - float4 position [[position]]; - float2 texcoord; -}; - -vertex VertexOut vertexPassthrough(uint vid [[vertex_id]]) -{ - const float2 positions[3] = { - float2(-1.0f, -1.0f), - float2( 3.0f, -1.0f), - float2(-1.0f, 3.0f) - }; - const float2 texcoords[3] = { - float2(0.0f, 1.0f), - float2(2.0f, 1.0f), - float2(0.0f, -1.0f) - }; - VertexOut out; - out.position = float4(positions[vid], 0.0f, 1.0f); - out.texcoord = texcoords[vid]; - return out; -} - -struct Uniforms { - float edrHeadroom; - float _pad0; - float _pad1; - float _pad2; -}; - -constant float3x3 BT2020_TO_DISPLAY_P3 = float3x3( - float3( 1.3441f, -0.1145f, -0.2298f), - float3(-0.2817f, 1.2095f, 0.0723f), - float3( 0.0053f, -0.0358f, 1.0305f) -); - -float3 toneMap(float3 c, float headroom) -{ - if (headroom <= 1.0f) { - float lum = dot(c, float3(0.2126f, 0.7152f, 0.0722f)); - float lumOut = lum / (1.0f + lum); - float scale = (lum > 0.0f) ? (lumOut / lum) : 0.0f; - return c * scale; - } - float3 out; - for (int i = 0; i < 3; ++i) { - float v = c[i]; - if (v <= headroom) { - out[i] = v; - } else { - out[i] = headroom * (v / (v + headroom - 1.0f)); - } - } - return out; -} - -fragment half4 fragmentHDR( - VertexOut in [[stage_in]], - texture2d srcTex [[texture(0)]], - sampler smp [[sampler(0)]], - constant Uniforms& uni [[buffer(0)]]) -{ - float4 rgba = srcTex.sample(smp, in.texcoord); - float3 p3 = BT2020_TO_DISPLAY_P3 * rgba.rgb; - - // Soft gamut compression: redistribute out-of-gamut negative energy toward - // the achromatic axis instead of hard-clamping (which creates a visible line). - float minVal = min(min(p3.r, p3.g), p3.b); - if (minVal < 0.0f) { - float lum = dot(p3, float3(0.2126f, 0.7152f, 0.0722f)); - float t = minVal / (minVal - lum + 1e-6f); - p3 = mix(p3, float3(lum), saturate(t)); - p3 = max(p3, float3(0.0f)); - } - - float3 mapped = toneMap(p3, uni.edrHeadroom); - return half4(half3(mapped), 1.0h); -} -""" diff --git a/tools/hdr-viewer/Sources/HDRViewer/Shaders.metal b/tools/hdr-viewer/Sources/HDRViewer/Shaders.metal deleted file mode 100644 index 05a9f9efa075..000000000000 --- a/tools/hdr-viewer/Sources/HDRViewer/Shaders.metal +++ /dev/null @@ -1,136 +0,0 @@ -#include -using namespace metal; - -// --------------------------------------------------------------------------- -// Vertex shader -// --------------------------------------------------------------------------- -// Generates a single full-screen triangle from 3 vertices with no vertex -// buffer. The triangle is large enough to cover the entire clip-space quad -// [-1,+1] x [-1,+1]. -// -// Vertex 0: (-1, -1) → UV (0, 1) bottom-left -// Vertex 1: ( 3, -1) → UV (2, 1) far right -// Vertex 2: (-1, 3) → UV (0,-1) far top -// -// The texture coordinate convention in Metal is (0,0) = top-left, which -// matches the row-major top-to-bottom pixel data sent by darktable. - -struct VertexOut { - float4 position [[position]]; - float2 texcoord; -}; - -vertex VertexOut vertexPassthrough(uint vid [[vertex_id]]) -{ - // Full-screen triangle trick – no vertex buffer needed - const float2 positions[3] = { - float2(-1.0f, -1.0f), - float2( 3.0f, -1.0f), - float2(-1.0f, 3.0f) - }; - // Map clip-space [-1,1] → UV [0,1]. Note: Metal UV origin is top-left, - // clip-space Y is bottom=−1, top=+1, so we flip Y. - const float2 texcoords[3] = { - float2(0.0f, 1.0f), - float2(2.0f, 1.0f), - float2(0.0f, -1.0f) - }; - - VertexOut out; - out.position = float4(positions[vid], 0.0f, 1.0f); - out.texcoord = texcoords[vid]; - return out; -} - -// --------------------------------------------------------------------------- -// Uniforms -// --------------------------------------------------------------------------- -struct Uniforms { - float edrHeadroom; // maximumExtendedDynamicRangeColorComponentValue - float _pad0; - float _pad1; - float _pad2; -}; - -// --------------------------------------------------------------------------- -// Color matrix: BT.2020 linear D65 → Linear Display-P3 D65 -// --------------------------------------------------------------------------- -// Derived from: BT.2020 → XYZ(D65) → Display-P3 -// Column vectors are BT.2020 primaries expressed in Display-P3. -// -// Each column of a float3x3 in MSL is a column vector. -constant float3x3 BT2020_TO_DISPLAY_P3 = float3x3( - // col 0 (R) col 1 (G) col 2 (B) - float3( 1.3441f, -0.1145f, -0.2298f), // row 0 → P3 R - float3(-0.2817f, 1.2095f, 0.0723f), // row 1 → P3 G - float3( 0.0053f, -0.0358f, 1.0305f) // row 2 → P3 B -); - -// --------------------------------------------------------------------------- -// Tone mapping -// --------------------------------------------------------------------------- -// We use a simple "knee" function: -// - Values ≤ SDR_WHITE pass through unchanged (pure linear) -// - Values above SDR_WHITE are compressed toward edrHeadroom using a -// smooth Reinhard-style knee so that specular highlights are visible -// as HDR signal rather than clipping. -// -// If the display does not support EDR (headroom == 1.0), a standard -// Reinhard curve is applied to keep everything in [0, 1]. -// -// This is intentionally minimal. Replace with your preferred operator. - -constant float SDR_WHITE = 1.0f; // in linear Display-P3 - -float3 toneMap(float3 c, float headroom) -{ - if (headroom <= 1.0f) { - // SDR display: simple Reinhard on luminance to avoid hue shifts - float lum = dot(c, float3(0.2126f, 0.7152f, 0.0722f)); - float lumOut = lum / (1.0f + lum); - float scale = (lum > 0.0f) ? (lumOut / lum) : 0.0f; - return c * scale; - } - - // HDR display path: pass through values that are already in [0, headroom]. - // Apply a soft knee only above headroom to handle extreme values. - float3 out; - for (int i = 0; i < 3; ++i) { - float v = c[i]; - if (v <= headroom) { - out[i] = v; // preserve HDR signal intact - } else { - // Soft compress above headroom using Reinhard scaled to headroom - out[i] = headroom * (v / (v + headroom - SDR_WHITE)); - } - } - return out; -} - -// --------------------------------------------------------------------------- -// Fragment shader -// --------------------------------------------------------------------------- -fragment half4 fragmentHDR( - VertexOut in [[stage_in]], - texture2d srcTex [[texture(0)]], - sampler smp [[sampler(0)]], - constant Uniforms& uni [[buffer(0)]]) -{ - // Sample the source texture (linear BT.2020, RGBA32Float) - float4 rgba = srcTex.sample(smp, in.texcoord); - float3 rgb = rgba.rgb; - - // 1. Convert BT.2020 linear → Linear Display-P3 - // The CAMetalLayer colorspace is extendedLinearDisplayP3, so values we - // write here are interpreted as linear Display-P3 by the compositor. - float3 p3 = BT2020_TO_DISPLAY_P3 * rgb; - - // 2. Clamp negative values (out-of-gamut colors below 0 – rare in practice) - p3 = max(p3, float3(0.0f)); - - // 3. Tone map to [0, edrHeadroom] - float3 mapped = toneMap(p3, uni.edrHeadroom); - - // Output as half4; Metal will store this as RGBA16Float in the drawable. - return half4(half3(mapped), 1.0h); -} diff --git a/tools/hdr-viewer/Sources/HDRViewer/main.swift b/tools/hdr-viewer/Sources/HDRViewer/main.swift deleted file mode 100644 index a8d0312f34c4..000000000000 --- a/tools/hdr-viewer/Sources/HDRViewer/main.swift +++ /dev/null @@ -1,11 +0,0 @@ -import AppKit - -// Ensure we run on the main thread -let app = NSApplication.shared -let delegate = AppDelegate() -app.delegate = delegate - -// Set activation policy before running -app.setActivationPolicy(.regular) - -app.run() diff --git a/tools/hdr-viewer/dt_hdr_client.c b/tools/hdr-viewer/dt_hdr_client.c deleted file mode 100644 index 643a8858dee0..000000000000 --- a/tools/hdr-viewer/dt_hdr_client.c +++ /dev/null @@ -1,180 +0,0 @@ -/* - * 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 "dt_hdr_client.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) { - ssize_t n = write(fd, p, left); - 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; - - 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/tools/hdr-viewer/dt_hdr_client.h b/tools/hdr-viewer/dt_hdr_client.h deleted file mode 100644 index 1b78091bfd25..000000000000 --- a/tools/hdr-viewer/dt_hdr_client.h +++ /dev/null @@ -1,73 +0,0 @@ -/* - * dt_hdr_client.h - * - * Minimal POSIX-only C client library for sending HDR pixel frames to the - * darktable HDR Viewer app (tools/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 From 01c23a96f0ed0607c83ae3d504c6c7b10e71ad84 Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Thu, 19 Mar 2026 13:14:45 +0100 Subject: [PATCH 4/4] Fix SIGPIPE on Linux, int overflow, connect timeout, and register conf key - Use send() with MSG_NOSIGNAL on Linux instead of write() to prevent SIGPIPE from killing darktable when the HDR viewer disconnects - Use size_t for pixel loop counter and dimensions to avoid int overflow on large images - Add 2-second cooldown after failed connect to avoid 200ms timeout on every preview frame when the viewer is not running - Move alloc/OMP loop inside the connect-success branch so we only allocate and copy pixels when the viewer is actually reachable - Register plugins/darkroom/hdr_viewer_enabled in darktableconfig.xml.in so the key persists correctly across restarts - Add comment that socket calls must stay outside OMP parallel sections Co-Authored-By: Claude Opus 4.6 (1M context) --- data/darktableconfig.xml.in | 7 ++++++ src/common/hdr_viewer.c | 4 ++++ src/develop/pixelpipe_hb.c | 47 +++++++++++++++++++++++-------------- 3 files changed, 41 insertions(+), 17 deletions(-) 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/common/hdr_viewer.c b/src/common/hdr_viewer.c index e6b3838aa668..9f8a554286b5 100644 --- a/src/common/hdr_viewer.c +++ b/src/common/hdr_viewer.c @@ -38,7 +38,11 @@ static int write_exact(int fd, const void *buf, size_t len) 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; diff --git a/src/develop/pixelpipe_hb.c b/src/develop/pixelpipe_hb.c index b86e31112650..71fc4585a6d3 100644 --- a/src/develop/pixelpipe_hb.c +++ b/src/develop/pixelpipe_hb.c @@ -3014,31 +3014,44 @@ static gboolean _dev_pixelpipe_process_rec(dt_dev_pixelpipe_t *pipe, // 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. - // Only attempt this when the preference is enabled (or always try; the connect() - // call returns immediately with -1 when the viewer is not running). + // NOTE: the socket calls below must remain outside any OMP parallel section. if(dt_conf_get_bool("plugins/darkroom/hdr_viewer_enabled")) { - const int w = roi_in.width; - const int h = roi_in.height; - const float *const rgba = (const float *const)input; - // Strip alpha channel: RGBA float → RGB float (packed, row-major) - float *rgb = dt_alloc_align_float((size_t)w * h * 3); - if(rgb) + // 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) { - DT_OMP_FOR() - for(int k = 0; k < w * h; 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]; - } + 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) { - dt_hdr_viewer_send_frame(viewer_fd, (uint32_t)w, (uint32_t)h, rgb); + // 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); } - dt_free_align(rgb); + else + { + // Viewer not reachable -- back off for 2 seconds before retrying + _hdr_viewer_next_attempt_us = now_us + 2 * G_USEC_PER_SEC; + } } } }