diff --git a/data/darktableconfig.xml.in b/data/darktableconfig.xml.in
index 2a4d274ca8e0..172919dae8f5 100644
--- a/data/darktableconfig.xml.in
+++ b/data/darktableconfig.xml.in
@@ -2685,6 +2685,27 @@
crossover ISO for X-Trans FDC demosaicing
up to, and including, this ISO, X-Trans frequency domain chroma demosaicing uses the hybrid mode for determining chroma; for all higher ISO values the pure FDC is used.
+
+ plugins/darkroom/spektrafilm/allow_download
+ bool
+ false
+ allow spektrafilm to download data packs
+ allow the spektrafilm module to fetch its spectral data pack over the network when the pack an edit needs is not installed locally. every file is verified against a checksum from the repository manifest before it is installed, and downloaded packs are written to the spektrafilm/packs subfolder of the configuration folder, so they are backed up with the rest of the configuration and survive clearing the cache. a pack installed by hand directly in spektrafilm/ is always preferred over a downloaded one.
+
+
+ plugins/darkroom/spektrafilm/repository
+ string
+ piratenpanda/darktable-spektrafilm
+ spektrafilm data repository
+ repository holding the spektrafilm spectral data packs, as owner/repo. the files are read over https; git is not required.
+
+
+ plugins/darkroom/spektrafilm/ref
+ string
+ main
+ spektrafilm data repository ref
+ tag or branch to read the spektrafilm data packs from. a tag is preferable where one exists: an immutable ref is what lets an old edit fetch the exact spectral table it was developed against, where a branch hands over whatever is current.
+
plugins/darkroom/denoiseprofile/show_compute_variance_mode
bool
diff --git a/data/kernels/programs.conf b/data/kernels/programs.conf
index 15ddf324b645..471b192a9ffd 100644
--- a/data/kernels/programs.conf
+++ b/data/kernels/programs.conf
@@ -42,3 +42,4 @@ capture.cl 38
agx.cl 39
colorharmonizer.cl 40
overlay.cl 41
+spektrafilm.cl 42
diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl
new file mode 100644
index 000000000000..d0eec2edb394
--- /dev/null
+++ b/data/kernels/spektrafilm.cl
@@ -0,0 +1,1102 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+/* spektrafilm.cl — OpenCL kernels for the native spektrafilm iop.
+ *
+ * Mirrors the CPU stage functions in spektra_sim.c (which are themselves a
+ * validated port of spektrafilm 0.3.x). Per-pixel colour science runs here;
+ * the Gaussian blurs (halation bounces, diffusion bank, DIR-coupler
+ * correction diffusion, grain clumps) are done by darktable's
+ * dt_gaussian_fast_blur_cl_buffer on the intermediate buffers, exactly as the
+ * CPU path uses sf_blur_plane3.
+ *
+ * Pipeline: expose (CAT16'd RGB -> xy -> tri2quad -> Mitchell-cubic 2D
+ * spectral LUT × brightness × 2^EV) -> [boost/diffusion/halation on linear]
+ * -> lograw -> develop_corr -> [host blur] -> develop -> [grain] ->
+ * print_expose (PCHIP 3D) -> print_develop -> scan (PCHIP 3D -> XYZ ->
+ * work RGB -> OkLCh compression).
+ *
+ * Conventions:
+ * - working buffers are float4 (.w carries alpha where relevant);
+ * - all tables come from sf_sim_gpu_export(): the 2D spectral LUT
+ * (tc_n×tc_n×3), the density curves (256×3), and the 3D PCHIP tables
+ * (steps³×3 values + per-axis slopes + per-cell bounds) as __global
+ * float buffers (they exceed __constant limits at 33³+);
+ * - small matrices are packed into one __constant float block, see the
+ * SF_M_* offsets below;
+ * - the CPU engine computes in double; these kernels are float, so expect
+ * ~1e-3 vs the CPU path (validated with POCL against sf_sim_process).
+ * - exact-spectral quality has NO GPU path; process_cl falls back to CPU.
+ */
+
+constant sampler_t sampleri =
+ CLK_NORMALIZED_COORDS_FALSE | CLK_ADDRESS_CLAMP_TO_EDGE | CLK_FILTER_NEAREST;
+
+#define SF_NLE 256
+#define SF_LOG_EPS 1e-10f
+
+/* offsets (in floats) into the packed matrix/constant buffer */
+#define SF_M_IN 0 /* 9: work RGB -> XYZ(film ref), CAT16 included */
+#define SF_M_OUT 9 /* 9: XYZ(view) -> work RGB, CAT02 included */
+#define SF_M_COUPLERS 18 /* 9: DIR coupler matrix, amount-scaled */
+#define SF_M_RGB2XYZ 27 /* 9: output RGB -> XYZ (plain, for OkLab) */
+#define SF_M_XYZ2RGB 36 /* 9 */
+#define SF_M_OK1 45 /* 9: OkLab M1 */
+#define SF_M_OK2 54 /* 9: OkLab M2 */
+#define SF_M_OK1I 63 /* 9: inv(M1) */
+#define SF_M_OK2I 72 /* 9: inv(M2) */
+#define SF_M_LM_DONOR 81 /* 6: langmuir donor K[3] + D_ref[3] (K=1e30 = linear) */
+#define SF_M_LM_RECV 87 /* 6: langmuir receiver Kr[3] + c_ref[3] */
+#define SF_M_TOTAL 93
+
+static inline float sf_clampf(float x, float lo, float hi)
+{
+ return fmin(fmax(x, lo), hi);
+}
+
+static inline float3 sf_mat3(__constant const float *m, float3 v)
+{
+ return (float3)(m[0] * v.x + m[1] * v.y + m[2] * v.z,
+ m[3] * v.x + m[4] * v.y + m[5] * v.z,
+ m[6] * v.x + m[7] * v.y + m[8] * v.z);
+}
+
+/* ---- [su] Mitchell-Netravali cubic on the tc_n×tc_n×3 spectral LUT ------ */
+
+static float sf_mitchell(float t)
+{
+ const float B = 1.0f / 3.0f, C = 1.0f / 3.0f;
+ const float x = fabs(t);
+ if(x < 1.0f)
+ return (1.0f / 6.0f)
+ * ((12.0f - 9.0f * B - 6.0f * C) * x * x * x
+ + (-18.0f + 12.0f * B + 6.0f * C) * x * x + (6.0f - 2.0f * B));
+ else if(x < 2.0f)
+ return (1.0f / 6.0f)
+ * ((-B - 6.0f * C) * x * x * x + (6.0f * B + 30.0f * C) * x * x
+ + (-12.0f * B - 48.0f * C) * x + (8.0f * B + 24.0f * C));
+ return 0.0f;
+}
+
+static inline int sf_reflect(int idx, int L)
+{
+ if(idx < 0) return -idx;
+ if(idx >= L) return 2 * (L - 1) - idx;
+ return idx;
+}
+
+static inline void sf_base_frac(float coord, int L, int *base, float *frac)
+{
+ coord = sf_clampf(coord, 0.0f, (float)(L - 1));
+ if(coord >= (float)(L - 1))
+ {
+ *base = L - 2;
+ *frac = 1.0f;
+ return;
+ }
+ *base = (int)floor(coord);
+ *frac = coord - *base;
+}
+
+static float3 sf_cubic2d(__global const float *lut, int L, float x, float y)
+{
+ int xb, yb;
+ float xf, yf;
+ sf_base_frac(x, L, &xb, &xf);
+ sf_base_frac(y, L, &yb, &yf);
+ float wx[4], wy[4];
+ for(int i = 0; i < 4; i++)
+ {
+ wx[i] = sf_mitchell(xf + 1.0f - i);
+ wy[i] = sf_mitchell(yf + 1.0f - i);
+ }
+ float3 acc = (float3)(0.0f);
+ float wsum = 0.0f;
+ for(int i = 0; i < 4; i++)
+ {
+ const int xi = sf_reflect(xb - 1 + i, L);
+ for(int j = 0; j < 4; j++)
+ {
+ const int yj = sf_reflect(yb - 1 + j, L);
+ const float w = wx[i] * wy[j];
+ wsum += w;
+ const size_t o = ((size_t)xi * L + yj) * 3;
+ acc += w * (float3)(lut[o], lut[o + 1], lut[o + 2]);
+ }
+ }
+ return (wsum != 0.0f) ? acc / wsum : acc;
+}
+
+/* ---- [dc] density curve interpolation over the uniform le grid ---------- */
+/* x-axis = le/gamma -> index t = (x*gamma - le0)/le_step, endpoint-clamped */
+static inline float sf_curve(__global const float *curves, float x, float gammac,
+ float le0, float le_step, int c)
+{
+ const float t = (x * gammac - le0) / le_step;
+ if(t <= 0.0f) return curves[c];
+ if(t >= (float)(SF_NLE - 1)) return curves[(SF_NLE - 1) * 3 + c];
+ const int i = (int)t;
+ const float f = t - i;
+ return curves[i * 3 + c] + f * (curves[(i + 1) * 3 + c] - curves[i * 3 + c]);
+}
+
+/* ---- [fi] monotone-PCHIP 3D LUT (values + per-axis slopes + cell clamp) - */
+
+static inline float sf_hermite(float y0, float y1, float m0, float m1, float t)
+{
+ const float t2 = t * t, t3 = t2 * t;
+ return (2.0f * t3 - 3.0f * t2 + 1.0f) * y0 + (t3 - 2.0f * t2 + t) * m0
+ + (-2.0f * t3 + 3.0f * t2) * y1 + (t3 - t2) * m1;
+}
+
+static float3 sf_pchip3d(__global const float *lut, __global const float *sx,
+ __global const float *sy, __global const float *sz,
+ __global const float *cmin, __global const float *cmax,
+ const int n, float r, float g, float b)
+{
+ const int m = n - 1;
+ int i, j, k;
+ float tr, tg, tb;
+ sf_base_frac(r, n, &i, &tr);
+ sf_base_frac(g, n, &j, &tg);
+ sf_base_frac(b, n, &k, &tb);
+ float out[3];
+#define AT(arr, ii, jj, kk, c) arr[((((size_t)(ii)) * n + (jj)) * n + (kk)) * 3 + (c)]
+ for(int c = 0; c < 3; c++)
+ {
+ const float v000 = sf_hermite(AT(lut, i, j, k, c), AT(lut, i + 1, j, k, c),
+ AT(sx, i, j, k, c), AT(sx, i + 1, j, k, c), tr);
+ const float v010 = sf_hermite(AT(lut, i, j + 1, k, c), AT(lut, i + 1, j + 1, k, c),
+ AT(sx, i, j + 1, k, c), AT(sx, i + 1, j + 1, k, c), tr);
+ const float v001 = sf_hermite(AT(lut, i, j, k + 1, c), AT(lut, i + 1, j, k + 1, c),
+ AT(sx, i, j, k + 1, c), AT(sx, i + 1, j, k + 1, c), tr);
+ const float v011
+ = sf_hermite(AT(lut, i, j + 1, k + 1, c), AT(lut, i + 1, j + 1, k + 1, c),
+ AT(sx, i, j + 1, k + 1, c), AT(sx, i + 1, j + 1, k + 1, c), tr);
+ const float sy00 = mix(AT(sy, i, j, k, c), AT(sy, i + 1, j, k, c), tr);
+ const float sy10 = mix(AT(sy, i, j + 1, k, c), AT(sy, i + 1, j + 1, k, c), tr);
+ const float sy01 = mix(AT(sy, i, j, k + 1, c), AT(sy, i + 1, j, k + 1, c), tr);
+ const float sy11 = mix(AT(sy, i, j + 1, k + 1, c), AT(sy, i + 1, j + 1, k + 1, c), tr);
+ const float vz0 = sf_hermite(v000, v010, sy00, sy10, tg);
+ const float vz1 = sf_hermite(v001, v011, sy01, sy11, tg);
+ const float sz0 = mix(mix(AT(sz, i, j, k, c), AT(sz, i + 1, j, k, c), tr),
+ mix(AT(sz, i, j + 1, k, c), AT(sz, i + 1, j + 1, k, c), tr), tg);
+ const float sz1
+ = mix(mix(AT(sz, i, j, k + 1, c), AT(sz, i + 1, j, k + 1, c), tr),
+ mix(AT(sz, i, j + 1, k + 1, c), AT(sz, i + 1, j + 1, k + 1, c), tr), tg);
+ float v = sf_hermite(vz0, vz1, sz0, sz1, tb);
+ const size_t ci = ((((size_t)i) * m + j) * m + k) * 3 + c;
+ v = sf_clampf(v, cmin[ci], cmax[ci]);
+ out[c] = v;
+ }
+#undef AT
+ return (float3)(out[0], out[1], out[2]);
+}
+
+/* ---- [gc] Reinhard knee + OkLCh output gamut compression ---------------- */
+
+static inline float sf_knee(float d, float threshold, float limit, float power)
+{
+ if(d <= threshold) return d;
+ const float scale = limit - threshold;
+ const float x = (d - threshold) / scale;
+ const float y = x / pow(1.0f + pow(x, power), 1.0f / power);
+ return threshold + scale * y;
+}
+
+static inline float3 sf_xyz_to_oklab(__constant const float *mats, float3 xyz)
+{
+ float3 lms = sf_mat3(mats + SF_M_OK1, xyz);
+ lms = (float3)(cbrt(lms.x), cbrt(lms.y), cbrt(lms.z));
+ return sf_mat3(mats + SF_M_OK2, lms);
+}
+
+static inline float3 sf_oklab_to_xyz(__constant const float *mats, float3 lab)
+{
+ float3 lms = sf_mat3(mats + SF_M_OK2I, lab);
+ lms = lms * lms * lms;
+ return sf_mat3(mats + SF_M_OK1I, lms);
+}
+
+static float sf_cmax_lookup(__global const float *table, const int nl, const int nh,
+ float L, float h)
+{
+ const float L_lo_v = 0.02f, L_hi_v = 1.0f;
+ L = sf_clampf(L, L_lo_v, L_hi_v);
+ const float h_step = 2.0f * M_PI_F / nh;
+ const float h_idx = (h + M_PI_F) / h_step;
+ const float h_floor = floor(h_idx);
+ int h_lo = ((int)h_floor) % nh;
+ if(h_lo < 0) h_lo += nh;
+ const int h_hi = (h_lo + 1) % nh;
+ const float h_frac = h_idx - h_floor;
+ const float L_idx = (L - L_lo_v) / (L_hi_v - L_lo_v) * (float)(nl - 1);
+ int L_lo = (int)floor(L_idx);
+ L_lo = clamp(L_lo, 0, nl - 2);
+ const float L_frac = L_idx - L_lo;
+ const float v00 = table[(size_t)L_lo * nh + h_lo];
+ const float v01 = table[(size_t)L_lo * nh + h_hi];
+ const float v10 = table[(size_t)(L_lo + 1) * nh + h_lo];
+ const float v11 = table[(size_t)(L_lo + 1) * nh + h_hi];
+ return v00 * (1 - L_frac) * (1 - h_frac) + v01 * (1 - L_frac) * h_frac
+ + v10 * L_frac * (1 - h_frac) + v11 * L_frac * h_frac;
+}
+
+/* ---- grain RNG, identical to spektra_core.h (see there for provenance) -- */
+static inline uint sf_h(uint x)
+{
+ x ^= x >> 16;
+ x *= 0x7feb352dU;
+ x ^= x >> 15;
+ x *= 0x846ca68bU;
+ x ^= x >> 16;
+ return x;
+}
+static inline float sf_u01(uint s)
+{
+ return (sf_h(s) & 0xffffff) / (float)0x1000000;
+}
+/* sf_nrm: sum-of-4-uniforms (Irwin-Hall) approximate standard normal,
+ instead of Box-Muller's sqrt+log+cos chain -- see spektra_core.h for the
+ full rationale (same formula, must match exactly so CPU and GPU renders
+ agree). */
+static inline float sf_nrm(uint s)
+{
+ const float u = sf_u01(s) + sf_u01(s * 2654435761u + 1u) + sf_u01(s * 2246822519u + 2u)
+ + sf_u01(s * 3266489917u + 3u);
+ return (u - 2.0f) * 1.7320508f; /* sqrt(3) */
+}
+static inline uint sf_pixel_seed(uint xi, uint yi, uint chan)
+{
+ return xi * 73856093u ^ yi * 19349663u ^ chan * 83492791u;
+}
+/* Single Poisson draw; must stay in lockstep with sf_poisson in spektra_core.h
+ (exact below 12, bounded normal above -- see the derivation there). */
+#define SF_POISSON_EXACT_MAX 12.0f
+static float sf_poisson(float lam, uint seed)
+{
+ if(lam <= 0.f) return 0.f;
+ if(lam < SF_POISSON_EXACT_MAX)
+ {
+ const float limit = exp(-lam);
+ float prod = 1.f;
+ int k = 0;
+ do
+ {
+ prod *= sf_u01(seed + (uint)k * 0x9e3779b9u);
+ k++;
+ } while(prod > limit && k < 64);
+ return (float)(k - 1);
+ }
+ return lam + native_sqrt(lam) * sf_nrm(seed);
+}
+
+/* Binomial(Poisson(lam), p) == Poisson(lam*p) exactly (Poisson thinning), so the
+ reference's two-stage compound draw is one Poisson here -- exactly unbiased,
+ no clamping. See sf_layer_particle in spektra_core.h. */
+static float sf_layer_particle(float density, float dmax, float npart, float unif, uint seed)
+{
+ const float p = sf_clampf(density / dmax, 1e-6f, 1.f - 1e-6f);
+ const float od = dmax / npart;
+ const float sat = 1.f - p * unif * (1.f - 1e-6f);
+ return sf_poisson(npart * p / sat, seed * 0x9e3779b9u + 1u) * od * sat;
+}
+
+/* ======================================================================== */
+/* per-pixel stage kernels */
+/* ======================================================================== */
+
+/* stage 1: input image -> linear film raw exposure (spektra_sim: sf_sim_expose) */
+__kernel void spektrafilm_expose(__read_only image2d_t in, __global float4 *plane,
+ const int w, const int h, __constant float *mats,
+ __global const float *tc_lut, const int tc_n,
+ const float ev_scale)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ float4 px = read_imagef(in, sampleri, (int2)(x, y));
+ float3 xyz = sf_mat3(mats + SF_M_IN, (float3)(px.x, px.y, px.z));
+ const float b = xyz.x + xyz.y + xyz.z;
+ const float inv = 1.0f / fmax(b, 1e-10f);
+ const float xx = xyz.x * inv, yy = xyz.y * inv;
+ /* [su] tri2quad */
+ const float tcx = sf_clampf((1.0f - xx) * (1.0f - xx), 0.0f, 1.0f);
+ /* careful: tri2quad computes from CIE xy, matching spektra_sim tri2quad() */
+ const float tcy = sf_clampf(yy / fmax(1.0f - xx, 1e-10f), 0.0f, 1.0f);
+ const float scale = (float)(tc_n - 1);
+ float3 raw = sf_cubic2d(tc_lut, tc_n, tcx * scale, tcy * scale);
+ const float bb = isfinite(b) ? b : 0.0f;
+ raw *= bb * ev_scale;
+ plane[(size_t)y * w + x] = (float4)(raw.x, raw.y, raw.z, px.w);
+}
+
+/* stage 3a: linear raw -> log exposure (in place) */
+__kernel void spektrafilm_lograw(__global float4 *plane, const int w, const int h)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ float4 p = plane[k];
+ p.x = log10(fmax(p.x, 0.0f) + SF_LOG_EPS);
+ p.y = log10(fmax(p.y, 0.0f) + SF_LOG_EPS);
+ p.z = log10(fmax(p.z, 0.0f) + SF_LOG_EPS);
+ plane[k] = p;
+}
+
+/* stage 3b: DIR coupler correction field (spektra_sim: sf_sim_develop_corr);
+ blurred host-side with dt_gaussian, then consumed by _develop below */
+__kernel void spektrafilm_develop_corr(__global const float4 *lograw, __global float4 *corr,
+ const int w, const int h,
+ __global const float *curves_norm,
+ __constant float *mats, const float g0,
+ const float g1, const float g2, const float le0,
+ const float le_step, const float dmax0,
+ const float dmax1, const float dmax2,
+ const int positive)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 lg = lograw[k];
+ const float gam[3] = { g0, g1, g2 };
+ const float dmx[3] = { dmax0, dmax1, dmax2 };
+ const float lgv[3] = { lg.x, lg.y, lg.z };
+ float silver[3];
+ for(int c = 0; c < 3; c++)
+ {
+ const float d = sf_curve(curves_norm, lgv[c], gam[c], le0, le_step, c);
+ silver[c] = positive ? dmx[c] - d : d;
+ /* Langmuir donor saturation (dev packs); K=1e30 degenerates to linear */
+ const float K = mats[SF_M_LM_DONOR + c], Dref = mats[SF_M_LM_DONOR + 3 + c];
+ silver[c] = silver[c] * (K + Dref) / (K + silver[c]);
+ }
+ __constant const float *M = mats + SF_M_COUPLERS; /* row donor -> col receiver */
+ float out[3];
+ for(int m = 0; m < 3; m++)
+ out[m] = silver[0] * M[0 * 3 + m] + silver[1] * M[1 * 3 + m] + silver[2] * M[2 * 3 + m];
+ corr[k] = (float4)(out[0], out[1], out[2], 0.0f);
+}
+
+/* stage 3c: develop to CMY film density (spektra_sim: sf_sim_develop).
+ `curves` is curves_before when couplers are on, curves_norm otherwise. */
+__kernel void spektrafilm_develop(__global const float4 *lograw, __global const float4 *corr,
+ const int use_corr, __global float4 *cmy, const int w,
+ const int h, __global const float *curves,
+ __constant float *mats, const float g0,
+ const float g1, const float g2, const float le0,
+ const float le_step)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 lg = lograw[k];
+ float4 cr = use_corr ? corr[k] : (float4)(0.0f);
+ /* receiver-side Langmuir on the ARRIVED (post-diffusion) inhibitor;
+ Kr=1e30 degenerates to linear */
+ float crv[3] = { cr.x, cr.y, cr.z };
+ for(int c = 0; c < 3; c++)
+ {
+ const float Kr = mats[SF_M_LM_RECV + c], cref = mats[SF_M_LM_RECV + 3 + c];
+ crv[c] = crv[c] * (Kr + cref) / (Kr + crv[c]);
+ }
+ const float gam[3] = { g0, g1, g2 };
+ const float lgv[3] = { lg.x - crv[0], lg.y - crv[1], lg.z - crv[2] };
+ float out[3];
+ for(int c = 0; c < 3; c++) out[c] = sf_curve(curves, lgv[c], gam[c], le0, le_step, c);
+ cmy[k] = (float4)(out[0], out[1], out[2], lg.w);
+}
+
+/* Inverse-lookup: given the already-computed NET total density `target` for
+ * one channel, find its fractional position on the [0, n) exposure-grid
+ * axis by searching `arr` (assumed monotonic -- guaranteed for a sum of
+ * same-signed CDF terms) via binary search plus linear interpolation. Walks
+ * a strided (non-contiguous) column of a [n][...] table without copying it
+ * out first -- the same "find where the total curve reads D" step
+ * spektrafilm's own interp_density_cmy_layers_channel performs. */
+static float sf_cl_grain_curve_inverse(__global const float *arr, int n, int stride, float target)
+{
+ const int increasing = arr[(n - 1) * stride] >= arr[0];
+ int lo = 0, hi = n - 1;
+ while(hi - lo > 1)
+ {
+ const int mid = (lo + hi) / 2;
+ const float v = arr[mid * stride];
+ if((increasing && v <= target) || (!increasing && v >= target)) lo = mid;
+ else hi = mid;
+ }
+ const float v0 = arr[lo * stride], v1 = arr[hi * stride];
+ const float denom = v1 - v0;
+ float frac = (fabs(denom) > 1e-9f) ? (target - v0) / denom : 0.0f;
+ frac = clamp(frac, 0.0f, 1.0f);
+ return (float)lo + frac;
+}
+
+/* Linearly interpolate a strided per-index array at the continuous index
+ * `pos` produced by sf_cl_grain_curve_inverse above. */
+static float sf_cl_grain_curve_sample(__global const float *arr, int n, int stride, float pos)
+{
+ int i0 = (int)pos;
+ if(i0 < 0) i0 = 0;
+ if(i0 > n - 2) i0 = (n - 2 < 0) ? 0 : n - 2;
+ const float frac = pos - (float)i0;
+ return arr[i0 * stride] * (1.0f - frac) + arr[(i0 + 1) * stride] * frac;
+}
+
+/* stage 4: grain. Restructured into three kernels (raw sub-layer sample,
+ accumulate, finalize) instead of one that directly produced the final
+ combined delta, so upstream's per-sub-layer dye-cloud blur
+ (layer_particle_model's blur_particle, grain.py) can run on each raw
+ sub-layer buffer independently between sampling and combining -- see
+ the matching comment in process()'s CPU path for the full rationale.
+ channel_idx is 1 for mono (matching the old kernels' convention of
+ using channel 1's curve/params for the achromatic draw) or 0/1/2 for
+ color; seed_ch is 0 for mono (so seeding stays sl*10, not 1+sl*10) or
+ the real channel index for color. layer_curve is [nle][max_sub][3]
+ row-major, layer_curve_total is [nle][3]; max_sub (SF_GRAIN_MAX_SUBLAYERS
+ host-side) is the table's fixed stride regardless of how many
+ sub-layers n_sub actually uses. Outputs the RAW (un-combined,
+ pre-dmin-subtraction) sample for ONE sub-layer into a flat w*h buffer --
+ called once per sub-layer, unlike the old kernels which looped every
+ sub-layer internally in one dispatch. */
+__kernel void spektrafilm_grain_gen_raw_sl(__global const float4 *dens, __global float *raw_out,
+ const int w, const int h,
+ const int roi_x, const int roi_y, const int mono,
+ const int channel_idx, const int seed_ch,
+ const int sl_idx, const int nle, const int max_sub,
+ const float unif_ch, const float npart_scale,
+ __global const float *layer_dmax,
+ __global const float *layer_npart,
+ __global const float *layer_dmin,
+ __global const float *layer_curve_total,
+ __global const float *layer_curve)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 d4 = dens[k];
+ const float density = mono ? (d4.x + d4.y + d4.z) / 3.0f
+ : (channel_idx == 0 ? d4.x : (channel_idx == 1 ? d4.y : d4.z));
+ const int lstride = max_sub * 3;
+ const int idx = sl_idx * 3 + channel_idx;
+ const float pos = sf_cl_grain_curve_inverse(layer_curve_total + channel_idx, nle, 3, density);
+ const float raw = sf_cl_grain_curve_sample(layer_curve + idx, nle, lstride, pos);
+ const float d_abs = raw + layer_dmin[idx];
+ const uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y),
+ (uint)(seed_ch + sl_idx * 10));
+ raw_out[k] = sf_layer_particle(d_abs, layer_dmax[idx], layer_npart[idx] * npart_scale,
+ unif_ch, seed);
+}
+
+/* Sums sub-layer buffers (each already dye-cloud-blurred by the host) into
+ a single accumulator, one sub-layer at a time: reset=1 initializes the
+ accumulator with the first sub-layer instead of requiring a separate
+ zero-fill dispatch, reset=0 adds subsequent ones. */
+__kernel void spektrafilm_grain_accumulate_1c(__global float *acc, __global const float *src,
+ const int w, const int h, const int reset)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ if(reset) acc[k] = src[k];
+ else acc[k] += src[k];
+}
+
+/* Subtracts the density floor and the original clean density from the
+ summed (already dye-blurred) sub-layers, scales by grain strength, and
+ writes into grain_buf's out_ch component (mono broadcasts to all
+ three). grain_buf is read-modify-write: for color, this kernel runs
+ once per output channel with the SAME buffer, each call only touching
+ its own component. */
+__kernel void spektrafilm_grain_finalize_channel(__global float4 *grain_buf,
+ __global const float *acc,
+ __global const float4 *dens,
+ const int w, const int h, const int mono,
+ const int channel_idx, const int out_ch,
+ const float dmin_ch, const float amount)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 d4 = dens[k];
+ const float density = mono ? (d4.x + d4.y + d4.z) / 3.0f
+ : (channel_idx == 0 ? d4.x : (channel_idx == 1 ? d4.y : d4.z));
+ const float g = acc[k] - dmin_ch;
+ const float delta = (g - density) * amount;
+ float4 gv = mono || out_ch == 0 ? (float4)(0.f, 0.f, 0.f, 0.f) : grain_buf[k];
+ if(mono) gv = (float4)(delta, delta, delta, 0.f);
+ else if(out_ch == 0) gv.x = delta;
+ else if(out_ch == 1) gv.y = delta;
+ else gv.z = delta;
+ grain_buf[k] = gv;
+}
+
+/* Add the raw, still-unblurred grain delta onto the CMY density. The clump
+ blur runs AFTER this, on the combined field, so it softens image detail and
+ grain alike -- that is what the reference blurs (_finalize_grain in grain.py
+ smooths the grained density itself, not an isolated grain layer), and it is
+ what the multiplicative unsharp mask further down is tuned to recover.
+ No centring pass: the Poisson sampler in sf_layer_particle is unbiased, so
+ the delta already has zero mean. No variance-restoration renorm either --
+ the reference's grain finalization has none; it just blurs and lets the
+ natural contrast reduction stand, matching real optical clumping. Restoring
+ full pre-blur variance made grain visibly higher-contrast, and therefore
+ visually coarser, than the reference at any matching sigma. */
+__kernel void spektrafilm_grain_add(__global float4 *dens_buf, __global const float4 *grain_buf,
+ const int w, const int h)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 d = dens_buf[k];
+ const float4 g = grain_buf[k];
+ dens_buf[k] = (float4)(d.x + g.x, d.y + g.y, d.z + g.z, d.w);
+}
+
+/* Multiplicative unsharp mask after grain blur (study b80), recovering the
+ acutance that blur removed from the combined image-plus-grain density.
+ cmy = orig * (orig / G_sigma(orig))^amount. Caller saved orig in `orig`
+ buffer and blurred `cmy` in place before launching this kernel.
+ Runs on the ABSOLUTE density: the grain floor (dmin, the sum of the
+ per-sublayer floors) is added back before the ratio and removed from the
+ result, matching the reference (_finalize_grain: blur -> USM ->
+ -= density_min). Without the floor the D/blur(D) ratio is
+ ill-conditioned in the deepest shadows and shadow noise gets amplified
+ the way the reference never does. */
+__kernel void spektrafilm_grain_usm(__global float4 *cmy, __global const float4 *orig,
+ const int w, const int h, const float amount,
+ const float dmin0, const float dmin1, const float dmin2)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 dmin = (float4)(dmin0, dmin1, dmin2, 0.0f);
+ const float4 D = orig[k] + dmin;
+ const float4 blur = cmy[k] + dmin;
+ const float eps = 1e-6f;
+ const float ratmax = 4.0f, ratmin = 1.0f / ratmax;
+ float4 out;
+ out.x = fmax(D.x * pow(fmax(fmin(D.x / fmax(blur.x, eps), ratmax), ratmin), amount) - dmin.x,
+ 0.0f);
+ out.y = fmax(D.y * pow(fmax(fmin(D.y / fmax(blur.y, eps), ratmax), ratmin), amount) - dmin.y,
+ 0.0f);
+ out.z = fmax(D.z * pow(fmax(fmin(D.z / fmax(blur.z, eps), ratmax), ratmin), amount) - dmin.z,
+ 0.0f);
+ out.w = 0.0f;
+ cmy[k] = out;
+}
+
+/* stage 5a: CMY film density -> print log exposure (sf_sim_print_expose) */
+__kernel void spektrafilm_print_expose(__global const float4 *cmy, __global float4 *loge,
+ const int w, const int h,
+ __global const float *lut, __global const float *sx,
+ __global const float *sy, __global const float *sz,
+ __global const float *cmn, __global const float *cmx,
+ const int steps, const float lo0, const float lo1,
+ const float lo2, const float hi0, const float hi1,
+ const float hi2, const float print_exposure)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 in = cmy[k];
+ const float scale = (float)(steps - 1);
+ const float r = (in.x - lo0) / (hi0 - lo0) * scale;
+ const float g = (in.y - lo1) / (hi1 - lo1) * scale;
+ const float b = (in.z - lo2) / (hi2 - lo2) * scale;
+ float3 l1 = sf_pchip3d(lut, sx, sy, sz, cmn, cmx, steps, r, g, b);
+ /* [st] raw = 10^l1 * print_exposure; back to log10 */
+ float3 out;
+ out.x = log10(fmax(exp10(l1.x) * print_exposure, 0.0f) + SF_LOG_EPS);
+ out.y = log10(fmax(exp10(l1.y) * print_exposure, 0.0f) + SF_LOG_EPS);
+ out.z = log10(fmax(exp10(l1.z) * print_exposure, 0.0f) + SF_LOG_EPS);
+ loge[k] = (float4)(out.x, out.y, out.z, in.w);
+}
+
+/* stage 5b: print log exposure -> print CMY density (sf_sim_print_develop) */
+__kernel void spektrafilm_print_develop(__global const float4 *loge, __global float4 *cmy,
+ const int w, const int h,
+ __global const float *print_curves, const float le0,
+ const float le_step)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 in = loge[k];
+ const float lgv[3] = { in.x, in.y, in.z };
+ float out[3];
+ for(int c = 0; c < 3; c++)
+ out[c] = sf_curve(print_curves, lgv[c], 1.0f, le0, le_step, c);
+ cmy[k] = (float4)(out[0], out[1], out[2], in.w);
+}
+
+/* stage 6: scan — CMY density -> log XYZ (PCHIP) -> XYZ -> work RGB with
+ OkLCh (mode 1) / ACES RGC (mode 2) gamut compression. Runs on the OUTPUT
+ grid, cropping (ox, oy) from the full-ROI plane and taking alpha from the
+ input image (spektra_sim: sf_sim_scan). */
+/* Scans the full padded ROI into a buffer rather than straight into the output
+ image: the scanner optics and the glare veil run after this on the scanned
+ RGB, and they need the padding. spektrafilm_crop_out does the crop. */
+__kernel void spektrafilm_scan(__global const float4 *cmy, __global float4 *rgb_out,
+ const int w, const int h,
+ __global const float *lut, __global const float *sx,
+ __global const float *sy, __global const float *sz,
+ __global const float *cmn, __global const float *cmx,
+ const int steps, const float lo0, const float lo1,
+ const float lo2, const float hi0, const float hi1,
+ const float hi2, __constant float *mats,
+ __global const float *cmax_table, const int cmax_nl,
+ const int cmax_nh, const int compress_mode,
+ const float out_luminance_boost,
+ const int bw_on, const float bw_m, const float bw_q)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 c4 = cmy[k];
+ const float scale = (float)(steps - 1);
+ const float r = (c4.x - lo0) / (hi0 - lo0) * scale;
+ const float g = (c4.y - lo1) / (hi1 - lo1) * scale;
+ const float b = (c4.z - lo2) / (hi2 - lo2) * scale;
+ float3 lx = sf_pchip3d(lut, sx, sy, sz, cmn, cmx, steps, r, g, b);
+ float3 xyz = (float3)(exp10(lx.x), exp10(lx.y), exp10(lx.z));
+ if(out_luminance_boost != 1.0f) xyz *= out_luminance_boost;
+ if(bw_on) /* scanner black/white point (positive film scans) */
+ {
+ const float yc = sf_clampf(bw_m * xyz.y + bw_q, 0.0f, 1.0f);
+ xyz *= yc / (xyz.y + 1e-10f);
+ }
+ float3 rgb = sf_mat3(mats + SF_M_OUT, xyz);
+
+ if(compress_mode == 1) /* OkLCh chroma + lightness compression */
+ {
+ float3 lab = sf_xyz_to_oklab(mats, sf_mat3(mats + SF_M_RGB2XYZ, rgb));
+ float L = sf_knee(lab.x, 0.7f, 1.0f, 2.2f); /* lightness first */
+ const float C = hypot(lab.y, lab.z);
+ const float hh = atan2(lab.z, lab.y);
+ const float C_max = fmax(sf_cmax_lookup(cmax_table, cmax_nl, cmax_nh, L, hh), 1e-9f);
+ const float d = sf_knee(C / C_max, 0.0f, 1.0f, 6.0f);
+ const float C_new = d * C_max;
+ float3 lab_new = (float3)(L, C_new * cos(hh), C_new * sin(hh));
+ rgb = sf_mat3(mats + SF_M_XYZ2RGB, sf_oklab_to_xyz(mats, lab_new));
+ }
+ else if(compress_mode == 2) /* ACES reference gamut compression style */
+ {
+ const float ach = fmax(rgb.x, fmax(rgb.y, rgb.z));
+ if(ach > 1e-12f)
+ {
+ float v[3] = { rgb.x, rgb.y, rgb.z };
+ for(int c = 0; c < 3; c++)
+ {
+ const float d = (ach - v[c]) / ach;
+ const float dc = sf_knee(d, 0.0f, 1.0f, 6.0f);
+ v[c] = ach * (1.0f - dc);
+ }
+ rgb = (float3)(v[0], v[1], v[2]);
+ }
+ }
+
+ rgb_out[k] = (float4)(rgb.x, rgb.y, rgb.z, 0.0f);
+}
+
+/* Crop the padded ROI down to roi_out and carry the input alpha through. */
+__kernel void spektrafilm_crop_out(__global const float4 *rgb, __read_only image2d_t in,
+ __write_only image2d_t out, const int w, const int ow,
+ const int oh, const int ox, const int oy)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= ow || y >= oh) return;
+ const float4 v = rgb[(size_t)(y + oy) * w + (x + ox)];
+ const float4 px = read_imagef(in, sampleri, (int2)(x + ox, y + oy));
+ write_imagef(out, (int2)(x, y), (float4)(v.x, v.y, v.z, px.w));
+}
+
+/* Additive unsharp mask on the scanned RGB (sf_unsharp_mask3): `rgb` holds the
+ blurred copy on entry, `orig` the unblurred one. */
+__kernel void spektrafilm_scan_usm(__global float4 *rgb, __global const float4 *orig,
+ const int w, const int h, const float amount)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 D = orig[k], blur = rgb[k];
+ rgb[k] = (float4)(D.x + amount * (D.x - blur.x), D.y + amount * (D.y - blur.y),
+ D.z + amount * (D.z - blur.z), D.w);
+}
+
+/* Viewing-glare field: lognormal of linear-space mean `mean` and shape (s, bias)
+ precomputed host-side, keyed on absolute image coordinates so the veil is
+ stable under pan and zoom (sf_glare). Blurred by the caller, then added. */
+__kernel void spektrafilm_glare_gen(__global float4 *field, const int w, const int h,
+ const int roi_x, const int roi_y, const float mean,
+ const float s, const float bias)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), 0x5eedu);
+ const float g = mean * exp(bias + s * sf_nrm(seed));
+ field[(size_t)y * w + x] = (float4)(g, g, g, 0.0f);
+}
+
+__kernel void spektrafilm_glare_add(__global float4 *rgb, __global const float4 *field,
+ const int w, const int h)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const size_t k = (size_t)y * w + x;
+ const float4 v = rgb[k], g = field[k];
+ rgb[k] = (float4)(v.x + g.x, v.y + g.y, v.z + g.z, v.w);
+}
+
+/* passthrough crop when no sim is available */
+__kernel void spektrafilm_passthrough(__read_only image2d_t in, __write_only image2d_t out,
+ const int ow, const int oh, const int ox, const int oy)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= ow || y >= oh) return;
+ write_imagef(out, (int2)(x, y), read_imagef(in, sampleri, (int2)(x + ox, y + oy)));
+}
+
+/* ======================================================================== */
+/* spatial-effect kernels (identical to the LUT module's; blurs host-side) */
+/* ======================================================================== */
+
+/* Direct (exact) separable Gaussian convolution, one pass along rows or
+ * columns. `weights` holds 2*radius+1 normalized taps built host-side by
+ * sf_gauss_kernel_1d() (spektra_core.c/.h) -- the same kernel the CPU path
+ * convolves with, so GPU and CPU renders match. Clamp-to-edge boundary.
+ * Separate _row/_col entry points (rather than a stride parameter) keep the
+ * inner loop's memory access pattern explicit at the call site. Separate
+ * _1c/_4c variants avoid packing a lone scatter-stage channel into an
+ * otherwise-wasted float4. */
+/* Young-van Vliet order-3 recursive Gaussian, one work-item per line. Same
+ coefficients and same edge-replicated seeding as sf_gauss_yvv_coeffs /
+ _sf_gauss_iir_1d on the CPU, so both paths deliver the identical blur above
+ SF_GAUSS_EXACT_MAX_SIGMA. Launch with global size (h, 1) for rows and
+ (w, 1) for columns. */
+__kernel void spektrafilm_yvv_row_4c(__global const float4 *src, __global float4 *dst,
+ const int w, const int h, const float B, const float B1,
+ const float B2, const float B3)
+{
+ /* One work-item per line. The launch asks for a second global dimension of
+ 1, but dt_opencl_enqueue_kernel_2d_args rounds every dimension up to the
+ device's preferred multiple, so this is entered by a whole row of items
+ per line -- all of which would otherwise run the same serial recursion
+ over the same addresses and race. Only item 0 may proceed. */
+ const int row = get_global_id(0);
+ if(row >= h || get_global_id(1) != 0) return;
+ __global const float4 *s = src + (size_t)row * w;
+ __global float4 *d = dst + (size_t)row * w;
+ float4 w1 = s[0], w2 = s[0], w3 = s[0];
+ for(int j = 0; j < w; j++)
+ {
+ const float4 v = B * s[j] + B1 * w1 + B2 * w2 + B3 * w3;
+ d[j] = v; w3 = w2; w2 = w1; w1 = v;
+ }
+ float4 v1 = d[w - 1], v2 = v1, v3 = v1;
+ for(int j = w - 1; j >= 0; j--)
+ {
+ const float4 v = B * d[j] + B1 * v1 + B2 * v2 + B3 * v3;
+ d[j] = v; v3 = v2; v2 = v1; v1 = v;
+ }
+}
+
+__kernel void spektrafilm_yvv_col_4c(__global const float4 *src, __global float4 *dst,
+ const int w, const int h, const float B, const float B1,
+ const float B2, const float B3)
+{
+ /* One work-item per line. The launch asks for a second global dimension of
+ 1, but dt_opencl_enqueue_kernel_2d_args rounds every dimension up to the
+ device's preferred multiple, so this is entered by a whole row of items
+ per line -- all of which would otherwise run the same serial recursion
+ over the same addresses and race. Only item 0 may proceed. */
+ const int col = get_global_id(0);
+ if(col >= w || get_global_id(1) != 0) return;
+ float4 w1 = src[col], w2 = w1, w3 = w1;
+ for(int i = 0; i < h; i++)
+ {
+ const size_t k = (size_t)i * w + col;
+ const float4 v = B * src[k] + B1 * w1 + B2 * w2 + B3 * w3;
+ dst[k] = v; w3 = w2; w2 = w1; w1 = v;
+ }
+ float4 v1 = dst[(size_t)(h - 1) * w + col], v2 = v1, v3 = v1;
+ for(int i = h - 1; i >= 0; i--)
+ {
+ const size_t k = (size_t)i * w + col;
+ const float4 v = B * dst[k] + B1 * v1 + B2 * v2 + B3 * v3;
+ dst[k] = v; v3 = v2; v2 = v1; v1 = v;
+ }
+}
+
+__kernel void spektrafilm_yvv_row_1c(__global const float *src, __global float *dst,
+ const int w, const int h, const float B, const float B1,
+ const float B2, const float B3)
+{
+ /* One work-item per line. The launch asks for a second global dimension of
+ 1, but dt_opencl_enqueue_kernel_2d_args rounds every dimension up to the
+ device's preferred multiple, so this is entered by a whole row of items
+ per line -- all of which would otherwise run the same serial recursion
+ over the same addresses and race. Only item 0 may proceed. */
+ const int row = get_global_id(0);
+ if(row >= h || get_global_id(1) != 0) return;
+ __global const float *s = src + (size_t)row * w;
+ __global float *d = dst + (size_t)row * w;
+ float w1 = s[0], w2 = s[0], w3 = s[0];
+ for(int j = 0; j < w; j++)
+ {
+ const float v = B * s[j] + B1 * w1 + B2 * w2 + B3 * w3;
+ d[j] = v; w3 = w2; w2 = w1; w1 = v;
+ }
+ float v1 = d[w - 1], v2 = v1, v3 = v1;
+ for(int j = w - 1; j >= 0; j--)
+ {
+ const float v = B * d[j] + B1 * v1 + B2 * v2 + B3 * v3;
+ d[j] = v; v3 = v2; v2 = v1; v1 = v;
+ }
+}
+
+__kernel void spektrafilm_yvv_col_1c(__global const float *src, __global float *dst,
+ const int w, const int h, const float B, const float B1,
+ const float B2, const float B3)
+{
+ /* One work-item per line. The launch asks for a second global dimension of
+ 1, but dt_opencl_enqueue_kernel_2d_args rounds every dimension up to the
+ device's preferred multiple, so this is entered by a whole row of items
+ per line -- all of which would otherwise run the same serial recursion
+ over the same addresses and race. Only item 0 may proceed. */
+ const int col = get_global_id(0);
+ if(col >= w || get_global_id(1) != 0) return;
+ float w1 = src[col], w2 = w1, w3 = w1;
+ for(int i = 0; i < h; i++)
+ {
+ const size_t k = (size_t)i * w + col;
+ const float v = B * src[k] + B1 * w1 + B2 * w2 + B3 * w3;
+ dst[k] = v; w3 = w2; w2 = w1; w1 = v;
+ }
+ float v1 = dst[(size_t)(h - 1) * w + col], v2 = v1, v3 = v1;
+ for(int i = h - 1; i >= 0; i--)
+ {
+ const size_t k = (size_t)i * w + col;
+ const float v = B * dst[k] + B1 * v1 + B2 * v2 + B3 * v3;
+ dst[k] = v; v3 = v2; v2 = v1; v1 = v;
+ }
+}
+
+__kernel void spektrafilm_gauss_row_4c(__global const float4 *src, __global float4 *dst,
+ const int w, const int h,
+ __global const float *weights, const int radius)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ float4 acc = (float4)(0.0f);
+ for(int k = -radius; k <= radius; k++)
+ {
+ int xx = x + k;
+ xx = xx < 0 ? 0 : (xx >= w ? w - 1 : xx);
+ acc += weights[k + radius] * src[(size_t)y * w + xx];
+ }
+ dst[(size_t)y * w + x] = acc;
+}
+
+__kernel void spektrafilm_gauss_col_4c(__global const float4 *src, __global float4 *dst,
+ const int w, const int h,
+ __global const float *weights, const int radius)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ float4 acc = (float4)(0.0f);
+ for(int k = -radius; k <= radius; k++)
+ {
+ int yy = y + k;
+ yy = yy < 0 ? 0 : (yy >= h ? h - 1 : yy);
+ acc += weights[k + radius] * src[(size_t)yy * w + x];
+ }
+ dst[(size_t)y * w + x] = acc;
+}
+
+__kernel void spektrafilm_gauss_row_1c(__global const float *src, __global float *dst,
+ const int w, const int h,
+ __global const float *weights, const int radius)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ float acc = 0.0f;
+ for(int k = -radius; k <= radius; k++)
+ {
+ int xx = x + k;
+ xx = xx < 0 ? 0 : (xx >= w ? w - 1 : xx);
+ acc += weights[k + radius] * src[(size_t)y * w + xx];
+ }
+ dst[(size_t)y * w + x] = acc;
+}
+
+__kernel void spektrafilm_gauss_col_1c(__global const float *src, __global float *dst,
+ const int w, const int h,
+ __global const float *weights, const int radius)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ float acc = 0.0f;
+ for(int k = -radius; k <= radius; k++)
+ {
+ int yy = y + k;
+ yy = yy < 0 ? 0 : (yy >= h ? h - 1 : yy);
+ acc += weights[k + radius] * src[(size_t)yy * w + x];
+ }
+ dst[(size_t)y * w + x] = acc;
+}
+
+__kernel void spektrafilm_scatter_combine(__global const float4 *raw, __global const float4 *core,
+ __global const float4 *tail, __global float4 *out,
+ const int w, const int h, const float s_amount,
+ const float ws_r, const float ws_g, const float ws_b)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ size_t k = (size_t)y * w + x;
+ float4 r = raw[k], c = core[k], t = tail[k], o;
+ o.x = r.x + s_amount * (((1.f - ws_r) * c.x + ws_r * t.x) - r.x);
+ o.y = r.y + s_amount * (((1.f - ws_g) * c.y + ws_g * t.y) - r.y);
+ o.z = r.z + s_amount * (((1.f - ws_b) * c.z + ws_b * t.z) - r.z);
+ o.w = r.w;
+ out[k] = o;
+}
+
+__kernel void spektrafilm_accum(__global const float4 *blurred, __global float4 *acc, const int w,
+ const int h, const float wk, const int reset)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ size_t k = (size_t)y * w + x;
+ float4 b = blurred[k];
+ float4 a = reset ? (float4)(0.f) : acc[k];
+ a.x += wk * b.x;
+ a.y += wk * b.y;
+ a.z += wk * b.z;
+ acc[k] = a;
+}
+
+/* Pull one channel out of a float4 buffer into a packed single-channel
+ * buffer, so it can be blurred on its own (1 channel of work) instead of
+ * blurring all 4 channels of a float4 buffer just to keep 1 of them. */
+__kernel void spektrafilm_channel_extract(__global const float4 *src, __global float *dst,
+ const int w, const int h, const int channel)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ size_t k = (size_t)y * w + x;
+ float4 s = src[k];
+ dst[k] = (channel == 0) ? s.x : (channel == 1) ? s.y : s.z;
+}
+
+/* Accumulate weight*blurred[k] (a single-channel buffer, already blurred with
+ * that channel's own sigma via spektrafilm_channel_extract + a 1-channel
+ * Gaussian blur) into acc[.channel] only, leaving the other two channels of
+ * acc untouched (unless reset, which zeroes all of acc.xyz once up front).
+ * Used to assemble a genuinely per-channel-sigma blur: each channel gets its
+ * own extract + blur + accum, at 1x the per-channel blur cost instead of
+ * blurring a full float4 (4x the work) just to keep one channel of it.
+ * Channel is 0=R, 1=G, 2=B; alpha (.w) is left as acc's own, unset here
+ * since none of the scatter/tail stages carry alpha. */
+__kernel void spektrafilm_channel_accum(__global const float *blurred, __global float4 *acc,
+ const int w, const int h, const float weight,
+ const int channel, const int reset)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ size_t k = (size_t)y * w + x;
+ const float bv = blurred[k];
+ float4 a = reset ? (float4)(0.f) : acc[k];
+ const float av = (channel == 0) ? a.x : (channel == 1) ? a.y : a.z;
+ const float nv = av + weight * bv;
+ if(channel == 0) a.x = nv; else if(channel == 1) a.y = nv; else a.z = nv;
+ acc[k] = a;
+}
+
+__kernel void spektrafilm_halation_apply(__global float4 *raw, __global const float4 *blur,
+ const int w, const int h, const float a_r, const float a_g,
+ const float a_b)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ size_t k = (size_t)y * w + x;
+ float4 r = raw[k], b = blur[k];
+ r.x = (r.x + a_r * b.x) / (1.f + a_r);
+ r.y = (r.y + a_g * b.y) / (1.f + a_g);
+ r.z = (r.z + a_b * b.z) / (1.f + a_b);
+ raw[k] = r;
+}
+
+/* Scene-referred ceiling, SF_BOOST_SPAN_EV stops above the protect threshold --
+ no frame reduction. See sf_boost_highlights() for the derivation. */
+#define SF_BOOST_SPAN_EV 4.0f
+__kernel void spektrafilm_boost(__global float4 *plane, const int w, const int h,
+ const float boost_ev, const float boost_range,
+ const float protect_ev)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const int k = y * w + x;
+ if(boost_ev <= 0.0f) return;
+
+ const float midgray = 0.184f;
+ const float rng = fmin(fmax(boost_range, 0.0f), 1.0f);
+ const float prot = fmax(protect_ev, 0.0f);
+ const float raw_x0 = midgray * exp2(prot);
+ const float maxv = midgray * exp2(prot + SF_BOOST_SPAN_EV);
+ const float a = pow(28.0f, 1.0f - rng);
+ const float x0 = raw_x0 / maxv;
+ const float denom = exp(a * (1.0f - x0)) - a * (1.0f - x0) - 1.0f;
+ if(denom <= 0.0f) return;
+ const float kk = (exp2(boost_ev) - 1.0f) / denom;
+ const float inv_max = 1.0f / maxv, boost_scale = kk * maxv;
+
+ float4 p = plane[k];
+ float v[3] = { p.x, p.y, p.z };
+ for(int c = 0; c < 3; c++)
+ {
+ if(v[c] > raw_x0)
+ {
+ const float dx = (v[c] - raw_x0) * inv_max;
+ v[c] = v[c] + boost_scale * (exp(a * dx) - a * dx - 1.0f);
+ }
+ }
+ plane[k] = (float4)(v[0], v[1], v[2], p.w);
+}
+
+__kernel void spektrafilm_diffusion_accum(__global const float4 *blurred, __global float4 *acc,
+ const int w, const int h, const float wr, const float wg,
+ const float wb, const int reset)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const int k = y * w + x;
+ float4 b = blurred[k];
+ float4 a = reset ? (float4)(0.f, 0.f, 0.f, 0.f) : acc[k];
+ acc[k] = (float4)(a.x + wr * b.x, a.y + wg * b.y, a.z + wb * b.z, b.w);
+}
+
+__kernel void spektrafilm_diffusion_mix(__global float4 *plane, __global const float4 *acc,
+ const int w, const int h, const float p_s)
+{
+ const int x = get_global_id(0), y = get_global_id(1);
+ if(x >= w || y >= h) return;
+ const int k = y * w + x;
+ float4 e = plane[k], s = acc[k];
+ plane[k] = (float4)((1.f - p_s) * e.x + p_s * s.x, (1.f - p_s) * e.y + p_s * s.y,
+ (1.f - p_s) * e.z + p_s * s.z, e.w);
+}
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 028e5d457062..c51b48e9b001 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -88,6 +88,9 @@ FILE(GLOB SOURCE_FILES
"common/ratings.c"
"common/resource_limits.c"
"common/selection.c"
+ "common/spektra_core.c"
+ "common/spektra_fetch.c"
+ "common/spektra_sim.c"
"common/splines.cpp"
"common/styles.c"
"common/system_signal_handling.c"
@@ -1023,6 +1026,14 @@ add_library(lib_darktable SHARED ${DARKTABLE_BINDIR}/preferences_gen.h ${DARKTAB
# since this isn't the same directory we do have to manually set it
set_source_files_properties(${DARKTABLE_BINDIR}/version_gen.c PROPERTIES GENERATED TRUE)
+# spektrafilm: -fno-math-errno tells the compiler math functions never set errno,
+# enabling more aggressive optimization around powf/exp2f/log10f calls.
+set_source_files_properties(
+ "common/spektra_core.c"
+ "common/spektra_sim.c"
+ PROPERTIES COMPILE_OPTIONS "-fno-math-errno"
+)
+
add_dependencies(lib_darktable generate_styles_string)
add_dependencies(lib_darktable generate_conf)
add_dependencies(lib_darktable generate_version)
diff --git a/src/common/iop_order.c b/src/common/iop_order.c
index 19a0ce90c28b..7f628cfd2522 100644
--- a/src/common/iop_order.c
+++ b/src/common/iop_order.c
@@ -145,6 +145,7 @@ const dt_iop_order_entry_t legacy_order[] = {
{ {45.5f }, "agx", 0},
{ {46.0f }, "filmic", 0},
{ {46.5f }, "filmicrgb", 0},
+ { { 46.7f }, "spektrafilm", 0 },
{ {47.0f }, "colisa", 0},
{ {48.0f }, "zonesystem", 0},
{ {49.0f }, "tonecurve", 0},
@@ -260,6 +261,7 @@ const dt_iop_order_entry_t v30_order[] = {
{ {45.3f }, "sigmoid", 0},
{ {45.5f }, "agx", 0},
{ {46.0f }, "filmicrgb", 0}, // same, upgraded
+ { { 46.7f }, "spektrafilm", 0 },
{ {36.0f }, "lut3d", 0}, // apply a creative style or film emulation, possibly non-linear
{ {47.0f }, "colisa", 0}, // edit contrast while damaging colour
{ {48.0f }, "tonecurve", 0}, // same
@@ -380,6 +382,7 @@ const dt_iop_order_entry_t v50_order[] = {
{ {45.3f }, "sigmoid", 0},
{ {45.5f }, "agx", 0},
{ {46.0f }, "filmicrgb", 0}, // same, upgraded
+ { { 46.7f }, "spektrafilm", 0 },
{ {36.0f }, "lut3d", 0}, // apply a creative style or film emulation, possibly non-linear
{ {47.0f }, "colisa", 0}, // edit contrast while damaging colour
{ {48.0f }, "tonecurve", 0}, // same
@@ -501,6 +504,7 @@ const dt_iop_order_entry_t v30_jpg_order[] = {
{ {45.5f }, "agx", 0},
{ { 45.3f }, "sigmoid", 0},
{ { 46.0f }, "filmicrgb", 0 }, // same, upgraded
+ { { 46.7f }, "spektrafilm", 0 },
{ { 36.0f }, "lut3d", 0 }, // apply a creative style or film emulation, possibly non-linear
{ { 47.0f }, "colisa", 0 }, // edit contrast while damaging colour
{ { 48.0f }, "tonecurve", 0 }, // same
@@ -624,6 +628,7 @@ const dt_iop_order_entry_t v50_jpg_order[] = {
{ { 45.3f }, "sigmoid", 0},
{ {45.5f }, "agx", 0},
{ { 46.0f }, "filmicrgb", 0 }, // same, upgraded
+ { { 46.7f }, "spektrafilm", 0 },
{ { 36.0f }, "lut3d", 0 }, // apply a creative style or film emulation, possibly non-linear
{ { 47.0f }, "colisa", 0 }, // edit contrast while damaging colour
{ { 48.0f }, "tonecurve", 0 }, // same
@@ -741,6 +746,7 @@ void dt_ioppr_migrate_legacy_iop_order_list(GList *iop_order_list)
_insert_before(iop_order_list, "nlmeans", "blurs");
_insert_before(iop_order_list, "filmicrgb", "sigmoid");
_insert_before(iop_order_list, "filmicrgb", "agx");
+ _insert_before(iop_order_list, "colisa", "spektrafilm");
_insert_before(iop_order_list, "colorbalancergb", "colorequal");
_insert_before(iop_order_list, "highlights", "rasterfile");
_insert_before(iop_order_list, "colorbalance", "colorharmonizer");
diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c
new file mode 100644
index 000000000000..6528d0538e6d
--- /dev/null
+++ b/src/common/spektra_core.c
@@ -0,0 +1,828 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+/* spektrafilm — spatial effects (grain blur and halation).
+ *
+ * These two operations are the only parts of the film simulation that a static
+ * LUT cannot carry, because they are neighbour-dependent. They live here (rather
+ * than in the inline-only spektra_core.h) so they can allocate scratch buffers
+ * for a proper direct Gaussian convolution (see _sf_gauss_kernel_1d below): a
+ * truncated, normalized kernel applied separably (row pass, transpose, row
+ * pass, transpose back), matching what the reference spektrafilm's own
+ * Gaussian blur actually does, rather than a recursive IIR approximation whose
+ * effective blur radius measurably departs from the requested sigma. Boundary
+ * handling is clamp-to-edge.
+ */
+
+#include "common/darktable.h"
+#include "common/imagebuf.h"
+
+#include
+#include
+
+#include "spektra_core.h"
+
+/* ------------------------------------------------------------------------ */
+/* Row-major Gaussian blur with transpose */
+/* ------------------------------------------------------------------------ */
+/* A naive column pass has stride = width*4 bytes per pixel access, striding
+ beyond cache-line reach at any realistic resolution. Instead:
+ 1. Row-pass (direct convolution) — good stride, stays in cache
+ 2. Cache-blocked transpose — temp → scratch, sequential access both ways
+ 3. Row-pass again on transposed data — second dimension, also good stride
+ 4. Transpose back
+ The transpose itself is tiled so it stays in L2. */
+
+/* Maximum kernel half-width: see SF_GAUSS_MAX_RADIUS in spektra_core.h. */
+
+/* Direct kernel for the small-sigma path, matching the reference's own
+ _gaussian_kernel_1d: truncate = 3, radius = int(3*sigma + 0.5). `kernel` must
+ have room for 2*max_radius+1 taps; returns the radius actually used. */
+int sf_gauss_kernel_1d(const float sigma, float *const kernel, const int max_radius)
+{
+ int radius = (int)(3.0f * sigma + 0.5f);
+ if(radius < 1) radius = 1;
+ if(radius > max_radius) radius = max_radius;
+ double sum = 0.0;
+ const double inv2s2 = 1.0 / (2.0 * (double)sigma * (double)sigma);
+ for(int i = -radius; i <= radius; i++)
+ {
+ const double v = exp(-(double)(i * i) * inv2s2);
+ kernel[i + radius] = (float)v;
+ sum += v;
+ }
+ const float invsum = (float)(1.0 / sum);
+ for(int i = 0; i < 2 * radius + 1; i++) kernel[i] *= invsum;
+ return radius;
+}
+
+/* SF_GAUSS_EXACT_MAX_SIGMA is defined in spektra_core.h, shared with
+ spektrafilm.c's GPU macros so both dispatch at the same sigma. */
+
+/* Young-van Vliet order-3 recursive Gaussian -- the same filter and the same
+ * coefficients the reference runs above its own crossover (_yvv_coeffs /
+ * _iir_horizontal in fast_gaussian_filter.py).
+ *
+ * What was here before was a Deriche-form recursion (alpha = 1.695/sigma) whose
+ * impulse response is ~1.18x wider than the sigma it is asked for, fixed up by
+ * a constant SF_GAUSS_SIGMA_CORRECTION. That made the blur accurate in absolute
+ * terms but not equal to the reference's, which is itself 8-11% wide over the
+ * range that matters (requested 5 -> effective 5.56, requested 30 -> 32.28).
+ * Every spatial radius in this module -- halation's 65 um first bounce, the
+ * scatter core and tail, the diffusion bank -- was chosen by eye against renders
+ * made THROUGH that filter, so matching it is what reproduces the intended look;
+ * being independently correct just makes every halo about 10% too tight. */
+void sf_gauss_yvv_coeffs(const float sigma_req, float out[4])
+{
+ /* clamped for both callers at once -- the CPU dispatch in _blur_flat_inplace
+ and _sf_yvv_blur_cl on the GPU both come through here, so they cannot drift
+ apart. See SF_GAUSS_MAX_IIR_SIGMA. */
+ const float sigma = fminf(sigma_req, SF_GAUSS_MAX_IIR_SIGMA);
+ const double s = (double)sigma;
+ const double q = (s >= 2.5) ? (0.98711 * s - 0.96330)
+ : (3.97156 - 4.14554 * sqrt(1.0 - 0.26891 * s));
+ const double q2 = q * q, q3 = q2 * q;
+ const double b0 = 1.57825 + 2.44413 * q + 1.4281 * q2 + 0.422205 * q3;
+ const double b1 = 2.44413 * q + 2.85619 * q2 + 1.26661 * q3;
+ const double b2 = -(1.4281 * q2 + 1.26661 * q3);
+ const double b3 = 0.422205 * q3;
+ out[1] = (float)(b1 / b0);
+ out[2] = (float)(b2 / b0);
+ out[3] = (float)(b3 / b0);
+ out[0] = (float)(1.0 - (b1 + b2 + b3) / b0);
+}
+
+/* Forward then backward sweep over `len` stride-1 elements. Both sweeps seed
+ * their state by replicating the edge sample, as the reference does. */
+static void _sf_gauss_iir_1d(const float *const in, float *const out, const int len,
+ const float B, const float B1, const float B2, const float B3)
+{
+ float w1 = in[0], w2 = in[0], w3 = in[0];
+ for(int i = 0; i < len; i++)
+ {
+ const float v = B * in[i] + B1 * w1 + B2 * w2 + B3 * w3;
+ out[i] = v;
+ w3 = w2;
+ w2 = w1;
+ w1 = v;
+ }
+ float y1 = out[len - 1], y2 = y1, y3 = y1;
+ for(int i = len - 1; i >= 0; i--)
+ {
+ const float v = B * out[i] + B1 * y1 + B2 * y2 + B3 * y3;
+ out[i] = v;
+ y3 = y2;
+ y2 = y1;
+ y1 = v;
+ }
+}
+
+/* Direct 1D convolution along stride-1 elements, clamp-to-edge boundary (a
+ plain "hold the edge value" extension -- the exact boundary rule matters
+ far less than the kernel itself once the kernel is exact). `in` and
+ `out` must NOT alias. */
+static void _sf_gauss_convolve_1d(const float *const in, float *const out, const int len,
+ const float *const kernel, const int radius)
+{
+ for(int x = 0; x < len; x++)
+ {
+ float acc = 0.0f;
+ for(int k = -radius; k <= radius; k++)
+ {
+ int xx = x + k;
+ xx = xx < 0 ? 0 : (xx >= len ? len - 1 : xx);
+ acc += kernel[k + radius] * in[xx];
+ }
+ out[x] = acc;
+ }
+}
+
+/* Cache-blocked transpose of a width×height float buffer to height×width.
+ Using BLOCK=64 keeps read/write working sets in L1/L2 during the pass. */
+#define SF_TRANSPOSE_BLOCK 64
+static void _sf_transpose(const float *const src, float *const dst,
+ const int w, const int h)
+{
+ DT_OMP_FOR()
+ for(int j0 = 0; j0 < h; j0 += SF_TRANSPOSE_BLOCK)
+ {
+ const int jlim = (j0 + SF_TRANSPOSE_BLOCK < h) ? j0 + SF_TRANSPOSE_BLOCK : h;
+ for(int i0 = 0; i0 < w; i0 += SF_TRANSPOSE_BLOCK)
+ {
+ const int ilim = (i0 + SF_TRANSPOSE_BLOCK < w) ? i0 + SF_TRANSPOSE_BLOCK : w;
+ for(int j = j0; j < jlim; j++)
+ for(int i = i0; i < ilim; i++)
+ dst[(size_t)i * h + j] = src[(size_t)j * w + i];
+ }
+ }
+}
+
+/* Single-channel exact Gaussian blur with cache-blocked transpose. `trans`
+ is a w*h intermediate buffer for the transpose (may be NULL: the row/col
+ passes then operate directly without the transpose, which is fine for
+ small buffers where cache locality matters less). */
+static void _blur_flat_inplace(float *const plane, const int w, const int h,
+ const float sigma, float *const trans, const int exact_only)
+{
+ const int use_iir = !exact_only && sigma >= SF_GAUSS_EXACT_MAX_SIGMA;
+ float kernel[2 * SF_GAUSS_MAX_RADIUS + 1];
+ int radius = 0;
+ float yvv[4] = { 0.0f, 0.0f, 0.0f, 0.0f };
+ if(use_iir) sf_gauss_yvv_coeffs(sigma, yvv);
+ else radius = sf_gauss_kernel_1d(sigma, kernel, SF_GAUSS_MAX_RADIUS);
+
+ if(trans && w >= 16 && h >= 16)
+ {
+ float *const temp = trans;
+ /* Pass 1: row-major on each row -> temp */
+ DT_OMP_FOR()
+ for(int j = 0; j < h; j++)
+ {
+ const size_t off = (size_t)j * w;
+ if(use_iir) _sf_gauss_iir_1d(plane + off, temp + off, w, yvv[0], yvv[1], yvv[2], yvv[3]);
+ else _sf_gauss_convolve_1d(plane + off, temp + off, w, kernel, radius);
+ }
+ /* Transpose temp (w×h) -> plane (h×w) */
+ _sf_transpose(temp, plane, w, h);
+ /* Pass 2: row-major on transposed data -> temp */
+ DT_OMP_FOR()
+ for(int j = 0; j < w; j++)
+ {
+ const size_t off = (size_t)j * h;
+ if(use_iir) _sf_gauss_iir_1d(plane + off, temp + off, h, yvv[0], yvv[1], yvv[2], yvv[3]);
+ else _sf_gauss_convolve_1d(plane + off, temp + off, h, kernel, radius);
+ }
+ /* Transpose back temp (h×w) -> plane (w×h) */
+ _sf_transpose(temp, plane, h, w);
+ }
+ else
+ {
+ /* small buffer: skip the cache-blocking transpose, convolve directly */
+ float *const row_tmp = dt_alloc_align_float((size_t)MAX(w, h));
+ if(row_tmp)
+ {
+ for(int j = 0; j < h; j++)
+ {
+ if(use_iir)
+ _sf_gauss_iir_1d(plane + (size_t)j * w, row_tmp, w, yvv[0], yvv[1], yvv[2], yvv[3]);
+ else
+ _sf_gauss_convolve_1d(plane + (size_t)j * w, row_tmp, w, kernel, radius);
+ memcpy(plane + (size_t)j * w, row_tmp, sizeof(float) * w);
+ }
+ float *const col_in = dt_alloc_align_float((size_t)h);
+ float *const col_out = dt_alloc_align_float((size_t)h);
+ if(col_in && col_out)
+ {
+ for(int i = 0; i < w; i++)
+ {
+ for(int j = 0; j < h; j++) col_in[j] = plane[(size_t)j * w + i];
+ if(use_iir) _sf_gauss_iir_1d(col_in, col_out, h, yvv[0], yvv[1], yvv[2], yvv[3]);
+ else _sf_gauss_convolve_1d(col_in, col_out, h, kernel, radius);
+ for(int j = 0; j < h; j++) plane[(size_t)j * w + i] = col_out[j];
+ }
+ }
+ dt_free_align(col_in);
+ dt_free_align(col_out);
+ }
+ dt_free_align(row_tmp);
+ }
+}
+
+static void _blur_channel(float *const buf, const int w, const int h, const int c,
+ const float sigma, float *const plane, float *const trans,
+ const int exact_only)
+{
+ if(sigma < 1e-6f) return;
+ const size_t npix = (size_t)w * h;
+ for(size_t i = 0; i < npix; i++) plane[i] = buf[i * 3 + c];
+ _blur_flat_inplace(plane, w, h, sigma, trans, exact_only);
+ for(size_t i = 0; i < npix; i++) buf[i * 3 + c] = plane[i];
+}
+
+/* Blur all three channels with the same sigma (grain clumps). Always uses
+ the exact kernel regardless of sigma: the fast IIR path has a known
+ ~18% effective-width error at the small sigmas grain's clump blur
+ typically uses, which would make grain visibly the wrong size relative
+ to upstream's own (exact-shape) Gaussian blur. Allocates trans buffer. */
+void sf_blur_plane3(float *const buf, const int w, const int h, const float sigma, float *const plane)
+{
+ if(sigma < 0.3f) return;
+ float *const trans = dt_alloc_align_float((size_t)w * h);
+ for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma, plane, trans, /*exact_only=*/1);
+ dt_free_align(trans);
+}
+
+/* Same exact-kernel blur as sf_blur_plane3, but for one flat w*h buffer
+ already in place -- used for grain's per-sublayer dye-cloud blur, where
+ each (channel, sub-layer) has its own sigma. No 0.3px floor (unlike
+ sf_blur_plane3's guard for the visible clump blur): the dye-cloud sigma
+ is often well under a pixel and still meaningfully softens the raw
+ particle draw, matching upstream's plain `> 0` check. Caller-provided
+ plane IS buf (in place); trans may be NULL for small buffers. */
+void sf_blur_plane1(float *const buf, const int w, const int h, const float sigma,
+ float *const plane, float *const trans)
+{
+ if(sigma < 1e-6f) return;
+ (void)plane; /* kept in the signature for API symmetry with sf_blur_plane3; unused: this
+ variant blurs buf in place, it doesn't need a separate extract/write-back
+ staging buffer the way the interleaved 3-channel path does. */
+ _blur_flat_inplace(buf, w, h, sigma, trans, /*exact_only=*/1);
+}
+
+/* Same as sf_blur_plane3, but allows the fast IIR path at large sigma: for
+ callers with no downstream dependency on the exact kernel's shape (DIR
+ coupler correction-field diffusion, unlike grain, isn't renormalizing
+ against it -- it's just smoothing a density correction, not restoring a
+ noise buffer's variance). */
+void sf_blur_plane3_fast(float *const buf, const int w, const int h, const float sigma, float *const plane)
+{
+ if(sigma < 0.3f) return;
+ float *const trans = dt_alloc_align_float((size_t)w * h);
+ for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma, plane, trans, /*exact_only=*/0);
+ dt_free_align(trans);
+}
+
+/* Multiplicative unsharp mask on density (study b80): out = D * (D / blur(D))^amount.
+ The reference (apply_multiplicative_unsharp_mask, diffusion.py) follows this
+ with a per-channel scalar renormalisation that restores each channel's total
+ density mass. That renormalisation is deliberately NOT reproduced here: it is
+ a whole-image reduction, and this function runs on whatever ROI or tile the
+ pixelpipe hands it, so the scale factor would differ between the preview pipe,
+ the export pipe and every tile of a tiled export -- the same pixel would come
+ out at a different density depending on how the image was cut up. The
+ correction it applies is in any case tiny: measured over synthetic CMY density
+ fields with grain from sigma_D = 0.01 to 0.08, the factor stays inside
+ [0.9945, 1.0], i.e. under 0.03 dB. Dropping it also makes this path agree with
+ spektrafilm_grain_usm in the .cl, which never had the renormalisation. */
+void sf_multiplicative_unsharp_mask3(float *const buf, const int w, const int h,
+ const float sigma, const float amount,
+ const float *const floor_d,
+ float *const orig, float *const work)
+{
+ if(sigma <= 0.0f || amount <= 0.0f) return;
+ const size_t nn = (size_t)w * h * 3;
+ dt_iop_image_copy(orig, buf, nn);
+ sf_blur_plane3(buf, w, h, sigma, work);
+ const float eps = 1e-6f;
+ const float ratio_max = 4.0f;
+ for(size_t i = 0; i < nn; i++)
+ {
+ const float d0 = floor_d[i % 3];
+ const float D = fmaxf(orig[i] + d0, 0.0f);
+ const float blur = fmaxf(buf[i] + d0, eps);
+ const float ratio = fmaxf(fminf(D / blur, ratio_max), 1.0f / ratio_max);
+ buf[i] = fmaxf(D * powf(ratio, amount) - d0, 0.0f);
+ }
+}
+
+/* Additive unsharp mask ([df] apply_unsharp_mask, the scanner's own sharpening
+ pass): out = D + amount * (D - blur(D)). Distinct from the multiplicative one
+ above, which is the grain-recovery pass in the density domain -- this runs on
+ the scanned RGB, can legitimately overshoot below zero at an edge, and is
+ left unclamped exactly as the reference leaves it. */
+void sf_unsharp_mask3(float *const buf, const int w, const int h, const float sigma,
+ const float amount, float *const orig, float *const work)
+{
+ if(sigma <= 0.0f || amount <= 0.0f) return;
+ const size_t nn = (size_t)w * h * 3;
+ dt_iop_image_copy(orig, buf, nn);
+ sf_blur_plane3(buf, w, h, sigma, work);
+ for(size_t i = 0; i < nn; i++) buf[i] = orig[i] + amount * (orig[i] - buf[i]);
+}
+
+/* Viewing glare ([gl] add_glare): a faint veil of the viewing illuminant, drawn
+ as a lognormal field of mean `percent`/100 and relative standard deviation
+ `roughness`, blurred by `blur` pixels.
+
+ The reference adds `glare_amount * illuminant_xyz` in XYZ before the output
+ matrix. That illuminant is normalized to Y = 1 and the matrix adapts it to the
+ output white, so it lands on RGB (1, 1, 1) exactly -- which is why this can be
+ a scalar added to all three channels after the matrix instead. It also lands
+ after the output gamut compression rather than before it; at the default
+ 0.03% the difference is far below a code value, and doing it here keeps the
+ whole spatial stage on one side of sf_sim_scan.
+
+ Lognormal with linear-space mean m and std s: sigma2 = ln(1 + (s/m)^2),
+ mu = ln(m) - sigma2/2, so with s/m = roughness the shape parameter does not
+ depend on the amount at all. */
+void sf_glare(float *const rgb, const int w, const int h, const float percent,
+ const float roughness, const float blur, const int roi_x, const int roi_y,
+ float *const field)
+{
+ const float mean = percent * 0.01f;
+ if(mean <= 0.0f) return;
+ const float sigma2 = logf(1.0f + roughness * roughness);
+ const float s = sqrtf(sigma2), bias = -0.5f * sigma2;
+
+ DT_OMP_FOR()
+ for(int y = 0; y < h; y++)
+ for(int x = 0; x < w; x++)
+ {
+ const uint32_t seed = sf_pixel_seed((uint32_t)(x + roi_x), (uint32_t)(y + roi_y), 0x5eedu);
+ field[(size_t)y * w + x] = mean * expf(bias + s * sf_nrm(seed));
+ }
+ float *const trans = dt_alloc_align_float((size_t)w * h);
+ sf_blur_plane1(field, w, h, blur, NULL, trans);
+ dt_free_align(trans);
+
+ const size_t npix = (size_t)w * h;
+ DT_OMP_FOR()
+ for(size_t i = 0; i < npix; i++)
+ {
+ const float g = field[i];
+ rgb[i * 3 + 0] += g;
+ rgb[i * 3 + 1] += g;
+ rgb[i * 3 + 2] += g;
+ }
+}
+
+/* Blur packed buffer with per-channel sigma. `trans` is a w*h intermediate. */
+static void _blur_per_channel(float *const buf, const int w, const int h, const float sigma[3],
+ float *const plane, float *const trans)
+{
+ for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma[c], plane, trans, /*exact_only=*/0);
+}
+
+/* Apply halation + scatter to a w*h*3 LINEAR plane, in place.
+ *
+ * Two stages, both physically motivated and run on linear irradiance:
+ * 1. Scatter (the emulsion point-spread function): a narrow core Gaussian plus
+ * a wide three-Gaussian tail, mixed per channel.
+ * 2. Multi-bounce halation: N reflections off the film base, each a wider
+ * Gaussian, weighted by a decaying series, mixed back per channel.
+ *
+ * `amount` scales the halation strength with a mild non-linearity so that 1.0 is
+ * the film-accurate value (red 0.05 / green 0.015 / blue 0.0) while higher values
+ * ramp up faster. `pixel_um` converts the micrometre-on-film radii to pixels. */
+/* Highlight boost (spektrafilm's pre-halation highlight reconstruction). On real
+ film the brightest highlights are clipped before they can scatter; this bows the
+ response upward above a threshold so blown highlights carry extra energy into the
+ halation/scatter that follows. Ported from spektrafilm's boost_highlights:
+ raw_x0 = midgray * 2^protect_ev (threshold; below it, unchanged)
+ a = 28^(1 - boost_range) (curve sharpness)
+ k = (2^boost_ev - 1) / (e^(a(1-x0)) - a(1-x0) - 1) (normaliser)
+ above x0: y = x + k*max * (e^(a*dx) - a*dx - 1), dx=(x-x0)/max
+ Operates in place on a linear w*h*3 plane; max is the plane's peak value. */
+void sf_boost_highlights(float *const raw, const int w, const int h, const float boost_ev,
+ const float boost_range, const float protect_ev)
+{
+ if(boost_ev <= 0.0f) return;
+ const size_t nn = (size_t)w * h * 3;
+
+ /* The reference normalises this curve by max(raw) over the whole frame, so its
+ brightest pixel lands exactly boost_ev stops higher. That is a whole-image
+ reduction, and this function only ever sees one ROI or one tile: the preview
+ pipe's downscale averages specular highlights down, so it measured a lower
+ peak and applied a different curve than the export, and a tiled export got a
+ different curve in every tile (tiling_callback sets overlap and factor, so
+ large images do get tiled).
+
+ Anchor the ceiling to the exposure scale instead. raw_x0 is already defined
+ as a number of stops above the film's calibrated middle grey, so defining
+ the ceiling the same way makes the whole curve scene-referred and identical
+ everywhere. Anchoring it to the film's own shoulder was the other candidate
+ and is a trap: the log exposure at 95% of curve excursion ranges from 2.5
+ (Velvia) to 272 (Vision3 250D) in raw units across the shipped stocks, which
+ would leave the slider nearly inert on negatives and violent on slides. */
+ const float midgray = 0.184f;
+ const float rng = fminf(fmaxf(boost_range, 0.0f), 1.0f);
+ const float prot = fmaxf(protect_ev, 0.0f);
+ const float raw_x0 = midgray * exp2f(prot);
+ const float maxv = midgray * exp2f(prot + SF_BOOST_SPAN_EV);
+ const float a = powf(28.0f, 1.0f - rng);
+ const float x0 = raw_x0 / maxv;
+ const float denom = expf(a * (1.0f - x0)) - a * (1.0f - x0) - 1.0f;
+ if(denom <= 0.0f) return;
+ const float k = (exp2f(boost_ev) - 1.0f) / denom;
+ const float inv_max = 1.0f / maxv, boost_scale = k * maxv;
+
+ for(size_t i = 0; i < nn; i++)
+ {
+ const float x = raw[i];
+ if(x > raw_x0)
+ {
+ const float dx = (x - raw_x0) * inv_max;
+ raw[i] = x + boost_scale * (expf(a * dx) - a * dx - 1.0f);
+ }
+ }
+}
+
+void sf_halation(float *const raw, const int w, const int h, const double pixel_um,
+ const double sc_core[3], const double sc_tail[3], const double w_s[3],
+ const float scatter_amount, const float scatter_scale,
+ const float halation_amount, const float halation_scale,
+ const double halation_strength[3], const double halation_first_sigma_um)
+{
+ if(scatter_amount <= 0.0f && halation_amount <= 0.0f) return;
+
+ /* tail = sum of three Gaussians (amplitude, radius multiplier) */
+ static const double tail_amp[3] = { 0.1633, 0.6496, 0.1870 };
+ static const double tail_rat[3] = { 0.5360, 1.5236, 2.7684 };
+ /* stage 1 (scatter): s_amount is the (1-s)*raw + s*scattered blend weight,
+ matching upstream's scatter_amount 1:1 (no extra curve). scl is the
+ shared core/tail spatial-scale multiplier.
+
+ Clamped to [0, 1] because the blend is CONVEX: s is the fraction of photons
+ that scatter, so s = 1 (upstream's own default and maximum) already means
+ "all of them". Past 1 the weight on the unscattered term goes negative and
+ the stage stops being a blur and starts subtracting the sharp image --
+ s = 2 gives 2*scattered - raw, an inverted ghost of the subject with a dark
+ halo around it, clipping to black wherever it drives raw below zero. */
+ const double s_amount = fmin(fmax((double)scatter_amount, 0.0), 1.0);
+ const double scl = fmax((double)scatter_scale, 1e-3);
+ /* stage 2 (halation): per-channel strength at halation_amount==1.0, and the
+ first-bounce radius, both per-film (sf_sim_halation_params()) since
+ upstream keys these off the profile's use/antihalation tags -- e.g. a
+ modern strong-AH stock scatters far less red/green back than a
+ rem-jet-removed one. hscl is halation's OWN spatial-scale multiplier,
+ independent from the scatter stage's scl above. halation_amount is a
+ direct linear multiplier on strength here, matching upstream's
+ a_tot = halation_strength * halation_amount exactly (no extra curve). */
+ const double a_tot[3] = { halation_strength[0] * (double)halation_amount,
+ halation_strength[1] * (double)halation_amount,
+ halation_strength[2] * (double)halation_amount };
+ const double first_sigma_um = halation_first_sigma_um; /* base bounce radius */
+ const double hscl = fmax((double)halation_scale, 1e-3);
+ const int n_bounces = 3;
+ const double rho = 0.5; /* bounce decay */
+
+ const size_t npix = (size_t)w * h;
+ const size_t nn = npix * 3;
+ float *const plane = dt_alloc_align_float(npix);
+ float *const trans = dt_alloc_align_float(npix);
+ if(!plane) { dt_free_align(trans); return; }
+
+ /* --- stage 1: scatter PSF (core + 3-component tail) --- */
+ if(s_amount > 0.0)
+ {
+ float *const core = dt_alloc_align_float(nn);
+ float *const tail = dt_alloc_align_float(nn);
+ float *const comp = dt_alloc_align_float(nn);
+ if(core && tail && comp)
+ {
+ dt_iop_image_copy(core, raw, nn);
+ float sc[3];
+ for(int c = 0; c < 3; c++) sc[c] = fmaxf((float)(sc_core[c] * scl / pixel_um), 1e-6f);
+ _blur_per_channel(core, w, h, sc, plane, trans);
+
+ memset(tail, 0, sizeof(float) * nn);
+ for(int g = 0; g < 3; g++)
+ {
+ dt_iop_image_copy(comp, raw, nn);
+ float lt[3];
+ for(int c = 0; c < 3; c++)
+ lt[c] = fmaxf((float)(tail_rat[g] * (sc_tail[c] * scl / pixel_um)), 1e-6f);
+ _blur_per_channel(comp, w, h, lt, plane, trans);
+ for(size_t i = 0; i < nn; i++) tail[i] += (float)tail_amp[g] * comp[i];
+ }
+ for(size_t i = 0; i < nn; i++)
+ {
+ const int c = i % 3;
+ const double scattered = (1.0 - w_s[c]) * core[i] + w_s[c] * tail[i];
+ raw[i] = (float)((1.0 - s_amount) * (double)raw[i] + s_amount * scattered);
+ }
+ }
+ dt_free_align(core);
+ dt_free_align(tail);
+ dt_free_align(comp);
+ }
+
+ /* --- stage 2: multi-bounce halation --- */
+ if(halation_amount > 0.0f && (a_tot[0] > 0.0 || a_tot[1] > 0.0 || a_tot[2] > 0.0))
+ {
+ double decay[8], dsum = 0.0;
+ for(int k = 1; k <= n_bounces; k++)
+ {
+ decay[k - 1] = pow(rho, k - 1);
+ dsum += decay[k - 1];
+ }
+ for(int k = 0; k < n_bounces; k++) decay[k] /= dsum;
+
+ float *const blur = dt_alloc_align_float(nn);
+ float *const comp = dt_alloc_align_float(nn);
+ if(blur && comp)
+ {
+ memset(blur, 0, sizeof(float) * nn);
+ for(int k = 1; k <= n_bounces; k++)
+ {
+ dt_iop_image_copy(comp, raw, nn);
+ const float sk = fmaxf((float)((first_sigma_um * hscl / pixel_um) * sqrt((double)k)), 1e-6f);
+ const float sig3[3] = { sk, sk, sk };
+ _blur_per_channel(comp, w, h, sig3, plane, trans);
+ const float wk = (float)decay[k - 1];
+ for(size_t i = 0; i < nn; i++) blur[i] += wk * comp[i];
+ }
+ for(size_t i = 0; i < nn; i++)
+ {
+ const int c = i % 3;
+ raw[i] = (float)((raw[i] + a_tot[c] * blur[i]) / (1.0 + a_tot[c]));
+ }
+ }
+ dt_free_align(blur);
+ dt_free_align(comp);
+ }
+
+ dt_free_align(plane);
+ dt_free_align(trans);
+}
+
+/* ---------------- diffusion filter (Black Pro-Mist family) ----------------
+ *
+ * spektrafilm's diffusion filter is an energy-conserving scatter:
+ * E_out = (1 - p_s) * E_in + p_s * (K_s * E_in)
+ * where the per-channel PSF K_s is a sum of radial exponentials grouped into
+ * core / halo / bloom. Each exponential exp(-r/lambda)/(2*pi*lambda^2) has
+ * per-axis sigma lambda*sqrt(3); we approximate each as a Gaussian of that sigma so
+ * the whole PSF becomes a weighted bank of Gaussian blurs (_blur_channel, this
+ * file's own exact direct convolution), summed per channel. The strength->p_s
+ * table, geometric lambda progressions, group weights and warmth
+ * redistribution are ported exactly from spektrafilm; only
+ * the exponential->Gaussian per-component shape is an approximation (a soft
+ * diffusion halo is dominated by scale, not tail shape). */
+
+#define SF_DIFFUSION_MAX_COMP 4
+
+typedef struct sf_diff_group_t
+{
+ double lambda_um;
+ double spread;
+ int n;
+ double alpha; /* bloom only; <=0 = uniform weights */
+} sf_diff_group_t;
+
+typedef struct sf_diff_family_t
+{
+ sf_diff_group_t core, halo, bloom;
+ double w_c, w_h, w_b;
+ double total_gain; /* family scatter gain in strength->p_s */
+ double halo_warmth_base; /* per-family halo warmth bias, added to the
+ user's own warmth slider before redistribution
+ (spektrafilm's DIFFUSION_FILTER_SHAPES
+ halo_warmth_base) */
+} sf_diff_family_t;
+
+/* All four families spektrafilm ships, values ported exactly from
+ model/diffusion.py's _DIFFUSION_FILTER_SHAPES / _DIFFUSION_FAMILY_TOTAL_GAIN. */
+static const sf_diff_family_t SF_FAMILY_GLIMMERGLASS = {
+ { 10.0, 1.5, 2, 0.0 }, { 50.0, 2.0, 3, 0.0 }, { 260.0, 2.5, 4, 3.2 },
+ 0.60, 0.30, 0.10, 0.65, 0.0
+};
+/* Black Pro-Mist (the app default family). */
+static const sf_diff_family_t SF_FAMILY_BPM = {
+ { 16.0, 1.5, 2, 0.0 }, { 95.0, 2.0, 3, 0.0 }, { 380.0, 2.5, 4, 3.5 },
+ 0.40, 0.47, 0.13, 0.75, 0.65
+};
+/* Classic Pro-Mist. */
+static const sf_diff_family_t SF_FAMILY_PRO_MIST = {
+ { 14.0, 1.5, 2, 0.0 }, { 150.0, 2.0, 3, 0.0 }, { 650.0, 2.5, 4, 2.9 },
+ 0.28, 0.42, 0.30, 1.05, 0.40
+};
+static const sf_diff_family_t SF_FAMILY_CINEBLOOM = {
+ { 20.0, 1.5, 2, 0.0 }, { 200.0, 2.0, 3, 0.0 }, { 1000.0, 2.5, 4, 2.5 },
+ 0.22, 0.30, 0.48, 1.00, 0.85
+};
+/* Index order must match dt_iop_spektrafilm_diffusion_family_t in spektrafilm.c. */
+static const sf_diff_family_t *const SF_DIFF_FAMILIES[4] = {
+ &SF_FAMILY_BPM, &SF_FAMILY_GLIMMERGLASS, &SF_FAMILY_PRO_MIST, &SF_FAMILY_CINEBLOOM
+};
+
+static const double SF_DIFF_BREAKS[5] = { 0.125, 0.25, 0.5, 1.0, 2.0 };
+static const double SF_DIFF_FRAC[5] = { 0.10, 0.20, 0.35, 0.55, 0.75 };
+static const double SF_HALO_WARMTH_AXIS[3] = { 1.30, 0.15, -1.45 };
+
+/* strength -> deflected fraction p_s (log2-interpolated table * family gain) */
+static double sf_diff_strength_to_ps(double strength, const sf_diff_family_t *fam)
+{
+ if(strength <= 0.0) return 0.0;
+ const double ls = log2(fmax(strength, 1e-6));
+ double base;
+ if(ls <= log2(SF_DIFF_BREAKS[0])) base = SF_DIFF_FRAC[0];
+ else if(ls >= log2(SF_DIFF_BREAKS[4])) base = SF_DIFF_FRAC[4];
+ else
+ {
+ base = SF_DIFF_FRAC[4];
+ for(int i = 0; i < 4; i++)
+ {
+ const double lo = log2(SF_DIFF_BREAKS[i]), hi = log2(SF_DIFF_BREAKS[i + 1]);
+ if(ls >= lo && ls <= hi)
+ {
+ const double t = (ls - lo) / (hi - lo);
+ base = SF_DIFF_FRAC[i] + t * (SF_DIFF_FRAC[i + 1] - SF_DIFF_FRAC[i]);
+ break;
+ }
+ }
+ }
+ return fmin(fmax(base * fam->total_gain, 0.0), 0.99);
+}
+
+/* expand a group into (lambda_um[], weight[]) summing to 1; returns count */
+static int sf_diff_expand(const sf_diff_group_t *g, const char is_bloom, double lam[SF_DIFFUSION_MAX_COMP],
+ double wgt[SF_DIFFUSION_MAX_COMP])
+{
+ int n = g->n < 1 ? 1 : (g->n > SF_DIFFUSION_MAX_COMP ? SF_DIFFUSION_MAX_COMP : g->n);
+ if(n == 1 || g->spread <= 1.0)
+ {
+ lam[0] = g->lambda_um;
+ wgt[0] = 1.0;
+ return 1;
+ }
+ const double llo = log(g->lambda_um / g->spread), lhi = log(g->lambda_um * g->spread);
+ double wsum = 0.0;
+ for(int k = 0; k < n; k++)
+ {
+ lam[k] = exp(llo + (lhi - llo) * k / (n - 1));
+ wgt[k] = is_bloom ? pow(lam[k], 2.0 - g->alpha) : 1.0;
+ wsum += wgt[k];
+ }
+ for(int k = 0; k < n; k++) wgt[k] /= wsum;
+ return n;
+}
+
+/* per-channel halo weights after energy-conserving warmth redistribution */
+static void sf_diff_halo_warmth(const double *wgt, int n, double warmth, double out[3][SF_DIFFUSION_MAX_COMP])
+{
+ if(n < 2)
+ {
+ for(int c = 0; c < 3; c++)
+ for(int k = 0; k < n; k++) out[c][k] = wgt[k];
+ return;
+ }
+ warmth = fmin(fmax(warmth, -1.5), 1.5);
+ double g[SF_DIFFUSION_MAX_COMP], gmean = 0.0, tt = 0.0;
+ for(int k = 0; k < n; k++)
+ {
+ g[k] = -1.0 + 2.0 * k / (n - 1);
+ gmean += wgt[k] * g[k];
+ tt += wgt[k];
+ }
+ gmean /= tt; /* weighted mean, to re-centre */
+ for(int k = 0; k < n; k++) g[k] -= gmean;
+ for(int c = 0; c < 3; c++)
+ {
+ double s = 0.0, raw[SF_DIFFUSION_MAX_COMP];
+ for(int k = 0; k < n; k++)
+ {
+ raw[k] = wgt[k] * (1.0 + warmth * SF_HALO_WARMTH_AXIS[c] * g[k]);
+ if(raw[k] < 0.0) raw[k] = 0.0;
+ s += raw[k];
+ }
+ for(int k = 0; k < n; k++) out[c][k] = (s > 0.0) ? raw[k] * (tt / s) : wgt[k];
+ }
+}
+
+/* Build the shared Gaussian bank (used by both CPU and GPU). */
+int sf_diffusion_build_plan(int family, float strength, float halo_warmth, sf_diffusion_plan_t *plan)
+{
+ plan->n = 0;
+ plan->p_s = 0.0f;
+ const int nfam = (int)(sizeof(SF_DIFF_FAMILIES) / sizeof(SF_DIFF_FAMILIES[0]));
+ const sf_diff_family_t *fam = SF_DIFF_FAMILIES[(family >= 0 && family < nfam) ? family : 0];
+ const double p_s = sf_diff_strength_to_ps((double)strength, fam);
+ if(p_s <= 0.0) return 0;
+
+ double clam[SF_DIFFUSION_MAX_COMP], cw[SF_DIFFUSION_MAX_COMP];
+ double hlam[SF_DIFFUSION_MAX_COMP], hw[SF_DIFFUSION_MAX_COMP];
+ double blam[SF_DIFFUSION_MAX_COMP], bw[SF_DIFFUSION_MAX_COMP];
+ const int nc = sf_diff_expand(&fam->core, 0, clam, cw);
+ const int nh = sf_diff_expand(&fam->halo, 0, hlam, hw);
+ const int nb = sf_diff_expand(&fam->bloom, 1, blam, bw);
+ double hch[3][SF_DIFFUSION_MAX_COMP];
+ /* effective_warmth = family base + user knob, matching
+ diffusion_filter_radial_profile()'s own "cfg base + halo_warmth". */
+ sf_diff_halo_warmth(hw, nh, fam->halo_warmth_base + (double)halo_warmth, hch);
+
+ /* Moment-matched Gaussian surrogate for one 2D isotropic exponential
+ exp(-r/lambda) / (2*pi*lambda^2). That kernel has E[r^2] = 6*lambda^2, and a
+ 2D Gaussian of per-axis sigma has E[r^2] = 2*sigma^2, so the second moments
+ match at sigma = lambda*sqrt(3). (The reference's own exponential surrogate
+ agrees: the SF_EXPTAIL_* mixture satisfies sum_k a_k * r_k^2 = 2.988 ~ 3.) */
+ const double L2 = 1.7320508075688772; /* sqrt(3) */
+ int idx = 0;
+ for(int k = 0; k < nc; k++) /* core: channel-independent */
+ {
+ plan->sigma_um[idx] = (float)(clam[k] * L2);
+ plan->wr[idx] = plan->wg[idx] = plan->wb[idx] = (float)(fam->w_c * cw[k]);
+ idx++;
+ }
+ for(int k = 0; k < nh; k++) /* halo: per channel (warmth) */
+ {
+ plan->sigma_um[idx] = (float)(hlam[k] * L2);
+ plan->wr[idx] = (float)(fam->w_h * hch[0][k]);
+ plan->wg[idx] = (float)(fam->w_h * hch[1][k]);
+ plan->wb[idx] = (float)(fam->w_h * hch[2][k]);
+ idx++;
+ }
+ for(int k = 0; k < nb; k++) /* bloom: channel-independent */
+ {
+ plan->sigma_um[idx] = (float)(blam[k] * L2);
+ plan->wr[idx] = plan->wg[idx] = plan->wb[idx] = (float)(fam->w_b * bw[k]);
+ idx++;
+ }
+ plan->n = idx;
+ plan->p_s = (float)p_s;
+ return 1;
+}
+
+/* Apply the diffusion filter in place on a linear w*h*3 plane. */
+void sf_diffusion_filter(float *const raw, const int w, const int h, const double pixel_um,
+ const int family, const float strength, const float spatial_scale,
+ const float halo_warmth)
+{
+ if(strength <= 0.0f || spatial_scale <= 0.0f) return;
+ sf_diffusion_plan_t plan;
+ if(!sf_diffusion_build_plan(family, strength, halo_warmth, &plan) || plan.p_s <= 0.0f) return;
+
+ const double sc = fmax((double)spatial_scale, 1e-6);
+ const size_t npix = (size_t)w * h, nn = npix * 3;
+
+ float *const acc = dt_alloc_align_float(nn);
+ float *const comp = dt_alloc_align_float(nn);
+ float *const plane1 = dt_alloc_align_float(npix);
+ float *const trans = dt_alloc_align_float(npix);
+ if(!acc || !comp || !plane1)
+ {
+ dt_free_align(acc);
+ dt_free_align(comp);
+ dt_free_align(plane1);
+ dt_free_align(trans);
+ return;
+ }
+ memset(acc, 0, sizeof(float) * nn);
+
+ for(int j = 0; j < plan.n; j++)
+ {
+ const float sigma = (float)(plan.sigma_um[j] * sc / fmax(pixel_um, 1e-3));
+ dt_iop_image_copy(comp, raw, nn);
+ for(int c = 0; c < 3; c++) _blur_channel(comp, w, h, c, sigma, plane1, trans, /*exact_only=*/0);
+ const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j];
+ for(size_t i = 0; i < npix; i++)
+ {
+ acc[i * 3 + 0] += wr * comp[i * 3 + 0];
+ acc[i * 3 + 1] += wg * comp[i * 3 + 1];
+ acc[i * 3 + 2] += wb * comp[i * 3 + 2];
+ }
+ }
+
+ const float ps = plan.p_s;
+ for(size_t i = 0; i < nn; i++) raw[i] = (1.0f - ps) * raw[i] + ps * acc[i];
+
+ dt_free_align(acc);
+ dt_free_align(comp);
+ dt_free_align(plane1);
+ dt_free_align(trans);
+}
diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h
new file mode 100644
index 000000000000..fd41938cf2a2
--- /dev/null
+++ b/src/common/spektra_core.h
@@ -0,0 +1,278 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+#pragma once
+#include
+#include
+
+#ifndef SPEKTRA_INLINE
+#define SPEKTRA_INLINE static inline
+#endif
+
+/* Spatial effects implemented in spektra_core.c (they need dt_alloc_align_float
+ and OpenMP linkage; everything else in this header is inline). */
+void sf_blur_plane3(float *buf, int w, int h, float sigma, float *plane);
+void sf_blur_plane3_fast(float *buf, int w, int h, float sigma, float *plane);
+/* Same exact-kernel blur as sf_blur_plane3, but operating directly on a
+ single flat w*h buffer (no 3-channel interleave) -- used for the
+ per-sublayer dye-cloud blur inside grain generation, where each
+ (channel, sub-layer) has its own sigma and needs its own buffer rather
+ than sharing one interleaved 3-channel pass. No lower sigma cutoff
+ (unlike sf_blur_plane3's 0.3px guard for the visible clump blur): the
+ dye-cloud sigma is often well under a pixel and still meaningfully
+ softens the raw particle draw, matching upstream's plain `> 0` check. */
+void sf_blur_plane1(float *buf, int w, int h, float sigma, float *plane, float *trans);
+/* Additive unsharp mask on the scanned RGB ([df] apply_unsharp_mask):
+ out = D + amount * (D - blur(D)). `orig` and `work` are w*h*3 and w*h
+ scratch buffers supplied by the caller. */
+void sf_unsharp_mask3(float *buf, int w, int h, float sigma, float amount,
+ float *orig, float *work);
+/* Viewing-glare veil ([gl] add_glare): adds a blurred lognormal field of mean
+ percent/100 (relative std `roughness`) to all three channels. `field` is a
+ w*h scratch buffer; roi_x/roi_y are absolute image coordinates so the veil
+ is stable under pan and zoom. */
+void sf_glare(float *rgb, int w, int h, float percent, float roughness, float blur,
+ int roi_x, int roi_y, float *field);
+
+void sf_multiplicative_unsharp_mask3(float *buf, int w, int h, float sigma, float amount,
+ const float *floor_d, float *orig, float *work);
+/* Two independently-controllable stages, matching upstream's HalationParams:
+ * scatter_amount / scatter_scale -- stage 1, in-emulsion core+tail scatter
+ * halation_amount / halation_scale -- stage 2, back-reflection multi-bounce
+ * halation_strength: per-channel (R,G,B) back-reflection strength at
+ * halation_amount==1.0; halation_first_sigma_um: first-bounce Gaussian radius
+ * in micrometres. Both come from sf_sim_halation_params() — per-film when the
+ * pack provides film_render_defaults[stock].halation, otherwise the generic
+ * still/strong-antihalation baseline. */
+/* `sc_core` / `sc_tail` / `w_s` are the per-channel in-emulsion scatter PSF from
+ sf_sim_scatter_params(): Gaussian core radius and exponential tail decay in
+ micrometres on film, and the core/tail mix weight. Already collapsed to one
+ value per channel for a single-emulsion stock, and already clamped by the
+ caller to what the ROI padding covers. */
+void sf_halation(float *raw, int w, int h, double pixel_um, const double sc_core[3],
+ const double sc_tail[3], const double w_s[3], float scatter_amount,
+ float scatter_scale, float halation_amount, float halation_scale,
+ const double halation_strength[3], double halation_first_sigma_um);
+/* Stops of headroom above the protect threshold over which the boost is spread.
+ The reference spreads it from the threshold up to the frame's own peak; that
+ peak is a whole-image reduction, which a per-ROI/per-tile pixelpipe cannot
+ reproduce consistently. A fixed span keeps the curve identical in every tile
+ and in both pipes, and puts both controls on the same footing -- protect_ev
+ sets where the boost starts, this sets how far above that it reaches full
+ strength. 4 EV matches the reference's typical frame peak for a normally
+ exposed scene (midgray + 4-6 EV). */
+#define SF_BOOST_SPAN_EV 4.0f
+void sf_boost_highlights(float *raw, int w, int h, float boost_ev, float boost_range,
+ float protect_ev);
+void sf_diffusion_filter(float *raw, int w, int h, double pixel_um, int family, float strength,
+ float spatial_scale, float halo_warmth);
+
+/* Diffusion-filter Gaussian bank, built host-side and consumed by the GPU path
+ (the CPU path builds it internally). Each entry is one Gaussian blur of the
+ linear plane, with a per-channel weight; the scattered image is their sum, and
+ the final mix is (1-p_s)*in + p_s*scatter. */
+#define SF_DIFFUSION_MAX_BANK 11 /* core(2) + halo(3) + bloom(4) + margin */
+typedef struct sf_diffusion_plan_t
+{
+ int n; /* number of Gaussian components */
+ float sigma_um[SF_DIFFUSION_MAX_BANK]; /* blur sigma in micrometres (×scale/pixel = px) */
+ float wr[SF_DIFFUSION_MAX_BANK]; /* per-channel weight (already ×group weight) */
+ float wg[SF_DIFFUSION_MAX_BANK];
+ float wb[SF_DIFFUSION_MAX_BANK];
+ float p_s; /* scatter fraction */
+} sf_diffusion_plan_t;
+
+/* Fill `plan` for the given strength/warmth. Returns 0 and sets plan->p_s=0 when
+ the filter is a no-op. spatial_scale/pixel are applied by the caller (sigma_px
+ = sigma_um * spatial_scale / pixel_um). */
+int sf_diffusion_build_plan(int family, float strength, float halo_warmth, sf_diffusion_plan_t *plan);
+
+
+SPEKTRA_INLINE float sf_clampf(float x, float lo, float hi)
+{
+ return x < lo ? lo : (x > hi ? hi : x);
+}
+
+/* ---------------- grain (validated) ----------------
+ *
+ * Grain must be random per pixel yet perfectly reproducible (stable under
+ * re-render, pan and zoom, and identical on CPU and GPU). So instead of a
+ * stateful PRNG we use a stateless integer HASH keyed on the pixel coordinates:
+ * hash(x, y, channel) -> a random-looking value for that exact pixel. The hash
+ * constants below are published, well-tested values, NOT tunable parameters;
+ * any good integer hash would do, and changing them only reshuffles the noise.
+ */
+
+/* sf_h: Chris Wellons' "lowbias32" integer hash finalizer. The multipliers and
+ shift sequence are the published, bias-minimised constants of that algorithm. */
+SPEKTRA_INLINE uint32_t sf_h(uint32_t x)
+{
+ x ^= x >> 16;
+ x *= 0x7feb352dU;
+ x ^= x >> 15;
+ x *= 0x846ca68bU;
+ x ^= x >> 16;
+ return x;
+}
+/* sf_u01: hash -> uniform float in [0,1) using the top 24 bits (float mantissa). */
+SPEKTRA_INLINE float sf_u01(uint32_t s)
+{
+ return (sf_h(s) & 0xffffff) / (float)0x1000000;
+}
+/* sf_nrm: one hash seed -> one approximate standard-normal sample via a
+ sum-of-4-uniforms (Irwin-Hall) approximation instead of Box-Muller's
+ sqrt+log+cos transcendental chain. Var[uniform(0,1)] = 1/12, so a sum of 4
+ has variance 4/12 = 1/3 and mean 2; rescaling by sqrt(3) and centering
+ gives unit variance, zero mean -- the two moments sf_layer_particle's
+ normal approximations actually rely on. The finite (not truly Gaussian)
+ tails this leaves behind aren't visually meaningful for film grain: real
+ emulsions don't have famously heavy statistical tails either, and the
+ difference from a true Gaussian only shows up several standard
+ deviations out, well past where grain is visible at all. Called twice per
+ particle draw, per sub-layer (up to SF_GRAIN_MAX_SUBLAYERS times for a
+ multi-sublayer film) -- worth being cheap. The four multipliers are
+ distinct, well-known odd hash constants (murmur3's c1/c2, Knuth's golden-
+ ratio multiplier, and one more), used only to decorrelate the four
+ uniform draws from each other. */
+SPEKTRA_INLINE float sf_nrm(uint32_t s)
+{
+ const float u = sf_u01(s) + sf_u01(s * 2654435761u + 1u) + sf_u01(s * 2246822519u + 2u)
+ + sf_u01(s * 3266489917u + 3u);
+ return (u - 2.0f) * 1.7320508f; /* sqrt(3) */
+}
+/* sf_layer_particle: draw the developed density of one emulsion layer as a
+ doubly-stochastic process. First the number of developed grains in this pixel
+ (mean lam, Poisson -> normal approximation), then the fraction that record
+ signal (binomial -> normal approximation). The 0x9e3779b9 / 0x85ebca6b offsets
+ are standard hash-mixing constants (golden ratio; murmurhash) that simply give
+ the two normal draws independent seeds. */
+/* sf_pixel_seed: combine pixel coordinates and a channel/sub-layer index into one
+ seed for the grain hash. The three large primes are Teschner et al.'s published
+ spatial-hash constants; XOR-mixing distinct primes per axis keeps neighbouring
+ pixels and channels from sharing a seed (which would correlate their grain).
+ Uses ABSOLUTE image coordinates so grain is stable while panning. */
+SPEKTRA_INLINE uint32_t sf_pixel_seed(uint32_t xi, uint32_t yi, uint32_t chan)
+{
+ return xi * 73856093u ^ yi * 19349663u ^ chan * 83492791u;
+}
+
+/* Maximum kernel half-width (taps = 2*radius+1) for sf_gauss_kernel_1d below.
+ Caps cost for pathologically large sigma (very high film_format_mm
+ combined with very low resolution); every physically-plausible sigma this
+ module uses stays far under this. Shared by spektra_core.c's CPU direct
+ convolution and spektrafilm.c's GPU host-side weight upload, so both
+ dispatch the identical kernel for a given sigma. */
+#define SF_GAUSS_MAX_RADIUS 512
+
+/* Sigma at which the direct kernel hands over to the recursive one. This is
+ the reference's own crossover (SMALL_SIGMA_MAX in fast_gaussian_filter.py),
+ and above it both sides now run the same Young-van Vliet filter, so a given
+ sigma produces the same blur here, on the GPU, and in the app. */
+#define SF_GAUSS_EXACT_MAX_SIGMA 3.0f
+
+/* Young-van Vliet order-3 recursive Gaussian coefficients (B, B1, B2, B3),
+ identical to the reference's _yvv_coeffs. Exported so the GPU host side can
+ build the same filter the CPU runs. */
+/* Widest sigma the recursive filter is asked for. Above this its float32
+ coefficients stop describing the filter we want: B falls to ~1e-7 while
+ B1..B3 stay near 3, and the poles walk out to the unit circle. Measured
+ effective vs requested sigma, single pass, float32:
+
+ requested 100 150 200 400 700
+ effective 102 155 254 875 105395
+
+ -- so it tracks to ~150, is unusable by 200, and diverges outright past ~700,
+ which is where cinebloom and pro-mist land at export resolution (their bloom
+ reaches 2500 um and 1625 um, ~1000 px on a 6000 px frame at 26 mm). The
+ divergence shows as full-height coloured striping: the column pass runs after
+ the row pass, so each column blows up on its own.
+
+ Clamping keeps the filter inside the range where it is a Gaussian at the cost
+ of a narrower halo than asked for at extreme diffusion settings. That is a
+ stopgap, not the answer -- a large-sigma blur wants downsample/blur/upsample,
+ which is also faster. This just stops it producing garbage in the meantime. */
+#define SF_GAUSS_MAX_IIR_SIGMA 150.0f
+void sf_gauss_yvv_coeffs(float sigma, float out[4]);
+
+/* Build a normalized, truncated 1D Gaussian kernel. truncate = 3 sigma with
+ * radius = int(3*sigma + 0.5), matching the reference's own
+ * _gaussian_kernel_1d default rather than scipy's truncate = 4 -- the
+ * reference never calls scipy for this. `kernel` must have room for
+ * 2*max_radius+1 taps; returns the radius actually used. Exported so both
+ * the CPU convolution (spektra_core.c) and the GPU host-side weight upload
+ * (spektrafilm.c's process_cl) build the identical kernel for a given sigma. */
+int sf_gauss_kernel_1d(float sigma, float *kernel, int max_radius);
+
+/* sf_poisson: one Poisson(lam) draw from a stateless seed.
+
+ Below SF_POISSON_EXACT_MAX the draw is EXACT (Knuth's product-of-uniforms).
+ That threshold is not a quality/speed compromise, it is where the normal
+ approximation stops being safe: sf_nrm is bounded at +-sqrt(12) (Irwin-Hall
+ over four uniforms), so lam + sqrt(lam)*sf_nrm() can only go negative when
+ lam < 12. Above the threshold no clamp is ever needed and the approximation is
+ mean- and variance-exact; below it, clamping a normal at zero is exactly what
+ biased the old sampler upward in the shadows. Cost: the exact branch averages
+ lam+1 hashes (<= 13), the fast branch 4 -- against 8 for the two sf_nrm draws
+ this replaces. */
+#define SF_POISSON_EXACT_MAX 12.0f
+SPEKTRA_INLINE float sf_poisson(float lam, uint32_t seed)
+{
+ if(lam <= 0.0f) return 0.0f;
+ if(lam < SF_POISSON_EXACT_MAX)
+ {
+ const float limit = expf(-lam);
+ float prod = 1.0f;
+ int k = 0;
+ do
+ {
+ prod *= sf_u01(seed + (uint32_t)k * 0x9e3779b9u);
+ k++;
+ } while(prod > limit && k < 64);
+ return (float)(k - 1);
+ }
+ return lam + sqrtf(lam) * sf_nrm(seed);
+}
+
+/* sf_layer_particle: draw the developed density of one emulsion layer.
+
+ The reference model (layer_particle_model, grain.py) draws N_s ~ Poisson(lam)
+ sensitised grains and develops each with probability p, i.e.
+ Binomial(Poisson(lam), p). Poisson thinning makes that composition EXACTLY
+ Poisson(lam * p), so the two-stage draw collapses to a single Poisson and the
+ intermediate grain count -- along with the two clamps that went with it --
+ disappears.
+
+ The mean is then exactly lam*p * od * sat = density, and the variance exactly
+ p * dmax^2 * sat / npart = D (Dmax - u D) / N, the target grain.py derives. */
+SPEKTRA_INLINE float sf_layer_particle(float density, float dmax, float npart, float unif,
+ uint32_t seed)
+{
+ const float p = sf_clampf(density / dmax, 1e-6f, 1 - 1e-6f);
+ const float od = dmax / npart;
+ const float sat = 1.f - p * unif * (1 - 1e-6f);
+ return sf_poisson(npart * p / sat, seed * 0x9e3779b9u + 1u) * od * sat;
+}
+/* SF_GRAIN_REF_UM: the fixed reference scale (spektrafilm's own
+ pixel_size_um=10) the particle model is generated at, independent of the
+ live pipe's pixel_um — this keeps grain CHARACTER constant across zoom.
+ Callers that turn the generated delta into visible clump STRUCTURE (the
+ blur step) must still convert this reference into real pixels via the
+ pipe's own pixel_um, or clump SIZE silently stops scaling with output
+ resolution — see the grain blur in spektrafilm.c/.cl and
+ _max_halo_sigma's ROI padding, all of which must agree. */
+#define SF_GRAIN_REF_UM 10.0f
diff --git a/src/common/spektra_fetch.c b/src/common/spektra_fetch.c
new file mode 100644
index 000000000000..10922a41af63
--- /dev/null
+++ b/src/common/spektra_fetch.c
@@ -0,0 +1,1096 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+#include "common/spektra_fetch.h"
+
+#include "common/spektra_sim.h"
+
+#include "common/curl_tools.h"
+#include "common/darktable.h"
+#include "common/file_location.h"
+#include "control/conf.h"
+#include "control/control.h"
+#include "develop/develop.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+/* ---------------------------------------------------------------------- */
+/* configuration */
+/* ---------------------------------------------------------------------- */
+
+#define CONF_ENABLED "plugins/darkroom/spektrafilm/allow_download"
+#define CONF_REPOSITORY "plugins/darkroom/spektrafilm/repository"
+#define CONF_REF "plugins/darkroom/spektrafilm/ref"
+
+/* Owner/repo holding the exported packs, and the git ref to read them at.
+ Both are overridable so a packager can point at a mirror and a developer can
+ test an unreleased pack without touching the source.
+
+ The manifest and the files it lists are fetched in one pass, so a ref that
+ moves underneath a running download does not install mismatched data: the
+ per-file checksum catches it and the install is discarded. Pointing this at
+ a tag is still worth doing once the data repository cuts one, for a
+ different reason -- an immutable ref is what makes an old edit reproducible,
+ where a branch hands whatever is current to a darktable that may predate
+ it. */
+#define SF_DEFAULT_REPOSITORY "piratenpanda/darktable-spektrafilm"
+#define SF_DEFAULT_REF "main"
+
+/* Files are read straight out of the repository tree over HTTPS. This keeps the
+ directory layout of the pack intact -- release assets live in a flat
+ namespace and would force profiles/kodak_portra_400.json to be renamed to
+ something like profiles-kodak_portra_400.json on the way up and back down. */
+#define SF_RAW_HOST "https://raw.githubusercontent.com"
+
+/* Bounds on anything the manifest can ask us to do. A manifest is remote input:
+ it decides how many files we create and how many bytes we write, so it needs
+ a ceiling that does not depend on it being well-formed. The spectral LUT is
+ the only large file at roughly 12 MB; the rest are JSON in the tens of KB. */
+#define SF_MAX_FILES 512
+#define SF_MAX_FILE_BYTES (64u * 1024u * 1024u)
+#define SF_MAX_TOTAL_BYTES (256u * 1024u * 1024u)
+#define SF_MAX_MANIFEST_BYTES (4u * 1024u * 1024u)
+
+/* Long enough for a 12 MB file on a slow link, short enough that a dead host
+ does not leave the thread parked forever. Connect is separate and tight. */
+#define SF_CONNECT_TIMEOUT 10L
+#define SF_TRANSFER_TIMEOUT 600L
+
+/* ---------------------------------------------------------------------- */
+/* module state */
+/* ---------------------------------------------------------------------- */
+
+typedef struct sf_fetch_file_t
+{
+ char *path; /* pack-relative, validated: no absolute paths, no ".." */
+ char *sha256; /* lowercase hex, 64 chars */
+ guint64 size; /* advertised; enforced as a ceiling while downloading */
+} sf_fetch_file_t;
+
+static struct
+{
+ GMutex lock; /* guards every field below */
+ GThread *thread; /* non-NULL while a fetch is in flight */
+ sf_fetch_state_t state;
+ gboolean cancel;
+ double progress;
+ char message[256];
+ guint generation; /* bumped whenever an install changes what is on disk */
+ gboolean inited;
+} _sf = { .state = SF_FETCH_IDLE };
+
+guint sf_fetch_generation(void)
+{
+ if(!_sf.inited) return 0;
+ g_mutex_lock(&_sf.lock);
+ const guint g = _sf.generation;
+ g_mutex_unlock(&_sf.lock);
+ return g;
+}
+
+static void _set_status(const sf_fetch_state_t state,
+ const double progress,
+ const char *msg)
+{
+ g_mutex_lock(&_sf.lock);
+ _sf.state = state;
+ if(progress >= 0.0) _sf.progress = CLAMP(progress, 0.0, 1.0);
+ if(msg) g_strlcpy(_sf.message, msg, sizeof(_sf.message));
+ g_mutex_unlock(&_sf.lock);
+}
+
+static gboolean _cancelled(void)
+{
+ g_mutex_lock(&_sf.lock);
+ const gboolean c = _sf.cancel;
+ g_mutex_unlock(&_sf.lock);
+ return c;
+}
+
+void sf_fetch_init(void)
+{
+ if(_sf.inited) return;
+ g_mutex_init(&_sf.lock);
+ _sf.state = SF_FETCH_IDLE;
+ _sf.inited = TRUE;
+}
+
+void sf_fetch_cleanup(void)
+{
+ if(!_sf.inited) return;
+ sf_fetch_cancel();
+ /* Join rather than detach: the worker writes into _sf and into the pack
+ directory, and darktable is on its way down. Letting it run past the mutex
+ being cleared is a use-after-free waiting for a slow link to time out. */
+ GThread *t = NULL;
+ g_mutex_lock(&_sf.lock);
+ t = _sf.thread;
+ _sf.thread = NULL;
+ g_mutex_unlock(&_sf.lock);
+ if(t) g_thread_join(t);
+ g_mutex_clear(&_sf.lock);
+ _sf.inited = FALSE;
+}
+
+gboolean sf_fetch_downloads_enabled(void)
+{
+ return dt_conf_key_exists(CONF_ENABLED) ? dt_conf_get_bool(CONF_ENABLED) : FALSE;
+}
+
+sf_fetch_state_t sf_fetch_status(char *msg, size_t msgsz, double *progress)
+{
+ g_mutex_lock(&_sf.lock);
+ const sf_fetch_state_t s = _sf.state;
+ if(msg && msgsz) g_strlcpy(msg, _sf.message, msgsz);
+ if(progress) *progress = _sf.progress;
+ g_mutex_unlock(&_sf.lock);
+ return s;
+}
+
+void sf_fetch_cancel(void)
+{
+ g_mutex_lock(&_sf.lock);
+ _sf.cancel = TRUE;
+ g_mutex_unlock(&_sf.lock);
+}
+
+/* ---------------------------------------------------------------------- */
+/* local pack discovery */
+/* ---------------------------------------------------------------------- */
+
+static void _config_pack_dir(char *dst, size_t dstsz)
+{
+ char cfg[PATH_MAX] = { 0 };
+ dt_loc_get_user_config_dir(cfg, sizeof(cfg));
+ g_snprintf(dst, dstsz, "%s%sspektrafilm", cfg, G_DIR_SEPARATOR_S);
+}
+
+/* Downloaded packs live under the config directory, not the cache directory.
+ Three reasons, in order of how much they bite:
+
+ - A pack is not reconstructible from anything local. Clearing the cache is
+ something users and packagers do freely, and doing it here would silently
+ make every edit developed against a non-current spectral table
+ unreproducible until it was fetched again.
+ - The temp directory and the final directory end up on the same filesystem,
+ so the install rename is atomic. Config and cache can sit on different
+ mounts, where rename() fails with EXDEV.
+ - It backs up with the rest of the darktable configuration, which is where
+ someone would expect to find data an edit depends on.
+
+ The `packs` subdirectory keeps them apart from a hand-installed pack, which
+ lives directly in /spektrafilm/. That separation is what lets the
+ resolver promise that a download never overwrites what the user put there. */
+static void _packs_dir(char *dst, size_t dstsz)
+{
+ char cfg[PATH_MAX] = { 0 };
+ dt_loc_get_user_config_dir(cfg, sizeof(cfg));
+ g_snprintf(dst, dstsz, "%s%sspektrafilm%spacks", cfg, G_DIR_SEPARATOR_S,
+ G_DIR_SEPARATOR_S);
+}
+
+/* Read the identity out of a pack's spectra_lut.f32 without loading it.
+ *
+ * The header is fixed-width up to the id string: "SFS2", int32 header version,
+ * int32 dims[3], int32 dtype, uint32 lut_hash, int32 id_len. Reading those 32
+ * bytes answers "is this the pack that edit wants" for the price of one open,
+ * where sf_pack_load() would pull roughly 12 MB of floats through the page
+ * cache to answer the same question. That matters because the answer is needed
+ * once per candidate directory, on the pixelpipe thread. */
+static gboolean _peek_lut_hash(const char *packdir, uint32_t *out_hash)
+{
+ gboolean ok = FALSE;
+ char *lut = g_build_filename(packdir, "spectra_lut.f32", NULL);
+ char *meta = g_build_filename(packdir, "pack.json", NULL);
+ char *profiles = g_build_filename(packdir, "profiles", NULL);
+
+ /* All three have to be there. A LUT and a pack.json with no profiles beside
+ them is not a usable pack -- it is a spectral table with no film to apply
+ it to, which is what a download looks like partway through and what a
+ half-deleted directory looks like afterwards. Accepting it would mean
+ resolving to a directory that then fails at profile load, which reads as
+ the module being broken rather than the data being absent. */
+ if(!g_file_test(meta, G_FILE_TEST_IS_REGULAR)) goto out;
+ if(!g_file_test(profiles, G_FILE_TEST_IS_DIR)) goto out;
+
+ FILE *fh = g_fopen(lut, "rb");
+ if(!fh) goto out;
+
+ char magic[4];
+ int32_t hdr_version = 0, dims[3], dtype = 0;
+ uint32_t lut_hash = 0;
+ if(fread(magic, 1, 4, fh) == 4 && memcmp(magic, "SFS2", 4) == 0
+ && fread(&hdr_version, 4, 1, fh) == 1 && hdr_version == 2
+ && fread(dims, 4, 3, fh) == 3 && fread(&dtype, 4, 1, fh) == 1
+ && fread(&lut_hash, 4, 1, fh) == 1)
+ {
+ *out_hash = lut_hash;
+ ok = TRUE;
+ }
+ fclose(fh);
+
+out:
+ g_free(lut);
+ g_free(meta);
+ g_free(profiles);
+ return ok;
+}
+
+static gboolean _downloaded_dir_for_hash(const uint32_t lut_hash, char *dst, size_t dstsz)
+{
+ char packs[PATH_MAX] = { 0 };
+ _packs_dir(packs, sizeof(packs));
+ g_snprintf(dst, dstsz, "%s%s%08x", packs, G_DIR_SEPARATOR_S, lut_hash);
+ uint32_t got = 0;
+ /* Trust the header, not the directory name. A user can copy a directory to
+ the wrong name and a truncated download can leave a plausible-looking
+ tree behind; both would otherwise be served as a match. */
+ return _peek_lut_hash(dst, &got) && got == lut_hash;
+}
+
+gboolean sf_fetch_have_lut_hash(const uint32_t lut_hash)
+{
+ if(!lut_hash) return FALSE;
+
+ char cfgdir[PATH_MAX] = { 0 };
+ _config_pack_dir(cfgdir, sizeof(cfgdir));
+ uint32_t got = 0;
+ if(_peek_lut_hash(cfgdir, &got) && got == lut_hash) return TRUE;
+
+ char cachedir[PATH_MAX] = { 0 };
+ return _downloaded_dir_for_hash(lut_hash, cachedir, sizeof(cachedir));
+}
+
+gboolean sf_fetch_resolve_pack_dir(const uint32_t wanted_lut_hash,
+ char *dst,
+ const size_t dstsz,
+ gboolean *out_exact)
+{
+ if(out_exact) *out_exact = FALSE;
+
+ char cfgdir[PATH_MAX] = { 0 };
+ _config_pack_dir(cfgdir, sizeof(cfgdir));
+ uint32_t cfg_hash = 0;
+ const gboolean cfg_ok = _peek_lut_hash(cfgdir, &cfg_hash);
+
+ /* No recorded preference: whatever is installed by hand is the answer. This
+ is the common path -- a fresh edit on a machine with a pack in place must
+ not consult the network or the downloaded packs at all. */
+ if(!wanted_lut_hash)
+ {
+ if(cfg_ok)
+ {
+ g_strlcpy(dst, cfgdir, dstsz);
+ if(out_exact) *out_exact = TRUE;
+ return TRUE;
+ }
+ }
+ else if(cfg_ok && cfg_hash == wanted_lut_hash)
+ {
+ g_strlcpy(dst, cfgdir, dstsz);
+ if(out_exact) *out_exact = TRUE;
+ return TRUE;
+ }
+
+ /* A specific table was asked for and the config directory cannot supply it.
+ Look for a downloaded one. */
+ if(wanted_lut_hash)
+ {
+ char cachedir[PATH_MAX] = { 0 };
+ if(_downloaded_dir_for_hash(wanted_lut_hash, cachedir, sizeof(cachedir)))
+ {
+ g_strlcpy(dst, cachedir, dstsz);
+ if(out_exact) *out_exact = TRUE;
+ return TRUE;
+ }
+ }
+
+ /* Nothing matches. Fall back to the config pack if there is one: rendering
+ with the wrong spectral table and a visible warning beats refusing to
+ render, and the module already words that warning precisely. */
+ if(cfg_ok)
+ {
+ g_strlcpy(dst, cfgdir, dstsz);
+ return TRUE;
+ }
+
+ /* Last resort: any downloaded pack, newest first. Reached when the user has
+ never installed one by hand and is opening an edit whose table was never
+ downloaded, but some other table was. */
+ char packs[PATH_MAX] = { 0 };
+ _packs_dir(packs, sizeof(packs));
+ GDir *d = g_dir_open(packs, 0, NULL);
+ if(d)
+ {
+ const char *ent = NULL;
+ char best[PATH_MAX] = { 0 };
+ gint64 best_mtime = -1;
+ while((ent = g_dir_read_name(d)))
+ {
+ /* Only ever consider a directory named for the table it claims to hold,
+ and only when the LUT header agrees with that name.
+
+ This is what keeps a download in progress invisible. The temp
+ directory is a sibling of the finished ones -- it has to be, so the
+ install rename stays on one filesystem and stays atomic -- and
+ pack.json and spectra_lut.f32 are the first two files fetched. From
+ that moment until the last profile lands, the temp directory looks
+ like a loadable pack to anything that just peeks at the header. A
+ reader picking it up would get a spectral table with no film profiles
+ behind it. Requiring a bare 8-hex-digit name excludes it by
+ construction, since it is named ".incoming-" precisely so it
+ cannot pass. */
+ if(strlen(ent) != 8 || strspn(ent, "0123456789abcdefABCDEF") != 8) continue;
+
+ char *cand = g_build_filename(packs, ent, NULL);
+ uint32_t h = 0;
+ const uint32_t named = (uint32_t)g_ascii_strtoull(ent, NULL, 16);
+ if(_peek_lut_hash(cand, &h) && h == named)
+ {
+ GStatBuf st;
+ const gint64 mt = (g_stat(cand, &st) == 0) ? (gint64)st.st_mtime : 0;
+ if(mt > best_mtime)
+ {
+ best_mtime = mt;
+ g_strlcpy(best, cand, sizeof(best));
+ }
+ }
+ g_free(cand);
+ }
+ g_dir_close(d);
+ if(best[0])
+ {
+ g_strlcpy(dst, best, dstsz);
+ return TRUE;
+ }
+ }
+
+ return FALSE;
+}
+
+/* ---------------------------------------------------------------------- */
+/* http */
+/* ---------------------------------------------------------------------- */
+
+typedef struct sf_buf_t
+{
+ char *data;
+ size_t len;
+ size_t cap;
+} sf_buf_t;
+
+static size_t _write_to_buf(void *ptr, size_t size, size_t nmemb, void *userdata)
+{
+ sf_buf_t *b = (sf_buf_t *)userdata;
+ const size_t n = size * nmemb;
+ if(b->len + n > b->cap) return 0; /* refuse rather than grow without bound */
+ memcpy(b->data + b->len, ptr, n);
+ b->len += n;
+ return n;
+}
+
+typedef struct sf_dl_t
+{
+ FILE *fh;
+ guint64 written;
+ guint64 limit; /* hard ceiling for this one file */
+ guint64 done_bytes; /* bytes finished before this file, for overall progress */
+ guint64 total_bytes;
+} sf_dl_t;
+
+static size_t _write_to_file(void *ptr, size_t size, size_t nmemb, void *userdata)
+{
+ sf_dl_t *d = (sf_dl_t *)userdata;
+ const size_t n = size * nmemb;
+ if(d->written + n > d->limit) return 0; /* server sent more than advertised */
+ const size_t w = fwrite(ptr, 1, n, d->fh);
+ d->written += w;
+ return w;
+}
+
+static int _progress_cb(void *clientp,
+ curl_off_t dltotal,
+ curl_off_t dlnow,
+ curl_off_t ultotal,
+ curl_off_t ulnow)
+{
+ (void)dltotal;
+ (void)ultotal;
+ (void)ulnow;
+ if(_cancelled()) return 1; /* aborts the transfer */
+
+ const sf_dl_t *d = (const sf_dl_t *)clientp;
+ if(d && d->total_bytes)
+ {
+ const double got = (double)(d->done_bytes + (guint64)dlnow);
+ _set_status(SF_FETCH_RUNNING, got / (double)d->total_bytes, NULL);
+ }
+ return 0;
+}
+
+static void _curl_common(CURL *curl, const char *url)
+{
+ dt_curl_init(curl, FALSE);
+ curl_easy_setopt(curl, CURLOPT_URL, url);
+ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
+ /* Redirects are expected -- the raw host hands off to a CDN -- but they must
+ stay on https, or a hijacked redirect could downgrade the transfer.
+ CURLOPT_REDIR_PROTOCOLS_STR arrived in 7.85; darktable's floor is 7.56, so
+ fall back to the deprecated bitmask below that. */
+#if LIBCURL_VERSION_NUM >= 0x075500
+ curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
+#else
+ curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
+#endif
+ curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, SF_CONNECT_TIMEOUT);
+ curl_easy_setopt(curl, CURLOPT_TIMEOUT, SF_TRANSFER_TIMEOUT);
+ curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L);
+ curl_easy_setopt(curl, CURLOPT_USERAGENT, "darktable-spektrafilm");
+}
+
+/* GET a small document into memory. Returns a NUL-terminated string the caller
+ frees, or NULL. */
+static char *_http_get_string(CURL *curl, const char *url, const size_t maxlen)
+{
+ sf_buf_t buf = { .data = g_malloc0(maxlen + 1), .len = 0, .cap = maxlen };
+
+ curl_easy_reset(curl);
+ _curl_common(curl, url);
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_to_buf);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
+
+ const CURLcode res = curl_easy_perform(curl);
+ if(res != CURLE_OK)
+ {
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] GET %s failed: %s", url,
+ curl_easy_strerror(res));
+ g_free(buf.data);
+ return NULL;
+ }
+ buf.data[buf.len] = 0;
+ return buf.data;
+}
+
+static gboolean _http_get_file(CURL *curl,
+ const char *url,
+ const char *path,
+ sf_dl_t *dl)
+{
+ FILE *fh = g_fopen(path, "wb");
+ if(!fh)
+ {
+ dt_print(DT_DEBUG_ALWAYS, "[spektrafilm] cannot write %s: %s", path,
+ strerror(errno));
+ return FALSE;
+ }
+ dl->fh = fh;
+ dl->written = 0;
+
+ curl_easy_reset(curl);
+ _curl_common(curl, url);
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_to_file);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, dl);
+ curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
+ curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, _progress_cb);
+ curl_easy_setopt(curl, CURLOPT_XFERINFODATA, dl);
+
+ const CURLcode res = curl_easy_perform(curl);
+ fclose(fh);
+ dl->fh = NULL;
+
+ if(res != CURLE_OK)
+ {
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] GET %s failed: %s", url,
+ curl_easy_strerror(res));
+ g_unlink(path);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/* ---------------------------------------------------------------------- */
+/* integrity */
+/* ---------------------------------------------------------------------- */
+
+static char *_sha256_file(const char *path)
+{
+ FILE *fh = g_fopen(path, "rb");
+ if(!fh) return NULL;
+
+ GChecksum *sum = g_checksum_new(G_CHECKSUM_SHA256);
+ guchar buf[64 * 1024];
+ size_t n;
+ while((n = fread(buf, 1, sizeof(buf), fh)) > 0) g_checksum_update(sum, buf, n);
+ fclose(fh);
+
+ char *hex = g_ascii_strdown(g_checksum_get_string(sum), -1);
+ g_checksum_free(sum);
+ return hex;
+}
+
+static gboolean _verify(const char *path, const char *expected_hex)
+{
+ char *got = _sha256_file(path);
+ if(!got) return FALSE;
+ const gboolean ok = (g_ascii_strcasecmp(got, expected_hex) == 0);
+ if(!ok)
+ dt_print(DT_DEBUG_ALWAYS,
+ "[spektrafilm] checksum mismatch for %s (expected %s, got %s)", path,
+ expected_hex, got);
+ g_free(got);
+ return ok;
+}
+
+/* ---------------------------------------------------------------------- */
+/* manifest */
+/* ---------------------------------------------------------------------- */
+
+static gboolean _valid_repository(const char *repo)
+{
+ return repo
+ && g_regex_match_simple("^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", repo, 0, 0);
+}
+
+static gboolean _valid_ref(const char *ref)
+{
+ return ref && g_regex_match_simple("^[A-Za-z0-9._/-]{1,128}$", ref, 0, 0)
+ && !strstr(ref, "..");
+}
+
+/* Every path in the manifest becomes a file we create. The manifest is remote
+ input, so a path that escapes the destination directory is a write-anywhere
+ primitive -- reject rather than sanitise, so a manifest that tries it fails
+ loudly instead of being quietly rewritten into something that works. */
+static gboolean _valid_relpath(const char *p)
+{
+ if(!p || !*p) return FALSE;
+ if(strlen(p) > 255) return FALSE;
+ if(g_path_is_absolute(p)) return FALSE;
+ if(p[0] == '/' || p[0] == '\\' || p[0] == '.') return FALSE;
+ if(strstr(p, "..")) return FALSE;
+ if(strchr(p, '\\')) return FALSE;
+ if(strchr(p, ':')) return FALSE; /* drive letters and NTFS streams */
+ /* one optional subdirectory (profiles/), nothing deeper */
+ const char *slash = strchr(p, '/');
+ if(slash && strchr(slash + 1, '/')) return FALSE;
+ return g_regex_match_simple("^[A-Za-z0-9._/-]+$", p, 0, 0);
+}
+
+static gboolean _valid_sha256(const char *s)
+{
+ return s && strlen(s) == 64
+ && g_regex_match_simple("^[A-Fa-f0-9]{64}$", s, 0, 0);
+}
+
+static void _files_free(GPtrArray *files)
+{
+ if(!files) return;
+ for(guint i = 0; i < files->len; i++)
+ {
+ sf_fetch_file_t *f = g_ptr_array_index(files, i);
+ g_free(f->path);
+ g_free(f->sha256);
+ g_free(f);
+ }
+ g_ptr_array_free(files, TRUE);
+}
+
+/* Parse the manifest and pull out the requested pack.
+ *
+ * Shape:
+ * { "format": 1,
+ * "packs": [ { "lut_hash": "a1b2c3d4", "lut_id": "...", "default": true,
+ * "base": "packs/2026-07",
+ * "files": [ {"path": "pack.json", "size": 1234,
+ * "sha256": "..."} ] } ] }
+ *
+ * wanted 0 picks the entry flagged default, else the first one. */
+static GPtrArray *_parse_manifest(const char *json,
+ const uint32_t wanted,
+ char **out_base,
+ uint32_t *out_hash,
+ guint64 *out_total,
+ int *out_unsupported_fmt)
+{
+ /* 0 when nothing was skipped for its format, else the format that was --
+ which way it misses decides what to tell the user, and the two point at
+ opposite fixes. */
+ if(out_unsupported_fmt) *out_unsupported_fmt = 0;
+
+ GPtrArray *files = NULL;
+ JsonParser *parser = json_parser_new();
+ GError *err = NULL;
+
+ if(!json_parser_load_from_data(parser, json, -1, &err))
+ {
+ dt_print(DT_DEBUG_ALWAYS, "[spektrafilm] bad manifest: %s",
+ err ? err->message : "parse error");
+ g_clear_error(&err);
+ goto out;
+ }
+
+ JsonNode *root = json_parser_get_root(parser);
+ if(!root || !JSON_NODE_HOLDS_OBJECT(root)) goto out;
+ JsonObject *robj = json_node_get_object(root);
+
+ if(!json_object_has_member(robj, "format")
+ || json_object_get_int_member(robj, "format") != 1)
+ {
+ dt_print(DT_DEBUG_ALWAYS,
+ "[spektrafilm] manifest format not understood by this build");
+ goto out;
+ }
+ if(!json_object_has_member(robj, "packs")) goto out;
+
+ JsonArray *packs = json_object_get_array_member(robj, "packs");
+ if(!packs) goto out;
+
+ JsonObject *chosen = NULL;
+ uint32_t chosen_hash = 0;
+ int saw_unsupported_fmt = 0;
+ const guint npacks = json_array_get_length(packs);
+ for(guint i = 0; i < npacks && !chosen; i++)
+ {
+ JsonObject *p = json_array_get_object_element(packs, i);
+ if(!p || !json_object_has_member(p, "lut_hash")) continue;
+
+ const char *hs = json_object_get_string_member(p, "lut_hash");
+ if(!hs) continue;
+ const uint32_t h = (uint32_t)g_ascii_strtoull(hs, NULL, 16);
+ if(!h) continue;
+
+ /* Skip anything this build could not load anyway. Checking here rather
+ than after the download is the difference between a clear message and
+ several MB spent on a pack that sf_pack_load() will reject. The field is
+ required rather than defaulted: an entry without it would fail the same
+ check in the loader after being downloaded, so accepting it here only
+ moves the error later. */
+ const int fmt = json_object_has_member(p, "pack_format")
+ ? (int)json_object_get_int_member(p, "pack_format")
+ : 0;
+ if(fmt < SF_PACK_FORMAT_MIN || fmt > SF_PACK_FORMAT_MAX)
+ {
+ dt_print(DT_DEBUG_DEV,
+ "[spektrafilm] manifest pack %08x is format %d, this build reads"
+ " %d..%d -- skipping",
+ h, fmt, SF_PACK_FORMAT_MIN, SF_PACK_FORMAT_MAX);
+ if(!wanted || h == wanted) saw_unsupported_fmt = fmt;
+ continue;
+ }
+
+ if(wanted)
+ {
+ if(h == wanted) { chosen = p; chosen_hash = h; }
+ }
+ else if(json_object_has_member(p, "default")
+ && json_object_get_boolean_member(p, "default"))
+ {
+ chosen = p;
+ chosen_hash = h;
+ }
+ }
+ /* No default flagged and none requested: take the first readable entry. The
+ format filter has to be repeated here -- taking "the first entry" without
+ it would hand back exactly the pack the loop above rejected. */
+ if(!chosen && !wanted)
+ {
+ for(guint i = 0; i < npacks && !chosen; i++)
+ {
+ JsonObject *p = json_array_get_object_element(packs, i);
+ if(!p || !json_object_has_member(p, "lut_hash")) continue;
+ const char *hs = json_object_get_string_member(p, "lut_hash");
+ const uint32_t h = hs ? (uint32_t)g_ascii_strtoull(hs, NULL, 16) : 0;
+ if(!h) continue;
+ const int fmt = json_object_has_member(p, "pack_format")
+ ? (int)json_object_get_int_member(p, "pack_format")
+ : 0;
+ if(fmt < SF_PACK_FORMAT_MIN || fmt > SF_PACK_FORMAT_MAX) continue;
+ chosen = p;
+ chosen_hash = h;
+ }
+ }
+ if(!chosen)
+ {
+ /* Distinguish "the repository has nothing for you" from "it has exactly
+ what you asked for, but this darktable cannot read it" -- and, within
+ the second, which side the mismatch falls on. A pack newer than this
+ build means update darktable; one older means the repository is behind
+ and needs re-exporting, which is somebody else's job entirely. Reporting
+ both as an upgrade prompt sends half of them after a release that does
+ not exist. */
+ if(out_unsupported_fmt) *out_unsupported_fmt = saw_unsupported_fmt;
+ goto out;
+ }
+
+ const char *base = json_object_has_member(chosen, "base")
+ ? json_object_get_string_member(chosen, "base")
+ : NULL;
+ if(!base || !_valid_relpath(base)) goto out;
+
+ JsonArray *farr = json_object_has_member(chosen, "files")
+ ? json_object_get_array_member(chosen, "files")
+ : NULL;
+ if(!farr) goto out;
+
+ const guint nfiles = json_array_get_length(farr);
+ if(!nfiles || nfiles > SF_MAX_FILES)
+ {
+ dt_print(DT_DEBUG_ALWAYS, "[spektrafilm] manifest lists %u files, refusing",
+ nfiles);
+ goto out;
+ }
+
+ files = g_ptr_array_new();
+ guint64 total = 0;
+ gboolean have_meta = FALSE, have_lut = FALSE;
+
+ for(guint i = 0; i < nfiles; i++)
+ {
+ JsonObject *fo = json_array_get_object_element(farr, i);
+ if(!fo) goto bad;
+
+ const char *path = json_object_has_member(fo, "path")
+ ? json_object_get_string_member(fo, "path")
+ : NULL;
+ const char *sha = json_object_has_member(fo, "sha256")
+ ? json_object_get_string_member(fo, "sha256")
+ : NULL;
+ const guint64 size = json_object_has_member(fo, "size")
+ ? (guint64)json_object_get_int_member(fo, "size")
+ : 0;
+
+ /* Every file must carry a checksum. Installing an unverified file is worse
+ than not installing it: the pack drives colour rendering, and a corrupt
+ LUT renders plausibly wrong rather than failing. */
+ if(!_valid_relpath(path) || !_valid_sha256(sha)) goto bad;
+ if(!size || size > SF_MAX_FILE_BYTES) goto bad;
+
+ total += size;
+ if(total > SF_MAX_TOTAL_BYTES) goto bad;
+
+ if(!g_strcmp0(path, "pack.json")) have_meta = TRUE;
+ if(!g_strcmp0(path, "spectra_lut.f32")) have_lut = TRUE;
+
+ sf_fetch_file_t *f = g_malloc0(sizeof(sf_fetch_file_t));
+ f->path = g_strdup(path);
+ f->sha256 = g_ascii_strdown(sha, -1);
+ f->size = size;
+ g_ptr_array_add(files, f);
+ }
+
+ /* A pack without these two is not loadable, and finding that out after
+ writing 200 files is a worse error message than finding it out now. */
+ if(!have_meta || !have_lut)
+ {
+ dt_print(DT_DEBUG_ALWAYS,
+ "[spektrafilm] manifest entry lacks pack.json or spectra_lut.f32");
+ goto bad;
+ }
+
+ *out_base = g_strdup(base);
+ *out_hash = chosen_hash;
+ *out_total = total;
+ g_object_unref(parser);
+ return files;
+
+bad:
+ _files_free(files);
+ files = NULL;
+out:
+ g_object_unref(parser);
+ return files;
+}
+
+/* ---------------------------------------------------------------------- */
+/* install */
+/* ---------------------------------------------------------------------- */
+
+static gboolean _rmdir_recursive(const char *path)
+{
+ GDir *d = g_dir_open(path, 0, NULL);
+ if(d)
+ {
+ const char *ent;
+ while((ent = g_dir_read_name(d)))
+ {
+ char *child = g_build_filename(path, ent, NULL);
+ if(g_file_test(child, G_FILE_TEST_IS_DIR))
+ _rmdir_recursive(child);
+ else
+ g_unlink(child);
+ g_free(child);
+ }
+ g_dir_close(d);
+ }
+ return g_rmdir(path) == 0;
+}
+
+typedef struct sf_worker_args_t
+{
+ uint32_t wanted;
+} sf_worker_args_t;
+
+/* Reprocess so the freshly installed pack takes effect without the user having
+ to reopen the image. Runs on the GUI thread; the worker cannot touch the
+ pixelpipe itself. */
+static gboolean _finished_idle(gpointer user_data)
+{
+ const gboolean ok = GPOINTER_TO_INT(user_data);
+ char msg[256] = { 0 };
+ sf_fetch_status(msg, sizeof(msg), NULL);
+
+ if(ok)
+ {
+ dt_control_log(_("spektrafilm: data pack installed"));
+ if(darktable.develop) dt_dev_reprocess_all(darktable.develop);
+ }
+ else
+ dt_control_log(_("spektrafilm: data pack download failed -- %s"), msg);
+
+ return G_SOURCE_REMOVE;
+}
+
+static gpointer _fetch_worker(gpointer data)
+{
+ sf_worker_args_t *args = (sf_worker_args_t *)data;
+ const uint32_t wanted = args->wanted;
+ g_free(args);
+
+ gboolean success = FALSE;
+ char *repo = NULL, *ref = NULL, *manifest_url = NULL, *manifest = NULL;
+ char *base = NULL, *tmpdir = NULL, *destdir = NULL, *profdir = NULL;
+ GPtrArray *files = NULL;
+ CURL *curl = NULL;
+
+ repo = dt_conf_key_exists(CONF_REPOSITORY) ? dt_conf_get_string(CONF_REPOSITORY)
+ : g_strdup(SF_DEFAULT_REPOSITORY);
+ ref = dt_conf_key_exists(CONF_REF) ? dt_conf_get_string(CONF_REF)
+ : g_strdup(SF_DEFAULT_REF);
+
+ if(!_valid_repository(repo) || !_valid_ref(ref))
+ {
+ _set_status(SF_FETCH_FAILED, -1.0, _("invalid repository configuration"));
+ goto out;
+ }
+
+ curl = curl_easy_init();
+ if(!curl)
+ {
+ _set_status(SF_FETCH_FAILED, -1.0, _("could not initialise download"));
+ goto out;
+ }
+
+ _set_status(SF_FETCH_RUNNING, 0.0, _("fetching manifest"));
+ manifest_url =
+ g_strdup_printf("%s/%s/%s/manifest.json", SF_RAW_HOST, repo, ref);
+ manifest = _http_get_string(curl, manifest_url, SF_MAX_MANIFEST_BYTES);
+ if(!manifest)
+ {
+ _set_status(SF_FETCH_FAILED, -1.0, _("could not reach the data repository"));
+ goto out;
+ }
+ if(_cancelled()) goto out;
+
+ uint32_t got_hash = 0;
+ guint64 total = 0;
+ int unsupported_fmt = 0;
+ files = _parse_manifest(manifest, wanted, &base, &got_hash, &total, &unsupported_fmt);
+ if(!files)
+ {
+ _set_status(SF_FETCH_FAILED, -1.0,
+ unsupported_fmt > SF_PACK_FORMAT_MAX
+ ? _("that data pack needs a newer darktable")
+ : unsupported_fmt
+ ? _("that data pack is too old for this darktable -- "
+ "the data repository needs re-exporting")
+ : (wanted ? _("no pack with that spectral table is published")
+ : _("could not read the pack manifest")));
+ goto out;
+ }
+
+ /* Download into a sibling temp directory and rename it into place at the end.
+ A half-written pack directory would be indistinguishable from a complete
+ one at the next startup: pack.json and a truncated LUT is exactly what
+ _peek_lut_hash accepts. */
+ char packs[PATH_MAX] = { 0 };
+ _packs_dir(packs, sizeof(packs));
+ if(g_mkdir_with_parents(packs, 0700))
+ {
+ _set_status(SF_FETCH_FAILED, -1.0, _("cannot create the pack directory"));
+ goto out;
+ }
+
+ destdir = g_strdup_printf("%s%s%08x", packs, G_DIR_SEPARATOR_S, got_hash);
+ tmpdir = g_strdup_printf("%s%s.incoming-%08x", packs, G_DIR_SEPARATOR_S, got_hash);
+ _rmdir_recursive(tmpdir); /* leftovers from an interrupted run */
+ profdir = g_build_filename(tmpdir, "profiles", NULL);
+ if(g_mkdir_with_parents(profdir, 0700))
+ {
+ _set_status(SF_FETCH_FAILED, -1.0, _("cannot create the pack directory"));
+ goto out;
+ }
+
+ sf_dl_t dl = { .total_bytes = total, .done_bytes = 0 };
+
+ for(guint i = 0; i < files->len; i++)
+ {
+ if(_cancelled())
+ {
+ _set_status(SF_FETCH_FAILED, -1.0, _("cancelled"));
+ goto out;
+ }
+
+ const sf_fetch_file_t *f = g_ptr_array_index(files, i);
+
+ char progress_msg[256];
+ g_snprintf(progress_msg, sizeof(progress_msg), _("downloading %s (%u/%u)"),
+ f->path, i + 1, files->len);
+ _set_status(SF_FETCH_RUNNING, -1.0, progress_msg);
+
+ char *url =
+ g_strdup_printf("%s/%s/%s/%s/%s", SF_RAW_HOST, repo, ref, base, f->path);
+ char *dest = g_build_filename(tmpdir, f->path, NULL);
+
+ /* One byte of slack over the advertised size so an off-by-one in the
+ exporter does not fail the whole install; the checksum is what actually
+ decides whether the bytes are right. */
+ dl.limit = f->size + 1;
+ const gboolean ok = _http_get_file(curl, url, dest, &dl);
+ if(ok) dl.done_bytes += dl.written;
+
+ g_free(url);
+
+ if(!ok || !_verify(dest, f->sha256))
+ {
+ g_free(dest);
+ _set_status(SF_FETCH_FAILED, -1.0,
+ ok ? _("a downloaded file failed its checksum")
+ : _("a file could not be downloaded"));
+ goto out;
+ }
+ g_free(dest);
+ }
+
+ /* The pack must actually carry the table the manifest claimed, or the
+ directory name is a lie and every later lookup for that hash misses. */
+ uint32_t installed_hash = 0;
+ if(!_peek_lut_hash(tmpdir, &installed_hash) || installed_hash != got_hash)
+ {
+ _set_status(SF_FETCH_FAILED, -1.0,
+ _("the downloaded pack does not carry the expected table"));
+ goto out;
+ }
+
+ _rmdir_recursive(destdir); /* replacing an older copy of the same hash */
+ if(g_rename(tmpdir, destdir) != 0)
+ {
+ dt_print(DT_DEBUG_ALWAYS, "[spektrafilm] cannot install pack into %s: %s",
+ destdir, strerror(errno));
+ _set_status(SF_FETCH_FAILED, -1.0, _("could not install the downloaded pack"));
+ goto out;
+ }
+
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] installed data pack %08x into %s",
+ got_hash, destdir);
+ /* Publish the new state before the status flips to DONE, so anything woken
+ by the completion sees a generation that already accounts for this
+ install rather than racing it. */
+ g_mutex_lock(&_sf.lock);
+ _sf.generation++;
+ g_mutex_unlock(&_sf.lock);
+ _set_status(SF_FETCH_DONE, 1.0, _("done"));
+ success = TRUE;
+
+out:
+ if(!success && tmpdir) _rmdir_recursive(tmpdir);
+ if(curl) curl_easy_cleanup(curl);
+ _files_free(files);
+ g_free(manifest);
+ g_free(manifest_url);
+ g_free(base);
+ g_free(repo);
+ g_free(ref);
+ g_free(tmpdir);
+ g_free(destdir);
+ g_free(profdir);
+
+ g_mutex_lock(&_sf.lock);
+ _sf.cancel = FALSE;
+ g_mutex_unlock(&_sf.lock);
+
+ if(darktable.gui)
+ g_idle_add(_finished_idle, GINT_TO_POINTER(success ? 1 : 0));
+
+ return NULL;
+}
+
+gboolean sf_fetch_start(const uint32_t wanted_lut_hash)
+{
+ if(!_sf.inited) return FALSE;
+
+ if(!sf_fetch_downloads_enabled())
+ {
+ dt_control_log(
+ _("spektrafilm: downloading data is disabled in preferences"));
+ return FALSE;
+ }
+
+ g_mutex_lock(&_sf.lock);
+ if(_sf.state == SF_FETCH_RUNNING)
+ {
+ g_mutex_unlock(&_sf.lock);
+ return FALSE;
+ }
+ /* The previous run left its handle behind so this one can join it. Joining a
+ thread that has already returned is cheap and reaps it; skipping this leaks
+ one GThread per fetch and, worse, leaves _sf.thread non-NULL forever, which
+ would make every later fetch look like one already in flight. */
+ GThread *prev = _sf.thread;
+ _sf.thread = NULL;
+ g_mutex_unlock(&_sf.lock);
+ if(prev) g_thread_join(prev);
+
+ g_mutex_lock(&_sf.lock);
+ _sf.cancel = FALSE;
+ _sf.progress = 0.0;
+ _sf.state = SF_FETCH_RUNNING;
+ g_strlcpy(_sf.message, _("starting"), sizeof(_sf.message));
+
+ sf_worker_args_t *args = g_malloc0(sizeof(sf_worker_args_t));
+ args->wanted = wanted_lut_hash;
+ _sf.thread = g_thread_new("sf-fetch", _fetch_worker, args);
+ const gboolean started = _sf.thread != NULL;
+ if(!started) _sf.state = SF_FETCH_FAILED;
+ g_mutex_unlock(&_sf.lock);
+
+ return started;
+}
+
+// clang-format off
+// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
+// clang-format on
diff --git a/src/common/spektra_fetch.h b/src/common/spektra_fetch.h
new file mode 100644
index 000000000000..bbeb2419ac9a
--- /dev/null
+++ b/src/common/spektra_fetch.h
@@ -0,0 +1,132 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+#pragma once
+
+#include
+#include
+
+/* Locating and, on request, fetching the spektrafilm spectral data pack.
+ *
+ * A pack is a directory holding pack.json, spectra_lut.f32 and a profiles
+ * subdirectory of per-stock JSON, which is what sf_pack_load() consumes. Two
+ * places can hold one:
+ *
+ * /spektrafilm/ hand-installed, user-managed
+ * /spektrafilm/packs// downloaded, one per spectral table
+ *
+ * The top level always wins when it can satisfy the request, so a user who
+ * exports a pack themselves with tools/spektrafilm_export_data.py never has a
+ * download silently override it. Downloads only ever write inside packs/,
+ * which is what keeps the two apart while leaving both under the directory
+ * that gets backed up with the rest of a darktable configuration.
+ *
+ * Packs are identified by the 32-bit hash of the spectral upsampling table they
+ * carry (sf_pack_lut_hash / the lut_hash recorded in every edit's params).
+ * Different spektrafilm releases revise that table and each revision renders
+ * differently, so the hash -- not a version string -- is the thing an edit needs
+ * matched. An editable dev install reports whatever pyproject.toml happens to
+ * say, so two materially different checkouts can claim the same version.
+ *
+ * Nothing here uses git. The remote is a plain HTTPS file tree served out of a
+ * git forge, read with libcurl, which is already a hard darktable dependency.
+ */
+
+/* ------------------------------------------------------------------ setup -- */
+
+void sf_fetch_init(void);
+void sf_fetch_cleanup(void);
+
+/* -------------------------------------------------------------- resolving -- */
+
+/* Pick the pack directory to hand to sf_pack_load().
+ *
+ * Purely local: stats a few files and reads at most 288 bytes of each candidate
+ * LUT header. It never opens a socket and never blocks on one, so it is safe to
+ * call from the pixelpipe.
+ *
+ * wanted_lut_hash is the hash the edit recorded, or 0 for "no preference"
+ * (a new edit, or one made before that field existed). With 0 the config
+ * directory is taken whatever table it carries; with a specific hash the config
+ * directory is only taken if it matches, then the download cache is searched.
+ *
+ * Returns TRUE and fills dst when some pack is usable. When it returns FALSE
+ * there is nothing installed at all, and the caller should offer a download.
+ *
+ * out_exact, when non-NULL, reports whether the returned directory actually
+ * carries wanted_lut_hash. FALSE there means a pack was found but it is the
+ * wrong one -- render with it anyway rather than showing the user a black
+ * frame, and let the existing mismatch warning explain the difference. */
+gboolean sf_fetch_resolve_pack_dir(uint32_t wanted_lut_hash,
+ char *dst,
+ size_t dstsz,
+ gboolean *out_exact);
+
+/* TRUE when a pack carrying this exact table is already on disk. */
+gboolean sf_fetch_have_lut_hash(uint32_t lut_hash);
+
+/* Bumped every time a download changes what is on disk.
+ *
+ * A caller that caches a loaded pack cannot detect a new one by watching the
+ * resolved directory alone: a download can land in a directory that was
+ * already probed and found wanting, leaving the path identical and the cached
+ * failure in place. Comparing this counter instead catches that case, which is
+ * precisely the one the download exists to resolve. Starts at 0 and only ever
+ * increases. Safe to call from any thread. */
+guint sf_fetch_generation(void);
+
+/* ------------------------------------------------------------ downloading -- */
+
+typedef enum sf_fetch_state_t
+{
+ SF_FETCH_IDLE = 0,
+ SF_FETCH_RUNNING,
+ SF_FETCH_DONE,
+ SF_FETCH_FAILED,
+} sf_fetch_state_t;
+
+/* Start a background download.
+ *
+ * wanted_lut_hash selects the manifest entry to install; 0 asks for whichever
+ * entry the manifest marks as default. Returns FALSE without doing anything if
+ * a fetch is already running, if downloads are disabled in preferences, or if
+ * the configured repository is malformed.
+ *
+ * The work happens on its own thread. On success the pack lands under
+ * /spektrafilm/packs// and the developed pixelpipe is
+ * reprocessed so the new data takes effect without the user reopening the
+ * image. Call from the GUI thread. */
+gboolean sf_fetch_start(uint32_t wanted_lut_hash);
+
+/* Ask a running fetch to stop. Returns once the flag is set, not once the
+ * thread has finished; the partially downloaded files are discarded. */
+void sf_fetch_cancel(void);
+
+/* Current state, plus a short human-readable message and 0..1 progress.
+ * msg and progress may be NULL. Safe to call from any thread. */
+sf_fetch_state_t sf_fetch_status(char *msg, size_t msgsz, double *progress);
+
+/* TRUE when the user has allowed spektrafilm to reach the network at all.
+ * Downloads are opt-in and never happen on their own. */
+gboolean sf_fetch_downloads_enabled(void);
+
+// clang-format off
+// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
+// clang-format on
diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c
new file mode 100644
index 000000000000..5c25a0e7f185
--- /dev/null
+++ b/src/common/spektra_sim.c
@@ -0,0 +1,4003 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+/* spektra_sim.c — native port of the spektrafilm runtime (see spektra_sim.h).
+ *
+ * Ported from spektrafilm 0.3.3 (GPLv3, Andrea Volpato). Section markers
+ * reference the Python files each block mirrors so future spektrafilm
+ * releases can be diffed against this port:
+ *
+ * [su] utils/spectral_upsampling.py tc_lut build, tri/quad transforms
+ * [gc] utils/gamut_compression.py Reinhard knee, xy radial, oklch
+ * [fi] utils/fast_interp_lut.py Mitchell 2D cubic, PCHIP 3D
+ * [dc] model/density_curves.py exposure->density interpolation
+ * [cp] model/couplers.py DIR coupler chemistry
+ * [mc] utils/morph_curves.py s023 print-curve morph (cdfs)
+ * [cf] model/color_filters.py dichroic enlarger filters
+ * [st] runtime stage modules (stages dir) stage orchestration & constants
+ */
+
+#include "spektra_sim.h"
+
+#include "common/darktable.h"
+#include "common/imagebuf.h"
+
+#include
+#include
+#include
+
+#include "spektra_core.h"
+
+#include
+/* ensure C99 math functions for SF_POW10F/SF_LOG10F (exp2f, log2f) */
+#if !defined(exp2f) && !defined(_GNU_SOURCE)
+/* exp2f and log2f are C99; every compiler since GCC 4.x / Clang 3.x has them */
+#endif
+#include
+#include
+#include
+#include
+
+#define SF_LOG_EPS 1e-10
+
+/* 4-pixel NEON 3×3 matrix multiply: vld3q → vmlaq_n ×3 → vst3q. */
+#if defined(__ARM_NEON)
+#include
+
+static inline void neon_mat3_mulv_batch(const float m[9], const float *in,
+ float out[12])
+{
+ float32x4x3_t rgb = vld3q_f32(in);
+ const float32x4_t r0 = rgb.val[0], r1 = rgb.val[1], r2 = rgb.val[2];
+ float32x4_t x0 = vmulq_n_f32(r0, m[0]);
+ x0 = vmlaq_n_f32(x0, r1, m[1]);
+ x0 = vmlaq_n_f32(x0, r2, m[2]);
+ float32x4_t x1 = vmulq_n_f32(r0, m[3]);
+ x1 = vmlaq_n_f32(x1, r1, m[4]);
+ x1 = vmlaq_n_f32(x1, r2, m[5]);
+ float32x4_t x2 = vmulq_n_f32(r0, m[6]);
+ x2 = vmlaq_n_f32(x2, r1, m[7]);
+ x2 = vmlaq_n_f32(x2, r2, m[8]);
+ rgb.val[0] = x0; rgb.val[1] = x1; rgb.val[2] = x2;
+ vst3q_f32(out, rgb);
+}
+#endif /* __ARM_NEON */
+
+/* Fast pow10 / log10 via exp2f/log2f. Using compiler builtins gives the
+ optimizer a better chance to inline/reduce them vs libm powf(10, x)
+ which internally computes exp2(x * log2(10)) with extra overhead. */
+#define SF_POW10F(x) __builtin_exp2f((x) * 3.321928094887362f) /* x * log2f(10) */
+#define SF_LOG10F(x) (__builtin_log2f(x) * 0.3010299956639812f) /* log2f(x) * log10(2) */
+#define SF_TC_KNEE_T 0.0 /* [gc] InputGamutCompressSpec.knee */
+#define SF_TC_KNEE_L 1.0
+#define SF_TC_KNEE_P 6.0
+#define SF_OUT_KNEE_T 0.0 /* [gc] OutputGamutCompressSpec.knee */
+#define SF_OUT_KNEE_L 1.0
+#define SF_OUT_KNEE_P 6.0
+#define SF_OUT_LIGHT_T 0.7 /* [gc] lightness_compression default */
+#define SF_OUT_LIGHT_L 1.0
+#define SF_OUT_LIGHT_P 2.2
+#define SF_CMAX_NL 64 /* [gc] _OKLCH_CMAX_TABLE_N_L */
+#define SF_CMAX_NH 720 /* [gc] _OKLCH_CMAX_TABLE_N_H */
+#define SF_CMAX_NBISECT 18
+#define SF_MIDGRAY 0.184
+/* [su] hanatos2025 sensitivity adaptation, surface half.
+ _HANATOS2025_MAX_CORRECTION_STOPS in spectral_upsampling.py -- the bound the
+ surface fit was produced under, so it is part of the model rather than a
+ safety clamp bolted on afterwards. */
+#define SF_HANATOS_MAX_CORRECTION_STOPS 2.0
+/* poly2d_deg4 takes 15 coefficients per channel. The warped variant of the
+ surface (eval_poly4_warp_log_exposure_surface) takes 16 -- 15 plus a Mobius
+ alpha -- and reading one as the other silently evaluates a different
+ surface, so the count is checked rather than assumed. */
+#define SF_SURFACE_NCOEF 15
+
+/* Generic (still film, strong-antihalation) halation baseline — the values
+ * spektra_core.c's sf_halation() used to hardcode unconditionally. Now the
+ * fallback used when a pack/stock has no film_render_defaults[stock].halation
+ * entry (see sf_sim_build() and sf_sim_halation_params() below). Mirrors
+ * upstream's ('still', 'strong') entry in params_builder.py's
+ * _HALATION_PRESETS. */
+#define SF_HALATION_STRENGTH_DEFAULT_R 0.05
+#define SF_HALATION_STRENGTH_DEFAULT_G 0.015
+#define SF_HALATION_STRENGTH_DEFAULT_B 0.0
+#define SF_HALATION_SIGMA_DEFAULT_UM 65.0
+
+/* [df] HalationParams scatter defaults. Unlike strength/first_sigma_um these are
+ * not touched by _HALATION_PRESETS, so every stock in the current release uses
+ * them -- but the pack carries them per film, so they are seeded here and then
+ * overwritten from film_render_defaults[stock].halation when present. */
+#define SF_SCATTER_CORE_DEFAULT_R 2.2
+#define SF_SCATTER_CORE_DEFAULT_G 2.0
+#define SF_SCATTER_CORE_DEFAULT_B 1.6
+#define SF_SCATTER_TAIL_DEFAULT_R 9.3
+#define SF_SCATTER_TAIL_DEFAULT_G 9.7
+#define SF_SCATTER_TAIL_DEFAULT_B 9.1
+#define SF_SCATTER_TAILW_DEFAULT_R 0.78
+#define SF_SCATTER_TAILW_DEFAULT_G 0.65
+#define SF_SCATTER_TAILW_DEFAULT_B 0.67
+
+/* ------------------------------------------------------------------------ */
+/* small linear algebra */
+/* ------------------------------------------------------------------------ */
+
+static void mat3_mul(double out[9], const double a[9], const double b[9])
+{
+ double r[9];
+ for(int i = 0; i < 3; i++)
+ for(int j = 0; j < 3; j++)
+ r[3 * i + j] = a[3 * i + 0] * b[0 + j] + a[3 * i + 1] * b[3 + j] + a[3 * i + 2] * b[6 + j];
+ memcpy(out, r, sizeof(r));
+}
+
+static void mat3_mulv(double out[3], const double m[9], const double v[3])
+{
+ double r0 = m[0] * v[0] + m[1] * v[1] + m[2] * v[2];
+ double r1 = m[3] * v[0] + m[4] * v[1] + m[5] * v[2];
+ double r2 = m[6] * v[0] + m[7] * v[1] + m[8] * v[2];
+ out[0] = r0;
+ out[1] = r1;
+ out[2] = r2;
+}
+
+static int mat3_inv(double out[9], const double m[9])
+{
+ const double a = m[0], b = m[1], c = m[2];
+ const double d = m[3], e = m[4], f = m[5];
+ const double g = m[6], h = m[7], i = m[8];
+ const double A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
+ const double det = a * A + b * B + c * C;
+ if(fabs(det) < 1e-15) return 0;
+ const double inv = 1.0 / det;
+ out[0] = A * inv;
+ out[1] = -(b * i - c * h) * inv;
+ out[2] = (b * f - c * e) * inv;
+ out[3] = B * inv;
+ out[4] = (a * i - c * g) * inv;
+ out[5] = -(a * f - c * d) * inv;
+ out[6] = C * inv;
+ out[7] = -(a * h - b * g) * inv;
+ out[8] = (a * e - b * d) * inv;
+ return 1;
+}
+
+/* whitepoint xy (Y=1) -> XYZ */
+static void xy_to_XYZ(double out[3], const double xy[2])
+{
+ const double y = fmax(xy[1], 1e-10);
+ out[0] = xy[0] / y;
+ out[1] = 1.0;
+ out[2] = (1.0 - xy[0] - xy[1]) / y;
+}
+
+/* von Kries chromatic adaptation matrix in a given cone space:
+ * A = M^-1 · diag(cone_dst / cone_src) · M */
+static void cat_matrix(double out[9], const double cone_m[9], const double src_xy[2],
+ const double dst_xy[2])
+{
+ double src_XYZ[3], dst_XYZ[3], cs[3], cd[3], minv[9], d[9] = { 0 };
+ xy_to_XYZ(src_XYZ, src_xy);
+ xy_to_XYZ(dst_XYZ, dst_xy);
+ mat3_mulv(cs, cone_m, src_XYZ);
+ mat3_mulv(cd, cone_m, dst_XYZ);
+ d[0] = cd[0] / cs[0];
+ d[4] = cd[1] / cs[1];
+ d[8] = cd[2] / cs[2];
+ mat3_inv(minv, cone_m);
+ double tmp[9];
+ mat3_mul(tmp, d, cone_m);
+ mat3_mul(out, minv, tmp);
+}
+
+/* CAT16 cone matrix (Li et al. 2017) — used by spektrafilm's input side */
+static const double SF_M_CAT16[9] = { 0.401288, 0.650173, -0.051461, -0.250268, 1.204414,
+ 0.045854, -0.002079, 0.048952, 0.953127 };
+/* CAT02 cone matrix — colour.XYZ_to_RGB default, used by the scanning side */
+static const double SF_M_CAT02[9] = { 0.7328, 0.4286, -0.1624, -0.7036, 1.6975,
+ 0.0061, 0.0030, 0.0136, 0.9834 };
+
+/* OkLab matrices (Ottosson 2020), as used by colour-science */
+static const double SF_OKLAB_M1[9]
+ = { 0.8189330101, 0.3618667424, -0.1288597137, 0.0329845436, 0.9293118715,
+ 0.0361456387, 0.0482003018, 0.2643662691, 0.6338517070 };
+static const double SF_OKLAB_M2[9]
+ = { 0.2104542553, 0.7936177850, -0.0040720468, 1.9779984951, -2.4285922050,
+ 0.4505937099, 0.0259040371, 0.7827717662, -0.8086757660 };
+
+/* ------------------------------------------------------------------------ */
+/* internal structures */
+/* ------------------------------------------------------------------------ */
+
+struct sf_pack_t
+{
+ char *version;
+ double wavelengths[SF_NWL];
+ double log_exposure[SF_NLE];
+ double cmfs[SF_NWL][3];
+ /* spectral locus polygon (closed: first vertex repeated at the end) */
+ int locus_n; /* number of vertices incl. the repeated closing vertex */
+ double (*locus)[2];
+ GHashTable *illuminants; /* name -> double[SF_NWL] */
+ GHashTable *dichroics; /* brand -> double[SF_NWL*3] */
+ JsonNode *neutral_filters; /* nested object database */
+ JsonNode *film_defaults; /* per-film render defaults */
+ JsonParser *parser; /* keeps the JSON tree alive */
+ /* identity of the spectral upsampling table, from its header */
+ char lut_id[256];
+ uint32_t lut_hash;
+ /* hanatos2025 irradiance spectra LUT */
+ int tc_n; /* 192 */
+ float *spectra; /* tc_n * tc_n * SF_NWL */
+};
+
+typedef struct sf_curves_model_t
+{
+ int n_layers;
+ int sept; /* [dc] model_type == "sept_norm_cdfs" (skewed septic CDF) */
+ double centers[3][8], amplitudes[3][8], sigmas[3][8];
+ double alphas[3][8]; /* per-layer skew; all zero for norm_cdfs */
+} sf_curves_model_t;
+
+struct sf_profile_t
+{
+ char *stock, *name, *type, *support, *stage, *use, *antihalation;
+ char *target_print, *channel_model;
+ char *reference_illuminant, *viewing_illuminant;
+ double log_sensitivity[SF_NWL][3];
+ double channel_density[SF_NWL][3];
+ double base_density[SF_NWL];
+ double development_time; /* resolved family member, 0 when the stock has none */
+ double dev_times[SF_MAX_DEV_TIMES];
+ int n_dev_times; /* 0 or 1 when the stock is characterised at one development */
+ double log_exposure[SF_NLE];
+ double density_curves[SF_NLE][3];
+ int window_n;
+ double window_params[8];
+ /* [su] hanatos2025_adaptation_surface_params: per-channel degree-4 polynomial
+ in tc, giving a log2 exposure correction. surface_n is 0 when the profile
+ carries none, which is how print stocks and the two B&W films arrive. */
+ int surface_n;
+ double surface_params[3][SF_SURFACE_NCOEF];
+ sf_curves_model_t curves_model;
+};
+
+struct sf_sim_t
+{
+ sf_sim_params_t p;
+ int film_positive;
+ int film_bw; /* single-emulsion stock widened to 3 channels: couple the grain */
+ int print_positive;
+
+ /* filming */
+ double m_in[9]; /* input linear RGB -> XYZ adapted to film ref illuminant */
+ float m_in_f[9]; /* float copy for fast per-pixel path */
+ float ev_scale_f;
+ double ev_scale;
+ int tc_n;
+ double *tc_lut; /* tc_n*tc_n*3 raw CMY exposure (double) */
+ float *tc_lut_f; /* float copy — halves cache footprint, enables fast path */
+
+ /* precomputed float inverses (avoid div per pixel in hot loops) */
+ float inv_le_step;
+ float log10_print_exposure;
+ float enl_inv_range[3];
+ float scan_inv_range[3];
+
+ /* film develop */
+ double le0, le_step; /* uniform log exposure grid */
+ double curves_norm[SF_NLE][3];
+ double curves_before[SF_NLE][3];
+ float curves_norm_f[SF_NLE][3]; /* float copies for fast per-pixel path */
+ float curves_before_f[SF_NLE][3];
+ /* per-sublayer, post-chemistry-morph raw (un-floored) curve values, valid
+ * only when film->curves_model.n_layers > 0 -- the layer-resolved sibling
+ * of curves_norm, built alongside it in the same call so grain (which
+ * needs sublayers) stays consistent with whatever chemistry the total
+ * curve reflects, matching upstream's own build_film_curves_layers /
+ * apply_print_curves_morph_with_layers intent. */
+ double film_curve_layers[SF_NLE][SF_GRAIN_MAX_SUBLAYERS][3];
+ bool film_morph_applied; /* true when film_curve_layers holds valid (morphed) data */
+ double gamma[3];
+ double couplers_M[3][3];
+ /* Langmuir saturating couplers (spektrafilm dev/0.4+); K = INFINITY keeps
+ the 0.3.x linear model. Donor side (negative film): inhibitor release
+ g(D) = D (K + D_ref)/(K + D). Receiver side (positive/reversal film):
+ response S(c) = c (Kr + c_ref)/(Kr + c), applied AFTER spatial diffusion.
+ D_ref = d_max/2; c_ref from the amount-independent unit matrix. */
+ /* DIR coupler inhibitor diffusion: gaussian core + exponential tail
+ (upstream models the tail as a 3-gaussian mixture, amp/ratio identical
+ to the halation tail constants). Per-film from film_render_defaults. */
+ double coupler_diff_um, coupler_tail_um, coupler_tail_w;
+ double couplers_donor_K[3], couplers_donor_Dref[3];
+ double couplers_recv_Kr[3], couplers_recv_cref[3];
+ int couplers_donor_lm, couplers_recv_lm; /* donor row -> receiver col, scaled by amount */
+ int couplers_active;
+ double film_dmax[3]; /* max of normalized film curves */
+ double film_dmin[3]; /* the SAME curves' own floor (mn); grain's D_ref = 1+dmin
+ must use this, not an independently-sourced value, or
+ dmax_c+dmin_c no longer reconstructs the real absolute
+ D-max and the particle count silently drifts */
+ /* per-film grain catalogue data (film_render_defaults[stock].grain); the
+ density floor lives in p.grain_density_min (shared with the enlarger/scan
+ table-range code below). Defaults to the legacy fixed constants when the
+ pack has no per-film grain entry (see sf_sim_build). */
+ double grain_rms[3], grain_uniformity[3];
+
+ /* Multi-sublayer grain model (matches spektrafilm's own "always multilayer"
+ particle model -- see grain.py's module doc and _realized_peak_correction:
+ summing several independent sub-layer particle draws, calibrated so their
+ combined peak variance matches the catalogue RMS-granularity, is what
+ keeps the realized grain amplitude correct; a single-layer approximation
+ alone was found to differ by as much as the ~1.5x the reference's own
+ docs warn a naive single-layer rule would carry). Active only when the
+ film's own fitted density-curve model (curves_model) has more than one
+ layer; grain_n_sublayers stays at 1 (the existing single-layer behavior,
+ using the plain film_dmax/film_dmin/curves_norm already computed above)
+ for every stock whose curve fit is single-layer to begin with -- which
+ is not an approximation in that case, since upstream treats a
+ single-layer curve model as the trivial one-sub-layer case of the same
+ general model, not a separate simplified path. */
+ int grain_n_sublayers;
+ double grain_particle_scale[SF_GRAIN_MAX_SUBLAYERS]; /* coarsest sub-layer == 1.0 */
+ /* per-sublayer absolute (fog-inclusive) Dmax and particle count, precomputed
+ once from the fitted curve model, same rms_granularity-driven area formula
+ as the single-layer path but corrected for how the sub-layers combine
+ (see spektra_sim.c's _sf_grain_layers_from_curves). */
+ double grain_layer_dmax[SF_GRAIN_MAX_SUBLAYERS][3];
+ double grain_layer_npart[SF_GRAIN_MAX_SUBLAYERS][3];
+ /* per-sublayer density floor (density_max_fractions[sl] * grain_density_min),
+ needed to convert the interpolated net sub-layer density back to the
+ absolute value sf_layer_particle() expects. */
+ double grain_layer_dmin[SF_GRAIN_MAX_SUBLAYERS][3];
+ /* raw (un-summed) per-sub-layer curve terms across the exposure grid, and
+ their sum (self-consistent by construction, used as the lookup axis to
+ recover each sub-layer's density from the already-computed *total*
+ density at render time -- the same inverse lookup spektrafilm's own
+ interp_density_cmy_layers_channel performs, just against a table built
+ here instead of scipy). Both indexed [le][sublayer][channel]. */
+ float grain_layer_curve[SF_NLE][SF_GRAIN_MAX_SUBLAYERS][3];
+ float grain_layer_curve_total[SF_NLE][3];
+
+ /* per-film halation preset (film_render_defaults[stock].halation in the
+ pack): back-reflection strength per channel + first-bounce sigma.
+ Defaults to SF_HALATION_STRENGTH_DEFAULT_* / SF_HALATION_SIGMA_DEFAULT_UM
+ when the pack has no per-stock entry (see sf_sim_build()). */
+ double halation_strength[3], halation_sigma_um;
+ /* [df] in-emulsion scatter PSF, per channel. Schema defaults for every stock
+ in the current release (_HALATION_PRESETS only overrides strength and
+ first_sigma_um), but the pack carries them per film, so a future release can
+ vary them without a code change here. */
+ double scatter_core_um[3], scatter_tail_um[3], scatter_tail_weight[3];
+
+ /* print exposure (exact spectral path) */
+ int has_print;
+ double illum_print[SF_NWL]; /* enlarger source × dichroic pack */
+ double illum_preflash[SF_NWL];
+ double print_sens[SF_NWL][3];
+ double film_chan_density[SF_NWL][3];
+ double film_base_density[SF_NWL];
+ double midgray_factor; /* scalar exposure factor (geomean logic) */
+ double preflash_raw[3];
+ double print_exposure;
+ double enl_lo[3], enl_hi[3];
+
+ /* print develop */
+ double print_curves[SF_NLE][3];
+ float print_curves_f[SF_NLE][3];
+
+ /* scanning (exact spectral path) */
+ double scan_chan_density[SF_NWL][3];
+ double scan_base_density[SF_NWL];
+ double illum_view[SF_NWL];
+ double cmfs[SF_NWL][3];
+ double xyz_norm;
+ double illum_view_xyz[3];
+ double scan_lo[3], scan_hi[3];
+ double m_out[9]; /* XYZ (viewing illum) -> linear output RGB (CAT02) */
+ /* scanner black/white point correction (positive film scans only):
+ xyz *= clip(bw_m*Y + bw_q, 0, 1)/Y after xyz = 10^log_xyz.
+ Mirrors color_reference.black_white_xyz_correction with
+ black_correction = white_correction = true (levels 0.01 / 0.98). */
+ int scan_bw_on;
+ double scan_bw_m, scan_bw_q;
+
+ /* 3D tables + PCHIP preparation (NULL when lut_steps == 0) */
+ int lut_steps;
+ double *enl_lut, *enl_sx, *enl_sy, *enl_sz, *enl_cmin, *enl_cmax;
+ double *scan_lut, *scan_sx, *scan_sy, *scan_sz, *scan_cmin, *scan_cmax;
+ float *enl_lut_f; /* float copies for fast trilinear per-pixel path */
+ float *scan_lut_f;
+
+ /* output gamut compression */
+ sf_output_compress_t out_compress;
+ double out_luminance_boost;
+ double out_rgb2xyz[9], out_xyz2rgb[9];
+ double oklab_m1inv[9], oklab_m2inv[9];
+ float *cmax; /* SF_CMAX_NL × SF_CMAX_NH */
+};
+
+/* ------------------------------------------------------------------------ */
+/* JSON helpers */
+/* ------------------------------------------------------------------------ */
+
+/* null JSON elements decode to NaN (JSON has no NaN literal; the exporter
+ * writes null for non-finite values) */
+static inline double json_elem_double(JsonArray *arr, int i)
+{
+ JsonNode *node = json_array_get_element(arr, i);
+ if(!node || json_node_is_null(node)) return NAN;
+ return json_node_get_double(node);
+}
+
+static gboolean json_read_darray(JsonObject *obj, const char *key, double *out, int n)
+{
+ if(!json_object_has_member(obj, key)) return FALSE;
+ JsonNode *node = json_object_get_member(obj, key);
+ if(!node || !JSON_NODE_HOLDS_ARRAY(node)) return FALSE; /* tolerate null values */
+ JsonArray *arr = json_node_get_array(node);
+ if(!arr || (int)json_array_get_length(arr) != n) return FALSE;
+ for(int i = 0; i < n; i++) out[i] = json_elem_double(arr, i);
+ return TRUE;
+}
+
+/* read an n×m nested array into row-major out */
+/* Read an array of unknown length, up to `maxn`; returns how many were read. */
+static int json_read_darray_upto(JsonObject *obj, const char *key, double *out, int maxn)
+{
+ if(!json_object_has_member(obj, key)) return 0;
+ JsonNode *node = json_object_get_member(obj, key);
+ if(!node || !JSON_NODE_HOLDS_ARRAY(node)) return 0;
+ JsonArray *arr = json_node_get_array(node);
+ if(!arr) return 0;
+ const int n = MIN((int)json_array_get_length(arr), maxn);
+ for(int i = 0; i < n; i++) out[i] = json_elem_double(arr, i);
+ return n;
+}
+
+/* IEEE 754 binary16 -> binary32. Handles subnormals and inf/NaN; the LUT is
+ plain finite reflectance data, but a decoder that quietly mangles the edge
+ cases is worse than one that costs a branch on a once-per-load path. */
+static inline float _sf_half_to_float(const uint16_t h)
+{
+ const uint32_t sign = (uint32_t)(h & 0x8000u) << 16;
+ const uint32_t exp = (h >> 10) & 0x1fu;
+ const uint32_t mant = h & 0x3ffu;
+ uint32_t bits;
+ if(exp == 0)
+ {
+ if(mant == 0)
+ bits = sign; /* +-0 */
+ else
+ {
+ /* subnormal: normalise */
+ uint32_t e = exp, m = mant;
+ int shift = 0;
+ while(!(m & 0x400u)) { m <<= 1; shift++; }
+ m &= 0x3ffu;
+ e = 127 - 15 - shift + 1;
+ bits = sign | (e << 23) | (m << 13);
+ }
+ }
+ else if(exp == 0x1fu)
+ bits = sign | 0x7f800000u | (mant << 13); /* inf / NaN */
+ else
+ bits = sign | ((exp + 127 - 15) << 23) | (mant << 13);
+ float f;
+ memcpy(&f, &bits, sizeof f);
+ return f;
+}
+
+static gboolean json_read_dmatrix(JsonObject *obj, const char *key, double *out, int n, int m)
+{
+ if(!json_object_has_member(obj, key)) return FALSE;
+ JsonNode *node = json_object_get_member(obj, key);
+ if(!node || !JSON_NODE_HOLDS_ARRAY(node)) return FALSE;
+ JsonArray *arr = json_node_get_array(node);
+ if(!arr || (int)json_array_get_length(arr) != n) return FALSE;
+ for(int i = 0; i < n; i++)
+ {
+ JsonArray *row = json_array_get_array_element(arr, i);
+ if(!row || (int)json_array_get_length(row) != m) return FALSE;
+ for(int j = 0; j < m; j++) out[i * m + j] = json_elem_double(row, j);
+ }
+ return TRUE;
+}
+
+static char *json_dup_string(JsonObject *obj, const char *key)
+{
+ if(!json_object_has_member(obj, key)) return NULL;
+ JsonNode *node = json_object_get_member(obj, key);
+ if(json_node_is_null(node)) return NULL;
+ return g_strdup(json_node_get_string(node));
+}
+
+static void set_error(char **errmsg, const char *fmt, ...)
+{
+ if(!errmsg) return;
+ va_list ap;
+ va_start(ap, fmt);
+ *errmsg = g_strdup_vprintf(fmt, ap);
+ va_end(ap);
+}
+
+/* ------------------------------------------------------------------------ */
+/* pack loading */
+/* ------------------------------------------------------------------------ */
+
+uint32_t sf_pack_lut_hash(const sf_pack_t *pack) { return pack ? pack->lut_hash : 0u; }
+const char *sf_pack_lut_id(const sf_pack_t *pack) { return pack ? pack->lut_id : ""; }
+
+void sf_pack_free(sf_pack_t *pack)
+{
+ if(!pack) return;
+ g_free(pack->version);
+ g_free(pack->locus);
+ if(pack->illuminants) g_hash_table_destroy(pack->illuminants);
+ if(pack->dichroics) g_hash_table_destroy(pack->dichroics);
+ if(pack->parser) g_object_unref(pack->parser);
+ free(pack->spectra);
+ g_free(pack);
+}
+
+sf_pack_t *sf_pack_load(const char *dir, char **errmsg)
+{
+ sf_pack_t *pack = g_new0(sf_pack_t, 1);
+ char *json_path = g_build_filename(dir, "pack.json", NULL);
+ char *lut_path = g_build_filename(dir, "spectra_lut.f32", NULL);
+
+ pack->parser = json_parser_new();
+ GError *gerr = NULL;
+ if(!json_parser_load_from_file(pack->parser, json_path, &gerr))
+ {
+ set_error(errmsg, "spektra_sim: cannot parse %s: %s", json_path,
+ gerr ? gerr->message : "unknown");
+ g_clear_error(&gerr);
+ goto fail;
+ }
+ JsonObject *root = json_node_get_object(json_parser_get_root(pack->parser));
+ pack->version = json_dup_string(root, "spektrafilm_version");
+
+ /* Container format check, before anything is read out of the object. A pack
+ from a newer exporter may have moved or redefined fields; JSON parsing
+ would not notice, and the result would be a pack that loads and renders
+ wrongly rather than one that fails.
+
+ The field is required, with no "assume 1 when absent" fallback. Every pack
+ the exporter has ever produced carries it, so an absent one is a
+ hand-edited or truncated pack.json rather than an older revision -- and
+ silently assuming a format for a file that never declared one is precisely
+ the guess this check exists to avoid. */
+ {
+ if(!json_object_has_member(root, "pack_format"))
+ {
+ set_error(errmsg, "spektra_sim: %s declares no pack_format", json_path);
+ goto fail;
+ }
+ const int fmt = (int)json_object_get_int_member(root, "pack_format");
+ if(fmt < SF_PACK_FORMAT_MIN || fmt > SF_PACK_FORMAT_MAX)
+ {
+ set_error(errmsg,
+ "spektra_sim: %s is pack format %d, this build reads %d..%d -- "
+ "%s",
+ json_path, fmt, SF_PACK_FORMAT_MIN, SF_PACK_FORMAT_MAX,
+ fmt > SF_PACK_FORMAT_MAX ? "update darktable"
+ : "re-export the pack");
+ goto fail;
+ }
+ }
+
+ if(!json_read_darray(root, "wavelengths", pack->wavelengths, SF_NWL)
+ || !json_read_darray(root, "log_exposure", pack->log_exposure, SF_NLE)
+ || !json_read_dmatrix(root, "cmfs", &pack->cmfs[0][0], SF_NWL, 3))
+ {
+ set_error(errmsg, "spektra_sim: pack.json misses wavelengths/log_exposure/cmfs "
+ "or grid sizes changed (expected %d wavelengths, %d exposures)",
+ SF_NWL, SF_NLE);
+ goto fail;
+ }
+
+ /* spectral locus polygon */
+ {
+ JsonArray *arr = json_object_get_array_member(root, "spectral_locus_xy");
+ if(!arr)
+ {
+ set_error(errmsg, "spektra_sim: pack.json misses spectral_locus_xy");
+ goto fail;
+ }
+ pack->locus_n = json_array_get_length(arr);
+ pack->locus = g_malloc0(sizeof(double) * 2 * pack->locus_n);
+ for(int i = 0; i < pack->locus_n; i++)
+ {
+ JsonArray *row = json_array_get_array_element(arr, i);
+ pack->locus[i][0] = json_array_get_double_element(row, 0);
+ pack->locus[i][1] = json_array_get_double_element(row, 1);
+ }
+ }
+
+ /* illuminants */
+ pack->illuminants = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
+ {
+ JsonObject *ill = json_object_get_object_member(root, "illuminants");
+ GList *members = ill ? json_object_get_members(ill) : NULL;
+ for(GList *m = members; m; m = m->next)
+ {
+ double *spd = g_new(double, SF_NWL);
+ if(json_read_darray(ill, m->data, spd, SF_NWL))
+ g_hash_table_insert(pack->illuminants, g_strdup(m->data), spd);
+ else
+ g_free(spd);
+ }
+ g_list_free(members);
+ }
+
+ /* dichroic filter curves */
+ pack->dichroics = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
+ {
+ JsonObject *df = json_object_get_object_member(root, "dichroic_filters");
+ GList *members = df ? json_object_get_members(df) : NULL;
+ for(GList *m = members; m; m = m->next)
+ {
+ double *f = g_new(double, SF_NWL * 3);
+ if(json_read_dmatrix(df, m->data, f, SF_NWL, 3))
+ g_hash_table_insert(pack->dichroics, g_strdup(m->data), f);
+ else
+ g_free(f);
+ }
+ g_list_free(members);
+ }
+
+ if(json_object_has_member(root, "neutral_print_filters"))
+ pack->neutral_filters = json_object_get_member(root, "neutral_print_filters");
+ if(json_object_has_member(root, "film_render_defaults"))
+ pack->film_defaults = json_object_get_member(root, "film_render_defaults");
+
+ /* Spectral upsampling LUT.
+
+ Upstream revises this table often -- arctic2026 alpha, alpha02, beta01
+ through beta04 so far, with the older ones deleted as each lands -- and
+ every revision changes every render. So the header carries the table's
+ identity as well as its shape, and that identity is recorded in params:
+ an edit made against one revision and reopened against another is reported
+ rather than silently rendering differently.
+
+ Stored as float16, which is what upstream computes and ships; widened here
+ on load. Reading it as float32 doubled the file for precision that was
+ never in the source data. */
+ {
+ FILE *fh = g_fopen(lut_path, "rb");
+ if(!fh)
+ {
+ set_error(errmsg, "spektra_sim: cannot open %s", lut_path);
+ goto fail;
+ }
+ char magic[4];
+ int32_t hdr_version = 0, dims[3], dtype = 0, id_len = 0;
+ uint32_t lut_hash = 0;
+ if(fread(magic, 1, 4, fh) != 4 || memcmp(magic, "SFS2", 4) != 0
+ || fread(&hdr_version, 4, 1, fh) != 1 || hdr_version != 2
+ || fread(dims, 4, 3, fh) != 3 || dims[0] != dims[1] || dims[2] != SF_NWL
+ || fread(&dtype, 4, 1, fh) != 1 || (dtype != 0 && dtype != 1)
+ || fread(&lut_hash, 4, 1, fh) != 1 || fread(&id_len, 4, 1, fh) != 1
+ || id_len < 0 || id_len > 255)
+ {
+ set_error(errmsg,
+ "spektra_sim: %s is not a v2 spectral LUT -- regenerate the data "
+ "pack with spektrafilm_export_data.py",
+ lut_path);
+ fclose(fh);
+ goto fail;
+ }
+ if(id_len && fread(pack->lut_id, 1, id_len, fh) != (size_t)id_len)
+ {
+ set_error(errmsg, "spektra_sim: truncated spectra lut header in %s", lut_path);
+ fclose(fh);
+ goto fail;
+ }
+ pack->lut_id[id_len] = 0;
+ pack->lut_hash = lut_hash;
+ pack->tc_n = dims[0];
+
+ const size_t count = (size_t)dims[0] * dims[1] * dims[2];
+ pack->spectra = malloc(count * sizeof(float));
+ if(!pack->spectra)
+ {
+ set_error(errmsg, "spektra_sim: out of memory for spectra lut");
+ fclose(fh);
+ goto fail;
+ }
+ gboolean ok;
+ if(dtype == 1) /* float16 -> float32 */
+ {
+ uint16_t *h16 = malloc(count * sizeof(uint16_t));
+ ok = h16 && fread(h16, sizeof(uint16_t), count, fh) == count;
+ if(ok)
+ for(size_t i = 0; i < count; i++) pack->spectra[i] = _sf_half_to_float(h16[i]);
+ free(h16);
+ }
+ else
+ ok = fread(pack->spectra, sizeof(float), count, fh) == count;
+ if(!ok)
+ {
+ set_error(errmsg, "spektra_sim: truncated spectra lut %s", lut_path);
+ fclose(fh);
+ goto fail;
+ }
+ fclose(fh);
+ }
+
+ g_free(json_path);
+ g_free(lut_path);
+ return pack;
+
+fail:
+ g_free(json_path);
+ g_free(lut_path);
+ sf_pack_free(pack);
+ return NULL;
+}
+
+const char *sf_pack_version(const sf_pack_t *pack)
+{
+ return pack ? pack->version : NULL;
+}
+
+bool sf_pack_neutral_filters(const sf_pack_t *pack, const char *print_stock,
+ const char *illuminant, const char *film_stock, double cmy[3])
+{
+ if(!pack || !pack->neutral_filters) return false;
+ JsonObject *db = json_node_get_object(pack->neutral_filters);
+ if(!db || !json_object_has_member(db, print_stock)) return false;
+ JsonObject *by_ill = json_object_get_object_member(db, print_stock);
+ if(!by_ill || !json_object_has_member(by_ill, illuminant)) return false;
+ JsonObject *by_film = json_object_get_object_member(by_ill, illuminant);
+ if(!by_film || !json_object_has_member(by_film, film_stock)) return false;
+ JsonArray *arr = json_object_get_array_member(by_film, film_stock);
+ if(!arr || json_array_get_length(arr) != 3) return false;
+ for(int i = 0; i < 3; i++) cmy[i] = json_array_get_double_element(arr, i);
+ return true;
+}
+
+bool sf_pack_film_coupler_diffusion(const sf_pack_t *pack, const char *film_stock,
+ double *size_um, double *tail_um, double *tail_w)
+{
+ if(!pack || !pack->film_defaults) return false;
+ JsonObject *db = json_node_get_object(pack->film_defaults);
+ if(!db || !json_object_has_member(db, film_stock)) return false;
+ JsonObject *film = json_object_get_object_member(db, film_stock);
+ if(!film || !json_object_has_member(film, "dir_couplers")) return false;
+ JsonObject *dc = json_object_get_object_member(film, "dir_couplers");
+ if(!dc) return false;
+ gboolean ok = FALSE;
+ if(json_object_has_member(dc, "diffusion_size_um"))
+ {
+ *size_um = json_object_get_double_member(dc, "diffusion_size_um");
+ ok = TRUE;
+ }
+ if(json_object_has_member(dc, "diffusion_tail_um"))
+ *tail_um = json_object_get_double_member(dc, "diffusion_tail_um");
+ if(json_object_has_member(dc, "diffusion_tail_weight"))
+ *tail_w = json_object_get_double_member(dc, "diffusion_tail_weight");
+ return ok;
+}
+
+/* Per-film grain catalogue data: film_render_defaults[stock].grain in the
+ pack, exported verbatim from spektrafilm's GrainParams (rms_granularity,
+ uniformity, density_min, particle_scale_sublayers — see
+ spektrafilm_export_data.py's _grain_export). Any output pointer may be
+ NULL. particle_scale[]/n_scale are optional (older packs, or stocks with
+ only one emulsion sub-layer, have no "particle_scale_sublayers" entry;
+ *n_scale is left at 0 in that case so the caller falls back to its
+ single-layer default). Returns false and leaves outputs untouched if the
+ stock has no "grain" entry at all. */
+bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock,
+ double rms[3], double uniformity[3], double density_min[3],
+ double particle_scale[SF_GRAIN_MAX_SUBLAYERS], int *n_scale)
+{
+ if(!pack || !pack->film_defaults) return false;
+ JsonObject *db = json_node_get_object(pack->film_defaults);
+ if(!db || !json_object_has_member(db, film_stock)) return false;
+ JsonObject *film = json_object_get_object_member(db, film_stock);
+ if(!film || !json_object_has_member(film, "grain")) return false;
+ JsonObject *gr = json_object_get_object_member(film, "grain");
+ if(!gr) return false;
+ gboolean ok = FALSE;
+ if(rms && json_object_has_member(gr, "rms_granularity"))
+ {
+ ok = json_read_darray(gr, "rms_granularity", rms, 3) || ok;
+ }
+ if(uniformity && json_object_has_member(gr, "uniformity"))
+ ok = json_read_darray(gr, "uniformity", uniformity, 3) || ok;
+ if(density_min && json_object_has_member(gr, "density_min"))
+ ok = json_read_darray(gr, "density_min", density_min, 3) || ok;
+ if(n_scale) *n_scale = 0;
+ if(particle_scale && n_scale && json_object_has_member(gr, "particle_scale_sublayers"))
+ {
+ JsonNode *node = json_object_get_member(gr, "particle_scale_sublayers");
+ JsonArray *arr = (node && JSON_NODE_HOLDS_ARRAY(node)) ? json_node_get_array(node) : NULL;
+ const int n = arr ? MIN((int)json_array_get_length(arr), SF_GRAIN_MAX_SUBLAYERS) : 0;
+ if(n > 0)
+ {
+ for(int i = 0; i < n; i++) particle_scale[i] = json_array_get_double_element(arr, i);
+ *n_scale = n;
+ }
+ }
+ return ok;
+}
+
+bool sf_pack_film_langmuir(const sf_pack_t *pack, const char *film_stock,
+ double donor_k[3], double receiver_k[3])
+{
+ if(!pack || !pack->film_defaults) return false;
+ JsonObject *db = json_node_get_object(pack->film_defaults);
+ if(!db || !json_object_has_member(db, film_stock)) return false;
+ JsonObject *film = json_object_get_object_member(db, film_stock);
+ if(!film || !json_object_has_member(film, "dir_couplers")) return false;
+ JsonObject *dc = json_object_get_object_member(film, "dir_couplers");
+ if(!dc || !json_object_has_member(dc, "langmuir_donor_k_rgb")) return false;
+ json_read_darray(dc, "langmuir_donor_k_rgb", donor_k, 3);
+ json_read_darray(dc, "langmuir_receiver_k_rgb", receiver_k, 3);
+ return true;
+}
+
+bool sf_pack_film_defaults(const sf_pack_t *pack, const char *film_stock,
+ double gamma_samelayer[3], double gamma_inter_r_gb[2],
+ double gamma_inter_g_rb[2], double gamma_inter_b_rg[2],
+ double halation_strength[3], double halation_sigma_um[3],
+ double scatter_core_um[3], double scatter_tail_um[3],
+ double scatter_tail_weight[3])
+{
+ if(!pack || !pack->film_defaults) return false;
+ JsonObject *db = json_node_get_object(pack->film_defaults);
+ if(!db || !json_object_has_member(db, film_stock)) return false;
+ JsonObject *entry = json_object_get_object_member(db, film_stock);
+ JsonObject *dc = json_object_get_object_member(entry, "dir_couplers");
+ JsonObject *ha = json_object_get_object_member(entry, "halation");
+ if(dc)
+ {
+ if(gamma_samelayer) json_read_darray(dc, "gamma_samelayer_rgb", gamma_samelayer, 3);
+ if(gamma_inter_r_gb) json_read_darray(dc, "gamma_interlayer_r_to_gb", gamma_inter_r_gb, 2);
+ if(gamma_inter_g_rb) json_read_darray(dc, "gamma_interlayer_g_to_rb", gamma_inter_g_rb, 2);
+ if(gamma_inter_b_rg) json_read_darray(dc, "gamma_interlayer_b_to_rg", gamma_inter_b_rg, 2);
+ }
+ if(ha)
+ {
+ if(halation_strength) json_read_darray(ha, "strength", halation_strength, 3);
+ if(halation_sigma_um) json_read_darray(ha, "first_sigma_um", halation_sigma_um, 3);
+ if(scatter_core_um) json_read_darray(ha, "scatter_core_um", scatter_core_um, 3);
+ if(scatter_tail_um) json_read_darray(ha, "scatter_tail_um", scatter_tail_um, 3);
+ if(scatter_tail_weight) json_read_darray(ha, "scatter_tail_weight", scatter_tail_weight, 3);
+ }
+ return true;
+}
+
+/* ------------------------------------------------------------------------ */
+/* profile loading */
+/* ------------------------------------------------------------------------ */
+
+void sf_profile_free(sf_profile_t *p)
+{
+ if(!p) return;
+ g_free(p->stock);
+ g_free(p->name);
+ g_free(p->type);
+ g_free(p->support);
+ g_free(p->stage);
+ g_free(p->use);
+ g_free(p->antihalation);
+ g_free(p->target_print);
+ g_free(p->channel_model);
+ g_free(p->reference_illuminant);
+ g_free(p->viewing_illuminant);
+ g_free(p);
+}
+
+sf_profile_t *sf_profile_load(const char *path, const float development_min, char **errmsg)
+{
+ JsonParser *parser = json_parser_new();
+ GError *gerr = NULL;
+ sf_profile_t *p = NULL;
+ if(!json_parser_load_from_file(parser, path, &gerr))
+ {
+ set_error(errmsg, "spektra_sim: cannot parse profile %s: %s", path,
+ gerr ? gerr->message : "unknown");
+ g_clear_error(&gerr);
+ g_object_unref(parser);
+ return NULL;
+ }
+ JsonObject *root = json_node_get_object(json_parser_get_root(parser));
+ JsonObject *info = json_object_get_object_member(root, "info");
+ JsonObject *data = json_object_get_object_member(root, "data");
+ if(!info || !data)
+ {
+ set_error(errmsg, "spektra_sim: profile %s misses info/data", path);
+ g_object_unref(parser);
+ return NULL;
+ }
+
+ p = g_new0(sf_profile_t, 1);
+ p->stock = json_dup_string(info, "stock");
+ p->name = json_dup_string(info, "name");
+ p->type = json_dup_string(info, "type");
+ p->support = json_dup_string(info, "support");
+ p->stage = json_dup_string(info, "stage");
+ p->use = json_dup_string(info, "use");
+ p->antihalation = json_dup_string(info, "antihalation");
+ p->target_print = json_dup_string(info, "target_print");
+ p->channel_model = json_dup_string(info, "channel_model");
+ p->reference_illuminant = json_dup_string(info, "reference_illuminant");
+ p->viewing_illuminant = json_dup_string(info, "viewing_illuminant");
+
+ gboolean ok = TRUE;
+ double wavelengths[SF_NWL];
+ ok &= json_read_darray(data, "wavelengths", wavelengths, SF_NWL);
+ ok &= json_read_dmatrix(data, "log_sensitivity", &p->log_sensitivity[0][0], SF_NWL, 3);
+ ok &= json_read_dmatrix(data, "channel_density", &p->channel_density[0][0], SF_NWL, 3);
+ /* Development-time family ([dc] select_development_time). A B&W stock can
+ carry one density curve, base+fog spectrum and curve-model row per
+ development time; the export script keeps the family intact and puts the
+ full list of times in `development_time`, so its length is what says whether
+ these arrays are a family or an already-widened single member. Packs that
+ collapsed at export time have one time (or none) and still load unchanged.
+
+ `development` is in minutes; <= 0 means "no choice made" and takes the
+ representative middle member, floor-middle when even, exactly as upstream's
+ select_development_time(None) does. */
+ double dev_times[SF_MAX_DEV_TIMES];
+ const int n_dev = json_read_darray_upto(data, "development_time", dev_times, SF_MAX_DEV_TIMES);
+ const gboolean dev_family = (n_dev > 1);
+ int dev_row = 0;
+ if(dev_family)
+ {
+ if(development_min <= 0.0f)
+ dev_row = (n_dev - 1) / 2;
+ else
+ {
+ double best = fabs(dev_times[0] - (double)development_min);
+ for(int i = 1; i < n_dev; i++)
+ {
+ const double e = fabs(dev_times[i] - (double)development_min);
+ if(e < best) { best = e; dev_row = i; }
+ }
+ }
+ p->development_time = dev_times[dev_row];
+ memcpy(p->dev_times, dev_times, sizeof(double) * n_dev);
+ p->n_dev_times = n_dev;
+ }
+ if(dev_family)
+ {
+ double bd[SF_NWL * SF_MAX_DEV_TIMES];
+ if(json_read_dmatrix(data, "base_density", bd, SF_NWL, n_dev))
+ for(int l = 0; l < SF_NWL; l++) p->base_density[l] = bd[l * n_dev + dev_row];
+ else
+ ok = FALSE;
+ }
+ else
+ ok &= json_read_darray(data, "base_density", p->base_density, SF_NWL);
+ ok &= json_read_darray(data, "log_exposure", p->log_exposure, SF_NLE);
+ if(dev_family)
+ {
+ /* (n_le, n_dev): take the selected column into all three widened channels */
+ double *dc = malloc(sizeof(double) * SF_NLE * SF_MAX_DEV_TIMES);
+ if(dc && json_read_dmatrix(data, "density_curves", dc, SF_NLE, n_dev))
+ for(int i = 0; i < SF_NLE; i++)
+ for(int c = 0; c < 3; c++) p->density_curves[i][c] = dc[i * n_dev + dev_row];
+ else
+ ok = FALSE;
+ free(dc);
+ }
+ else
+ ok &= json_read_dmatrix(data, "density_curves", &p->density_curves[0][0], SF_NLE, 3);
+ if(!ok)
+ {
+ set_error(errmsg, "spektra_sim: profile %s has unexpected data shapes "
+ "(model grid change? re-run the exporter and update the module)",
+ path);
+ sf_profile_free(p);
+ g_object_unref(parser);
+ return NULL;
+ }
+
+ /* optional pieces */
+ if(json_object_has_member(data, "hanatos2025_adaptation_window_params"))
+ {
+ JsonNode *node = json_object_get_member(data, "hanatos2025_adaptation_window_params");
+ JsonArray *arr = (node && JSON_NODE_HOLDS_ARRAY(node)) ? json_node_get_array(node) : NULL;
+ p->window_n = arr ? MIN((int)json_array_get_length(arr), 8) : 0;
+ for(int i = 0; i < p->window_n; i++)
+ p->window_params[i] = json_array_get_double_element(arr, i);
+ }
+ if(json_object_has_member(data, "hanatos2025_adaptation_surface_params"))
+ {
+ JsonNode *node = json_object_get_member(data, "hanatos2025_adaptation_surface_params");
+ JsonArray *arr = (node && JSON_NODE_HOLDS_ARRAY(node)) ? json_node_get_array(node) : NULL;
+ JsonArray *row0 = (arr && json_array_get_length(arr) == 3)
+ ? json_array_get_array_element(arr, 0)
+ : NULL;
+ const int ncoef = row0 ? (int)json_array_get_length(row0) : 0;
+ if(ncoef == SF_SURFACE_NCOEF
+ && json_read_dmatrix(data, "hanatos2025_adaptation_surface_params",
+ &p->surface_params[0][0], 3, SF_SURFACE_NCOEF))
+ p->surface_n = SF_SURFACE_NCOEF;
+ else if(ncoef)
+ /* Not silently ignored: an unrecognised width is most likely the warped
+ variant (16), and evaluating it here as if it were the plain one would
+ apply a wrong correction of up to two stops rather than none. */
+ g_warning("spektra_sim: profile %s has %d surface coefficients per channel, "
+ "expected %d; skipping the hanatos2025 surface adaptation",
+ path, ncoef, SF_SURFACE_NCOEF);
+ }
+
+ if(json_object_has_member(data, "density_curves_model"))
+ {
+ JsonNode *mnode = json_object_get_member(data, "density_curves_model");
+ JsonObject *m = (mnode && JSON_NODE_HOLDS_OBJECT(mnode)) ? json_node_get_object(mnode) : NULL;
+ JsonNode *cnode = (m && json_object_has_member(m, "centers"))
+ ? json_object_get_member(m, "centers") : NULL;
+ JsonArray *centers = (cnode && JSON_NODE_HOLDS_ARRAY(cnode)) ? json_node_get_array(cnode) : NULL;
+ /* Layout of centers/amplitudes/sigmas, resolved from declared metadata
+ rather than guessed from the array shape.
+
+ For a colour stock the outer axis is the channel: (3, n_layers), which is
+ what the reference's DensityCurvesModel documents and what
+ apply_print_curves_morph reads as "n_channels = model.centers.shape[0]".
+
+ For a single-emulsion (B&W) stock there is only one channel, and the outer
+ axis is the DEVELOPMENT-TIME family -- (n_dev, n_layers). The reference
+ collapses it in select_development_time() (density_curves.py) before the
+ model is ever evaluated, defaulting to the middle member.
+
+ So the axis follows from channel_model and nothing else. It used to be
+ guessed from the row count as well, because the exporter widened a mono
+ stock's single model row to three copies while leaving a development
+ family at its natural row count -- one field, two layouts, in one pack.
+ pack_format 2 stops that widening, so the rule is now what the data
+ declares rather than what its shape suggests. The guess is worth
+ remembering: an earlier pack fed Double-X's model as channel-major and
+ rendered it from a curve off by 1.34 density, and the last version of it
+ survived only because the one replicated stock had identical rows.
+ tools/check_profiles.py in the data repository proves the axis by
+ reconstruction and fails a pack that gets it wrong. */
+ const int outer_len = centers ? MIN((int)json_array_get_length(centers), SF_MAX_DEV_TIMES) : 0;
+ JsonArray *row0 = (outer_len > 0) ? json_array_get_array_element(centers, 0) : NULL;
+ const int inner_len = row0 ? MIN((int)json_array_get_length(row0), 8) : 0;
+ const gboolean bw = p->channel_model && strcmp(p->channel_model, "bw") == 0;
+ const gboolean dev_major = bw;
+ if(outer_len > 0 && inner_len > 0 && (dev_major || outer_len == 3))
+ {
+ const int nl = inner_len;
+ p->curves_model.n_layers = nl;
+ /* [dc] model_type selects the per-layer sigmoid. Anything other than the
+ two known families would be silently rendered as a Gaussian fit, so say
+ so rather than shipping wrong curves quietly. */
+ char *model_type = json_dup_string(m, "model_type");
+ if(model_type && strcmp(model_type, "sept_norm_cdfs") == 0)
+ p->curves_model.sept = 1;
+ else if(model_type && strcmp(model_type, "norm_cdfs") != 0)
+ g_warning("spektra_sim: profile %s has unknown density_curves_model.model_type "
+ "'%s'; rendering it as norm_cdfs", path, model_type);
+ g_free(model_type);
+ double c[SF_MAX_DEV_TIMES * 8], a[SF_MAX_DEV_TIMES * 8], s2[SF_MAX_DEV_TIMES * 8],
+ al[SF_MAX_DEV_TIMES * 8];
+ /* alphas is optional (null or absent for every norm_cdfs profile) and has
+ the same shape as centers when present. */
+ const gboolean has_alphas = json_object_has_member(m, "alphas")
+ && !JSON_NODE_HOLDS_NULL(json_object_get_member(m, "alphas"));
+ if(!has_alphas || !json_read_dmatrix(m, "alphas", al, outer_len, inner_len))
+ memset(al, 0, sizeof(al));
+ if(json_read_dmatrix(m, "centers", c, outer_len, inner_len)
+ && json_read_dmatrix(m, "amplitudes", a, outer_len, inner_len)
+ && json_read_dmatrix(m, "sigmas", s2, outer_len, inner_len))
+ {
+ /* dev_major: one member of the family for every widened channel. With a
+ real family the row is the one selected above from `development`; on a
+ pre-collapse pack the times are gone, so fall back to the middle row,
+ which is the member the rest of that profile was reduced to. */
+ const int row = !dev_major ? 0
+ : dev_family ? MIN(dev_row, outer_len - 1)
+ : (outer_len - 1) / 2;
+ for(int ch = 0; ch < 3; ch++)
+ for(int l = 0; l < nl; l++)
+ {
+ const int idx = (dev_major ? row : ch) * inner_len + l;
+ p->curves_model.centers[ch][l] = c[idx];
+ p->curves_model.amplitudes[ch][l] = a[idx];
+ p->curves_model.sigmas[ch][l] = s2[idx];
+ p->curves_model.alphas[ch][l] = al[idx];
+ }
+ }
+ else
+ p->curves_model.n_layers = 0; /* malformed data: don't leave partial state */
+ }
+ }
+
+ g_object_unref(parser);
+ return p;
+}
+
+const char *sf_profile_stock(const sf_profile_t *p) { return p->stock; }
+const char *sf_profile_name(const sf_profile_t *p) { return p->name; }
+const char *sf_profile_stage(const sf_profile_t *p) { return p->stage; }
+const char *sf_profile_type(const sf_profile_t *p) { return p->type; }
+const char *sf_profile_target_print(const sf_profile_t *p) { return p->target_print; }
+const char *sf_profile_channel_model(const sf_profile_t *p) { return p->channel_model; }
+int sf_profile_dev_times(const sf_profile_t *p, double *out, int maxn)
+{
+ if(!p || p->n_dev_times <= 1) return 0;
+ const int n = MIN(p->n_dev_times, maxn);
+ if(out) for(int i = 0; i < n; i++) out[i] = p->dev_times[i];
+ return n;
+}
+
+/* ------------------------------------------------------------------------ */
+/* parameter defaults & colour spaces */
+/* ------------------------------------------------------------------------ */
+
+/* linear RGB -> XYZ matrices (source-white relative), colour-science values */
+/* NOTE: colour-science ships the *published rounded* matrices for sRGB and
+ * ProPhoto (not the primaries-derived ones); we match those exactly so the
+ * numerics agree with the spektrafilm reference. */
+static const double SF_M_SRGB_TO_XYZ[9]
+ = { 0.4124, 0.3576, 0.1805, 0.2126, 0.7152, 0.0722, 0.0193, 0.1192, 0.9505 };
+static const double SF_SRGB_WHITE_XY[2] = { 0.3127, 0.3290 };
+
+static const double SF_M_PROPHOTO_TO_XYZ[9]
+ = { 0.7977, 0.1352, 0.0313, 0.2880, 0.7119, 0.0001, 0.0, 0.0, 0.8249 };
+static const double SF_D50_WHITE_XY[2] = { 0.3457, 0.3585 };
+
+static const double SF_M_REC2020_TO_XYZ[9]
+ = { 0.6369580483012913, 0.1446169035862083, 0.1688809751641721,
+ 0.2627002120112671, 0.6779980715188708, 0.0593017164698620,
+ 0.0000000000000000, 0.0280726930490874, 1.0609850577107909 };
+
+void sf_sim_params_defaults(sf_sim_params_t *p)
+{
+ memset(p, 0, sizeof(*p));
+ p->exposure_comp_ev = 0.0;
+ p->density_curve_gamma = 1.0;
+ p->couplers_active = true;
+ p->couplers_amount = 1.0;
+ /* generic negative-film gammas ([st] params_builder); overwritten from the
+ * pack's per-film digested defaults in sf_sim_build() */
+ const double gs[3] = { 0.336, 0.319, 0.273 };
+ const double gr[2] = { 0.353, 0.302 }, gg[2] = { 0.154, 0.353 }, gb[2] = { 0.168, 0.226 };
+ memcpy(p->gamma_samelayer, gs, sizeof(gs));
+ memcpy(p->gamma_inter_r_gb, gr, sizeof(gr));
+ memcpy(p->gamma_inter_g_rb, gg, sizeof(gg));
+ memcpy(p->gamma_inter_b_rg, gb, sizeof(gb));
+ p->inhibition_samelayer = 1.0;
+ p->inhibition_interlayer = 1.0;
+ p->grain_density_min[0] = p->grain_density_min[1] = p->grain_density_min[2] = 0.03;
+ p->enlarger_illuminant = "TH-KG3";
+ p->dichroic_brand = "custom";
+ p->print_exposure = 1.0;
+ p->print_exposure_compensation = true;
+ p->normalize_print_exposure = true;
+ p->c_filter_neutral = 0.0;
+ p->m_filter_neutral = 65.0;
+ p->y_filter_neutral = 55.0;
+ p->neutral_from_db = true;
+ p->morph_active = false;
+ p->morph_gamma = p->morph_gamma_fast = p->morph_gamma_slow = 1.0;
+ p->morph_gamma_r = p->morph_gamma_g = p->morph_gamma_b = 1.0;
+ p->film_morph_active = false;
+ p->film_morph_gamma = p->film_morph_gamma_fast = p->film_morph_gamma_slow = 1.0;
+ p->film_morph_developer_exhaustion = 0.0;
+ p->scan_film = false;
+ p->adaptation_bandwidth = true;
+ p->adaptation_surface = false;
+ p->lut_steps = 0;
+ p->input_gamut_compress = true;
+ p->output_compress = SF_OUTPUT_COMPRESS_OKLCH;
+ p->out_luminance_boost = 1.0;
+ sf_sim_params_set_input_prophoto(p); /* reference IOParams default */
+ sf_sim_params_set_output_srgb(p);
+}
+
+void sf_sim_params_set_input_srgb(sf_sim_params_t *p)
+{
+ memcpy(p->input_rgb_to_xyz, SF_M_SRGB_TO_XYZ, sizeof(SF_M_SRGB_TO_XYZ));
+ memcpy(p->input_white_xy, SF_SRGB_WHITE_XY, sizeof(SF_SRGB_WHITE_XY));
+}
+
+void sf_sim_params_set_input_prophoto(sf_sim_params_t *p)
+{
+ memcpy(p->input_rgb_to_xyz, SF_M_PROPHOTO_TO_XYZ, sizeof(SF_M_PROPHOTO_TO_XYZ));
+ memcpy(p->input_white_xy, SF_D50_WHITE_XY, sizeof(SF_D50_WHITE_XY));
+}
+
+void sf_sim_params_set_input_rec2020(sf_sim_params_t *p)
+{
+ memcpy(p->input_rgb_to_xyz, SF_M_REC2020_TO_XYZ, sizeof(SF_M_REC2020_TO_XYZ));
+ memcpy(p->input_white_xy, SF_SRGB_WHITE_XY, sizeof(SF_SRGB_WHITE_XY));
+}
+
+void sf_sim_params_set_output_srgb(sf_sim_params_t *p)
+{
+ memcpy(p->output_rgb_to_xyz, SF_M_SRGB_TO_XYZ, sizeof(SF_M_SRGB_TO_XYZ));
+ mat3_inv(p->output_xyz_to_rgb, SF_M_SRGB_TO_XYZ);
+ memcpy(p->output_white_xy, SF_SRGB_WHITE_XY, sizeof(SF_SRGB_WHITE_XY));
+}
+
+void sf_sim_params_set_output_rec2020(sf_sim_params_t *p)
+{
+ memcpy(p->output_rgb_to_xyz, SF_M_REC2020_TO_XYZ, sizeof(SF_M_REC2020_TO_XYZ));
+ mat3_inv(p->output_xyz_to_rgb, SF_M_REC2020_TO_XYZ);
+ memcpy(p->output_white_xy, SF_SRGB_WHITE_XY, sizeof(SF_SRGB_WHITE_XY));
+}
+
+/* ------------------------------------------------------------------------ */
+/* [su] triangular <-> square chromaticity coordinates */
+/* ------------------------------------------------------------------------ */
+
+static inline void tri2quad(double out[2], const double tc[2])
+{
+ const double tx = tc[0], ty = tc[1];
+ double y = ty / fmax(1.0 - tx, 1e-10);
+ double x = (1.0 - tx) * (1.0 - tx);
+ out[0] = CLAMP(x, 0.0, 1.0);
+ out[1] = CLAMP(y, 0.0, 1.0);
+}
+
+/* [su] hanika_sigmoid: algebraic sigmoid matching Jakob & Hanika 2019, bounding
+ the polynomial to +-max_val. Soft, so the surface approaches the bound
+ asymptotically instead of flattening onto it. */
+static inline double hanika_sigmoid(double z, double max_val)
+{
+ const double t = z / max_val;
+ return z / sqrt(1.0 + t * t);
+}
+
+/* [su] poly2d_deg4: degree-4 polynomial in tc, centred on center_tc.
+ params[0] is deliberately unread -- upstream drops the constant term so the
+ correction is exactly zero at the centre, which is what keeps the reference
+ white unmoved. */
+static double poly2d_deg4(const double tc[2], const double params[SF_SURFACE_NCOEF],
+ const double center_tc[2])
+{
+ const double x = tc[0] - center_tc[0], y = tc[1] - center_tc[1];
+ const double x2 = x * x, y2 = y * y, xy = x * y;
+ const double x3 = x2 * x, y3 = y2 * y;
+ return params[1] * x + params[2] * y + params[3] * x2 + params[4] * y2
+ + params[5] * xy + params[6] * x3 + params[7] * y3 + params[8] * (x2 * y)
+ + params[9] * (x * y2) + params[10] * (x2 * x2) + params[11] * (y2 * y2)
+ + params[12] * (x3 * y) + params[13] * (x2 * y2) + params[14] * (x * y3);
+}
+
+static inline void quad2tri(double out[2], const double xy[2])
+{
+ const double sq = sqrt(xy[0]);
+ out[0] = 1.0 - sq;
+ out[1] = xy[1] * sq;
+}
+
+static inline void tri2quad_f(float out[2], const float tc[2])
+{
+ const float tx = tc[0], ty = tc[1];
+ float y = ty / fmaxf(1.0f - tx, 1e-10f);
+ float x = (1.0f - tx) * (1.0f - tx);
+ out[0] = CLAMP(x, 0.0f, 1.0f);
+ out[1] = CLAMP(y, 0.0f, 1.0f);
+}
+
+/* ------------------------------------------------------------------------ */
+/* [gc] Reinhard knee, radial xy compression toward the spectral locus */
+/* ------------------------------------------------------------------------ */
+
+static inline double reinhard_knee(double d, double threshold, double limit, double power)
+{
+ if(d <= threshold) return d;
+ const double scale = limit - threshold;
+ const double x = (d - threshold) / scale;
+ const double y = x / pow(1.0 + pow(x, power), 1.0 / power);
+ return threshold + scale * y;
+}
+
+/* distance from origin along unit direction to the first polygon crossing */
+static double ray_polygon_distance(const double origin[2], const double dir[2],
+ const double (*poly)[2], int n_vertices)
+{
+ double t_min = INFINITY;
+ for(int k = 0; k + 1 < n_vertices; k++)
+ {
+ const double ax = poly[k][0], ay = poly[k][1];
+ const double ex = poly[k + 1][0] - ax, ey = poly[k + 1][1] - ay;
+ const double denom = dir[0] * ey - dir[1] * ex;
+ if(fabs(denom) <= 1e-12) continue;
+ const double ox = origin[0] - ax, oy = origin[1] - ay;
+ const double t = (-ox * ey + oy * ex) / denom;
+ const double s = (-ox * dir[1] + oy * dir[0]) / denom;
+ if(t > 1e-9 && s >= 0.0 && s <= 1.0 && t < t_min) t_min = t;
+ }
+ return t_min;
+}
+
+static void compress_xy_radial(double out[2], const double xy[2], const double white[2],
+ const double (*locus)[2], int locus_n)
+{
+ const double dx = xy[0] - white[0], dy = xy[1] - white[1];
+ const double dist = sqrt(dx * dx + dy * dy);
+ if(dist < 1e-9)
+ {
+ out[0] = xy[0];
+ out[1] = xy[1];
+ return;
+ }
+ const double dir[2] = { dx / dist, dy / dist };
+ const double boundary = ray_polygon_distance(white, dir, locus, locus_n);
+ const double d_norm = dist / fmax(boundary, 1e-12);
+ const double d_c = reinhard_knee(d_norm, SF_TC_KNEE_T, SF_TC_KNEE_L, SF_TC_KNEE_P);
+ out[0] = white[0] + dir[0] * d_c * boundary;
+ out[1] = white[1] + dir[1] * d_c * boundary;
+}
+
+/* ------------------------------------------------------------------------ */
+/* [fi] Mitchell–Netravali 2D cubic LUT interpolation (reflected bounds) */
+/* ------------------------------------------------------------------------ */
+
+static inline double mitchell_weight(double t)
+{
+ const double B = 1.0 / 3.0, C = 1.0 / 3.0;
+ const double x = fabs(t);
+ if(x < 1.0)
+ return (1.0 / 6.0)
+ * ((12.0 - 9.0 * B - 6.0 * C) * x * x * x + (-18.0 + 12.0 * B + 6.0 * C) * x * x
+ + (6.0 - 2.0 * B));
+ else if(x < 2.0)
+ return (1.0 / 6.0)
+ * ((-B - 6.0 * C) * x * x * x + (6.0 * B + 30.0 * C) * x * x
+ + (-12.0 * B - 48.0 * C) * x + (8.0 * B + 24.0 * C));
+ return 0.0;
+}
+
+static inline int safe_index(int idx, int L)
+{
+ if(idx < 0) return -idx;
+ if(idx >= L) return 2 * (L - 1) - idx;
+ return idx;
+}
+
+static inline void cubic_base_fraction(double coord, int L, int *base, double *frac)
+{
+ coord = CLAMP(coord, 0.0, (double)(L - 1));
+ if(coord >= (double)(L - 1))
+ {
+ *base = L - 2;
+ *frac = 1.0;
+ return;
+ }
+ *base = (int)floor(coord);
+ *frac = coord - *base;
+}
+
+/* lut: L×L×3 doubles, coords already scaled to [0, L-1] */
+static void cubic_interp_2d(double out[3], const double *lut, int L, double x, double y)
+{
+ int xb, yb;
+ double xf, yf;
+ cubic_base_fraction(x, L, &xb, &xf);
+ cubic_base_fraction(y, L, &yb, &yf);
+ double wx[4], wy[4];
+ for(int i = 0; i < 4; i++)
+ {
+ wx[i] = mitchell_weight(xf + 1.0 - i);
+ wy[i] = mitchell_weight(yf + 1.0 - i);
+ }
+ double acc[3] = { 0, 0, 0 }, wsum = 0.0;
+ for(int i = 0; i < 4; i++)
+ {
+ const int xi = safe_index(xb - 1 + i, L);
+ for(int j = 0; j < 4; j++)
+ {
+ const int yj = safe_index(yb - 1 + j, L);
+ const double w = wx[i] * wy[j];
+ wsum += w;
+ const double *px = lut + ((size_t)xi * L + yj) * 3;
+ acc[0] += w * px[0];
+ acc[1] += w * px[1];
+ acc[2] += w * px[2];
+ }
+ }
+ if(wsum != 0.0)
+ for(int c = 0; c < 3; c++) acc[c] /= wsum;
+ out[0] = acc[0];
+ out[1] = acc[1];
+ out[2] = acc[2];
+}
+
+/* Float variants of cubic_interp_2d / expose_pixel — halves LUT cache footprint
+ and avoids double conversion overhead in the hot per-pixel expose loop. */
+
+/* bilinear sampling on the same layout with clamped ("nearest") bounds —
+ * used only for the tc_lut compression remap at build time */
+static void bilinear_2d_clamped(double out[3], const double *lut, int L, double x, double y)
+{
+ x = CLAMP(x, 0.0, (double)(L - 1));
+ y = CLAMP(y, 0.0, (double)(L - 1));
+ const int x0 = (int)floor(x), y0 = (int)floor(y);
+ const int x1 = MIN(x0 + 1, L - 1), y1 = MIN(y0 + 1, L - 1);
+ const double tx = x - x0, ty = y - y0;
+ for(int c = 0; c < 3; c++)
+ {
+ const double v00 = lut[((size_t)x0 * L + y0) * 3 + c];
+ const double v01 = lut[((size_t)x0 * L + y1) * 3 + c];
+ const double v10 = lut[((size_t)x1 * L + y0) * 3 + c];
+ const double v11 = lut[((size_t)x1 * L + y1) * 3 + c];
+ out[c] = (v00 * (1 - ty) + v01 * ty) * (1 - tx) + (v10 * (1 - ty) + v11 * ty) * tx;
+ }
+}
+
+/* Fast bilinear 2D on a float LUT — replaces Mitchell 4×4 cubic for the hot
+ TC upsampling path. The 192² grid is fine enough that the cubic-vs-linear
+ difference between grid points is invisible. ~48 ops vs ~12 ops per pixel. */
+static void bilinear_interp_2d_f(float out[3], const float *lut, int L, float x, float y)
+{
+ x = CLAMP(x, 0.0f, (float)(L - 1));
+ y = CLAMP(y, 0.0f, (float)(L - 1));
+ const int x0 = (int)x, y0 = (int)y;
+ const int x1 = x0 < L - 1 ? x0 + 1 : x0;
+ const int y1 = y0 < L - 1 ? y0 + 1 : y0;
+ const float tx = x - x0, ty = y - y0;
+ for(int c = 0; c < 3; c++)
+ {
+ const float v00 = lut[((size_t)x0 * L + y0) * 3 + c];
+ const float v01 = lut[((size_t)x0 * L + y1) * 3 + c];
+ const float v10 = lut[((size_t)x1 * L + y0) * 3 + c];
+ const float v11 = lut[((size_t)x1 * L + y1) * 3 + c];
+ out[c] = (v00 * (1.0f - ty) + v01 * ty) * (1.0f - tx) + (v10 * (1.0f - ty) + v11 * ty) * tx;
+ }
+}
+
+/* Trilinear 3D on a float LUT — replaces PCHIP 3D cubic for the hot scan/print
+ LUT path. The 17³ grid is smooth (density→log XYZ from spectral integrals),
+ so PCHIP's monotonicity guarantee adds negligible quality over linear. */
+static void trilinear_interp_3d_f(float out[3], const float *lut, int n, float r, float g, float b)
+{
+ r = CLAMP(r, 0.0f, (float)(n - 1));
+ g = CLAMP(g, 0.0f, (float)(n - 1));
+ b = CLAMP(b, 0.0f, (float)(n - 1));
+ const int i = (int)r, j = (int)g, k = (int)b;
+ const int i1 = i < n - 1 ? i + 1 : i;
+ const int j1 = j < n - 1 ? j + 1 : j;
+ const int k1 = k < n - 1 ? k + 1 : k;
+ const float tr = r - i, tg = g - j, tb = b - k;
+ const float omtr = 1.0f - tr, omtg = 1.0f - tg, omtb = 1.0f - tb;
+#define TL(idx) lut[((size_t)(idx) * n + (j)) * n + (k)]
+#define TL3(idx) lut[((((size_t)(idx) * n + (j)) * n + (k)) * 3]
+ for(int c = 0; c < 3; c++)
+ {
+ const float v00 = lut[((((size_t)i) * n + j) * n + k) * 3 + c] * omtr
+ + lut[((((size_t)i1) * n + j) * n + k) * 3 + c] * tr;
+ const float v01 = lut[((((size_t)i) * n + j1) * n + k) * 3 + c] * omtr
+ + lut[((((size_t)i1) * n + j1) * n + k) * 3 + c] * tr;
+ const float v10 = lut[((((size_t)i) * n + j) * n + k1) * 3 + c] * omtr
+ + lut[((((size_t)i1) * n + j) * n + k1) * 3 + c] * tr;
+ const float v11 = lut[((((size_t)i) * n + j1) * n + k1) * 3 + c] * omtr
+ + lut[((((size_t)i1) * n + j1) * n + k1) * 3 + c] * tr;
+ const float v0 = v00 * omtg + v01 * tg;
+ const float v1 = v10 * omtg + v11 * tg;
+ out[c] = v0 * omtb + v1 * tb;
+ }
+#undef TL
+#undef TL3
+}
+
+/* ------------------------------------------------------------------------ */
+/* [fi] monotone PCHIP 3D LUT interpolation */
+/* ------------------------------------------------------------------------ */
+
+static void fill_monotone_slopes_1d(const double *values, double *slopes, int size)
+{
+ if(size == 1)
+ {
+ slopes[0] = 0.0;
+ return;
+ }
+ double deltas[64] = { 0 }; /* zero-init: gcc -Wmaybe-uninitialized cannot prove size bounds */
+ for(int i = 0; i < size - 1; i++) deltas[i] = values[i + 1] - values[i];
+ if(size == 2)
+ {
+ slopes[0] = slopes[1] = deltas[0];
+ return;
+ }
+ double left = 0.5 * (3.0 * deltas[0] - deltas[1]);
+ if(left * deltas[0] <= 0.0)
+ left = 0.0;
+ else if(deltas[0] * deltas[1] < 0.0 && fabs(left) > fabs(3.0 * deltas[0]))
+ left = 3.0 * deltas[0];
+ slopes[0] = left;
+ for(int i = 1; i < size - 1; i++)
+ {
+ const double dp = deltas[i - 1], dn = deltas[i];
+ slopes[i] = (dp == 0.0 || dn == 0.0 || dp * dn <= 0.0) ? 0.0 : 2.0 * dp * dn / (dp + dn);
+ }
+ double right = 0.5 * (3.0 * deltas[size - 2] - deltas[size - 3]);
+ if(right * deltas[size - 2] <= 0.0)
+ right = 0.0;
+ else if(deltas[size - 2] * deltas[size - 3] < 0.0 && fabs(right) > fabs(3.0 * deltas[size - 2]))
+ right = 3.0 * deltas[size - 2];
+ slopes[size - 1] = right;
+}
+
+typedef struct sf_pchip3d_t
+{
+ int n;
+ const double *lut, *sx, *sy, *sz, *cmin, *cmax;
+} sf_pchip3d_t;
+
+/* precompute per-axis monotone slopes and per-cell bounds for an n³×3 LUT */
+static void pchip3d_prepare(const double *lut, int n, double *sx, double *sy, double *sz,
+ double *cmin, double *cmax)
+{
+ double line[64], slopes[64];
+#define LUT(i, j, k, c) lut[((((size_t)(i)) * n + (j)) * n + (k)) * 3 + (c)]
+#define SLOT(arr, i, j, k, c) arr[((((size_t)(i)) * n + (j)) * n + (k)) * 3 + (c)]
+ for(int j = 0; j < n; j++)
+ for(int k = 0; k < n; k++)
+ for(int c = 0; c < 3; c++)
+ {
+ for(int i = 0; i < n; i++) line[i] = LUT(i, j, k, c);
+ fill_monotone_slopes_1d(line, slopes, n);
+ for(int i = 0; i < n; i++) SLOT(sx, i, j, k, c) = slopes[i];
+ }
+ for(int i = 0; i < n; i++)
+ for(int k = 0; k < n; k++)
+ for(int c = 0; c < 3; c++)
+ {
+ for(int j = 0; j < n; j++) line[j] = LUT(i, j, k, c);
+ fill_monotone_slopes_1d(line, slopes, n);
+ for(int j = 0; j < n; j++) SLOT(sy, i, j, k, c) = slopes[j];
+ }
+ for(int i = 0; i < n; i++)
+ for(int j = 0; j < n; j++)
+ for(int c = 0; c < 3; c++)
+ {
+ for(int k = 0; k < n; k++) line[k] = LUT(i, j, k, c);
+ fill_monotone_slopes_1d(line, slopes, n);
+ for(int k = 0; k < n; k++) SLOT(sz, i, j, k, c) = slopes[k];
+ }
+ const int m = n - 1;
+ for(int i = 0; i < m; i++)
+ for(int j = 0; j < m; j++)
+ for(int k = 0; k < m; k++)
+ for(int c = 0; c < 3; c++)
+ {
+ double mn = LUT(i, j, k, c), mx = mn;
+ for(int di = 0; di < 2; di++)
+ for(int dj = 0; dj < 2; dj++)
+ for(int dk = 0; dk < 2; dk++)
+ {
+ const double s = LUT(i + di, j + dj, k + dk, c);
+ if(s < mn) mn = s;
+ if(s > mx) mx = s;
+ }
+ const size_t idx = ((((size_t)i) * m + j) * m + k) * 3 + c;
+ cmin[idx] = mn;
+ cmax[idx] = mx;
+ }
+#undef LUT
+#undef SLOT
+}
+
+static inline double hermite_value(double y0, double y1, double m0, double m1, double t)
+{
+ const double t2 = t * t, t3 = t2 * t;
+ return (2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * m0
+ + (-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * m1;
+}
+
+static inline double linear_mix(double v0, double v1, double t) { return v0 + t * (v1 - v0); }
+
+/* r, g, b in [0, n-1] index units */
+static void pchip3d_interp(const sf_pchip3d_t *P, double r, double g, double b, double out[3])
+{
+ const int n = P->n, m = n - 1;
+ int i, j, k;
+ double tr, tg, tb;
+ cubic_base_fraction(r, n, &i, &tr);
+ cubic_base_fraction(g, n, &j, &tg);
+ cubic_base_fraction(b, n, &k, &tb);
+#define AT(arr, ii, jj, kk, c) arr[((((size_t)(ii)) * n + (jj)) * n + (kk)) * 3 + (c)]
+ for(int c = 0; c < 3; c++)
+ {
+ const double v000 = hermite_value(AT(P->lut, i, j, k, c), AT(P->lut, i + 1, j, k, c),
+ AT(P->sx, i, j, k, c), AT(P->sx, i + 1, j, k, c), tr);
+ const double v010
+ = hermite_value(AT(P->lut, i, j + 1, k, c), AT(P->lut, i + 1, j + 1, k, c),
+ AT(P->sx, i, j + 1, k, c), AT(P->sx, i + 1, j + 1, k, c), tr);
+ const double v001
+ = hermite_value(AT(P->lut, i, j, k + 1, c), AT(P->lut, i + 1, j, k + 1, c),
+ AT(P->sx, i, j, k + 1, c), AT(P->sx, i + 1, j, k + 1, c), tr);
+ const double v011
+ = hermite_value(AT(P->lut, i, j + 1, k + 1, c), AT(P->lut, i + 1, j + 1, k + 1, c),
+ AT(P->sx, i, j + 1, k + 1, c), AT(P->sx, i + 1, j + 1, k + 1, c), tr);
+ const double sy00 = linear_mix(AT(P->sy, i, j, k, c), AT(P->sy, i + 1, j, k, c), tr);
+ const double sy10 = linear_mix(AT(P->sy, i, j + 1, k, c), AT(P->sy, i + 1, j + 1, k, c), tr);
+ const double sy01 = linear_mix(AT(P->sy, i, j, k + 1, c), AT(P->sy, i + 1, j, k + 1, c), tr);
+ const double sy11
+ = linear_mix(AT(P->sy, i, j + 1, k + 1, c), AT(P->sy, i + 1, j + 1, k + 1, c), tr);
+ const double vz0 = hermite_value(v000, v010, sy00, sy10, tg);
+ const double vz1 = hermite_value(v001, v011, sy01, sy11, tg);
+ const double sz0
+ = linear_mix(linear_mix(AT(P->sz, i, j, k, c), AT(P->sz, i + 1, j, k, c), tr),
+ linear_mix(AT(P->sz, i, j + 1, k, c), AT(P->sz, i + 1, j + 1, k, c), tr), tg);
+ const double sz1 = linear_mix(
+ linear_mix(AT(P->sz, i, j, k + 1, c), AT(P->sz, i + 1, j, k + 1, c), tr),
+ linear_mix(AT(P->sz, i, j + 1, k + 1, c), AT(P->sz, i + 1, j + 1, k + 1, c), tr), tg);
+ double v = hermite_value(vz0, vz1, sz0, sz1, tb);
+ const size_t cidx = ((((size_t)i) * m + j) * m + k) * 3 + c;
+ v = CLAMP(v, P->cmin[cidx], P->cmax[cidx]);
+ out[c] = v;
+ }
+#undef AT
+}
+
+/* ------------------------------------------------------------------------ */
+/* [dc] density curve interpolation helpers */
+/* ------------------------------------------------------------------------ */
+
+/* np.interp over an increasing xp of size n, endpoint-clamped */
+static double interp_general(double x, const double *xp, const double *fp, int n)
+{
+ if(x <= xp[0]) return fp[0];
+ if(x >= xp[n - 1]) return fp[n - 1];
+ int lo = 0, hi = n - 1;
+ while(hi - lo > 1)
+ {
+ const int mid = (lo + hi) >> 1;
+ if(xp[mid] <= x)
+ lo = mid;
+ else
+ hi = mid;
+ }
+ const double dx = xp[hi] - xp[lo];
+ if(dx <= 0.0) return fp[hi];
+ const double t = (x - xp[lo]) / dx;
+ return fp[lo] + t * (fp[hi] - fp[lo]);
+}
+
+/* [dc] interpolate one channel of a (SF_NLE, 3) curve table over the uniform
+ * log-exposure grid divided by the per-channel gamma factor:
+ * x-axis = le/gamma -> index t = (x*gamma - le0) / le_step */
+static inline double interp_curve_uniform(double x, double gammac, double le0,
+ double le_step, const double (*curves)[3], int c)
+{
+ const double t = (x * gammac - le0) / le_step;
+ if(t <= 0.0) return curves[0][c];
+ if(t >= (double)(SF_NLE - 1)) return curves[SF_NLE - 1][c];
+ const int i = (int)t;
+ const double f = t - i;
+ return curves[i][c] + f * (curves[i + 1][c] - curves[i][c]);
+}
+
+/* Float variant using precomputed inv_le_step — avoids double→float conversions
+ and replaces division with multiply in the hot per-pixel path. */
+static inline float interp_curve_uniform_f(float x, float gammac, float le0,
+ float inv_le_step,
+ const float (*curves)[3], int c)
+{
+ const float t = (x * gammac - le0) * inv_le_step;
+ if(t <= 0.0f) return curves[0][c];
+ if(t >= (float)(SF_NLE - 1)) return curves[SF_NLE - 1][c];
+ const int i = (int)t;
+ const float f = t - i;
+ return curves[i][c] + f * (curves[i + 1][c] - curves[i][c]);
+}
+
+/* ------------------------------------------------------------------------ */
+/* [mc] cdfs density curve model + s023 morph */
+/* ------------------------------------------------------------------------ */
+
+static inline double norm_cdf(double z) { return 0.5 * (1.0 + erf(z * M_SQRT1_2)); }
+
+/* [dc] density_curves.py's septic-polynomial CDF: an order-7 Hermite
+ * smoothstep on a support of SF_SEPT_K sigmas, with a median-preserving skew
+ * warp v = u + alpha*u*(1-u)*(2u-1)^2. The warp vanishes at u = 0.5, so the
+ * 0.5 crossing stays on the layer centre for every alpha, and alpha == 0
+ * reproduces the symmetric Gaussian fit to within the minimax fit error. */
+#define SF_SEPT_K 5.8013
+static double sept_cdf(double z, double alpha)
+{
+ double u = z / SF_SEPT_K + 0.5;
+ u = u < 0.0 ? 0.0 : (u > 1.0 ? 1.0 : u);
+ double v = u;
+ if(alpha != 0.0)
+ {
+ const double t = 2.0 * u - 1.0;
+ v = u + alpha * u * (1.0 - u) * t * t;
+ v = v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v);
+ }
+ const double v2 = v * v;
+ return (v2 * v2) * (35.0 + v * (-84.0 + v * (70.0 - 20.0 * v)));
+}
+
+/* One emulsion layer's sigmoid, dispatched by the profile's model_type
+ * ([dc] _layer_cdf_values). `alpha` is ignored by the Gaussian family. */
+static double layer_cdf(double z, int sept, double alpha)
+{
+ return sept ? sept_cdf(z, alpha) : norm_cdf(z);
+}
+
+/* evaluate one channel of the cdfs model over the log-exposure grid.
+ * signed z: negated for positive profiles ([mc] _signed_z) */
+static void eval_cdfs_channel(double *out, const double *le, int nle, const double *centers,
+ const double *amps, const double *sigmas, const double *alphas,
+ int sept, int n_layers, int positive)
+{
+ for(int i = 0; i < nle; i++) out[i] = 0.0;
+ for(int l = 0; l < n_layers; l++)
+ {
+ const double alpha = alphas ? alphas[l] : 0.0;
+ for(int i = 0; i < nle; i++)
+ {
+ double z = (le[i] - centers[l]) / sigmas[l];
+ if(positive) z = -z;
+ out[i] += amps[l] * layer_cdf(z, sept, alpha);
+ }
+ }
+}
+
+#define SF_SIGMA_FLOOR 0.05 /* [mc] NormCdfsFitConfig.sigma_floor */
+
+/* [mc] apply_print_curves_morph without developer exhaustion.
+ * With morph inactive this reduces to a plain model evaluation. */
+/* Build the multi-sublayer grain model from a film's fitted density-curve
+ * model (sf_curves_model_t) -- spektrafilm's own grain.py documents the
+ * model as "always multilayer": several independent emulsion sub-layers are
+ * sampled separately and summed, calibrated (_realized_peak_correction /
+ * _coarsest_area_from_curves in grain.py) so their COMBINED peak variance
+ * matches the catalogue RMS-granularity. A single-sublayer curve fit is
+ * just the trivial one-sublayer case of the same model, so it needs no
+ * separate branch here beyond n_layers<=1 meaning the loops below run once.
+ *
+ * `density_min` is the film's overall (fog) floor per channel
+ * (p->grain_density_min, already updated in place by sf_pack_film_grain);
+ * `uniformity`/`rms` are that same per-film grain catalogue data;
+ * `particle_scale`/`n_scale` come from the pack's particle_scale_sublayers
+ * (falls back to a single coarsest layer, scale 1.0, if the pack has none or
+ * the stock's curve fit itself is single-layer).
+ *
+ * Writes s->grain_n_sublayers, grain_particle_scale, grain_layer_dmax,
+ * grain_layer_npart, grain_layer_dmin, grain_layer_curve and
+ * grain_layer_curve_total. */
+static void _sf_build_grain_layers(sf_sim_t *s, const sf_profile_t *film,
+ const double density_min[3], const double uniformity[3],
+ const double rms[3], const double particle_scale[SF_GRAIN_MAX_SUBLAYERS],
+ int n_scale)
+{
+ const sf_curves_model_t *m = &film->curves_model;
+ const int nl = (m->n_layers > 1 && n_scale > 1) ? MIN(m->n_layers, MIN(n_scale, SF_GRAIN_MAX_SUBLAYERS))
+ : 1;
+ s->grain_n_sublayers = nl;
+ const float ref_um = SF_GRAIN_REF_UM;
+ const double pix_ref = (double)ref_um * (double)ref_um;
+ const double A48 = 3.14159265358979 * 24.0 * 24.0;
+
+ if(nl <= 1)
+ {
+ /* trivial one-sublayer case: reuse the plain total curve already
+ computed above (s->curves_norm) verbatim, so behavior for any stock
+ whose own curve fit is single-layer (or has no fit / no per-stock
+ particle_scale_sublayers at all) is byte-for-byte identical to the
+ pre-existing single-layer path -- this is not an approximate
+ fallback, it's what upstream's own model reduces to in this case. */
+ s->grain_particle_scale[0] = 1.0;
+ for(int c = 0; c < 3; c++)
+ {
+ double mx = -1e300;
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double v = s->curves_norm[i][c];
+ s->grain_layer_curve[i][0][c] = (float)v;
+ s->grain_layer_curve_total[i][c] = (float)v;
+ if(v > mx) mx = v;
+ }
+ s->grain_layer_dmin[0][c] = density_min[c];
+ s->grain_layer_dmax[0][c] = mx + density_min[c];
+ const double d_ref_c = 1.0 + density_min[c];
+ const double sig = rms[c] / 1000.0;
+ const double denom = fmax(d_ref_c * (s->grain_layer_dmax[0][c] - uniformity[c] * d_ref_c), 1e-6);
+ const double a_grain = sig * sig * A48 / denom;
+ s->grain_layer_npart[0][c] = pix_ref / fmax(a_grain, 1e-4);
+ }
+ return;
+ }
+
+ /* multi-sublayer case: use the (possibly chemistry-morphed) per-sublayer
+ curves computed alongside curves_norm when the film-side chemistry
+ morph is active, matching upstream's "regenerating the grain
+ sublayers from the same morphed params so grain stays consistent";
+ otherwise evaluate each sub-layer's raw CDF term directly, matching
+ grain.py's interp_density_cmy_layers_channel/_coarsest_area_from_curves
+ inputs. */
+ const int positive = s->film_positive;
+ double layer_curve[SF_NLE][SF_GRAIN_MAX_SUBLAYERS][3];
+ double layer_max_raw[SF_GRAIN_MAX_SUBLAYERS][3];
+ if(s->film_morph_applied)
+ {
+ for(int c = 0; c < 3; c++)
+ for(int l = 0; l < nl; l++)
+ {
+ double mx = -1e300;
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double v = s->film_curve_layers[i][l][c];
+ layer_curve[i][l][c] = v;
+ if(v > mx) mx = v;
+ }
+ layer_max_raw[l][c] = mx;
+ }
+ }
+ else
+ {
+ for(int c = 0; c < 3; c++)
+ {
+ double centers[8], amps[8], sigmas[8], alphas[8];
+ memcpy(centers, m->centers[c], sizeof(double) * nl);
+ memcpy(amps, m->amplitudes[c], sizeof(double) * nl);
+ memcpy(sigmas, m->sigmas[c], sizeof(double) * nl);
+ memcpy(alphas, m->alphas[c], sizeof(double) * nl);
+ for(int l = 0; l < nl; l++)
+ {
+ double mx = -1e300;
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ double z = (film->log_exposure[i] - centers[l]) / sigmas[l];
+ if(positive) z = -z;
+ const double v = amps[l] * layer_cdf(z, m->sept, alphas[l]);
+ layer_curve[i][l][c] = v;
+ if(v > mx) mx = v;
+ }
+ layer_max_raw[l][c] = mx;
+ }
+ }
+ }
+
+ double density_max_total_raw[3], density_max_fractions[SF_GRAIN_MAX_SUBLAYERS][3];
+ double layer_dmin[SF_GRAIN_MAX_SUBLAYERS][3], layer_dmax[SF_GRAIN_MAX_SUBLAYERS][3];
+ for(int c = 0; c < 3; c++)
+ {
+ double tot = 0.0;
+ for(int l = 0; l < nl; l++) tot += layer_max_raw[l][c];
+ density_max_total_raw[c] = fmax(tot, 1e-9);
+ for(int l = 0; l < nl; l++)
+ {
+ density_max_fractions[l][c] = layer_max_raw[l][c] / density_max_total_raw[c];
+ layer_dmin[l][c] = density_max_fractions[l][c] * density_min[c];
+ layer_dmax[l][c] = layer_max_raw[l][c] + layer_dmin[l][c];
+ }
+ }
+
+ /* _coarsest_area_from_curves: peak (over the exposure grid) of the summed
+ per-sublayer variance shape, weighted by particle_scale/fraction. */
+ double peak[3] = { 0.0, 0.0, 0.0 };
+ for(int c = 0; c < 3; c++)
+ {
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ double sum_sl = 0.0;
+ for(int l = 0; l < nl; l++)
+ {
+ const double d_abs = layer_curve[i][l][c] + layer_dmin[l][c];
+ const double weight = particle_scale[l] / density_max_fractions[l][c];
+ sum_sl += weight * d_abs * (layer_dmax[l][c] - uniformity[c] * d_abs);
+ }
+ if(sum_sl > peak[c]) peak[c] = sum_sl;
+ }
+ peak[c] = fmax(peak[c], 1e-9);
+ }
+
+ for(int c = 0; c < 3; c++)
+ {
+ const double sigma_in = rms[c] / 1000.0;
+ const double a_coarsest = sigma_in * sigma_in * A48 / peak[c];
+ for(int l = 0; l < nl; l++)
+ {
+ const double particle_area = a_coarsest * particle_scale[l];
+ s->grain_layer_npart[l][c] = pix_ref * density_max_fractions[l][c] / fmax(particle_area, 1e-9);
+ s->grain_layer_dmax[l][c] = layer_dmax[l][c];
+ s->grain_layer_dmin[l][c] = layer_dmin[l][c];
+ for(int i = 0; i < SF_NLE; i++) s->grain_layer_curve[i][l][c] = (float)layer_curve[i][l][c];
+ }
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ double t = 0.0;
+ for(int l = 0; l < nl; l++) t += layer_curve[i][l][c];
+ s->grain_layer_curve_total[i][c] = (float)t;
+ }
+ }
+ for(int l = nl; l < SF_GRAIN_MAX_SUBLAYERS; l++) s->grain_particle_scale[l] = 0.0;
+ for(int l = 0; l < nl; l++) s->grain_particle_scale[l] = particle_scale[l];
+}
+
+/* [mc] Gumbel-max CDF, width/location matched to give a plausible
+ * "exhausted developer" shoulder shape when blended with the fitted
+ * norm_cdfs model (matches morph_curves.py's _gumbel_matched_cdf). */
+static double _sf_gumbel_matched_cdf(double z)
+{
+ const double location = -log(log(2.0));
+ const double width = 0.5 * log(2.0) * sqrt(2.0 * M_PI);
+ return exp(-exp(-(z / width + location)));
+}
+
+/* norm_cdf blended toward the Gumbel shoulder by gumbel_mix in [0,1] -- the
+ * per-layer building block for developer exhaustion (matches
+ * morph_curves.py's _layer_cdf; `z` already sign-flipped for positive
+ * stocks by the caller, matching eval_cdfs_channel's own convention). */
+static double _sf_layer_cdf_mixed(double z, int sept, double alpha, double gumbel_mix)
+{
+ double cdf = layer_cdf(z, sept, alpha);
+ if(gumbel_mix > 0.0) cdf = (1.0 - gumbel_mix) * cdf + gumbel_mix * _sf_gumbel_matched_cdf(z);
+ return cdf;
+}
+
+/* Summed channel density at one exposure point, given explicit per-layer
+ * centers/amplitudes/sigmas and an optional per-layer gumbel_mix (NULL ==
+ * all zero) -- the evaluation primitive the exhaustion offset solver below
+ * needs (matches morph_curves.py's _evaluate_channel_density, at a single
+ * point since that's all the solver needs). */
+static double _sf_channel_density_at(double x, const double *centers, const double *amps,
+ const double *sigmas, const double *alphas, int sept,
+ int n_layers, const double *gumbel_mix, int positive)
+{
+ double total = 0.0;
+ for(int l = 0; l < n_layers; l++)
+ {
+ double z = (x - centers[l]) / sigmas[l];
+ if(positive) z = -z;
+ total += amps[l] * _sf_layer_cdf_mixed(z, sept, alphas ? alphas[l] : 0.0,
+ gumbel_mix ? gumbel_mix[l] : 0.0);
+ }
+ return total;
+}
+
+/* Find the horizontal (center) offset that keeps D(0) -- density at zero
+ * log-exposure, i.e. midgray/fog -- unchanged once developer exhaustion
+ * (a gumbel blend toward a matched shoulder) is applied, so exhaustion
+ * changes shoulder shape without shifting midgray. Matches
+ * morph_curves.py's _developer_exhaustion_center_offset (same bracket
+ * [-0.25, 0.25], doubled up to 12 times looking for a sign change), but
+ * bisection instead of Brent's method: standard C, no extra dependency,
+ * and precision matters far more than speed here since this runs once per
+ * channel per sim build, never per pixel. Returns 0 if gumbel_mix is zero
+ * or no sign change is found (matching upstream's own fallback-to-zero). */
+static double _sf_developer_exhaustion_offset(const double *centers, const double *amps,
+ const double *sigmas, const double *alphas,
+ int sept, int n_layers,
+ double gumbel_mix, int positive)
+{
+ if(gumbel_mix <= 0.0) return 0.0;
+
+ const double target_d0 = _sf_channel_density_at(0.0, centers, amps, sigmas, alphas, sept, n_layers, NULL, positive);
+ double gmix[SF_GRAIN_MAX_SUBLAYERS];
+ for(int l = 0; l < n_layers; l++) gmix[l] = gumbel_mix;
+
+ double shifted[SF_GRAIN_MAX_SUBLAYERS];
+ double lo = -0.25, hi = 0.25;
+ for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + lo;
+ double r_lo = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0;
+ for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + hi;
+ double r_hi = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0;
+ if(r_lo == 0.0) return lo;
+ if(r_hi == 0.0) return hi;
+
+ int bracketed = 0;
+ for(int iter = 0; iter < 12; iter++)
+ {
+ if(r_lo * r_hi < 0.0) { bracketed = 1; break; }
+ lo *= 2.0;
+ hi *= 2.0;
+ for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + lo;
+ r_lo = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0;
+ for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + hi;
+ r_hi = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0;
+ if(r_lo == 0.0) return lo;
+ if(r_hi == 0.0) return hi;
+ }
+ if(!bracketed) return 0.0;
+
+ double mid = 0.0;
+ for(int iter = 0; iter < 60; iter++)
+ {
+ mid = 0.5 * (lo + hi);
+ for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + mid;
+ const double r_mid = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0;
+ if(r_mid == 0.0 || (hi - lo) < 1e-12) break;
+ if((r_lo < 0.0) == (r_mid < 0.0)) { lo = mid; r_lo = r_mid; }
+ else hi = mid;
+ }
+ return mid;
+}
+
+/* Apply the s023 coupled-gamma + developer-exhaustion morph to one
+ * channel's fitted curve-model parameters (matches morph_curves.py's
+ * _morph_channel_params). Amplitudes are unchanged by this morph -- only
+ * centers/sigmas move, plus the per-layer gumbel_mix output (uniformly
+ * `exhaustion` across every layer, matching upstream). Layer speed order
+ * (fast/mid/slow) is by ascending center, same as _sf_build_grain_layers'
+ * own convention elsewhere in this file. The per-layer skew (alpha) is not
+ * touched by the morph -- upstream's _morph_channel_params carries it through
+ * unchanged too -- but it is passed on so the exhaustion solver evaluates the
+ * same sigmoid family the curves are built from. */
+static void _sf_morph_channel(const double centers_in[], const double sigmas_in[],
+ const double alphas_in[], int sept, int nl,
+ int positive, double gamma, double gamma_fast, double gamma_slow,
+ double exhaustion, const double amps[],
+ double centers_out[], double sigmas_out[], double gumbel_mix_out[])
+{
+ int order[SF_GRAIN_MAX_SUBLAYERS];
+ for(int i = 0; i < nl; i++) order[i] = i;
+ for(int i = 0; i < nl; i++)
+ for(int j = i + 1; j < nl; j++)
+ if(centers_in[order[j]] < centers_in[order[i]])
+ {
+ const int t = order[i];
+ order[i] = order[j];
+ order[j] = t;
+ }
+ const int i_fast = order[0], i_mid = order[nl / 2], i_slow = order[nl - 1];
+
+ const double g_fast = gamma * gamma_fast;
+ const double g_mid = gamma * gamma_slow; /* [mc]: mid intentionally uses the "slow" factor */
+ const double g_slow = g_mid;
+
+ for(int i = 0; i < nl; i++)
+ {
+ centers_out[i] = centers_in[i];
+ sigmas_out[i] = sigmas_in[i];
+ gumbel_mix_out[i] = exhaustion;
+ }
+ sigmas_out[i_fast] = fmax(sigmas_in[i_fast] / g_fast, SF_SIGMA_FLOOR);
+ centers_out[i_fast] = centers_in[i_fast] / g_fast;
+ sigmas_out[i_mid] = fmax(sigmas_in[i_mid] / g_mid, SF_SIGMA_FLOOR);
+ centers_out[i_mid] = centers_in[i_mid] / g_mid;
+ sigmas_out[i_slow] = fmax(sigmas_in[i_slow] / g_slow, SF_SIGMA_FLOOR);
+ centers_out[i_slow] = centers_in[i_slow] / g_slow;
+
+ if(exhaustion > 0.0)
+ {
+ const double offset = _sf_developer_exhaustion_offset(centers_out, amps, sigmas_out,
+ alphas_in, sept, nl,
+ exhaustion, positive);
+ for(int i = 0; i < nl; i++) centers_out[i] += offset;
+ }
+}
+
+/* Film-side counterpart of build_print_curves: applies the s023 chemistry
+ * morph (gamma / fast / slow / developer exhaustion) to the film's own
+ * fitted density-curve model, and -- since grain needs to stay consistent
+ * with whatever curve chemistry produces (matches upstream's own
+ * "regenerating the grain sublayers from the same morphed params") --
+ * also writes the per-sublayer breakdown to layers_out. Only called when
+ * film->curves_model.n_layers > 0; the caller falls back to the profile's
+ * static density_curves array otherwise (matching upstream's own check). */
+static void build_film_curves(double (*curves)[3], double (*layers_out)[SF_GRAIN_MAX_SUBLAYERS][3],
+ const sf_profile_t *film, const sf_sim_params_t *p)
+{
+ const int positive = (film->type && strcmp(film->type, "positive") == 0);
+ const sf_curves_model_t *m = &film->curves_model;
+ const int nl = m->n_layers;
+
+ for(int c = 0; c < 3; c++)
+ {
+ double centers[SF_GRAIN_MAX_SUBLAYERS], amps[SF_GRAIN_MAX_SUBLAYERS],
+ sigmas[SF_GRAIN_MAX_SUBLAYERS], alphas[SF_GRAIN_MAX_SUBLAYERS];
+ memcpy(centers, m->centers[c], sizeof(double) * nl);
+ memcpy(amps, m->amplitudes[c], sizeof(double) * nl);
+ memcpy(sigmas, m->sigmas[c], sizeof(double) * nl);
+ memcpy(alphas, m->alphas[c], sizeof(double) * nl);
+
+ double mcenters[SF_GRAIN_MAX_SUBLAYERS], msigmas[SF_GRAIN_MAX_SUBLAYERS],
+ gmix[SF_GRAIN_MAX_SUBLAYERS];
+ if(p->film_morph_active)
+ _sf_morph_channel(centers, sigmas, alphas, m->sept, nl, positive, p->film_morph_gamma,
+ p->film_morph_gamma_fast, p->film_morph_gamma_slow,
+ p->film_morph_developer_exhaustion, amps, mcenters, msigmas, gmix);
+ else
+ {
+ memcpy(mcenters, centers, sizeof(double) * nl);
+ memcpy(msigmas, sigmas, sizeof(double) * nl);
+ for(int i = 0; i < nl; i++) gmix[i] = 0.0;
+ }
+
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ double total = 0.0;
+ for(int l = 0; l < nl; l++)
+ {
+ double z = (film->log_exposure[i] - mcenters[l]) / msigmas[l];
+ if(positive) z = -z;
+ const double v = amps[l] * _sf_layer_cdf_mixed(z, m->sept, alphas[l], gmix[l]);
+ layers_out[i][l][c] = v;
+ total += v;
+ }
+ curves[i][c] = total;
+ }
+ }
+}
+
+static void build_print_curves(double (*curves)[3], const sf_profile_t *print,
+ const sf_sim_params_t *p)
+{
+ const int positive = (print->type && strcmp(print->type, "positive") == 0);
+ const sf_curves_model_t *m = &print->curves_model;
+ const int nl = m->n_layers;
+
+ for(int c = 0; c < 3; c++)
+ {
+ double centers[8], amps[8], sigmas[8], alphas[8];
+ memcpy(centers, m->centers[c], sizeof(centers));
+ memcpy(amps, m->amplitudes[c], sizeof(amps));
+ memcpy(sigmas, m->sigmas[c], sizeof(sigmas));
+ memcpy(alphas, m->alphas[c], sizeof(alphas));
+
+ if(p->morph_active && nl > 0)
+ {
+ /* speed-layer indices by ascending center ([mc] _speed_layer_indices) */
+ int order[8];
+ for(int i = 0; i < nl; i++) order[i] = i;
+ for(int i = 0; i < nl; i++)
+ for(int j = i + 1; j < nl; j++)
+ if(centers[order[j]] < centers[order[i]])
+ {
+ const int t = order[i];
+ order[i] = order[j];
+ order[j] = t;
+ }
+ const int i_fast = order[0], i_mid = order[nl / 2], i_slow = order[nl - 1];
+ const double gch = (c == 0) ? p->morph_gamma_r : (c == 1) ? p->morph_gamma_g
+ : p->morph_gamma_b;
+ const double g_fast = p->morph_gamma * gch * p->morph_gamma_fast;
+ /* [mc] note: the mid sub-layer intentionally uses gamma_factor_slow */
+ const double g_mid = p->morph_gamma * gch * p->morph_gamma_slow;
+ const double g_slow = g_mid;
+ sigmas[i_fast] = fmax(sigmas[i_fast] / g_fast, SF_SIGMA_FLOOR);
+ centers[i_fast] = centers[i_fast] / g_fast;
+ sigmas[i_mid] = fmax(sigmas[i_mid] / g_mid, SF_SIGMA_FLOOR);
+ centers[i_mid] = centers[i_mid] / g_mid;
+ sigmas[i_slow] = fmax(sigmas[i_slow] / g_slow, SF_SIGMA_FLOOR);
+ centers[i_slow] = centers[i_slow] / g_slow;
+ }
+
+ double column[SF_NLE];
+ eval_cdfs_channel(column, print->log_exposure, SF_NLE, centers, amps, sigmas, alphas,
+ m->sept, nl, positive);
+ for(int i = 0; i < SF_NLE; i++) curves[i][c] = column[i];
+ }
+}
+
+/* ------------------------------------------------------------------------ */
+/* [cf] dichroic enlarger filters */
+/* ------------------------------------------------------------------------ */
+
+/* filtered[l] = src[l] * prod_c (1 - (1 - F[l][c]) * (1 - 10^(-cc_c/100))) */
+static void apply_dichroic_cc(double *out, const double *src, const double *filters,
+ const double cc[3])
+{
+ double dim[3];
+ for(int c = 0; c < 3; c++) dim[c] = 1.0 - pow(10.0, -cc[c] / 100.0);
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ double total = 1.0;
+ for(int c = 0; c < 3; c++) total *= 1.0 - (1.0 - filters[l * 3 + c]) * dim[c];
+ out[l] = src[l] * total;
+ }
+}
+
+/* ------------------------------------------------------------------------ */
+/* exact spectral kernels shared by build (LUT fill) and per-pixel paths */
+/* ------------------------------------------------------------------------ */
+
+/* [st] printing._film_cmy_to_print_log_raw — WITHOUT the print_exposure and
+ * second log step, which run outside the (optional) 3D table */
+static void cmy_to_print_lograw(const sf_sim_t *s, const double cmy[3], double out[3])
+{
+ double raw[3] = { 0.0, 0.0, 0.0 };
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ double ds = s->film_base_density[l];
+ for(int c = 0; c < 3; c++) ds += s->film_chan_density[l][c] * cmy[c];
+ /* [st] density_to_light zeroes NaN transmittance (missing spectral data) */
+ double light = s->illum_print[l] * pow(10.0, -ds);
+ if(!isfinite(light)) light = 0.0;
+ for(int m = 0; m < 3; m++) raw[m] += light * s->print_sens[l][m];
+ }
+ for(int m = 0; m < 3; m++)
+ {
+ double r = raw[m] * s->midgray_factor + s->preflash_raw[m];
+ out[m] = log10(fmax(r, 0.0) + SF_LOG_EPS);
+ }
+}
+
+/* np.interp equivalent over xp = -curve[i] (ascending for positive film),
+ fp = le[i]; endpoint-clamped exactly like numpy */
+static double interp_ascending(double x, const double *curve, const double *le, int n)
+{
+ if(x <= -curve[0]) return le[0];
+ if(x >= -curve[n - 1]) return le[n - 1];
+ for(int i = 0; i < n - 1; i++)
+ {
+ const double x0 = -curve[i], x1 = -curve[i + 1];
+ if(x >= x0 && x <= x1)
+ {
+ const double t = (x1 > x0) ? (x - x0) / (x1 - x0) : 0.0;
+ return le[i] + t * (le[i + 1] - le[i]);
+ }
+ }
+ return le[n - 1];
+}
+
+/* [st] scanning cmy_to_log_xyz */
+static void cmy_to_log_xyz(const sf_sim_t *s, const double cmy[3], double out[3])
+{
+ double xyz[3] = { 0.0, 0.0, 0.0 };
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ double ds = s->scan_base_density[l];
+ for(int c = 0; c < 3; c++) ds += s->scan_chan_density[l][c] * cmy[c];
+ double light = s->illum_view[l] * pow(10.0, -ds);
+ if(!isfinite(light)) light = 0.0;
+ for(int m = 0; m < 3; m++) xyz[m] += light * s->cmfs[l][m];
+ }
+ for(int m = 0; m < 3; m++)
+ out[m] = log10(fmax(xyz[m] / s->xyz_norm, 0.0) + SF_LOG_EPS);
+}
+
+/* ------------------------------------------------------------------------ */
+/* [gc] OkLab conversions and output C_max(L, h) table */
+/* ------------------------------------------------------------------------ */
+
+static inline void xyz_to_oklab(const double xyz[3], double lab[3])
+{
+ double lms[3];
+ mat3_mulv(lms, SF_OKLAB_M1, xyz);
+ for(int i = 0; i < 3; i++) lms[i] = cbrt(lms[i]);
+ mat3_mulv(lab, SF_OKLAB_M2, lms);
+}
+
+static inline void oklab_to_xyz(const sf_sim_t *s, const double lab[3], double xyz[3])
+{
+ double lms[3];
+ mat3_mulv(lms, s->oklab_m2inv, lab);
+ for(int i = 0; i < 3; i++) lms[i] = lms[i] * lms[i] * lms[i];
+ mat3_mulv(xyz, s->oklab_m1inv, lms);
+}
+
+#define SF_CMAX_L_LO 0.02 /* [gc] _get_output_c_max_table oklch L_grid */
+#define SF_CMAX_L_HI 1.0
+
+/* bisect the max in-cube OkLch chroma per (L, h) ([gc] _build_polar_..._table) */
+static void build_cmax_table(sf_sim_t *s)
+{
+ s->cmax = malloc(sizeof(float) * SF_CMAX_NL * SF_CMAX_NH);
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(int i = 0; i < SF_CMAX_NL; i++)
+ {
+ const double L = SF_CMAX_L_LO
+ + (SF_CMAX_L_HI - SF_CMAX_L_LO) * i / (double)(SF_CMAX_NL - 1);
+ for(int j = 0; j < SF_CMAX_NH; j++)
+ {
+ const double h = -M_PI + 2.0 * M_PI * j / (double)SF_CMAX_NH;
+ const double ch = cos(h), sh = sin(h);
+ double lo = 0.0, hi = 0.5;
+ for(int b = 0; b < SF_CMAX_NBISECT; b++)
+ {
+ const double mid = 0.5 * (lo + hi);
+ const double lab[3] = { L, mid * ch, mid * sh };
+ double xyz[3], rgb[3];
+ oklab_to_xyz(s, lab, xyz);
+ mat3_mulv(rgb, s->out_xyz2rgb, xyz);
+ const int in_gamut = rgb[0] >= -1e-6 && rgb[0] <= 1.0 + 1e-6 && rgb[1] >= -1e-6
+ && rgb[1] <= 1.0 + 1e-6 && rgb[2] >= -1e-6 && rgb[2] <= 1.0 + 1e-6;
+ if(in_gamut)
+ lo = mid;
+ else
+ hi = mid;
+ }
+ s->cmax[(size_t)i * SF_CMAX_NH + j] = (float)lo;
+ }
+ }
+}
+
+/* [gc] _c_max_lookup — bilinear, L clamped, hue wrapped */
+static inline double cmax_lookup(const sf_sim_t *s, double L, double h)
+{
+ L = CLAMP(L, SF_CMAX_L_LO, SF_CMAX_L_HI);
+ const double h_step = 2.0 * M_PI / SF_CMAX_NH;
+ const double h_idx = (h + M_PI) / h_step;
+ const double h_floor = floor(h_idx);
+ int h_lo = ((int)h_floor) % SF_CMAX_NH;
+ if(h_lo < 0) h_lo += SF_CMAX_NH;
+ const int h_hi = (h_lo + 1) % SF_CMAX_NH;
+ const double h_frac = h_idx - h_floor;
+
+ const double L_idx
+ = (L - SF_CMAX_L_LO) / (SF_CMAX_L_HI - SF_CMAX_L_LO) * (double)(SF_CMAX_NL - 1);
+ int L_lo = (int)floor(L_idx);
+ L_lo = CLAMP(L_lo, 0, SF_CMAX_NL - 2);
+ const int L_hi = L_lo + 1;
+ const double L_frac = L_idx - L_lo;
+
+ const float *T = s->cmax;
+ const double v00 = T[(size_t)L_lo * SF_CMAX_NH + h_lo];
+ const double v01 = T[(size_t)L_lo * SF_CMAX_NH + h_hi];
+ const double v10 = T[(size_t)L_hi * SF_CMAX_NH + h_lo];
+ const double v11 = T[(size_t)L_hi * SF_CMAX_NH + h_hi];
+ return v00 * (1 - L_frac) * (1 - h_frac) + v01 * (1 - L_frac) * h_frac
+ + v10 * L_frac * (1 - h_frac) + v11 * L_frac * h_frac;
+}
+
+/* [gc] compress_rgb_oklch_chroma with lightness_compression (0.7, 1, 2.2) */
+static void compress_rgb_oklch(const sf_sim_t *s, double rgb[3])
+{
+ double xyz[3], lab[3];
+ mat3_mulv(xyz, s->out_rgb2xyz, rgb);
+ xyz_to_oklab(xyz, lab);
+ double L = lab[0];
+ const double a = lab[1], b = lab[2];
+ /* lightness first, so C_max is looked up at the corrected L */
+ L = reinhard_knee(L, SF_OUT_LIGHT_T, SF_OUT_LIGHT_L, SF_OUT_LIGHT_P);
+ const double C = hypot(a, b);
+ const double h = atan2(b, a);
+ const double C_max = fmax(cmax_lookup(s, L, h), 1e-9);
+ const double d = reinhard_knee(C / C_max, SF_OUT_KNEE_T, SF_OUT_KNEE_L, SF_OUT_KNEE_P);
+ const double C_new = d * C_max;
+ const double lab_new[3] = { L, C_new * cos(h), C_new * sin(h) };
+ oklab_to_xyz(s, lab_new, xyz);
+ mat3_mulv(rgb, s->out_xyz2rgb, xyz);
+}
+
+/* [gc] compress_rgb_aces_rgc — per-channel knee on achromatic distance */
+static void compress_rgb_aces(double rgb[3])
+{
+ const double ach = fmax(rgb[0], fmax(rgb[1], rgb[2]));
+ if(ach <= 1e-12) return;
+ for(int c = 0; c < 3; c++)
+ {
+ const double d = (ach - rgb[c]) / ach;
+ const double dc = reinhard_knee(d, SF_OUT_KNEE_T, SF_OUT_KNEE_L, SF_OUT_KNEE_P);
+ rgb[c] = ach * (1.0 - dc);
+ }
+}
+
+/* Runs one RGB triple through the full simulation up to (but not including)
+ * the highlight/gamut compressor (compress_rgb_oklch/aces), using
+ * `boost_override` in place of sim->out_luminance_boost, and returns the
+ * resulting OkLab lightness -- the precompression-boost picker's actual
+ * measurement primitive. This needs the pre-compression value specifically:
+ * the reinhard knee (see SF_OUT_LIGHT_T/_L/_P) asymptotically approaches its
+ * limit regardless of how hard the input is pushed, so measuring the
+ * post-compression lightness would tell the picker almost nothing about how
+ * much boost is actually needed.
+ *
+ * No solver/iteration needed: the boost multiplies XYZ uniformly, XYZ->LMS
+ * is linear, so boosted LMS = boost * original LMS; OkLab's L is a fixed
+ * linear combination of LMS^(1/3), so L(boost) = boost^(1/3) * L(1) exactly.
+ * One probe at any boost value is enough to solve for the boost that hits a
+ * target L in closed form (see the caller in spektrafilm.c). */
+float sf_sim_probe_lightness(const sf_sim_t *sim, const float rgb_in[3], float boost_override)
+{
+ sf_sim_t tmp_sim = *sim;
+ tmp_sim.out_luminance_boost = (double)boost_override;
+ tmp_sim.out_compress = SF_OUTPUT_COMPRESS_OFF;
+
+ float raw[3];
+ sf_sim_expose(&tmp_sim, rgb_in, raw, 1, 3, 3);
+ sf_sim_lograw(raw, 1, 3);
+ float corr[3] = { 0.0f, 0.0f, 0.0f };
+ if(tmp_sim.couplers_active) sf_sim_develop_corr(&tmp_sim, raw, corr, 1, 3);
+ float cmy[3];
+ sf_sim_develop(&tmp_sim, raw, corr, cmy, 1, 3, 3);
+ if(tmp_sim.has_print)
+ {
+ sf_sim_print_expose(&tmp_sim, cmy, cmy, 1, 3, 3);
+ sf_sim_print_develop(&tmp_sim, cmy, cmy, 1, 3, 3);
+ }
+ float rgb_out[3];
+ sf_sim_scan(&tmp_sim, cmy, rgb_out, 1, 3, 3);
+
+ const double rgb_d[3] = { rgb_out[0], rgb_out[1], rgb_out[2] };
+ double xyz[3], lab[3];
+ mat3_mulv(xyz, tmp_sim.out_rgb2xyz, rgb_d);
+ xyz_to_oklab(xyz, lab);
+ return (float)lab[0];
+}
+
+/* ------------------------------------------------------------------------ */
+/* build */
+/* ------------------------------------------------------------------------ */
+
+static void illuminant_xy_from_spd(double out[2], const double *spd,
+ const double cmfs[][3])
+{
+ double xyz[3] = { 0.0, 0.0, 0.0 };
+ for(int l = 0; l < SF_NWL; l++)
+ for(int c = 0; c < 3; c++) xyz[c] += spd[l] * cmfs[l][c];
+ const double sum = xyz[0] + xyz[1] + xyz[2];
+ out[0] = xyz[0] / sum;
+ out[1] = xyz[1] / sum;
+}
+
+/* [su] one 2D LUT lookup of the filming stage: linear RGB -> raw exposure */
+static void expose_pixel(const double m_in[9], const double *tc_lut, int tc_n,
+ const double rgb[3], double raw[3])
+{
+ double xyz[3];
+ mat3_mulv(xyz, m_in, rgb);
+ const double b = xyz[0] + xyz[1] + xyz[2];
+ const double xy[2] = { xyz[0] / fmax(b, 1e-10), xyz[1] / fmax(b, 1e-10) };
+ double tc[2];
+ tri2quad(tc, xy);
+ const double scale = (double)(tc_n - 1);
+ cubic_interp_2d(raw, tc_lut, tc_n, tc[0] * scale, tc[1] * scale);
+ const double bb = isfinite(b) ? b : 0.0;
+ for(int c = 0; c < 3; c++) raw[c] *= bb;
+}
+
+/* Float per-pixel expose using float LUT/input. The linear color matrix
+ product is the same as expose_pixel but stored/operated in float. */
+static void expose_pixel_f(const float m_in[9], const float *tc_lut, int tc_n,
+ const float rgb[3], float raw[3])
+{
+ float xyz[3];
+ for(int i = 0; i < 3; i++)
+ xyz[i] = m_in[i * 3] * rgb[0] + m_in[i * 3 + 1] * rgb[1] + m_in[i * 3 + 2] * rgb[2];
+ const float b = xyz[0] + xyz[1] + xyz[2];
+ const float xy[2] = { xyz[0] / fmaxf(b, 1e-10f), xyz[1] / fmaxf(b, 1e-10f) };
+ float tc[2];
+ tri2quad_f(tc, xy);
+ const float scale = (float)(tc_n - 1);
+ bilinear_interp_2d_f(raw, tc_lut, tc_n, tc[0] * scale, tc[1] * scale);
+ const float bb = isfinite(b) ? b : 0.0f;
+ for(int c = 0; c < 3; c++) raw[c] *= bb;
+}
+
+/* [st] filming._simple_rgb_to_density_spectral: the gray reference used to
+ * balance the print exposure. NOTE the reference computes this in *sRGB*
+ * (the _rgb_to_film_raw defaults), independent of the io input space. */
+static void midgray_density_spectral(const sf_sim_t *s, const sf_profile_t *film,
+ const double film_ref_xy[2], double gray,
+ double ds[SF_NWL])
+{
+ double m_srgb[9], cat[9];
+ cat_matrix(cat, SF_M_CAT16, SF_SRGB_WHITE_XY, film_ref_xy);
+ mat3_mul(m_srgb, cat, SF_M_SRGB_TO_XYZ);
+
+ const double rgb[3] = { gray, gray, gray };
+ double raw[3];
+ expose_pixel(m_srgb, s->tc_lut, s->tc_n, rgb, raw);
+
+ double cmy[3];
+ for(int c = 0; c < 3; c++)
+ {
+ const double lograw = log10(raw[c] + SF_LOG_EPS);
+ /* develop_simple: UNNORMALIZED stock curves */
+ cmy[c] = interp_curve_uniform(lograw, s->gamma[c], s->le0, s->le_step,
+ film->density_curves, c);
+ }
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ ds[l] = film->base_density[l];
+ for(int c = 0; c < 3; c++) ds[l] += film->channel_density[l][c] * cmy[c];
+ }
+}
+
+/* [st] printing._exposure_factor: 1 / geomean of the midgray print raw */
+static double exposure_factor(const sf_sim_t *s, const double ds[SF_NWL])
+{
+ double raw[3] = { 0.0, 0.0, 0.0 };
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ double light = s->illum_print[l] * pow(10.0, -ds[l]);
+ if(!isfinite(light)) light = 0.0;
+ for(int m = 0; m < 3; m++) raw[m] += light * s->print_sens[l][m];
+ }
+ double log_sum = 0.0;
+ for(int m = 0; m < 3; m++) log_sum += log(fmax(raw[m], 1e-10));
+ return 1.0 / exp(log_sum / 3.0);
+}
+
+/* fill a steps^3 table by sampling fn over [lo, hi]^3 and prepare PCHIP */
+typedef void (*sf_cell_fn)(const sf_sim_t *, const double[3], double[3]);
+
+static void build_lut3d(const sf_sim_t *s, sf_cell_fn fn, const double lo[3],
+ const double hi[3], int steps, double **lut, double **sx,
+ double **sy, double **sz, double **cmin, double **cmax_)
+{
+ const size_t n3 = (size_t)steps * steps * steps * 3;
+ const size_t m3 = (size_t)(steps - 1) * (steps - 1) * (steps - 1) * 3;
+ *lut = malloc(n3 * sizeof(double));
+ *sx = malloc(n3 * sizeof(double));
+ *sy = malloc(n3 * sizeof(double));
+ *sz = malloc(n3 * sizeof(double));
+ *cmin = malloc(m3 * sizeof(double));
+ *cmax_ = malloc(m3 * sizeof(double));
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(int i = 0; i < steps; i++)
+ for(int j = 0; j < steps; j++)
+ for(int k = 0; k < steps; k++)
+ {
+ const double cmy[3] = { lo[0] + (hi[0] - lo[0]) * i / (double)(steps - 1),
+ lo[1] + (hi[1] - lo[1]) * j / (double)(steps - 1),
+ lo[2] + (hi[2] - lo[2]) * k / (double)(steps - 1) };
+ fn(s, cmy, *lut + ((((size_t)i) * steps + j) * steps + k) * 3);
+ }
+ pchip3d_prepare(*lut, steps, *sx, *sy, *sz, *cmin, *cmax_);
+}
+
+void sf_sim_free(sf_sim_t *s)
+{
+ if(!s) return;
+ free(s->tc_lut);
+ free(s->tc_lut_f);
+ free(s->enl_lut); free(s->enl_sx); free(s->enl_sy); free(s->enl_sz);
+ free(s->enl_cmin); free(s->enl_cmax);
+ free(s->scan_lut); free(s->scan_sx); free(s->scan_sy); free(s->scan_sz);
+ free(s->scan_cmin); free(s->scan_cmax);
+ free(s->enl_lut_f); free(s->scan_lut_f);
+ free(s->cmax);
+ g_free(s);
+}
+
+double sf_sim_film_dmax(const sf_sim_t *sim, int ch)
+{
+ return sim->film_dmax[CLAMP(ch, 0, 2)];
+}
+
+sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film,
+ const sf_profile_t *print, const sf_sim_params_t *params,
+ char **errmsg)
+{
+ if(!pack || !film || !params || (!print && !params->scan_film))
+ {
+ set_error(errmsg, "spektra_sim: build needs pack, film and (unless scan_film) print");
+ return NULL;
+ }
+ sf_sim_t *s = g_new0(sf_sim_t, 1);
+ s->p = *params;
+ sf_sim_params_t *p = &s->p;
+ s->film_positive = (film->type && strcmp(film->type, "positive") == 0);
+ s->film_bw = (film->channel_model && strcmp(film->channel_model, "bw") == 0);
+ s->print_positive = (print && print->type && strcmp(print->type, "positive") == 0);
+ s->has_print = !p->scan_film;
+ s->out_compress = p->output_compress;
+ s->out_luminance_boost = p->out_luminance_boost;
+ s->print_exposure = p->print_exposure;
+ s->lut_steps = p->lut_steps;
+ if(s->lut_steps == 1) s->lut_steps = 0;
+ if(s->lut_steps > 64) s->lut_steps = 64; /* pchip line buffers are 64 wide */
+ memcpy(s->cmfs, pack->cmfs, sizeof(s->cmfs));
+
+ /* per-film digested coupler gammas from the pack — applied only when the
+ * caller left the generic defaults untouched */
+ {
+ sf_sim_params_t generic;
+ sf_sim_params_defaults(&generic);
+ if(memcmp(p->gamma_samelayer, generic.gamma_samelayer, sizeof(p->gamma_samelayer)) == 0
+ && memcmp(p->gamma_inter_r_gb, generic.gamma_inter_r_gb, sizeof(p->gamma_inter_r_gb)) == 0
+ && memcmp(p->gamma_inter_g_rb, generic.gamma_inter_g_rb, sizeof(p->gamma_inter_g_rb)) == 0
+ && memcmp(p->gamma_inter_b_rg, generic.gamma_inter_b_rg, sizeof(p->gamma_inter_b_rg)) == 0)
+ sf_pack_film_defaults(pack, film->stock, p->gamma_samelayer, p->gamma_inter_r_gb,
+ p->gamma_inter_g_rb, p->gamma_inter_b_rg, NULL, NULL, NULL,
+ NULL, NULL);
+ }
+
+ /* per-film halation preset from the pack's film_render_defaults[stock].halation
+ * (upstream keys this off the profile's use/antihalation tags — modern
+ * strong-AH stocks get a much weaker, tighter halo than e.g. a rem-jet-removed
+ * or redscale stock). Seed with the generic still/strong-AH baseline first so
+ * a pack/stock without this data reproduces the previous fixed behaviour
+ * exactly; sf_pack_film_defaults() only overwrites entries it actually finds. */
+ {
+ s->halation_strength[0] = SF_HALATION_STRENGTH_DEFAULT_R;
+ s->halation_strength[1] = SF_HALATION_STRENGTH_DEFAULT_G;
+ s->halation_strength[2] = SF_HALATION_STRENGTH_DEFAULT_B;
+ double sigma3[3] = { SF_HALATION_SIGMA_DEFAULT_UM, SF_HALATION_SIGMA_DEFAULT_UM,
+ SF_HALATION_SIGMA_DEFAULT_UM };
+ const double core_d[3] = { SF_SCATTER_CORE_DEFAULT_R, SF_SCATTER_CORE_DEFAULT_G,
+ SF_SCATTER_CORE_DEFAULT_B };
+ const double tail_d[3] = { SF_SCATTER_TAIL_DEFAULT_R, SF_SCATTER_TAIL_DEFAULT_G,
+ SF_SCATTER_TAIL_DEFAULT_B };
+ const double tw_d[3] = { SF_SCATTER_TAILW_DEFAULT_R, SF_SCATTER_TAILW_DEFAULT_G,
+ SF_SCATTER_TAILW_DEFAULT_B };
+ memcpy(s->scatter_core_um, core_d, sizeof(core_d));
+ memcpy(s->scatter_tail_um, tail_d, sizeof(tail_d));
+ memcpy(s->scatter_tail_weight, tw_d, sizeof(tw_d));
+ sf_pack_film_defaults(pack, film->stock, NULL, NULL, NULL, NULL, s->halation_strength,
+ sigma3, s->scatter_core_um, s->scatter_tail_um,
+ s->scatter_tail_weight);
+ /* all known presets use one sigma for R/G/B (see _HALATION_PRESETS
+ upstream); take the first channel rather than plumb a 3-wide sigma
+ through sf_halation() for a split that doesn't currently exist. */
+ s->halation_sigma_um = sigma3[0];
+ }
+
+ /* neutral enlarger filters from the release database */
+ if(s->has_print && p->neutral_from_db)
+ {
+ double cmy[3];
+ if(sf_pack_neutral_filters(pack, print->stock, p->enlarger_illuminant, film->stock, cmy))
+ {
+ p->c_filter_neutral = cmy[0];
+ p->m_filter_neutral = cmy[1];
+ p->y_filter_neutral = cmy[2];
+ }
+ }
+
+ /* ----- filming: input matrix and tc_lut ------------------------------- */
+ const double *illu_ref = g_hash_table_lookup(pack->illuminants, film->reference_illuminant);
+ if(!illu_ref)
+ {
+ set_error(errmsg, "spektra_sim: pack misses reference illuminant '%s'",
+ film->reference_illuminant);
+ sf_sim_free(s);
+ return NULL;
+ }
+ double film_ref_xy[2];
+ illuminant_xy_from_spd(film_ref_xy, illu_ref, pack->cmfs);
+ {
+ double cat[9];
+ cat_matrix(cat, SF_M_CAT16, p->input_white_xy, film_ref_xy);
+ mat3_mul(s->m_in, cat, p->input_rgb_to_xyz);
+ }
+ s->ev_scale = pow(2.0, p->exposure_comp_ev);
+
+ /* [su] compute_hanatos2025_tc_lut: spectra × (sensitivity × window / norm) */
+ const int n = pack->tc_n;
+ s->tc_n = n;
+ s->tc_lut = malloc((size_t)n * n * 3 * sizeof(double));
+ {
+ double sens_w[SF_NWL][3];
+ for(int l = 0; l < SF_NWL; l++)
+ for(int m = 0; m < 3; m++)
+ {
+ const double v = pow(10.0, film->log_sensitivity[l][m]);
+ sens_w[l][m] = isfinite(v) ? v : 0.0;
+ }
+ /* [su] apply_hanatos2025_adaptation_bandwidth: the erf4 spectral bandpass,
+ white-balance preserving (hence the per-channel renormalisation below).
+ On by default, as the reference resolves it; switchable for the same
+ reason the surface below is, and so the two halves of the adaptation can
+ be told apart when a render is compared against the reference. */
+ if(p->adaptation_bandwidth && film->window_n == 4)
+ {
+ const double c_uv = film->window_params[0], s_uv = film->window_params[1];
+ const double c_ir = film->window_params[2], s_ir = film->window_params[3];
+ double w[SF_NWL];
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ const double wl = pack->wavelengths[l];
+ const double e_uv = 0.5 * (1.0 + erf((wl - c_uv) / (s_uv * M_SQRT2)));
+ const double e_ir = 0.5 * (1.0 - erf((wl - c_ir) / (s_ir * M_SQRT2)));
+ w[l] = e_uv * e_ir;
+ }
+ for(int m = 0; m < 3; m++)
+ {
+ double num = 0.0, den = 0.0;
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ num += sens_w[l][m] * illu_ref[l] * w[l];
+ den += sens_w[l][m] * illu_ref[l];
+ }
+ const double norm = num / den;
+ for(int l = 0; l < SF_NWL; l++) sens_w[l][m] *= w[l] / norm;
+ }
+ }
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(int i = 0; i < n; i++)
+ for(int j = 0; j < n; j++)
+ {
+ const float *spec = pack->spectra + ((size_t)i * n + j) * SF_NWL;
+ double acc[3] = { 0.0, 0.0, 0.0 };
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ const double sp = spec[l];
+ for(int m = 0; m < 3; m++) acc[m] += sp * sens_w[l][m];
+ }
+ double *dst = s->tc_lut + ((size_t)i * n + j) * 3;
+ dst[0] = acc[0];
+ dst[1] = acc[1];
+ dst[2] = acc[2];
+ }
+ /* [su] compute_hanatos2025_tc_lut, apply_surface: raw_lut *= 2**surface.
+ A per-chromaticity, per-channel log2 exposure correction, evaluated on the
+ same tc grid as the LUT and centred on the film's reference illuminant, so
+ it is exactly zero at that white and grows away from it -- the second half
+ of the hanatos2025 sensitivity adaptation, the first being the spectral
+ bandpass window folded into sens_w above. Both preserve white balance,
+ which is why the window's per-channel renormalisation and this surface's
+ missing constant term matter as much as the shapes themselves.
+
+ Runs after the sensitivity product and before the gamut-compression
+ remap below, matching upstream's order: the remap resamples this LUT, so
+ it has to see the corrected values.
+
+ Build-time, once per sim. The GPU path uploads the corrected table and
+ needs no kernel of its own. Skipped for a profile that carries no
+ surface parameters, as upstream skips it on an empty array.
+
+ Off unless the caller asks for it (params->adaptation_surface, default
+ false), the second of the two adaptation switches. The profiles ship this enabled, but the reference runtime's
+ SettingsParams.apply_hanatos2025_adaptation_surface is false and takes
+ precedence there, so applying it whenever a profile carries the
+ coefficients diverges from a reference render by as much as the
+ sigmoid's +-2 stop bound wherever the chromaticity is far from the film's
+ reference white. Runtime-selectable rather than compiled out, because the
+ correction is the model's own and becomes right the day the reference
+ turns it on. */
+ if(p->adaptation_surface && film->surface_n == SF_SURFACE_NCOEF)
+ {
+ double center_tc[2];
+ tri2quad(center_tc, film_ref_xy);
+ const double step = 1.0 / (double)(n - 1);
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(int i = 0; i < n; i++)
+ for(int j = 0; j < n; j++)
+ {
+ /* tc grid identical to upstream's meshgrid(linspace(0, 1, n),
+ indexing='ij'): the first index runs tc.x, the second tc.y, which
+ is also how cubic_interp_2d addresses this table. */
+ const double tc[2] = { (double)i * step, (double)j * step };
+ double *dst = s->tc_lut + ((size_t)i * n + j) * 3;
+ for(int c = 0; c < 3; c++)
+ {
+ const double raw = poly2d_deg4(tc, film->surface_params[c], center_tc);
+ const double stops = hanika_sigmoid(raw, SF_HANATOS_MAX_CORRECTION_STOPS);
+ dst[c] *= exp2(stops);
+ }
+ }
+ }
+ /* [gc] remap_tc_lut_for_compression: new_lut[tc] = old_lut[compress(tc)] */
+ if(p->input_gamut_compress)
+ {
+ double *old = malloc((size_t)n * n * 3 * sizeof(double));
+ memcpy(old, s->tc_lut, (size_t)n * n * 3 * sizeof(double));
+ const double scale = (double)(n - 1);
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(int i = 0; i < n; i++)
+ for(int j = 0; j < n; j++)
+ {
+ const double tc[2] = { i / scale, j / scale };
+ double xy[2], cxy[2], ctc[2];
+ quad2tri(xy, tc);
+ compress_xy_radial(cxy, xy, film_ref_xy, pack->locus, pack->locus_n);
+ tri2quad(ctc, cxy);
+ bilinear_2d_clamped(s->tc_lut + ((size_t)i * n + j) * 3, old, n,
+ ctc[0] * scale, ctc[1] * scale);
+ }
+ free(old);
+ }
+ /* float copies for the fast per-pixel expose path */
+ for(int c = 0; c < 9; c++) s->m_in_f[c] = (float)s->m_in[c];
+ s->ev_scale_f = (float)s->ev_scale;
+ s->tc_lut_f = malloc((size_t)n * n * 3 * sizeof(float));
+ if(s->tc_lut_f)
+ for(size_t i = 0; i < (size_t)n * n * 3; i++)
+ s->tc_lut_f[i] = (float)s->tc_lut[i];
+ }
+
+ /* ----- film develop ---------------------------------------------------- */
+ s->le0 = film->log_exposure[0];
+ s->le_step = (film->log_exposure[SF_NLE - 1] - film->log_exposure[0]) / (SF_NLE - 1);
+ s->inv_le_step = (float)(1.0 / s->le_step);
+ for(int c = 0; c < 3; c++) s->gamma[c] = p->density_curve_gamma;
+ if(p->film_morph_active && film->curves_model.n_layers > 0)
+ {
+ double curves_tmp[SF_NLE][3];
+ build_film_curves(curves_tmp, s->film_curve_layers, film, p);
+ for(int c = 0; c < 3; c++)
+ {
+ double mn = INFINITY, mx = -INFINITY;
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double v = curves_tmp[i][c];
+ if(v < mn) mn = v;
+ if(v > mx) mx = v;
+ }
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double v = curves_tmp[i][c] - mn;
+ s->curves_norm[i][c] = v;
+ s->curves_norm_f[i][c] = (float)v;
+ }
+ s->film_dmax[c] = mx - mn;
+ s->film_dmin[c] = mn;
+ }
+ s->film_morph_applied = true;
+ }
+ else
+ {
+ for(int c = 0; c < 3; c++)
+ {
+ double mn = INFINITY, mx = -INFINITY;
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double v = film->density_curves[i][c];
+ if(v < mn) mn = v;
+ if(v > mx) mx = v;
+ }
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double v = film->density_curves[i][c] - mn;
+ s->curves_norm[i][c] = v;
+ s->curves_norm_f[i][c] = (float)v;
+ }
+ s->film_dmax[c] = mx - mn;
+ s->film_dmin[c] = mn;
+ }
+ s->film_morph_applied = false;
+ }
+ /* [cp] per-film grain catalogue data (film_render_defaults[stock].grain);
+ falls back to spektrafilm's original single fixed profile when the pack
+ predates per-film grain or the stock has no entry. density_min shares
+ p->grain_density_min with the enlarger/scan table-range code below, so
+ it is overwritten in place rather than kept as a separate sim field. */
+ {
+ /* matches SF_GRAIN_LEGACY_RMS / SF_GRAIN_LEGACY_UNIFORMITY in
+ spektra_core.h — spektrafilm's original single fixed grain profile */
+ const double legacy_rms[3] = { 6.0, 8.0, 10.0 };
+ const double legacy_unif[3] = { 0.97, 0.97, 0.97 };
+ for(int c = 0; c < 3; c++)
+ {
+ s->grain_rms[c] = legacy_rms[c];
+ s->grain_uniformity[c] = legacy_unif[c];
+ }
+ /* particle_scale_sublayers defaults to spektrafilm's own GrainParams
+ default [1.0, 0.5, 0.25] (coarsest == 1) when the pack has none for
+ this stock, so a film whose curve fit IS multilayer still gets a
+ physically reasonable sub-layer split rather than silently
+ collapsing to one layer for lack of catalogue data. */
+ double particle_scale[SF_GRAIN_MAX_SUBLAYERS] = { 1.0, 0.5, 0.25, 0, 0, 0, 0, 0 };
+ int n_scale = 3;
+ sf_pack_film_grain(pack, film->stock, s->grain_rms, s->grain_uniformity,
+ p->grain_density_min, particle_scale, &n_scale);
+ if(n_scale <= 0) n_scale = 3; /* pack had a "grain" entry but no scale array */
+ _sf_build_grain_layers(s, film, p->grain_density_min, s->grain_uniformity,
+ s->grain_rms, particle_scale, n_scale);
+ }
+ /* A single-emulsion stock is one panchromatic layer. The reference reaches
+ every per-channel constant through match_channels(values, n_ch), which at
+ n_ch == 1 returns values[:1] -- the FIRST channel, for all of them. Collapse
+ them here, once, so no consumer has to know: otherwise a B&W frame is
+ rendered with chromatic grain statistics and a chromatic halation strength,
+ and the achromatic grain draw (which reads channel 1) picks the green
+ figure where the reference uses red. */
+ if(s->film_bw)
+ {
+ for(int c = 1; c < 3; c++)
+ {
+ s->grain_rms[c] = s->grain_rms[0];
+ s->grain_uniformity[c] = s->grain_uniformity[0];
+ s->halation_strength[c] = s->halation_strength[0];
+ s->scatter_core_um[c] = s->scatter_core_um[0];
+ s->scatter_tail_um[c] = s->scatter_tail_um[0];
+ s->scatter_tail_weight[c] = s->scatter_tail_weight[0];
+ }
+ }
+
+ /* [cp] coupler matrix: donor row -> receiver column, scaled by amount.
+
+ A single-emulsion (B&W) stock is one panchromatic layer, which the
+ reference models as n_ch == 1: compute_dir_couplers_matrix() populates
+ M_inter only for n_ch == 3, so the matrix is 1x1 self-inhibition and the
+ interlayer gammas are inert. couplers.toml says so explicitly -- "B&W
+ (n_ch == 1) uses only gamma_samelayer_rgb[0]; its interlayer entries are
+ inert but kept so the params mirror the color defaults" -- and they are
+ NOT zero there (defaults.bw.negative carries the color values verbatim).
+
+ Since this file widens a B&W emulsion to three identical channels, every
+ output channel would otherwise pick up its whole matrix COLUMN instead of
+ just the diagonal: with defaults.bw.negative that is 1.79x / 2.28x / 1.87x
+ the reference's single-channel correction, and the three differ by 28%, so
+ an achromatic stock acquires a channel-dependent density shift. Both parts
+ matter -- zeroing the off-diagonal is not enough, because the diagonal
+ alone already spans 0.5159 / 0.5934 / 0.2829. Use gamma_samelayer[0] for
+ all three, which is the value the reference's one channel uses. */
+ s->couplers_active = p->couplers_active;
+ {
+ double M[3][3] = { { 0 } };
+ if(s->film_bw)
+ {
+ const double self_bw = p->gamma_samelayer[0] * p->inhibition_samelayer;
+ M[0][0] = M[1][1] = M[2][2] = self_bw;
+ }
+ else
+ {
+ M[0][0] = p->gamma_samelayer[0] * p->inhibition_samelayer;
+ M[1][1] = p->gamma_samelayer[1] * p->inhibition_samelayer;
+ M[2][2] = p->gamma_samelayer[2] * p->inhibition_samelayer;
+ M[0][1] = p->gamma_inter_r_gb[0] * p->inhibition_interlayer;
+ M[0][2] = p->gamma_inter_r_gb[1] * p->inhibition_interlayer;
+ M[1][0] = p->gamma_inter_g_rb[0] * p->inhibition_interlayer;
+ M[1][2] = p->gamma_inter_g_rb[1] * p->inhibition_interlayer;
+ M[2][0] = p->gamma_inter_b_rg[0] * p->inhibition_interlayer;
+ M[2][1] = p->gamma_inter_b_rg[1] * p->inhibition_interlayer;
+ }
+ for(int i = 0; i < 3; i++)
+ for(int j = 0; j < 3; j++) s->couplers_M[i][j] = M[i][j] * p->couplers_amount;
+
+ /* [cp] Langmuir parameters (dev/0.4+ packs; absent -> linear 0.3.x).
+ Negative: donor-side saturation, K = k*d_max, D_ref = d_max/2.
+ Positive/reversal: linear donor, receiver-side saturation with
+ c_ref[m] = sum_k D_ref[k]*M_unit[k][m] from the amount-INdependent
+ matrix, Kr = k_recv * 2*c_ref. */
+ for(int c = 0; c < 3; c++)
+ {
+ s->couplers_donor_K[c] = INFINITY;
+ s->couplers_recv_Kr[c] = INFINITY;
+ s->couplers_donor_Dref[c] = 0.5 * s->film_dmax[c];
+ s->couplers_recv_cref[c] = 0.0;
+ }
+ s->couplers_donor_lm = 0;
+ s->couplers_recv_lm = 0;
+ s->coupler_diff_um = SF_COUPLER_BLUR_UM;
+ s->coupler_tail_um = 0.0;
+ s->coupler_tail_w = 0.0;
+ sf_pack_film_coupler_diffusion(pack, film->stock, &s->coupler_diff_um,
+ &s->coupler_tail_um, &s->coupler_tail_w);
+ if(s->coupler_tail_w <= 0.0 || s->coupler_tail_um <= 0.0)
+ {
+ s->coupler_tail_um = 0.0;
+ s->coupler_tail_w = 0.0;
+ }
+ double lm_donor[3], lm_recv[3];
+ if(sf_pack_film_langmuir(pack, film->stock, lm_donor, lm_recv))
+ {
+ if(s->film_positive)
+ {
+ s->couplers_recv_lm = 1;
+ for(int m = 0; m < 3; m++)
+ {
+ double cref = 0.0;
+ for(int k = 0; k < 3; k++) cref += s->couplers_donor_Dref[k] * M[k][m];
+ s->couplers_recv_cref[m] = cref;
+ s->couplers_recv_Kr[m] = lm_recv[m] * 2.0 * cref;
+ }
+ }
+ else
+ {
+ s->couplers_donor_lm = 1;
+ for(int c = 0; c < 3; c++)
+ s->couplers_donor_K[c] = lm_donor[c] * s->film_dmax[c];
+ }
+ }
+ }
+ /* [cp] compute_density_curves_before_dir_couplers */
+ if(s->couplers_active)
+ {
+ /* The inversion below reads le_0 as the x-axis of an interpolation, which
+ needs it strictly increasing: le_0 = le - cac is invertible only while
+ d(cac)/d(le) < 1. Past that the same corrected exposure maps to two
+ densities and the film has no "before couplers" curve at all -- the model
+ has left the physical regime, not merely become inaccurate.
+
+ The breakdown is stock-dependent and can sit well inside the slider's
+ range: measured over the shipped profiles it is amount ~1.60 for
+ Portra 400, ~1.66 for Double-X, ~2.09 for Vision3 250D and ~1.00 for
+ Velvia 100. Beyond it both this code and the reference feed unsorted x to
+ an interpolator -- np.interp there, binary search here -- and each returns
+ a different arbitrary bracket, which is why high amounts diverge between
+ the two while low ones agree.
+
+ So find the largest amount that stays invertible and use that, rather than
+ emitting curves nobody can reproduce. Bisection on a monotone predicate,
+ 32 iterations, once per sim build. */
+ {
+ double lo = 0.0, hi = 1.0;
+ for(int it = 0; it < 32; it++)
+ {
+ const double mid = 0.5 * (lo + hi);
+ int ok = 1;
+ for(int m = 0; m < 3 && ok; m++)
+ {
+ double prev = -INFINITY;
+ for(int i = 0; i < SF_NLE && ok; i++)
+ {
+ double cac = 0.0;
+ for(int k = 0; k < 3; k++)
+ {
+ double silver = s->film_positive ? s->film_dmax[k] - s->curves_norm[i][k]
+ : s->curves_norm[i][k];
+ if(s->couplers_donor_lm)
+ silver = silver * (s->couplers_donor_K[k] + s->couplers_donor_Dref[k])
+ / (s->couplers_donor_K[k] + silver);
+ cac += silver * s->couplers_M[k][m] * mid;
+ }
+ if(s->couplers_recv_lm)
+ cac = cac * (s->couplers_recv_Kr[m] + s->couplers_recv_cref[m])
+ / (s->couplers_recv_Kr[m] + cac);
+ const double v = film->log_exposure[i] - cac;
+ if(v <= prev) ok = 0;
+ prev = v;
+ }
+ }
+ if(ok) lo = mid; else hi = mid;
+ }
+ if(lo < 0.999)
+ {
+ /* couplers_M is already amount-scaled, so scale it again by the surviving
+ fraction and keep every downstream consumer -- inversion, forward
+ correction, GPU export -- on one matrix. */
+ for(int i = 0; i < 3; i++)
+ for(int j = 0; j < 3; j++) s->couplers_M[i][j] *= lo;
+ /* Only when the reduction is real: this used to fire at the default
+ amount on ordinary stocks and print identical before/after values,
+ because a sub-0.1% trim rounds away at three decimals. */
+ dt_print(DT_DEBUG_PIPE,
+ "[spektrafilm] DIR couplers: %s is invertible only to %.1f%% of the"
+ " requested amount; using %.3f instead of %.3f",
+ film->stock, 100.0 * lo, p->couplers_amount * lo, p->couplers_amount);
+ }
+ }
+
+ double le_0[SF_NLE][3];
+ for(int i = 0; i < SF_NLE; i++)
+ for(int m = 0; m < 3; m++)
+ {
+ double cac = 0.0;
+ for(int k = 0; k < 3; k++)
+ {
+ double silver = s->film_positive ? s->film_dmax[k] - s->curves_norm[i][k]
+ : s->curves_norm[i][k];
+ if(s->couplers_donor_lm)
+ silver = silver * (s->couplers_donor_K[k] + s->couplers_donor_Dref[k])
+ / (s->couplers_donor_K[k] + silver);
+ cac += silver * s->couplers_M[k][m];
+ }
+ if(s->couplers_recv_lm)
+ cac = cac * (s->couplers_recv_Kr[m] + s->couplers_recv_cref[m])
+ / (s->couplers_recv_Kr[m] + cac);
+ le_0[i][m] = film->log_exposure[i] - cac;
+ }
+ for(int c = 0; c < 3; c++)
+ {
+ double xp[SF_NLE], fp[SF_NLE];
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ xp[i] = le_0[i][c];
+ fp[i] = s->film_positive ? -s->curves_norm[i][c] : s->curves_norm[i][c];
+ }
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double v = interp_general(film->log_exposure[i], xp, fp, SF_NLE);
+ s->curves_before[i][c] = s->film_positive ? -v : v;
+ s->curves_before_f[i][c] = (float)s->curves_before[i][c];
+ }
+ }
+ }
+ else
+ {
+ memcpy(s->curves_before, s->curves_norm, sizeof(s->curves_before));
+ memcpy(s->curves_before_f, s->curves_norm_f, sizeof(s->curves_before_f));
+ }
+
+ /* ----- printing -------------------------------------------------------- */
+ if(s->has_print)
+ {
+ const double *illu_src = g_hash_table_lookup(pack->illuminants, p->enlarger_illuminant);
+ const double *filters = g_hash_table_lookup(pack->dichroics, p->dichroic_brand);
+ if(!illu_src || !filters)
+ {
+ set_error(errmsg, "spektra_sim: pack misses enlarger illuminant '%s' or dichroic '%s'",
+ p->enlarger_illuminant, p->dichroic_brand);
+ sf_sim_free(s);
+ return NULL;
+ }
+ const double cc_print[3] = { p->c_filter_neutral, p->m_filter_neutral + p->m_filter_shift,
+ p->y_filter_neutral + p->y_filter_shift };
+ const double cc_pre[3] = { p->c_filter_neutral, p->m_filter_neutral + p->preflash_m_shift,
+ p->y_filter_neutral + p->preflash_y_shift };
+ apply_dichroic_cc(s->illum_print, illu_src, filters, cc_print);
+ apply_dichroic_cc(s->illum_preflash, illu_src, filters, cc_pre);
+ for(int l = 0; l < SF_NWL; l++)
+ for(int m = 0; m < 3; m++)
+ {
+ const double v = pow(10.0, print->log_sensitivity[l][m]);
+ s->print_sens[l][m] = isfinite(v) ? v : 0.0;
+ }
+ memcpy(s->film_chan_density, film->channel_density, sizeof(s->film_chan_density));
+ memcpy(s->film_base_density, film->base_density, sizeof(s->film_base_density));
+
+ /* [st] midgray print balance (geometric-mean normalization) */
+ s->midgray_factor = 1.0;
+ {
+ double ds_mid[SF_NWL], ds_comp[SF_NWL];
+ midgray_density_spectral(s, film, film_ref_xy, SF_MIDGRAY, ds_mid);
+ const double f_mid = exposure_factor(s, ds_mid);
+ double f_comp = 1.0;
+ if(p->print_exposure_compensation)
+ {
+ midgray_density_spectral(s, film, film_ref_xy, SF_MIDGRAY * s->ev_scale, ds_comp);
+ f_comp = exposure_factor(s, ds_comp);
+ }
+ if(p->print_exposure_compensation && !p->normalize_print_exposure)
+ s->midgray_factor = f_comp / f_mid;
+ else if(p->normalize_print_exposure && p->print_exposure_compensation)
+ s->midgray_factor = f_comp;
+ else if(p->normalize_print_exposure && !p->print_exposure_compensation)
+ s->midgray_factor = f_mid;
+ else
+ s->midgray_factor = 1.0;
+ }
+ /* [st] preflash through the base density only */
+ s->preflash_raw[0] = s->preflash_raw[1] = s->preflash_raw[2] = 0.0;
+ if(p->preflash_exposure > 0.0)
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ double light = s->illum_preflash[l] * pow(10.0, -film->base_density[l]);
+ if(!isfinite(light)) light = 0.0;
+ for(int m = 0; m < 3; m++)
+ s->preflash_raw[m] += light * s->print_sens[l][m] * p->preflash_exposure;
+ }
+
+ /* enlarger table range: [-grain density_min, nanmax(unnormalized curves)] */
+ for(int c = 0; c < 3; c++)
+ {
+ double mx = -INFINITY;
+ for(int i = 0; i < SF_NLE; i++)
+ if(film->density_curves[i][c] > mx) mx = film->density_curves[i][c];
+ s->enl_lo[c] = -p->grain_density_min[c];
+ s->enl_hi[c] = mx;
+ s->enl_inv_range[c] = (float)(1.0 / (mx + p->grain_density_min[c]));
+ }
+ s->log10_print_exposure = (float)log10(fmax(p->print_exposure, 1e-10));
+ build_print_curves(s->print_curves, print, p);
+ for(int i = 0; i < SF_NLE; i++)
+ for(int c = 0; c < 3; c++)
+ s->print_curves_f[i][c] = (float)s->print_curves[i][c];
+ }
+
+ /* ----- scanning -------------------------------------------------------- */
+ {
+ const sf_profile_t *sp = s->has_print ? print : film;
+ memcpy(s->scan_chan_density, sp->channel_density, sizeof(s->scan_chan_density));
+ memcpy(s->scan_base_density, sp->base_density, sizeof(s->scan_base_density));
+ const double *illu_view = g_hash_table_lookup(pack->illuminants, sp->viewing_illuminant);
+ if(!illu_view)
+ {
+ set_error(errmsg, "spektra_sim: pack misses viewing illuminant '%s'",
+ sp->viewing_illuminant);
+ sf_sim_free(s);
+ return NULL;
+ }
+ memcpy(s->illum_view, illu_view, sizeof(s->illum_view));
+ s->xyz_norm = 0.0;
+ for(int l = 0; l < SF_NWL; l++) s->xyz_norm += illu_view[l] * pack->cmfs[l][1];
+ for(int c = 0; c < 3; c++)
+ {
+ s->illum_view_xyz[c] = 0.0;
+ for(int l = 0; l < SF_NWL; l++) s->illum_view_xyz[c] += illu_view[l] * pack->cmfs[l][c];
+ s->illum_view_xyz[c] /= s->xyz_norm;
+ }
+ /* scan table range */
+ if(s->has_print)
+ for(int c = 0; c < 3; c++)
+ {
+ double mn = INFINITY, mx = -INFINITY;
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double v = print->density_curves[i][c];
+ if(v < mn) mn = v;
+ if(v > mx) mx = v;
+ }
+ s->scan_lo[c] = mn;
+ s->scan_hi[c] = mx;
+ }
+ else
+ for(int c = 0; c < 3; c++)
+ {
+ s->scan_lo[c] = -p->grain_density_min[c];
+ s->scan_hi[c] = s->film_dmax[c]; /* == nanmax(curves) - min; see below */
+ }
+ /* reference uses nanmax of the raw film curves for scan_film */
+ if(!s->has_print)
+ for(int c = 0; c < 3; c++)
+ {
+ double mx = -INFINITY;
+ for(int i = 0; i < SF_NLE; i++)
+ if(film->density_curves[i][c] > mx) mx = film->density_curves[i][c];
+ s->scan_hi[c] = mx;
+ }
+ for(int c = 0; c < 3; c++)
+ s->scan_inv_range[c] = (float)(1.0 / (s->scan_hi[c] - s->scan_lo[c]));
+ /* output matrix: CAT02 from the viewing illuminant to the output white */
+ double view_xy[2] = { s->illum_view_xyz[0]
+ / (s->illum_view_xyz[0] + s->illum_view_xyz[1]
+ + s->illum_view_xyz[2]),
+ s->illum_view_xyz[1]
+ / (s->illum_view_xyz[0] + s->illum_view_xyz[1]
+ + s->illum_view_xyz[2]) };
+ double cat[9];
+ cat_matrix(cat, SF_M_CAT02, view_xy, p->output_white_xy);
+ mat3_mul(s->m_out, p->output_xyz_to_rgb, cat);
+ }
+
+ /* ----- scanner black/white point for positive film scans ---------------- */
+ /* A slide has base density and never reaches the paper's D-max; a real
+ scanner sets black/white points. Reference: color_reference.py with
+ scanner.black_correction = white_correction = true, which upstream's UI
+ uses for slides -- off (upstream default) the scan is washed out. Only
+ affects scan-film mode with positive film; negatives are untouched. */
+ s->scan_bw_on = 0;
+ s->scan_bw_m = 1.0;
+ s->scan_bw_q = 0.0;
+ if(!s->has_print && s->film_positive)
+ {
+ /* upstream treats the 0.98 / 0.01 scanner levels as sRGB-encoded and
+ linearizes them (color_reference._remove_sRGB_cctf) */
+ const double white_level = pow((0.98 + 0.055) / 1.055, 2.4);
+ const double black_level = 0.01 / 12.92;
+ double cmy_black[3], cmy_white[3] = { 0.0, 0.0, 0.0 };
+ for(int c = 0; c < 3; c++)
+ {
+ double mx = -INFINITY;
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double v = film->density_curves[i][c];
+ if(isfinite(v) && v > mx) mx = v;
+ }
+ cmy_black[c] = mx;
+ }
+ double lxb[3], lxw[3];
+ cmy_to_log_xyz(s, cmy_black, lxb);
+ cmy_to_log_xyz(s, cmy_white, lxw);
+ const double y_black = pow(10.0, lxb[1]), y_white = pow(10.0, lxw[1]);
+ const double m = (white_level - black_level) / (y_white - y_black + 1e-10);
+ const double q = black_level - m * y_black;
+ s->scan_bw_on = 1;
+ s->scan_bw_m = m;
+ s->scan_bw_q = q;
+
+ /* film exposure correction so midgray still lands on midgray after the
+ correction (reference: black_white_filming_exposure_correction) */
+ const double midgray_corrected = (0.184 - q) / m;
+ if(midgray_corrected > 0.0)
+ {
+ const double density_midgray = -log10(0.184);
+ const double density_midgray_corrected = -log10(midgray_corrected);
+ double dmin_av = 0.0;
+ int nvalid = 0;
+ for(int i = 0; i < SF_NWL; i++)
+ if(isfinite(film->base_density[i]))
+ {
+ dmin_av += film->base_density[i];
+ nvalid++;
+ }
+ dmin_av = nvalid ? dmin_av / nvalid : 0.0;
+ double curve_av[SF_NLE];
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ double sum = 0.0;
+ int nc = 0;
+ for(int c = 0; c < 3; c++)
+ if(isfinite(film->density_curves[i][c]))
+ {
+ sum += film->density_curves[i][c];
+ nc++;
+ }
+ curve_av[i] = nc ? sum / nc : 0.0;
+ }
+ /* np.interp(x, -curve_av, log_exposure): -curve_av ascends for positive
+ film (density falls with exposure); endpoint clamp like np.interp */
+ const double le_mid_c = -interp_ascending(-(density_midgray_corrected - dmin_av),
+ curve_av, film->log_exposure, SF_NLE);
+ const double le_mid = -interp_ascending(-(density_midgray - dmin_av), curve_av,
+ film->log_exposure, SF_NLE);
+ const double exposure_correction = pow(10.0, le_mid_c - le_mid);
+ s->ev_scale /= exposure_correction; /* raw *= 1/correction */
+ }
+ }
+
+ /* ----- runtime 3D tables ------------------------------------------------ */
+ if(s->lut_steps >= 2)
+ {
+ if(s->has_print)
+ {
+ build_lut3d(s, cmy_to_print_lograw, s->enl_lo, s->enl_hi, s->lut_steps, &s->enl_lut,
+ &s->enl_sx, &s->enl_sy, &s->enl_sz, &s->enl_cmin, &s->enl_cmax);
+ const size_t n3 = (size_t)s->lut_steps * s->lut_steps * s->lut_steps * 3;
+ s->enl_lut_f = malloc(n3 * sizeof(float));
+ if(s->enl_lut_f)
+ for(size_t i = 0; i < n3; i++) s->enl_lut_f[i] = (float)s->enl_lut[i];
+ }
+ build_lut3d(s, cmy_to_log_xyz, s->scan_lo, s->scan_hi, s->lut_steps, &s->scan_lut,
+ &s->scan_sx, &s->scan_sy, &s->scan_sz, &s->scan_cmin, &s->scan_cmax);
+ {
+ const size_t n3 = (size_t)s->lut_steps * s->lut_steps * s->lut_steps * 3;
+ s->scan_lut_f = malloc(n3 * sizeof(float));
+ if(s->scan_lut_f)
+ for(size_t i = 0; i < n3; i++) s->scan_lut_f[i] = (float)s->scan_lut[i];
+ }
+ }
+
+ /* ----- output gamut compression ----------------------------------------- */
+ memcpy(s->out_rgb2xyz, p->output_rgb_to_xyz, sizeof(s->out_rgb2xyz));
+ memcpy(s->out_xyz2rgb, p->output_xyz_to_rgb, sizeof(s->out_xyz2rgb));
+ mat3_inv(s->oklab_m1inv, SF_OKLAB_M1);
+ mat3_inv(s->oklab_m2inv, SF_OKLAB_M2);
+ if(s->out_compress == SF_OUTPUT_COMPRESS_OKLCH) build_cmax_table(s);
+
+ return s;
+}
+
+/* ------------------------------------------------------------------------ */
+/* per-pixel stages */
+/* ------------------------------------------------------------------------ */
+
+void sf_sim_expose(const sf_sim_t *sim, const float *rgb_in, float *raw, size_t npix,
+ int nch_in, int nch_out)
+{
+#if defined(__ARM_NEON)
+ if(nch_in == 3 && nch_out == 3 && sim->tc_lut_f && npix >= 4)
+ {
+ const size_t n4 = npix & ~(size_t)3; /* round down to multiple of 4 */
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(size_t px = 0; px < n4; px += 4)
+ {
+ const float *in = rgb_in + px * 3;
+ float *out = raw + px * 3;
+ float xyz[12];
+ neon_mat3_mulv_batch(sim->m_in_f, in, xyz);
+ for(int k = 0; k < 4; k++)
+ {
+ const float *xyz_k = xyz + k * 3;
+ float r[3];
+ const float b = xyz_k[0] + xyz_k[1] + xyz_k[2];
+ const float xy[2] = { xyz_k[0] / fmaxf(b, 1e-10f), xyz_k[1] / fmaxf(b, 1e-10f) };
+ float tc[2];
+ tri2quad_f(tc, xy);
+ const float scale = (float)(sim->tc_n - 1);
+ bilinear_interp_2d_f(r, sim->tc_lut_f, sim->tc_n, tc[0] * scale, tc[1] * scale);
+ const float bb = isfinite(b) ? b : 0.0f;
+ for(int c = 0; c < 3; c++) out[k * 3 + c] = r[c] * bb * sim->ev_scale_f;
+ }
+ }
+ /* remainder (1-3 pixels, scalar fallback) */
+ for(size_t px = n4; px < npix; px++)
+ {
+ const float *in = rgb_in + px * 3;
+ float *out = raw + px * 3;
+ float r[3];
+ expose_pixel_f(sim->m_in_f, sim->tc_lut_f, sim->tc_n, in, r);
+ for(int c = 0; c < 3; c++) out[c] = r[c] * sim->ev_scale_f;
+ }
+ return;
+ }
+#endif
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(size_t px = 0; px < npix; px++)
+ {
+ const float *in = rgb_in + px * nch_in;
+ float *out = raw + px * nch_out;
+ float r[3];
+ expose_pixel_f(sim->m_in_f, sim->tc_lut_f, sim->tc_n, in, r);
+ for(int c = 0; c < 3; c++) out[c] = r[c] * sim->ev_scale_f;
+ }
+}
+
+void sf_sim_lograw(float *raw, size_t npix, int nch)
+{
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(size_t px = 0; px < npix; px++)
+ {
+ float *v = raw + px * nch;
+ for(int c = 0; c < 3; c++)
+ v[c] = SF_LOG10F(fmaxf(v[c], 0.0f) + SF_LOG_EPS);
+ }
+}
+
+void sf_sim_develop_corr(const sf_sim_t *sim, const float *lograw, float *corr,
+ size_t npix, int nch_in)
+{
+ if(!sim->couplers_active)
+ {
+ memset(corr, 0, npix * 3 * sizeof(float));
+ return;
+ }
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(size_t px = 0; px < npix; px++)
+ {
+ const float *in = lograw + px * nch_in;
+ float *out = corr + px * 3;
+ float silver[3];
+ for(int c = 0; c < 3; c++)
+ {
+ const float d = interp_curve_uniform_f(in[c], (float)sim->gamma[c], (float)sim->le0,
+ sim->inv_le_step, sim->curves_norm_f, c);
+ silver[c] = sim->film_positive ? (float)sim->film_dmax[c] - d : d;
+ if(sim->couplers_donor_lm)
+ silver[c] = silver[c] * ((float)sim->couplers_donor_K[c] + (float)sim->couplers_donor_Dref[c])
+ / ((float)sim->couplers_donor_K[c] + silver[c]);
+ }
+ for(int m = 0; m < 3; m++)
+ {
+ float acc = 0.0f;
+ for(int k = 0; k < 3; k++) acc += silver[k] * (float)sim->couplers_M[k][m];
+ out[m] = acc;
+ }
+ }
+}
+
+void sf_sim_develop(const sf_sim_t *sim, const float *lograw, const float *corr,
+ float *cmy, size_t npix, int nch_in, int nch_out)
+{
+ const int use_corr = sim->couplers_active && corr != NULL;
+ const float(*curves)[3] = use_corr ? sim->curves_before_f : sim->curves_norm_f;
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(size_t px = 0; px < npix; px++)
+ {
+ const float *in = lograw + px * nch_in;
+ const float *cr = use_corr ? corr + px * 3 : NULL;
+ float *out = cmy + px * nch_out;
+ for(int c = 0; c < 3; c++)
+ {
+ float crv = cr ? cr[c] : 0.0f;
+ /* receiver-side Langmuir applies to the inhibitor that ARRIVES, i.e.
+ after the spatial diffusion blur, hence here and not in _corr */
+ if(cr && sim->couplers_recv_lm)
+ crv = crv * ((float)sim->couplers_recv_Kr[c] + (float)sim->couplers_recv_cref[c])
+ / ((float)sim->couplers_recv_Kr[c] + crv);
+ const float x = in[c] - crv;
+ out[c] = interp_curve_uniform_f(x, (float)sim->gamma[c], (float)sim->le0,
+ sim->inv_le_step, curves, c);
+ }
+ }
+}
+
+void sf_sim_print_expose(const sf_sim_t *sim, const float *cmy, float *lograw,
+ size_t npix, int nch_in, int nch_out)
+{
+ const int steps = sim->lut_steps;
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(size_t px = 0; px < npix; px++)
+ {
+ const float *in = cmy + px * nch_in;
+ float *out = lograw + px * nch_out;
+ float l1[3];
+ if(steps >= 2 && sim->enl_lut_f)
+ {
+ const float scale = (float)(steps - 1);
+ const float r = (in[0] - (float)sim->enl_lo[0]) * sim->enl_inv_range[0] * scale;
+ const float g = (in[1] - (float)sim->enl_lo[1]) * sim->enl_inv_range[1] * scale;
+ const float b = (in[2] - (float)sim->enl_lo[2]) * sim->enl_inv_range[2] * scale;
+ trilinear_interp_3d_f(l1, sim->enl_lut_f, steps, r, g, b);
+ }
+ else if(steps >= 2)
+ {
+ const sf_pchip3d_t P = { steps, sim->enl_lut, sim->enl_sx, sim->enl_sy, sim->enl_sz,
+ sim->enl_cmin, sim->enl_cmax };
+ const double scale = (double)(steps - 1);
+ const double r = (in[0] - sim->enl_lo[0]) * sim->enl_inv_range[0] * scale;
+ const double g = (in[1] - sim->enl_lo[1]) * sim->enl_inv_range[1] * scale;
+ const double b = (in[2] - sim->enl_lo[2]) * sim->enl_inv_range[2] * scale;
+ double l1d[3];
+ pchip3d_interp(&P, r, g, b, l1d);
+ l1[0] = (float)l1d[0]; l1[1] = (float)l1d[1]; l1[2] = (float)l1d[2];
+ }
+ else
+ {
+ double l1d[3];
+ const double c[3] = { in[0], in[1], in[2] };
+ cmy_to_print_lograw(sim, c, l1d);
+ l1[0] = (float)l1d[0]; l1[1] = (float)l1d[1]; l1[2] = (float)l1d[2];
+ }
+ /* [st] 10^l1 * print_exposure -> log domain: out = l1 + log10(print_exposure) */
+ for(int m = 0; m < 3; m++)
+ {
+ const float v = l1[m] + sim->log10_print_exposure;
+ out[m] = (v < -30.0f) ? -30.0f : v; /* prevent -inf from degenerate inputs */
+ }
+ }
+}
+
+void sf_sim_print_develop(const sf_sim_t *sim, const float *lograw, float *cmy,
+ size_t npix, int nch_in, int nch_out)
+{
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(size_t px = 0; px < npix; px++)
+ {
+ const float *in = lograw + px * nch_in;
+ float *out = cmy + px * nch_out;
+ for(int c = 0; c < 3; c++)
+ out[c] = interp_curve_uniform_f(in[c], 1.0f, (float)sim->le0,
+ sim->inv_le_step, sim->print_curves_f, c);
+ }
+}
+
+void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, size_t npix,
+ int nch_in, int nch_out)
+{
+ const int steps = sim->lut_steps;
+#ifdef _OPENMP
+#pragma omp parallel for schedule(static)
+#endif
+ for(size_t px = 0; px < npix; px++)
+ {
+ const float *in = cmy + px * nch_in;
+ float *out = rgb_out + px * nch_out;
+ float lx[3];
+ if(steps >= 2 && sim->scan_lut_f)
+ {
+ const float scale = (float)(steps - 1);
+ const float r = (in[0] - (float)sim->scan_lo[0]) * sim->scan_inv_range[0] * scale;
+ const float g = (in[1] - (float)sim->scan_lo[1]) * sim->scan_inv_range[1] * scale;
+ const float b = (in[2] - (float)sim->scan_lo[2]) * sim->scan_inv_range[2] * scale;
+ trilinear_interp_3d_f(lx, sim->scan_lut_f, steps, r, g, b);
+ }
+ else if(steps >= 2)
+ {
+ const sf_pchip3d_t P = { steps, sim->scan_lut, sim->scan_sx, sim->scan_sy, sim->scan_sz,
+ sim->scan_cmin, sim->scan_cmax };
+ const double scale = (double)(steps - 1);
+ const double r = (in[0] - sim->scan_lo[0]) * sim->scan_inv_range[0] * scale;
+ const double g = (in[1] - sim->scan_lo[1]) * sim->scan_inv_range[1] * scale;
+ const double b = (in[2] - sim->scan_lo[2]) * sim->scan_inv_range[2] * scale;
+ double lxd[3];
+ pchip3d_interp(&P, r, g, b, lxd);
+ lx[0] = (float)lxd[0]; lx[1] = (float)lxd[1]; lx[2] = (float)lxd[2];
+ }
+ else
+ {
+ double lxd[3];
+ const double c[3] = { in[0], in[1], in[2] };
+ cmy_to_log_xyz(sim, c, lxd);
+ lx[0] = (float)lxd[0]; lx[1] = (float)lxd[1]; lx[2] = (float)lxd[2];
+ }
+ double xyz[3]; double rgb[3];
+ for(int m = 0; m < 3; m++) xyz[m] = SF_POW10F(lx[m]);
+ if(sim->out_luminance_boost != 1.0)
+ for(int m = 0; m < 3; m++) xyz[m] *= sim->out_luminance_boost;
+ if(sim->scan_bw_on)
+ {
+ const double y = xyz[1];
+ double yc = sim->scan_bw_m * y + sim->scan_bw_q;
+ yc = yc < 0.0 ? 0.0 : (yc > 1.0 ? 1.0 : yc);
+ const double sc = yc / (y + 1e-10);
+ for(int m = 0; m < 3; m++) xyz[m] *= sc;
+ }
+ mat3_mulv(rgb, sim->m_out, xyz);
+ if(sim->out_compress == SF_OUTPUT_COMPRESS_OKLCH)
+ compress_rgb_oklch(sim, rgb);
+ else if(sim->out_compress == SF_OUTPUT_COMPRESS_ACES_RGC)
+ compress_rgb_aces(rgb);
+ for(int c = 0; c < 3; c++) out[c] = (float)rgb[c];
+ }
+}
+
+/* ------------------------------------------------------------------------ */
+/* GPU export: float copies of the per-pixel tables */
+/* ------------------------------------------------------------------------ */
+
+static float *dup_f(const double *src, size_t n)
+{
+ float *dst = malloc(n * sizeof(float));
+ if(dst)
+ for(size_t i = 0; i < n; i++) dst[i] = (float)src[i];
+ return dst;
+}
+
+static void cp9f(float dst[9], const double src[9])
+{
+ for(int i = 0; i < 9; i++) dst[i] = (float)src[i];
+}
+
+/* variants that keep the 2D array type so the compiler sees the full extent
+ (a plain &a[0][0] decay trips -Werror=stringop-overread on gcc) */
+static void cp33f(float dst[9], const double src[3][3])
+{
+ for(int i = 0; i < 3; i++)
+ for(int j = 0; j < 3; j++) dst[i * 3 + j] = (float)src[i][j];
+}
+
+static float *dup_f3(const double (*src)[3], size_t rows)
+{
+ float *dst = malloc(rows * 3 * sizeof(float));
+ if(dst)
+ for(size_t i = 0; i < rows; i++)
+ for(int c = 0; c < 3; c++) dst[i * 3 + c] = (float)src[i][c];
+ return dst;
+}
+
+sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *s)
+{
+ if(!s || s->lut_steps < 2) return NULL; /* exact spectral: no GPU path */
+ sf_sim_gpu_t *g = calloc(1, sizeof(sf_sim_gpu_t));
+ if(!g) return NULL;
+
+ cp9f(g->m_in, s->m_in);
+ g->ev_scale = (float)s->ev_scale;
+ g->tc_n = s->tc_n;
+ g->tc_lut = dup_f(s->tc_lut, (size_t)s->tc_n * s->tc_n * 3);
+
+ for(int c = 0; c < 3; c++) g->gamma[c] = (float)s->gamma[c];
+ g->le0 = (float)s->le0;
+ g->le_step = (float)s->le_step;
+ g->curves_norm = dup_f3(s->curves_norm, SF_NLE);
+ g->curves_before = dup_f3(s->curves_before, SF_NLE);
+ cp33f(g->couplers_M, (const double (*)[3])s->couplers_M);
+ for(int c = 0; c < 3; c++) g->film_dmax[c] = (float)s->film_dmax[c];
+ for(int c = 0; c < 3; c++)
+ {
+ g->grain_rms[c] = (float)s->grain_rms[c];
+ g->grain_uniformity[c] = (float)s->grain_uniformity[c];
+ g->halation_strength[c] = (float)s->halation_strength[c];
+ }
+ g->halation_first_sigma_um = (float)s->halation_sigma_um;
+ for(int c = 0; c < 3; c++)
+ {
+ g->scatter_core_um[c] = (float)s->scatter_core_um[c];
+ g->scatter_tail_um[c] = (float)s->scatter_tail_um[c];
+ g->scatter_tail_weight[c] = (float)s->scatter_tail_weight[c];
+ }
+ g->film_positive = s->film_positive;
+ g->couplers_active = s->couplers_active;
+
+ g->grain_n_sublayers = s->grain_n_sublayers;
+ /* What the sampler adds: the sum of the per-sub-layer floors. The GPU combine
+ subtracts this exactly as the CPU one now does, so the two agree. This used
+ to be film_dmin here and grain_density_min on the CPU -- two different wrong
+ values, which is why the paths brightened by different amounts. */
+ sf_sim_grain_dmin_total(s, g->grain_dmin);
+ for(int l = 0; l < SF_GRAIN_MAX_SUBLAYERS; l++)
+ {
+ g->grain_particle_scale[l] = (float)s->grain_particle_scale[l];
+ for(int c = 0; c < 3; c++)
+ {
+ g->grain_layer_dmax[l][c] = (float)s->grain_layer_dmax[l][c];
+ g->grain_layer_npart[l][c] = (float)s->grain_layer_npart[l][c];
+ g->grain_layer_dmin[l][c] = (float)s->grain_layer_dmin[l][c];
+ }
+ }
+ g->grain_layer_curve = &s->grain_layer_curve[0][0][0]; /* borrowed */
+ g->grain_layer_curve_total = &s->grain_layer_curve_total[0][0]; /* borrowed */
+
+ g->has_print = s->has_print;
+ g->steps = s->lut_steps;
+ const size_t n3 = (size_t)s->lut_steps * s->lut_steps * s->lut_steps * 3;
+ const size_t m3 = (size_t)(s->lut_steps - 1) * (s->lut_steps - 1) * (s->lut_steps - 1) * 3;
+ if(s->has_print)
+ {
+ for(int c = 0; c < 3; c++)
+ {
+ g->enl_lo[c] = (float)s->enl_lo[c];
+ g->enl_hi[c] = (float)s->enl_hi[c];
+ }
+ g->enl_lut = dup_f(s->enl_lut, n3);
+ g->enl_sx = dup_f(s->enl_sx, n3);
+ g->enl_sy = dup_f(s->enl_sy, n3);
+ g->enl_sz = dup_f(s->enl_sz, n3);
+ g->enl_cmin = dup_f(s->enl_cmin, m3);
+ g->enl_cmax = dup_f(s->enl_cmax, m3);
+ g->print_exposure = (float)s->print_exposure;
+ g->print_curves = dup_f3(s->print_curves, SF_NLE);
+ }
+ for(int c = 0; c < 3; c++)
+ {
+ g->scan_lo[c] = (float)s->scan_lo[c];
+ g->scan_hi[c] = (float)s->scan_hi[c];
+ }
+ g->scan_lut = dup_f(s->scan_lut, n3);
+ g->scan_sx = dup_f(s->scan_sx, n3);
+ g->scan_sy = dup_f(s->scan_sy, n3);
+ g->scan_sz = dup_f(s->scan_sz, n3);
+ g->scan_cmin = dup_f(s->scan_cmin, m3);
+ g->scan_cmax = dup_f(s->scan_cmax, m3);
+ cp9f(g->m_out, s->m_out);
+ g->scan_bw_on = s->scan_bw_on;
+ g->scan_bw_m = (float)s->scan_bw_m;
+ g->scan_bw_q = (float)s->scan_bw_q;
+ g->film_bw = s->film_bw;
+ g->coupler_diff_um = (float)s->coupler_diff_um;
+ g->coupler_tail_um = (float)s->coupler_tail_um;
+ g->coupler_tail_w = (float)s->coupler_tail_w;
+ g->couplers_donor_lm = s->couplers_donor_lm;
+ g->couplers_recv_lm = s->couplers_recv_lm;
+ for(int c = 0; c < 3; c++)
+ {
+ /* INFINITY-safe: when linear, ship K large enough that the float
+ formula degenerates to identity even without isinf checks */
+ g->couplers_donor_K[c] = s->couplers_donor_lm ? (float)s->couplers_donor_K[c] : 1e30f;
+ g->couplers_donor_Dref[c] = (float)s->couplers_donor_Dref[c];
+ g->couplers_recv_Kr[c] = s->couplers_recv_lm ? (float)s->couplers_recv_Kr[c] : 1e30f;
+ g->couplers_recv_cref[c] = (float)s->couplers_recv_cref[c];
+ }
+
+ g->out_compress = s->out_compress;
+ g->out_luminance_boost = (float)s->out_luminance_boost;
+ cp9f(g->out_rgb2xyz, s->out_rgb2xyz);
+ cp9f(g->out_xyz2rgb, s->out_xyz2rgb);
+ cp9f(g->oklab_m1, SF_OKLAB_M1);
+ cp9f(g->oklab_m2, SF_OKLAB_M2);
+ cp9f(g->oklab_m1inv, s->oklab_m1inv);
+ cp9f(g->oklab_m2inv, s->oklab_m2inv);
+ g->cmax_table = s->cmax; /* borrowed; may be NULL when compression != oklch */
+ g->cmax_nl = SF_CMAX_NL;
+ g->cmax_nh = SF_CMAX_NH;
+ return g;
+}
+
+void sf_sim_gpu_free(sf_sim_gpu_t *g)
+{
+ if(!g) return;
+ free(g->tc_lut);
+ free(g->curves_norm);
+ free(g->curves_before);
+ free(g->enl_lut); free(g->enl_sx); free(g->enl_sy); free(g->enl_sz);
+ free(g->enl_cmin); free(g->enl_cmax);
+ free(g->print_curves);
+ free(g->scan_lut); free(g->scan_sx); free(g->scan_sy); free(g->scan_sz);
+ free(g->scan_cmin); free(g->scan_cmax);
+ free(g);
+}
+
+int sf_sim_film_bw(const sf_sim_t *sim) { return sim ? sim->film_bw : 0; }
+
+void sf_sim_coupler_diffusion(const sf_sim_t *sim, double *size_um, double *tail_um,
+ double *tail_w)
+{
+ *size_um = sim ? sim->coupler_diff_um : SF_COUPLER_BLUR_UM;
+ *tail_um = sim ? sim->coupler_tail_um : 0.0;
+ *tail_w = sim ? sim->coupler_tail_w : 0.0;
+}
+
+void sf_sim_halation_params(const sf_sim_t *sim, double strength[3], double *first_sigma_um)
+{
+ const double dflt[3] = { SF_HALATION_STRENGTH_DEFAULT_R, SF_HALATION_STRENGTH_DEFAULT_G,
+ SF_HALATION_STRENGTH_DEFAULT_B };
+ if(strength)
+ {
+ for(int c = 0; c < 3; c++) strength[c] = sim ? sim->halation_strength[c] : dflt[c];
+ }
+ if(first_sigma_um) *first_sigma_um = sim ? sim->halation_sigma_um : SF_HALATION_SIGMA_DEFAULT_UM;
+}
+
+void sf_sim_scatter_params(const sf_sim_t *sim, double core_um[3], double tail_um[3],
+ double tail_weight[3])
+{
+ static const double core_d[3] = { SF_SCATTER_CORE_DEFAULT_R, SF_SCATTER_CORE_DEFAULT_G,
+ SF_SCATTER_CORE_DEFAULT_B };
+ static const double tail_d[3] = { SF_SCATTER_TAIL_DEFAULT_R, SF_SCATTER_TAIL_DEFAULT_G,
+ SF_SCATTER_TAIL_DEFAULT_B };
+ static const double tw_d[3] = { SF_SCATTER_TAILW_DEFAULT_R, SF_SCATTER_TAILW_DEFAULT_G,
+ SF_SCATTER_TAILW_DEFAULT_B };
+ for(int c = 0; c < 3; c++)
+ {
+ if(core_um) core_um[c] = sim ? sim->scatter_core_um[c] : core_d[c];
+ if(tail_um) tail_um[c] = sim ? sim->scatter_tail_um[c] : tail_d[c];
+ if(tail_weight) tail_weight[c] = sim ? sim->scatter_tail_weight[c] : tw_d[c];
+ }
+}
+
+void sf_sim_grain_dmin_total(const sf_sim_t *sim, float dmin_total[3])
+{
+ for(int c = 0; c < 3; c++) dmin_total[c] = 0.0f;
+ if(!sim) return;
+ const int nl = sim->grain_n_sublayers > 0 ? sim->grain_n_sublayers : 1;
+ for(int c = 0; c < 3; c++)
+ {
+ double t = 0.0;
+ for(int l = 0; l < nl; l++) t += sim->grain_layer_dmin[l][c];
+ dmin_total[c] = (float)t;
+ }
+}
+
+void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3])
+{
+ /* spektrafilm's original single fixed profile, for a sim-less caller */
+ static const float legacy_rms[3] = { 6.0f, 8.0f, 10.0f };
+ static const float legacy_unif[3] = { 0.97f, 0.97f, 0.97f };
+ static const float legacy_dmin[3] = { 0.03f, 0.03f, 0.03f };
+ for(int c = 0; c < 3; c++)
+ {
+ rms[c] = sim ? (float)sim->grain_rms[c] : legacy_rms[c];
+ uniformity[c] = sim ? (float)sim->grain_uniformity[c] : legacy_unif[c];
+ /* film_dmin (this module's own curve floor), NOT p.grain_density_min:
+ the grain formula reconstructs dmax_abs = dmax_c + dmin, which is only
+ the film's real absolute D-max when dmin is the SAME floor that
+ produced dmax_c. An independently-sourced density_min (e.g. from a
+ separate curve-fit pass upstream) breaks that identity and silently
+ biases the particle count. */
+ dmin[c] = sim ? (float)sim->film_dmin[c] : legacy_dmin[c];
+ }
+}
+
+void sf_sim_grain_layers(const sf_sim_t *sim, sf_grain_layers_t *out)
+{
+ if(!sim)
+ {
+ /* no sim: single trivial layer, empty lookup table (caller must guard
+ n==1 && layer_curve==NULL by using its own legacy single-layer path,
+ exactly as sf_sim_film_grain3's callers already do without a sim). */
+ out->n = 1;
+ out->particle_scale[0] = 1.0;
+ out->layer_curve = NULL;
+ out->layer_curve_total = NULL;
+ return;
+ }
+ out->n = sim->grain_n_sublayers;
+ for(int l = 0; l < SF_GRAIN_MAX_SUBLAYERS; l++)
+ {
+ out->particle_scale[l] = sim->grain_particle_scale[l];
+ for(int c = 0; c < 3; c++)
+ {
+ out->layer_dmax[l][c] = sim->grain_layer_dmax[l][c];
+ out->layer_npart[l][c] = sim->grain_layer_npart[l][c];
+ out->layer_dmin[l][c] = sim->grain_layer_dmin[l][c];
+ }
+ }
+ out->layer_curve = sim->grain_layer_curve;
+ out->layer_curve_total = sim->grain_layer_curve_total;
+}
+
+/* Given the already-computed NET total density `target` for one channel,
+ * find its fractional position on the [0, SF_NLE) exposure-grid axis by
+ * searching `arr` (assumed monotonic -- guaranteed here, since it's a sum of
+ * same-signed-CDF terms that all move the same direction over the grid) via
+ * binary search plus linear interpolation between the two straddling grid
+ * points. `stride` lets this walk a non-contiguous column of a [SF_NLE][..]
+ * array (e.g. one channel of grain_layer_curve_total) without copying it
+ * out first. This is the same "find where the total curve reads D" step
+ * spektrafilm's own interp_density_cmy_layers_channel performs (there, via
+ * a table built ad hoc each call; here, against grain_layer_curve_total,
+ * built once at sim-build time). */
+static float _sf_grain_curve_inverse(const float *arr, int n, int stride, float target)
+{
+ const int increasing = arr[(n - 1) * stride] >= arr[0];
+ int lo = 0, hi = n - 1;
+ while(hi - lo > 1)
+ {
+ const int mid = (lo + hi) / 2;
+ const float v = arr[mid * stride];
+ if((increasing && v <= target) || (!increasing && v >= target)) lo = mid;
+ else hi = mid;
+ }
+ const float v0 = arr[lo * stride], v1 = arr[hi * stride];
+ const float denom = v1 - v0;
+ float frac = (fabsf(denom) > 1e-9f) ? (target - v0) / denom : 0.0f;
+ if(frac < 0.0f) frac = 0.0f;
+ if(frac > 1.0f) frac = 1.0f;
+ return (float)lo + frac;
+}
+
+/* Linearly interpolate a (possibly strided) per-index array at the
+ * continuous index `pos` produced by _sf_grain_curve_inverse above. */
+static float _sf_grain_curve_sample(const float *arr, int n, int stride, float pos)
+{
+ int i0 = (int)pos;
+ if(i0 < 0) i0 = 0;
+ if(i0 > n - 2) i0 = (n - 2 < 0) ? 0 : n - 2;
+ const float frac = pos - (float)i0;
+ return arr[i0 * stride] * (1.0f - frac) + arr[(i0 + 1) * stride] * frac;
+}
+
+/* Multi-sublayer grain delta, for a film whose fitted density-curve model has
+ * more than one emulsion sub-layer (sf_sim_grain_layers().n > 1; n == 1 is the
+ * trivial case of the same model). Each sub-layer is drawn
+ * independently at its own precomputed dmax/npart, using its own density
+ * recovered by inverse-interpolating the self-consistent summed sub-layer
+ * curve at the exposure position the already-computed total density
+ * corresponds to -- exactly what spektrafilm's own
+ * interp_density_cmy_layers_channel does, just against a table built once
+ * at sim-build time instead of a per-pixel scipy call. The per-sublayer
+ * draws are SUMMED (not averaged) before taking the delta, matching
+ * upstream's _channel_sublayer_grain -- each draw already carries its own
+ * (smaller, for finer sub-layers) share of the total variance, so summing
+ * them reproduces the combined multilayer noise the catalogue RMS was
+ * calibrated against, rather than diluting it the way an average would. */
+void sf_grain_delta_ml(const sf_grain_layers_t *layers, const float dens[3], float amount,
+ float out_delta[3], uint32_t xi, uint32_t yi, int mono,
+ const float dmin_c[3], const float unif_c[3], float npart_scale)
+{
+ const int nsub = layers->n;
+ const int nle = SF_NLE;
+ const int lstride = SF_GRAIN_MAX_SUBLAYERS * 3;
+
+ if(mono)
+ {
+ const float dm = (dens[0] + dens[1] + dens[2]) / 3.0f;
+ const float pos = _sf_grain_curve_inverse(&layers->layer_curve_total[0][1], nle, 3, dm);
+ float total_abs = 0.0f;
+ for(int sl = 0; sl < nsub; sl++)
+ {
+ const float raw = _sf_grain_curve_sample(&layers->layer_curve[0][sl][1], nle, lstride, pos);
+ const float d_abs = raw + (float)layers->layer_dmin[sl][1];
+ total_abs += sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][1],
+ (float)layers->layer_npart[sl][1] * npart_scale,
+ unif_c[1], sf_pixel_seed(xi, yi, (uint32_t)(sl * 10)));
+ }
+ const float g = total_abs - dmin_c[1];
+ const float d = (g - dm) * amount;
+ out_delta[0] = out_delta[1] = out_delta[2] = d;
+ return;
+ }
+
+ for(int c = 0; c < 3; c++)
+ {
+ const float pos = _sf_grain_curve_inverse(&layers->layer_curve_total[0][c], nle, 3, dens[c]);
+ float total_abs = 0.0f;
+ for(int sl = 0; sl < nsub; sl++)
+ {
+ const float raw = _sf_grain_curve_sample(&layers->layer_curve[0][sl][c], nle, lstride, pos);
+ const float d_abs = raw + (float)layers->layer_dmin[sl][c];
+ total_abs += sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][c],
+ (float)layers->layer_npart[sl][c] * npart_scale,
+ unif_c[c], sf_pixel_seed(xi, yi, (uint32_t)(c + sl * 10)));
+ }
+ const float g = total_abs - dmin_c[c];
+ out_delta[c] = (g - dens[c]) * amount;
+ }
+}
+
+/* Same per-sub-layer sampling as sf_grain_delta_ml, but returns each
+ * sub-layer's RAW absolute sample (before dmin subtraction, before
+ * summing across sub-layers) into raw_out[0..layers->n-1], so the caller
+ * can run the per-sub-layer dye-cloud blur (upstream's
+ * layer_particle_model blur_particle) on each one independently before
+ * combining -- a spatial blur needs a whole-image buffer per sub-layer,
+ * which this single-pixel function can't do itself, so it just hands
+ * back the un-combined per-sub-layer values for the caller to buffer,
+ * blur, and combine afterward. channel_idx is 1 for mono (matching
+ * sf_grain_delta_ml's own convention of using channel 1's curve/params
+ * for the achromatic draw) or 0/1/2 for color; seed_ch is the seed
+ * component sf_grain_delta_ml adds on top of sl*10 (0 for mono, the
+ * real channel index for color) -- kept as a separate parameter rather
+ * than reusing channel_idx so the mono case still seeds like sl*10, not
+ * (1 + sl*10), matching sf_grain_delta_ml exactly. */
+void sf_grain_raw_samples_ml(const sf_grain_layers_t *layers, float density, int channel_idx,
+ int seed_ch, uint32_t xi, uint32_t yi, float unif_c,
+ float npart_scale, float *raw_out)
+{
+ const int nsub = layers->n;
+ const int nle = SF_NLE;
+ const int lstride = SF_GRAIN_MAX_SUBLAYERS * 3;
+ const float pos = _sf_grain_curve_inverse(&layers->layer_curve_total[0][channel_idx], nle, 3,
+ density);
+ for(int sl = 0; sl < nsub; sl++)
+ {
+ const float raw = _sf_grain_curve_sample(&layers->layer_curve[0][sl][channel_idx], nle,
+ lstride, pos);
+ const float d_abs = raw + (float)layers->layer_dmin[sl][channel_idx];
+ raw_out[sl] = sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][channel_idx],
+ (float)layers->layer_npart[sl][channel_idx] * npart_scale,
+ unif_c, sf_pixel_seed(xi, yi, (uint32_t)(seed_ch + sl * 10)));
+ }
+}
+
diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h
new file mode 100644
index 000000000000..7085c8f1b1c7
--- /dev/null
+++ b/src/common/spektra_sim.h
@@ -0,0 +1,480 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+/* spektra_sim — native port of the spektrafilm runtime pipeline.
+ *
+ * This is a C implementation of the deterministic per-pixel model of
+ * spektrafilm (https://github.com/andreavolpato/spektrafilm), the spectral
+ * film simulation by Andrea Volpato (GPLv3; film modeling powered by
+ * spektrafilm). It replaces the baked .cube-bundle approach: all colour
+ * science is computed at parameter-commit time from a *data pack* exported
+ * from a spektrafilm release (measured stock profiles, the hanatos2025
+ * irradiance spectra LUT, illuminant SPDs, dichroic filter curves), so a new
+ * spektrafilm release is adopted by re-running the exporter — no rebake, no
+ * code changes as long as the model version matches.
+ *
+ * Model version tracked by this port: spektrafilm 0.3.x runtime
+ * (SimulationPipeline: filming.expose → filming.develop → printing.expose →
+ * printing.develop → scanning.scan). The stochastic / spatial effects
+ * (grain, halation, scatter, diffusion filters, coupler diffusion blur) are
+ * intentionally *not* in this engine — they act between the per-pixel
+ * stages and stay in the caller (darktable already has fast gaussian
+ * infrastructure; see spektra_core.c).
+ *
+ * Pipeline stages exposed here (all deterministic, all pure per-pixel):
+ *
+ * rgb_in --expose--> raw (linear film exposure) [caller: highlight
+ * boost, diffusion filter, scatter, halation in linear domain]
+ * raw --lograw--> log_raw
+ * log_raw --develop_corr--> DIR coupler correction [caller: blur]
+ * (log_raw, corr) --develop--> cmy film density [caller: grain]
+ * cmy --print_expose--> log_raw_print
+ * log_raw_print --print_develop--> cmy print density
+ * cmy --scan--> rgb_out (linear, output primaries, gamut compressed)
+ *
+ * The heavy spectral integrals (print exposure through the filtered
+ * enlarger illuminant, scan through the viewing illuminant to XYZ) can run
+ * either exactly per pixel or through runtime-built 3D tables with monotone
+ * PCHIP interpolation — the same two paths the reference implementation
+ * has (use_enlarger_lut / use_scanner_lut). The tables are built here from
+ * profile data at sf_sim_build() time; nothing is pre-baked on disk.
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define SF_NWL 81 /* 380..780 nm in 5 nm steps — spektrafilm SPECTRAL_SHAPE */
+#define SF_NLE 256 /* log-exposure grid — spektrafilm LOG_EXPOSURE */
+/* max emulsion sub-layers a film's fitted density-curve model can have
+ (matches sf_curves_model_t's centers/amplitudes/sigmas[3][8] in
+ spektra_sim.c) and the max particle_scale_sublayers entries read below. */
+#define SF_GRAIN_MAX_SUBLAYERS 8
+
+/* Pack container format, as declared by pack.json's "pack_format".
+ *
+ * This versions the CONTAINER, not the data in it, and it is deliberately
+ * separate from the spektrafilm version string: that string is unreliable as
+ * an identifier (an editable dev install reports whatever pyproject.toml
+ * happens to say), while this is a number the exporter controls and bumps only
+ * when the layout changes in a way an older reader would get wrong.
+ *
+ * MIN exists so a build can drop support for a layout it can no longer read,
+ * rather than parsing an old pack into plausible-looking nonsense. MAX exists
+ * so a pack written by a newer exporter is refused with an explanation instead
+ * of being silently misread -- pack.json is a permissive JSON object, so a
+ * future revision that moves or reinterprets a field would otherwise parse
+ * without complaint. Widen MAX only once this reader actually handles the new
+ * layout.
+ *
+ * The declaration is mandatory in pack.json; there is no format that predates
+ * the field, so nothing needs to be assumed for one that omits it. */
+#define SF_PACK_FORMAT_MIN 2
+#define SF_PACK_FORMAT_MAX 2
+
+
+
+typedef struct sf_pack_t sf_pack_t;
+typedef struct sf_profile_t sf_profile_t;
+typedef struct sf_sim_t sf_sim_t;
+
+/* ---------------------------------------------------------------- pack -- */
+
+/* Load a data pack directory (pack.json + spectra_lut.f32 + profiles/).
+ * On failure returns NULL and sets *errmsg (caller frees with free()). */
+sf_pack_t *sf_pack_load(const char *dir, char **errmsg);
+/* Identity of the spectral upsampling table this pack carries. The hash is what
+ * params record; the string is for the message shown when they disagree. */
+uint32_t sf_pack_lut_hash(const sf_pack_t *pack);
+const char *sf_pack_lut_id(const sf_pack_t *pack);
+void sf_pack_free(sf_pack_t *pack);
+const char *sf_pack_version(const sf_pack_t *pack);
+
+/* Neutral enlarger filter database lookup (Kodak CC units, CMY order).
+ * Returns true and fills cmy[3] when a calibration exists for the triple. */
+bool sf_pack_neutral_filters(const sf_pack_t *pack, const char *print_stock,
+ const char *illuminant, const char *film_stock,
+ double cmy[3]);
+
+/* Per-film digested render defaults from the release (DIR coupler gamma
+ * matrix, halation preset). Any pointer may be NULL. Returns false if the
+ * stock has no entry (generic defaults are then left untouched). */
+/* Langmuir K factors from film_render_defaults (dev/0.4+ packs); returns
+ false and leaves outputs untouched when the pack predates them. */
+bool sf_pack_film_langmuir(const sf_pack_t *pack, const char *film_stock,
+ double donor_k[3], double receiver_k[3]);
+
+bool sf_pack_film_defaults(const sf_pack_t *pack, const char *film_stock,
+ double gamma_samelayer[3],
+ double gamma_inter_r_gb[2],
+ double gamma_inter_g_rb[2],
+ double gamma_inter_b_rg[2],
+ double halation_strength[3],
+ double halation_sigma_um[3],
+ double scatter_core_um[3],
+ double scatter_tail_um[3],
+ double scatter_tail_weight[3]);
+
+/* ------------------------------------------------------------- profile -- */
+
+/* `development_min` picks a member of a B&W stock's development-time family, in
+ * minutes, snapping to the nearest available; <= 0 means "no choice made" and
+ * takes the representative middle member, mirroring
+ * select_development_time(None) (density_curves.py). Ignored for colour stocks
+ * and for stocks with no family. */
+/* Widest development-time family we index (kodak_doublex ships 5). */
+#define SF_MAX_DEV_TIMES 8
+sf_profile_t *sf_profile_load(const char *path, float development_min, char **errmsg);
+void sf_profile_free(sf_profile_t *profile);
+/* ---------------------------------------------------------- GPU export -- */
+
+/* Float copies of everything a per-pixel GPU port needs. Buffers are malloc'd
+ * and owned by this struct EXCEPT cmax_table, which borrows the sim's own
+ * float table (keep the sim alive while using the export).
+ * Only the table-based paths export: lut_steps must be >= 2 (exact spectral
+ * has no GPU path) or sf_sim_gpu_export() returns NULL. */
+typedef struct sf_sim_gpu_t
+{
+ /* expose: work RGB -> film raw exposure */
+ float m_in[9];
+ float ev_scale;
+ int tc_n;
+ float *tc_lut; /* tc_n * tc_n * 3 */
+ /* film develop */
+ float gamma[3];
+ float le0, le_step;
+ float *curves_norm; /* SF_NLE*3 */
+ float *curves_before; /* SF_NLE*3 (== curves_norm when couplers off) */
+ float couplers_M[9]; /* row donor -> column receiver, amount-scaled */
+ float film_dmax[3];
+ int film_positive, couplers_active;
+ /* printing (has_print == 0 in scan-film mode; buffers NULL then) */
+ int has_print, steps;
+ float enl_lo[3], enl_hi[3];
+ float *enl_lut, *enl_sx, *enl_sy, *enl_sz; /* steps^3 * 3 */
+ float *enl_cmin, *enl_cmax; /* (steps-1)^3 * 3 */
+ float print_exposure;
+ float *print_curves; /* SF_NLE*3 */
+ /* scanning */
+ float scan_lo[3], scan_hi[3];
+ float *scan_lut, *scan_sx, *scan_sy, *scan_sz, *scan_cmin, *scan_cmax;
+ float m_out[9]; /* XYZ(view illuminant) -> output RGB, CAT02 included */
+ int scan_bw_on; /* scanner black/white point (positive film scans) */
+ float scan_bw_m, scan_bw_q;
+ /* Langmuir couplers (dev/0.4+ packs; flags 0 on 0.3.x = linear) */
+ int film_bw; /* B&W stock: achromatic (channel-coupled) grain */
+ float coupler_diff_um, coupler_tail_um, coupler_tail_w;
+ int couplers_donor_lm, couplers_recv_lm;
+ float couplers_donor_K[3], couplers_donor_Dref[3];
+ float couplers_recv_Kr[3], couplers_recv_cref[3];
+ /* per-film grain catalogue data (film_render_defaults[stock].grain in the
+ pack): RMS-granularity, uniformity and density floor, replacing the
+ earlier one-size-fits-all constants */
+ float grain_rms[3], grain_uniformity[3], grain_dmin[3];
+ /* multi-sublayer grain model (see sf_grain_layers_t / _sf_build_grain_layers
+ in spektra_sim.c): n==1 for a single-layer curve fit, the existing
+ single-layer behavior. The two per-exposure-grid tables are borrowed
+ pointers into the sim's own storage (same convention as cmax_table
+ below), not copied -- process_cl() turns them into a device constant
+ buffer the same way it already does for cmax_table. */
+ int grain_n_sublayers;
+ float grain_particle_scale[SF_GRAIN_MAX_SUBLAYERS];
+ float grain_layer_dmax[SF_GRAIN_MAX_SUBLAYERS][3];
+ float grain_layer_npart[SF_GRAIN_MAX_SUBLAYERS][3];
+ float grain_layer_dmin[SF_GRAIN_MAX_SUBLAYERS][3];
+ const float *grain_layer_curve; /* [SF_NLE][SF_GRAIN_MAX_SUBLAYERS][3], borrowed */
+ const float *grain_layer_curve_total; /* [SF_NLE][3], borrowed */
+ /* per-film halation preset (film_render_defaults[stock].halation in the
+ pack): back-reflection strength per channel and first-bounce radius;
+ falls back to SF_HALATION_STRENGTH_DEFAULT_* / SF_HALATION_SIGMA_DEFAULT_UM
+ (spektra_sim.c) when the pack has no per-stock entry. See
+ sf_sim_halation_params(). */
+ float halation_strength[3], halation_first_sigma_um;
+ float scatter_core_um[3], scatter_tail_um[3], scatter_tail_weight[3];
+ /* output gamut compression */
+ int out_compress; /* sf_output_compress_t */
+ float out_luminance_boost;
+ float out_rgb2xyz[9], out_xyz2rgb[9];
+ float oklab_m1[9], oklab_m2[9], oklab_m1inv[9], oklab_m2inv[9];
+ const float *cmax_table; /* cmax_nl * cmax_nh, borrowed from the sim */
+ int cmax_nl, cmax_nh;
+} sf_sim_gpu_t;
+
+sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *sim);
+void sf_sim_gpu_free(sf_sim_gpu_t *g);
+
+int sf_sim_film_bw(const sf_sim_t *sim);
+/* per-film grain catalogue data (rms_granularity, uniformity, density_min);
+ falls back to the legacy fixed constants (SF_GRAIN_LEGACY_* in
+ spektra_core.h) when sim is NULL or the pack predates per-film grain. */
+void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3]);
+/* Sum of the per-sub-layer density floors, per channel -- the amount the grain
+ * sampler actually adds to a pixel's density, and so the amount the caller has
+ * to take back off to recover a zero-mean delta. Equals grain_density_min for a
+ * single-layer stock, but NOT in general: the multi-sub-layer table's floors are
+ * density_max_fractions[l] * density_min and their sum is not constrained to
+ * density_min. Subtracting grain_density_min instead left the delta with a
+ * constant positive mean of (sum - density_min) per unit grain strength. */
+void sf_sim_grain_dmin_total(const sf_sim_t *sim, float dmin_total[3]);
+
+/* Multi-sublayer grain model (see _sf_build_grain_layers in spektra_sim.c):
+ * n==1 for any stock whose own fitted density-curve model is single-layer
+ * (or the pack has no particle_scale_sublayers for it) -- the existing
+ * single-layer behavior, not a fallback approximation of a separate case.
+ * layer_curve/layer_curve_total point into the sim's own storage (valid for
+ * the sim's lifetime; not copied, since the table is a small but non-trivial
+ * SF_NLE*SF_GRAIN_MAX_SUBLAYERS*3 floats). */
+typedef struct sf_grain_layers_t
+{
+ int n;
+ double particle_scale[SF_GRAIN_MAX_SUBLAYERS];
+ double layer_dmax[SF_GRAIN_MAX_SUBLAYERS][3];
+ double layer_npart[SF_GRAIN_MAX_SUBLAYERS][3];
+ double layer_dmin[SF_GRAIN_MAX_SUBLAYERS][3];
+ const float (*layer_curve)[SF_GRAIN_MAX_SUBLAYERS][3]; /* [SF_NLE][sublayer][channel] */
+ const float (*layer_curve_total)[3]; /* [SF_NLE][channel] */
+} sf_grain_layers_t;
+void sf_sim_grain_layers(const sf_sim_t *sim, sf_grain_layers_t *out);
+/* Multi-sublayer grain delta (see _sf_build_grain_layers / sf_grain_layers_t
+ * above). A single-layer curve fit is the trivial n == 1 case of the same
+ * model, so every stock goes through here.
+ * npart_scale rescales the build-time-precomputed layer_npart (built at the
+ * fixed SF_GRAIN_REF_UM reference scale, since it depends on curve/coupler
+ * state baked in at sf_sim_build time, not just resolution) up to the live
+ * pipe's real pixel_um: pass (pixel_um*pixel_um)/(SF_GRAIN_REF_UM*SF_GRAIN_REF_UM). */
+void sf_grain_delta_ml(const sf_grain_layers_t *layers, const float dens[3], float amount,
+ float out_delta[3], uint32_t xi, uint32_t yi, int mono,
+ const float dmin_c[3], const float unif_c[3], float npart_scale);
+/* Same per-sub-layer sampling as sf_grain_delta_ml, but returns each
+ * sub-layer's raw (un-combined, pre-dmin-subtraction) sample into
+ * raw_out[0..layers->n-1] instead, so the caller can dye-cloud-blur each
+ * sub-layer's whole-image buffer independently (upstream's
+ * layer_particle_model blur_particle) before summing them -- see the
+ * definition in spektra_sim.c for the full rationale. raw_out must have
+ * room for at least layers->n floats. */
+void sf_grain_raw_samples_ml(const sf_grain_layers_t *layers, float density, int channel_idx,
+ int seed_ch, uint32_t xi, uint32_t yi, float unif_c,
+ float npart_scale, float *raw_out);
+/* SF_GRAIN_MAX_SUBLAYERS is defined earlier in this header, next to SF_NLE
+ (both are needed by sf_sim_gpu_t above, which comes before this point). */
+bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock,
+ double rms[3], double uniformity[3], double density_min[3],
+ double particle_scale[SF_GRAIN_MAX_SUBLAYERS], int *n_scale);
+#define SF_COUPLER_BLUR_UM 20.0 /* gaussian core default when pack lacks it */
+/* exponential-tail gaussian mixture (upstream fit, n=3) — shared with halation */
+#define SF_EXPTAIL_A0 0.1633
+#define SF_EXPTAIL_A1 0.6496
+#define SF_EXPTAIL_A2 0.1870
+#define SF_EXPTAIL_R0 0.5360
+#define SF_EXPTAIL_R1 1.5236
+#define SF_EXPTAIL_R2 2.7684
+
+void sf_sim_coupler_diffusion(const sf_sim_t *sim, double *size_um, double *tail_um,
+ double *tail_w);
+bool sf_pack_film_coupler_diffusion(const sf_pack_t *pack, const char *film_stock,
+ double *size_um, double *tail_um, double *tail_w);
+
+/* per-film halation preset (film_render_defaults[stock].halation in the pack):
+ * back-reflection strength per channel (R/G/B) and the first-bounce Gaussian
+ * radius in micrometres. Both `strength` and `first_sigma_um` may be NULL if
+ * the caller only wants one. Falls back to the generic still-film /
+ * strong-antihalation baseline (SF_HALATION_STRENGTH_DEFAULT_* /
+ * SF_HALATION_SIGMA_DEFAULT_UM in spektra_sim.c) when `sim` is NULL or the
+ * pack predates per-stock halation data. */
+void sf_sim_halation_params(const sf_sim_t *sim, double strength[3], double *first_sigma_um);
+/* [df] per-film in-emulsion scatter PSF: Gaussian core radius, exponential tail
+ * decay (both micrometres on film) and the core/tail mix weight, per channel.
+ * Falls back to the schema defaults when sim is NULL or the pack has no entry. */
+void sf_sim_scatter_params(const sf_sim_t *sim, double core_um[3], double tail_um[3],
+ double tail_weight[3]);
+
+const char *sf_profile_stock(const sf_profile_t *p);
+const char *sf_profile_name(const sf_profile_t *p);
+const char *sf_profile_stage(const sf_profile_t *p); /* "filming" / "printing" */
+const char *sf_profile_type(const sf_profile_t *p); /* "negative" / "positive" */
+const char *sf_profile_target_print(const sf_profile_t *p); /* may be NULL */
+const char *sf_profile_channel_model(const sf_profile_t *p); /* "color" / "bw" / NULL */
+/* Copies out the development times this stock is characterised at, in minutes,
+ * and returns how many there are (0 when it has no family). */
+int sf_profile_dev_times(const sf_profile_t *p, double *out, int maxn);
+
+/* -------------------------------------------------------------- params -- */
+
+typedef enum sf_output_compress_t
+{
+ SF_OUTPUT_COMPRESS_OFF = 0,
+ SF_OUTPUT_COMPRESS_OKLCH = 1, /* reference default */
+ SF_OUTPUT_COMPRESS_ACES_RGC = 2,
+} sf_output_compress_t;
+
+typedef struct sf_sim_params_t
+{
+ /* camera / filming */
+ double exposure_comp_ev; /* 0 */
+ double density_curve_gamma; /* 1 */
+ /* [su] SettingsParams.apply_hanatos2025_adaptation_bandwidth /
+ * ..._adaptation_surface: the two halves of the hanatos2025 sensitivity
+ * adaptation, both applied in sf_sim_build() and both skipped for a profile
+ * that carries no coefficients for them.
+ *
+ * bandwidth is the erf4 spectral bandpass folded into the film's own
+ * sensitivities (white-balance preserving, hence the per-channel
+ * renormalisation), and matches the reference on.
+ *
+ * surface is the per-chromaticity exposure correction applied to the tc LUT
+ * afterwards. The profiles set it true, but the reference runtime's own
+ * setting is false and wins, so a reference render does NOT include it --
+ * hence false here as well, and a caller-facing switch rather than a
+ * profile-driven one. It is worth up to +-2 stops per channel away from the
+ * reference white, so the two states are visibly different renders. */
+ bool adaptation_bandwidth; /* true */
+ bool adaptation_surface; /* false */
+
+ /* DIR couplers (matrix part; spatial diffusion is the caller's blur) */
+ bool couplers_active; /* true */
+ double couplers_amount; /* 1 */
+ double gamma_samelayer[3]; /* filled from pack film defaults */
+ double gamma_inter_r_gb[2], gamma_inter_g_rb[2], gamma_inter_b_rg[2];
+ double inhibition_samelayer; /* 1 */
+ double inhibition_interlayer; /* 1 */
+
+ /* grain reference floor — used for table ranges even when grain itself
+ * runs in the caller (reference: GrainParams.density_min) */
+ double grain_density_min[3]; /* (0.03, 0.03, 0.03) */
+
+ /* enlarger */
+ const char *enlarger_illuminant; /* "TH-KG3" */
+ const char *dichroic_brand; /* "custom" — reference color_enlarger default */
+ double print_exposure; /* 1 */
+ bool print_exposure_compensation;/* true */
+ bool normalize_print_exposure; /* true */
+ double c_filter_neutral, m_filter_neutral, y_filter_neutral; /* CC units;
+ seeded from the pack database in sf_sim_build() when neutral_from_db */
+ bool neutral_from_db; /* true */
+ double y_filter_shift, m_filter_shift; /* user CC shifts */
+ double preflash_exposure; /* 0 */
+ double preflash_y_shift, preflash_m_shift;
+
+ /* print curve morph (s023) — identity at defaults */
+ bool morph_active;
+ double morph_gamma, morph_gamma_fast, morph_gamma_slow;
+ double morph_gamma_r, morph_gamma_g, morph_gamma_b;
+
+ /* film curve chemistry (s023) — same morph as print's, applied to the
+ * film's own fitted density-curve model instead, plus developer
+ * exhaustion (not yet wired for print). Identity at defaults. */
+ bool film_morph_active;
+ double film_morph_gamma, film_morph_gamma_fast, film_morph_gamma_slow;
+ double film_morph_developer_exhaustion;
+
+ /* scanning / output */
+ bool scan_film; /* false: full negative→print→scan chain */
+ int lut_steps; /* 0 = exact spectral per pixel;
+ >=2 = runtime 3D tables (ref default 17;
+ 33 recommended for production) */
+
+ /* input colour handling: linear RGB -> XYZ (source-white relative) and the
+ * source whitepoint xy. The engine appends a CAT16 adaptation to the film
+ * reference illuminant, matching spektrafilm's _rgb_to_tc_b(). */
+ double input_rgb_to_xyz[9];
+ double input_white_xy[2];
+ bool input_gamut_compress; /* true — radial xy Reinhard (0,1,6) */
+
+ /* output colour handling: XYZ (output-white relative) <-> linear output
+ * RGB and the output whitepoint xy. The engine prepends a CAT02
+ * adaptation from the viewing illuminant, matching colour.XYZ_to_RGB
+ * defaults used by spektrafilm's scanning stage. */
+ double output_rgb_to_xyz[9];
+ double output_xyz_to_rgb[9];
+ double output_white_xy[2];
+ sf_output_compress_t output_compress; /* SF_OUTPUT_COMPRESS_OKLCH */
+ double out_luminance_boost; /* 1.0 = pre-gamut XYZ multiplier before OkLCh compressor */
+} sf_sim_params_t;
+
+void sf_sim_params_defaults(sf_sim_params_t *p);
+/* convenience: fill input or output side with sRGB / ProPhoto matrices */
+void sf_sim_params_set_input_srgb(sf_sim_params_t *p);
+void sf_sim_params_set_input_prophoto(sf_sim_params_t *p);
+void sf_sim_params_set_input_rec2020(sf_sim_params_t *p);
+void sf_sim_params_set_output_srgb(sf_sim_params_t *p);
+void sf_sim_params_set_output_rec2020(sf_sim_params_t *p);
+
+/* --------------------------------------------------------------- build -- */
+
+/* Build all runtime tables. print may be NULL when params->scan_film.
+ * Seeds params->gamma_* coupler defaults and neutral filters from the pack
+ * unless the caller already customized them (see .neutral_from_db). */
+sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film,
+ const sf_profile_t *print, const sf_sim_params_t *params,
+ char **errmsg);
+void sf_sim_free(sf_sim_t *sim);
+
+/* info for the caller's spatial effects */
+double sf_sim_film_dmax(const sf_sim_t *sim, int ch); /* normalized curve max */
+
+/* ------------------------------------------------------ per-pixel API --- */
+/* All buffers are interleaved float with `nch` floats per pixel (>= 3);
+ * channels 0..2 are read/written, remaining channels are left untouched.
+ * In-place operation (in == out) is allowed for every stage. */
+
+/* linear input RGB -> linear film raw exposure (includes 2^ev) */
+void sf_sim_expose(const sf_sim_t *sim, const float *rgb_in, float *raw,
+ size_t npix, int nch_in, int nch_out);
+
+/* raw -> log10(max(raw,0) + 1e-10), in place */
+void sf_sim_lograw(float *raw, size_t npix, int nch);
+
+/* DIR coupler correction field (to be spatially blurred by the caller).
+ * corr is a 3-channel interleaved buffer. No-op fill of zeros when couplers
+ * are inactive. */
+void sf_sim_develop_corr(const sf_sim_t *sim, const float *lograw, float *corr,
+ size_t npix, int nch_in);
+
+/* (lograw, blurred corr) -> cmy film density. corr may be NULL (no couplers). */
+void sf_sim_develop(const sf_sim_t *sim, const float *lograw, const float *corr,
+ float *cmy, size_t npix, int nch_in, int nch_out);
+
+/* cmy film density -> log print raw exposure (through the enlarger) */
+void sf_sim_print_expose(const sf_sim_t *sim, const float *cmy, float *lograw,
+ size_t npix, int nch_in, int nch_out);
+
+/* log print raw -> cmy print density */
+void sf_sim_print_develop(const sf_sim_t *sim, const float *lograw, float *cmy,
+ size_t npix, int nch_in, int nch_out);
+
+/* cmy density (print, or film when scan_film) -> linear output RGB,
+ * gamut compressed per params->output_compress */
+void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out,
+ size_t npix, int nch_in, int nch_out);
+
+
+/* pre-compression OkLab lightness a single RGB triple would land at, using
+ * boost_override in place of the sim's own out_luminance_boost -- see
+ * spektra_sim.c for the full rationale (used by the precompression-boost
+ * picker in spektrafilm.c). */
+float sf_sim_probe_lightness(const sf_sim_t *sim, const float rgb_in[3], float boost_override);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/iop/CMakeLists.txt b/src/iop/CMakeLists.txt
index df00dc8c2989..64b371dfbfb8 100644
--- a/src/iop/CMakeLists.txt
+++ b/src/iop/CMakeLists.txt
@@ -98,6 +98,7 @@ add_iop(rawoverexposed "rawoverexposed.c")
add_iop(velvia "velvia.c")
add_iop(vignette "vignette.c")
add_iop(splittoning "splittoning.c")
+add_iop(spektrafilm "spektrafilm.c")
add_iop(grain "grain.c")
add_iop(clahe "clahe.c")
add_iop(bilateral "bilateral.cc")
diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c
new file mode 100644
index 000000000000..ac4237fdd5b4
--- /dev/null
+++ b/src/iop/spektrafilm.c
@@ -0,0 +1,3748 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+
+/* spektrafilm — native spectral film simulation.
+ *
+ * Film modeling powered by spektrafilm (https://github.com/andreavolpato/spektrafilm),
+ * GPLv3, © Andrea Volpato. Film/paper profile data CC BY-SA 4.0.
+ *
+ * This module computes the full spektrafilm colour pipeline natively per pixel:
+ *
+ * scene-linear work RGB
+ * -> CAT16 to the film's reference illuminant, xy -> spectral upsampling
+ * (hanatos2025 tc LUT) x film sensitivity = camera exposure
+ * -> highlight boost / diffusion / halation (linear, spatial)
+ * -> log exposure -> DIR coupler correction (blurred) = film development
+ * -> CMY film density -> grain (density, spatial)
+ * -> enlarger (dichroic-filtered light through the negative,
+ * print paper sensitivity, midgray-balanced) = print exposure
+ * -> print diffusion filter (optional) (density, spatial)
+ * -> print density curves (with optional contrast morph)
+ * -> viewing illuminant through the print, CMFs -> XYZ
+ * -> CAT02 -> work RGB -> OkLCh gamut compression = scanning
+ *
+ * The per-pixel colour science lives in spektra_sim.c (a validated port of
+ * spektrafilm 0.3.x, max deviation < 1e-4 vs the Python reference); the
+ * spatial effects (grain / halation / diffusion / highlight boost) live in
+ * spektra_core.h/.c, both shared with the OpenCL-side ports.
+ *
+ * Data: drop a data pack exported by tools/spektrafilm_export_data.py into
+ * /spektrafilm/ (pack.json + spectra_lut.f32)
+ * /spektrafilm/profiles/ (*.json film + paper profiles)
+ * Upgrading to a new spektrafilm release = re-running the exporter.
+ *
+ * This is a scene-to-display view transform: enable it INSTEAD of
+ * sigmoid / filmic / agx.
+ *
+ * Both CPU (process, OpenMP) and GPU (process_cl, data/kernels/spektrafilm.cl)
+ * paths exist. The GPU kernels were validated against the CPU engine with
+ * POCL to ~1e-6; exact-spectral quality stays CPU-only.
+ */
+
+#include "bauhaus/bauhaus.h"
+#include "common/darktable.h"
+#include "common/file_location.h"
+#include "control/control.h"
+#include "develop/imageop.h"
+#include "develop/pixelpipe_cache.h"
+#include "develop/tiling.h"
+#include "develop/imageop_gui.h"
+#include "develop/imageop_math.h"
+#include "common/imagebuf.h"
+#include "common/iop_profile.h"
+#include "common/spektra_fetch.h"
+#include "common/opencl.h"
+#include "common/gaussian.h"
+#include "gui/accelerators.h"
+#include "dtgtk/button.h"
+#include "dtgtk/paint.h"
+#include "gui/color_picker_proxy.h"
+#include "gui/gtk.h"
+#include "iop/iop_api.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define SPEKTRA_INLINE static inline
+#include "common/spektra_core.h"
+#include "common/spektra_sim.h"
+
+DT_MODULE_INTROSPECTION(1, dt_iop_spektrafilm_params_t)
+
+/* Spatial-scale constants, micrometres on film unless noted (see the LUT
+ module for the full rationale; these are shared with modify_roi_in() and
+ tiling_callback() so the halo math stays in sync). */
+#define SF_HALATION_FIRST_SIGMA_UM 65.0f
+#define SF_HALATION_PSF_SIGMAS 1.7320508f /* sqrt(3) */
+/* widest stage-1 scatter component: max(sc_tail)*max(tail_rat) from
+ spektra_core.c's sf_halation() = 9.7 * 2.7684 um, rounded up */
+#define SF_SCATTER_TAIL_MAX_UM 27.0f
+/* Per-channel ceilings the ROI padding above is sized for. The scatter PSF is
+ per-film pack data now, and modify_roi_in() runs before the sim exists, so it
+ cannot measure the real values -- clamp them to what the padding covers
+ instead, exactly as hal_sigma_um is clamped to SF_HALATION_FIRST_SIGMA_UM.
+ 9.7 * SF_EXPTAIL_R2 = 26.85, which is where the 27.0 above comes from. */
+#define SF_SCATTER_CORE_CLAMP_UM 2.2f
+#define SF_SCATTER_TAIL_CLAMP_UM 9.7f
+/* [gl] GlareParams.roughness / .blur -- the reference exposes these but leaves
+ them at these values for every profile, so only the amount gets a slider. */
+#define SF_GLARE_ROUGHNESS 0.7f
+#define SF_GLARE_BLUR_PX 0.5f
+#define SF_GRAIN_BLUR_FACTOR 0.8f
+#define SF_GRAIN_SIZE_MIN 0.05f
+/* Upstream's GrainParams.blur_dye_clouds_um (params_schema.py): a SECOND,
+ * per-sub-layer blur applied to the raw particle draw INSIDE the particle
+ * sampler itself (layer_particle_model in grain.py), before the main
+ * clump blur above ever runs -- sigma = SF_GRAIN_DYE_BLUR_UM *
+ * sqrt(od_particle), where od_particle = dmax/npart is that sub-layer's
+ * own per-particle optical density. Passed through verbatim (no
+ * pixel_um conversion anywhere in the reference's own call chain,
+ * despite the "_um" name) -- ported as literally as upstream computes it
+ * rather than second-guessing the naming. No variance-restoration
+ * afterward either, same as the main clump blur. */
+#define SF_GRAIN_DYE_BLUR_UM 2.0f
+/* Push/pull processing is really two things happening together: shooting
+ * at an effective ISO different from box speed (already modeled via
+ * exposure_ev), plus extended/reduced development time, which increases
+ * or decreases contrast -- the gamma knob. There's no single fixed
+ * physical constant for how much contrast one stop of push buys (it
+ * depends on the specific film/developer combination, which isn't
+ * modeled here), so this is a documented approximation: each stop
+ * multiplies gamma by this factor, a commonly-cited rule of thumb
+ * (roughly a 15% contrast increase per stop). Compounds naturally across
+ * multiple stops (push 2 = factor^2), which suits gamma being a
+ * multiplicative quantity in this model to begin with. */
+#define SF_PUSH_PULL_GAMMA_PER_STOP 1.15f
+#define SF_HALO_SIGMAS 4.0f
+/* DIR coupler inhibitor diffusion; spektrafilm params_schema
+ dir_couplers.diffusion_size_um default (a plain gaussian in the reference) */
+
+#define SF_NAME_LEN 128
+#define SF_PATH_LEN 1024
+
+typedef enum dt_iop_spektrafilm_quality_t
+{
+ DT_SPEKTRAFILM_Q_DRAFT = 0, // $DESCRIPTION: "draft (17³ table)"
+ DT_SPEKTRAFILM_Q_STANDARD = 1, // $DESCRIPTION: "standard (33³ table)"
+ DT_SPEKTRAFILM_Q_HIGH = 2, // $DESCRIPTION: "high (49³ table)"
+ DT_SPEKTRAFILM_Q_EXACT = 3, // $DESCRIPTION: "exact spectral (very slow)"
+} dt_iop_spektrafilm_quality_t;
+
+/* order must match SF_DIFF_FAMILIES[] in spektra_core.c */
+typedef enum dt_iop_spektrafilm_diffusion_family_t
+{
+ DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST = 0, // $DESCRIPTION: "black pro-mist"
+ DT_SPEKTRAFILM_DIFF_GLIMMERGLASS = 1, // $DESCRIPTION: "glimmerglass"
+ DT_SPEKTRAFILM_DIFF_PRO_MIST = 2, // $DESCRIPTION: "pro-mist"
+ DT_SPEKTRAFILM_DIFF_CINEBLOOM = 3, // $DESCRIPTION: "cinebloom"
+} dt_iop_spektrafilm_diffusion_family_t;
+
+typedef struct dt_iop_spektrafilm_params_t
+{
+ uint32_t film_hash; // $DEFAULT: 0 (0 = first available filming stock)
+ /* Identity of the spectral upsampling table this edit was developed against.
+ Upstream revises it often and every revision changes the render, so an edit
+ reopened against a different one is reported rather than silently rendering
+ differently. 0 means "not recorded" (an edit older than this field, or one
+ made while no pack was loaded) and never warns. Diagnostic only -- nothing
+ downstream reads it. */
+ uint32_t lut_hash; // $DEFAULT: 0
+ uint32_t paper_hash; // $DEFAULT: 0 (0 = the film's target print stock)
+ float exposure_ev; // $MIN: -4.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "film exposure"
+ /* "compensation" because it is an offset either way: with auto print
+ exposure on it shifts the automatic result rather than being ignored, which
+ the bare name implied. */
+ float print_exposure_ev; // $MIN: -3.0 $MAX: 3.0 $DEFAULT: 0.0 $DESCRIPTION: "print exposure compensation"
+ gboolean print_auto_exposure; // $DEFAULT: FALSE $DESCRIPTION: "auto print exposure"
+ float print_contrast; // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print contrast"
+ float filter_m; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration M"
+ float filter_y; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration Y"
+ float couplers_amount; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 1.0 $DESCRIPTION: "DIR couplers"
+ float preflash_exposure; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash exposure"
+ float preflash_m_shift; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash M filter shift"
+ float preflash_y_shift; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash Y filter shift"
+ gboolean scan_film; // $DEFAULT: FALSE $DESCRIPTION: "scan the film (skip print)"
+ dt_iop_spektrafilm_quality_t quality; // $DEFAULT: DT_SPEKTRAFILM_Q_STANDARD $DESCRIPTION: "quality"
+ gboolean halation_on; // $DEFAULT: TRUE $DESCRIPTION: "enable halation"
+ float scatter_amount; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter amount"
+ float scatter_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter size"
+ float halation_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "halation strength"
+ float halation_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "halation size"
+ float boost_ev; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 0.0 $DESCRIPTION: "highlight boost"
+ float boost_range; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.3 $DESCRIPTION: "boost range"
+ float protect_ev; // $MIN: 0.0 $MAX: 6.0 $DEFAULT: 4.0 $DESCRIPTION: "boost protect"
+ gboolean diffusion_on; // $DEFAULT: FALSE $DESCRIPTION: "enable diffusion filter"
+ dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; // $DEFAULT: DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST $DESCRIPTION: "diffusion filter type"
+ float diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "diffusion strength"
+ float diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "diffusion size"
+ float diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "diffusion halo warmth"
+ gboolean print_diffusion_on; // $DEFAULT: FALSE $DESCRIPTION: "enable print diffusion"
+ dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; // $DEFAULT: DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST $DESCRIPTION: "print diffusion filter type"
+ float print_diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "print diffusion strength"
+ float print_diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "print diffusion size"
+ float print_diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "print diffusion halo warmth"
+ gboolean grain_on; // $DEFAULT: TRUE $DESCRIPTION: "enable grain"
+ float grain_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain strength"
+ float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size"
+ /* The format combobox above picks a film GAUGE (35mm); this is the frame's
+ LONG EDGE (36 mm). Both are right and both were called "format", which read
+ as the preset contradicting the slider. */
+ float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "frame long edge"
+ float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost"
+ float grain_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "grain recovery sharpness"
+ float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.5 $DESCRIPTION: "grain recovery strength"
+ float film_gamma_factor; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "development gamma"
+ float film_gamma_factor_fast; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "fast layer gamma"
+ float film_gamma_factor_slow; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "slow layer gamma"
+ float film_developer_exhaustion; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "developer exhaustion"
+ float push_pull_stops; // $MIN: -4.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "push/pull"
+ float scan_blur; // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "scanner blur"
+ float scan_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpness"
+ float scan_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpen strength"
+ /* Off by default. Glare is a viewing-condition simulation, not a film
+ property, and it is the last thing in the chain: leaving it on lifts the
+ black point of every render, which hides the real per-paper black points
+ while you are still judging them. The reference defaults it on because it
+ renders finished images; this is an editing tool. Note that this is NOT
+ the same reasoning as scan_usm_amount, which does default to the
+ reference's own 0.7 -- sharpening is part of what a scan looks like,
+ whereas glare is a property of the room the print is viewed in. */
+ float glare_percent; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "viewing glare"
+ float development_min; // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "development time"
+ float print_development_min; // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "development time"
+ /* The two halves of the hanatos2025 sensitivity adaptation, each defaulting
+ the way the reference resolves it. Bandwidth on: the filming stage agrees
+ with the reference to 4e-15 that way. Surface off: the film profiles carry
+ the correction surface and enable it, but the reference runtime's own
+ setting disables it and wins, so its renders are made without it. Both are
+ left switchable because the coefficients are part of the profile data and
+ the upstream defaults may flip; see spektra_sim.h. */
+ gboolean adaptation_bandwidth; // $DEFAULT: TRUE $DESCRIPTION: "bandwidth adaptation"
+ gboolean adaptation_surface; // $DEFAULT: FALSE $DESCRIPTION: "surface adaptation"
+} dt_iop_spektrafilm_params_t;
+
+/* one discovered profile: stock (= file base name), display name, stage */
+typedef struct sf_prof_entry_t
+{
+ char stock[SF_NAME_LEN];
+ char name[SF_NAME_LEN];
+ char target_print[SF_NAME_LEN];
+ gboolean printing; /* stage == "printing" */
+ gboolean positive; /* info.type == "positive" (slide / reversal) */
+ gboolean bw; /* channel_model == "bw" */
+ /* development times this stock is characterised at; n_dev == 0 means a single
+ characterisation, i.e. nothing for the development slider to choose */
+ int n_dev;
+ double dev_times[SF_MAX_DEV_TIMES];
+ uint32_t hash;
+} sf_prof_entry_t;
+
+typedef struct dt_iop_spektrafilm_gui_data_t
+{
+ GtkWidget *film, *paper;
+ GtkWidget *output_boost;
+ GtkWidget *film_format_combo, *film_format_mm_slider;
+ GtkWidget *exposure_ev, *scan_film;
+ GtkWidget *push_pull_stops, *film_gamma_factor;
+ GtkWidget *film_gamma_factor_fast, *film_gamma_factor_slow, *film_developer_exhaustion;
+ GtkWidget *quality, *adaptation_bandwidth, *adaptation_surface;
+ GtkWidget *print_exposure_ev, *print_auto_exposure, *print_contrast;
+ GtkWidget *filter_m, *filter_y, *couplers_amount;
+ GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift;
+ GtkWidget *grain_on, *grain_amount, *grain_size;
+ GtkWidget *scan_blur, *scan_usm_sigma, *scan_usm_amount, *glare_percent;
+ GtkWidget *development_min, *print_development_min;
+ GtkWidget *grain_usm_sigma, *grain_usm_amount;
+ GtkWidget *halation_on, *scatter_amount, *scatter_scale, *halation_amount, *halation_scale;
+ GtkWidget *boost_ev, *boost_range, *protect_ev;
+ GtkWidget *diffusion_on, *diffusion_filter_family, *diffusion_strength, *diffusion_scale, *diffusion_warmth;
+ GtkWidget *print_diffusion_on, *print_diffusion_filter_family;
+ GtkWidget *print_diffusion_strength, *print_diffusion_scale, *print_diffusion_warmth;
+
+ /* every profile found on disk, sorted, films and papers together --
+ e->printing separates them. Owns its sf_prof_entry_t nodes. */
+ GList *entries;
+ GtkNotebook *notebook;
+
+ /* data-pack row in the header: a button and a status line, both hidden while
+ the installed pack already satisfies the edit. Shown only when there is
+ something to do, so the common case stays a clean two-combobox header. */
+ /* main_box holds everything except the data-pack row, so the whole module can
+ be collapsed to just that row while no usable pack exists. */
+ GtkWidget *main_box;
+ GtkWidget *data_box, *data_button, *data_status;
+ guint data_poll; /* g_timeout id while a download runs, 0 otherwise */
+ uint32_t data_wanted; /* spectral table the button will ask for */
+ sf_fetch_state_t data_last_state; /* to spot the moment a fetch finishes */
+} dt_iop_spektrafilm_gui_data_t;
+
+static const struct { const char *label; float mm; } _format_presets[] = {
+ { "half-frame", 24.0f }, { "35mm", 36.0f }, { "6x6", 56.0f },
+ { "6x7", 69.0f }, { "6x9", 84.0f }, { "4x5", 120.0f },
+ { "8x10", 244.0f },
+ { "Super 8", 5.79f }, { "16mm", 10.26f }, { "Super 16", 12.52f },
+ { "Super 35", 24.89f }, { "VistaVision", 37.72f },
+ { "65mm 5-perf", 52.63f }, { "IMAX 15-perf", 69.6f },
+};
+#define FORMAT_PRESETS_N ((int)(sizeof(_format_presets) / sizeof(_format_presets[0])))
+#define FORMAT_PRESET_CUSTOM FORMAT_PRESETS_N
+
+static int _format_mm_to_preset(float mm)
+{
+ for(int i = 0; i < FORMAT_PRESETS_N; i++)
+ if(fabsf(_format_presets[i].mm - mm) < 0.01f) return i;
+ return FORMAT_PRESET_CUSTOM;
+}
+
+static void _format_changed(GtkWidget *combo, gpointer user_data)
+{
+ dt_iop_module_t *self = (dt_iop_module_t *)user_data;
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
+ const int pi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film_format_combo));
+ if(pi >= 0 && pi < FORMAT_PRESETS_N)
+ {
+ DT_ENTER_GUI_UPDATE();
+ dt_bauhaus_slider_set(g->film_format_mm_slider, _format_presets[pi].mm);
+ DT_LEAVE_GUI_UPDATE();
+ p->film_format_mm = _format_presets[pi].mm;
+ dt_dev_add_history_item(darktable.develop, self, TRUE);
+ }
+ /* The millimetre slider only means anything on "custom" -- on a preset it is
+ a read-only echo, and one that reads as a contradiction ("35mm" setting
+ "36 mm") because the preset names a film GAUGE while the slider is the
+ frame's LONG EDGE. Showing it only when it is editable removes both the
+ row and the confusion. */
+ gtk_widget_set_visible(g->film_format_mm_slider, pi < 0 || pi >= FORMAT_PRESETS_N);
+}
+
+static void _format_slider_changed(GtkWidget *slider, gpointer user_data)
+{
+ dt_iop_module_t *self = (dt_iop_module_t *)user_data;
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ const float mm = dt_bauhaus_slider_get(g->film_format_mm_slider);
+ dt_bauhaus_combobox_set_from_value(g->film_format_combo, _format_mm_to_preset(mm));
+}
+
+static void _populate_format_combo(dt_iop_module_t *self)
+{
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ dt_bauhaus_combobox_clear(g->film_format_combo);
+ dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "still"));
+ for(int i = 0; i < 7; i++)
+ dt_bauhaus_combobox_add_full(g->film_format_combo, _( _format_presets[i].label ),
+ DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(i), NULL, TRUE);
+ dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "cine"));
+ for(int i = 7; i < FORMAT_PRESETS_N; i++)
+ dt_bauhaus_combobox_add_full(g->film_format_combo, _( _format_presets[i].label ),
+ DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(i), NULL, TRUE);
+ dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "custom"));
+ dt_bauhaus_combobox_add_full(g->film_format_combo, _("custom"),
+ DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(FORMAT_PRESETS_N), NULL, TRUE);
+}
+
+/* per-piece data: parameter snapshot + a lazily (re)built simulation.
+ The sim depends on the pipe's work profile, which is only reliably known in
+ process(), so the build happens there guarded by a mutex. */
+typedef struct dt_iop_spektrafilm_data_t
+{
+ dt_iop_spektrafilm_params_t p;
+ /* engine cache */
+ dt_pthread_mutex_t lock;
+ sf_sim_t *sim;
+ sf_sim_gpu_t *gpu; /* float tables for process_cl; NULL for exact quality */
+ uint64_t sim_key; /* hash of everything the sim build depends on */
+ char sim_error[256];
+ char sim_warning[256];
+ /* multi-sublayer grain GPU constant buffers (see process_cl's grain
+ stage): built from d->gpu's grain_layer_* tables, which only change
+ when d->gpu itself is rebuilt (a new film/paper/quality choice), never
+ per-tile. Cached here and keyed on the `gpu` pointer they were built
+ from AND on the device they were built on, instead of being re-uploaded
+ on every process_cl() call -- tiled processing calls process_cl() once
+ per tile, so uploading these fresh every time was pure per-tile overhead
+ for data that never changes between tiles of the same image.
+
+ The device is part of the key because a cl_mem belongs to the context
+ that created it. piece->data lives as long as the pipe, but pipe->devid
+ is reassigned by dt_opencl_lock_device() on every run -- that is what the
+ device pool is for -- so on a machine with more than one OpenCL device a
+ pipe can upload these on one device and, next run, hand them to a kernel
+ on another. The handles are still valid, just foreign, and clSetKernelArg
+ dereferences them inside the driver: it segfaults there rather than
+ returning an error, so there is nothing to check afterwards. */
+ const sf_sim_gpu_t *grain_cl_built_for;
+ int grain_cl_devid;
+ cl_mem grain_cl_dmax, grain_cl_npart, grain_cl_dmin, grain_cl_total, grain_cl_curve;
+} dt_iop_spektrafilm_data_t;
+
+typedef struct dt_iop_spektrafilm_global_data_t
+{
+ int kernel_expose, kernel_lograw, kernel_develop_corr, kernel_develop;
+ int kernel_grain_gen_raw_sl, kernel_grain_accumulate_1c, kernel_grain_finalize_channel,
+ kernel_grain_add, kernel_grain_usm;
+ int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough;
+ int kernel_crop_out, kernel_scan_usm, kernel_glare_gen, kernel_glare_add;
+ int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply;
+ int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c;
+ int kernel_yvv_row_4c, kernel_yvv_col_4c, kernel_yvv_row_1c, kernel_yvv_col_1c;
+ int kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix;
+} dt_iop_spektrafilm_global_data_t;
+
+/* the data pack is large (spectra LUT ~12 MB) and shared by all pieces;
+ load it once per process (lazily, under _pack_lock), freed in
+ cleanup_global(). Kept in module-static storage rather than global_data so
+ every pipe sees the same pack. */
+static sf_pack_t *_pack = NULL;
+/* Directory _pack was loaded from. Which directory that is depends on the edit:
+ an image developed against an older spectral table resolves to a different
+ pack than one developed against the current one. Keeping the path lets
+ _ensure_sim() notice the resolved directory has changed and reload, and lets
+ _scan_profiles() read profiles out of the SAME pack rather than always out of
+ the config directory -- profiles and spectral table have to come from one
+ place or the film list will not match the data behind it. */
+static char _pack_path[SF_PATH_LEN] = { 0 };
+/* Value of sf_fetch_generation() when _pack / _pack_error were last decided.
+ A download can install a pack into a directory this module already probed
+ and rejected, which leaves the resolved path unchanged -- so a path
+ comparison alone cannot tell that the answer has changed. */
+static guint _pack_gen = 0;
+static char _pack_error[256] = { 0 };
+static dt_pthread_mutex_t _pack_lock;
+
+void init_global(dt_iop_module_so_t *self)
+{
+ dt_pthread_mutex_init(&_pack_lock, NULL);
+ sf_fetch_init();
+ const int program = 42; /* spektrafilm.cl in data/kernels/programs.conf */
+ dt_iop_spektrafilm_global_data_t *gd = malloc(sizeof(dt_iop_spektrafilm_global_data_t));
+ self->data = gd;
+ gd->kernel_expose = dt_opencl_create_kernel(program, "spektrafilm_expose");
+ gd->kernel_lograw = dt_opencl_create_kernel(program, "spektrafilm_lograw");
+ gd->kernel_develop_corr = dt_opencl_create_kernel(program, "spektrafilm_develop_corr");
+ gd->kernel_develop = dt_opencl_create_kernel(program, "spektrafilm_develop");
+ gd->kernel_grain_gen_raw_sl = dt_opencl_create_kernel(program, "spektrafilm_grain_gen_raw_sl");
+ gd->kernel_grain_accumulate_1c = dt_opencl_create_kernel(program, "spektrafilm_grain_accumulate_1c");
+ gd->kernel_grain_finalize_channel
+ = dt_opencl_create_kernel(program, "spektrafilm_grain_finalize_channel");
+ gd->kernel_grain_add = dt_opencl_create_kernel(program, "spektrafilm_grain_add");
+ gd->kernel_grain_usm = dt_opencl_create_kernel(program, "spektrafilm_grain_usm");
+ gd->kernel_print_expose = dt_opencl_create_kernel(program, "spektrafilm_print_expose");
+ gd->kernel_print_develop = dt_opencl_create_kernel(program, "spektrafilm_print_develop");
+ gd->kernel_scan = dt_opencl_create_kernel(program, "spektrafilm_scan");
+ gd->kernel_crop_out = dt_opencl_create_kernel(program, "spektrafilm_crop_out");
+ gd->kernel_scan_usm = dt_opencl_create_kernel(program, "spektrafilm_scan_usm");
+ gd->kernel_glare_gen = dt_opencl_create_kernel(program, "spektrafilm_glare_gen");
+ gd->kernel_glare_add = dt_opencl_create_kernel(program, "spektrafilm_glare_add");
+ gd->kernel_passthrough = dt_opencl_create_kernel(program, "spektrafilm_passthrough");
+ gd->kernel_scatter_combine = dt_opencl_create_kernel(program, "spektrafilm_scatter_combine");
+ gd->kernel_accum = dt_opencl_create_kernel(program, "spektrafilm_accum");
+ gd->kernel_channel_extract = dt_opencl_create_kernel(program, "spektrafilm_channel_extract");
+ gd->kernel_yvv_row_4c = dt_opencl_create_kernel(program, "spektrafilm_yvv_row_4c");
+ gd->kernel_yvv_col_4c = dt_opencl_create_kernel(program, "spektrafilm_yvv_col_4c");
+ gd->kernel_yvv_row_1c = dt_opencl_create_kernel(program, "spektrafilm_yvv_row_1c");
+ gd->kernel_yvv_col_1c = dt_opencl_create_kernel(program, "spektrafilm_yvv_col_1c");
+ gd->kernel_gauss_row_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_4c");
+ gd->kernel_gauss_col_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_4c");
+ gd->kernel_gauss_row_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_1c");
+ gd->kernel_gauss_col_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_1c");
+ gd->kernel_channel_accum = dt_opencl_create_kernel(program, "spektrafilm_channel_accum");
+ gd->kernel_halation_apply = dt_opencl_create_kernel(program, "spektrafilm_halation_apply");
+ gd->kernel_boost = dt_opencl_create_kernel(program, "spektrafilm_boost");
+ gd->kernel_diffusion_accum = dt_opencl_create_kernel(program, "spektrafilm_diffusion_accum");
+ gd->kernel_diffusion_mix = dt_opencl_create_kernel(program, "spektrafilm_diffusion_mix");
+}
+
+void cleanup_global(dt_iop_module_so_t *self)
+{
+ dt_iop_spektrafilm_global_data_t *gd = (dt_iop_spektrafilm_global_data_t *)self->data;
+ if(gd)
+ {
+ dt_opencl_free_kernel(gd->kernel_expose);
+ dt_opencl_free_kernel(gd->kernel_lograw);
+ dt_opencl_free_kernel(gd->kernel_develop_corr);
+ dt_opencl_free_kernel(gd->kernel_develop);
+ dt_opencl_free_kernel(gd->kernel_grain_gen_raw_sl);
+ dt_opencl_free_kernel(gd->kernel_grain_accumulate_1c);
+ dt_opencl_free_kernel(gd->kernel_grain_finalize_channel);
+ dt_opencl_free_kernel(gd->kernel_grain_add);
+ dt_opencl_free_kernel(gd->kernel_grain_usm);
+ dt_opencl_free_kernel(gd->kernel_print_expose);
+ dt_opencl_free_kernel(gd->kernel_print_develop);
+ dt_opencl_free_kernel(gd->kernel_scan);
+ dt_opencl_free_kernel(gd->kernel_crop_out);
+ dt_opencl_free_kernel(gd->kernel_scan_usm);
+ dt_opencl_free_kernel(gd->kernel_glare_gen);
+ dt_opencl_free_kernel(gd->kernel_glare_add);
+ dt_opencl_free_kernel(gd->kernel_passthrough);
+ dt_opencl_free_kernel(gd->kernel_scatter_combine);
+ dt_opencl_free_kernel(gd->kernel_accum);
+ dt_opencl_free_kernel(gd->kernel_channel_extract);
+ dt_opencl_free_kernel(gd->kernel_yvv_row_4c);
+ dt_opencl_free_kernel(gd->kernel_yvv_col_4c);
+ dt_opencl_free_kernel(gd->kernel_yvv_row_1c);
+ dt_opencl_free_kernel(gd->kernel_yvv_col_1c);
+ dt_opencl_free_kernel(gd->kernel_gauss_row_4c);
+ dt_opencl_free_kernel(gd->kernel_gauss_col_4c);
+ dt_opencl_free_kernel(gd->kernel_gauss_row_1c);
+ dt_opencl_free_kernel(gd->kernel_gauss_col_1c);
+ dt_opencl_free_kernel(gd->kernel_channel_accum);
+ dt_opencl_free_kernel(gd->kernel_halation_apply);
+ dt_opencl_free_kernel(gd->kernel_boost);
+ dt_opencl_free_kernel(gd->kernel_diffusion_accum);
+ dt_opencl_free_kernel(gd->kernel_diffusion_mix);
+ free(self->data);
+ self->data = NULL;
+ }
+ /* Before the pack lock goes away: a running fetch reprocesses the pipe on
+ completion, so it has to be joined while there is still a pipe to touch. */
+ sf_fetch_cleanup();
+ dt_pthread_mutex_lock(&_pack_lock);
+ if(_pack)
+ {
+ sf_pack_free(_pack);
+ _pack = NULL;
+ }
+ _pack_path[0] = 0;
+ _pack_gen = 0;
+ _pack_error[0] = 0;
+ dt_pthread_mutex_unlock(&_pack_lock);
+ dt_pthread_mutex_destroy(&_pack_lock);
+}
+
+const char *name(void)
+{
+ return _("spektrafilm");
+}
+
+const char *aliases(void)
+{
+ return _("film simulation|analog|spectral|grain|halation|print");
+}
+
+const char **description(dt_iop_module_t *self)
+{
+ return dt_iop_set_description(
+ self,
+ _("simulates the physical process of developing and printing analog film,\n"
+ "using spectral emulsion and paper data from the spektrafilm project"),
+ _("creative"), _("linear, RGB, scene-referred"), _("non-linear, RGB"),
+ _("non-linear, RGB, display-referred"));
+}
+
+int default_group(void)
+{
+ /* Same grouping as the other display transforms (filmicrgb, sigmoid, agx),
+ not the grading modules. It matters beyond tidiness: "only use one display
+ transform" is the module's first piece of advice, and filing it under
+ colour put it in a different group from every module it conflicts with. */
+ return IOP_GROUP_TONE | IOP_GROUP_TECHNICAL;
+}
+
+int flags(void)
+{
+ return IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_INCLUDE_IN_STYLES | IOP_FLAGS_ALLOW_TILING;
+}
+
+dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, dt_dev_pixelpipe_t *p,
+ dt_dev_pixelpipe_iop_t *pi)
+{
+ return IOP_CS_RGB;
+}
+
+/* ---------------------------------------------------------------------- */
+/* profile discovery */
+/* ---------------------------------------------------------------------- */
+
+/* Stable identity for a profile in params: the stock name hashed, so a rescan
+ or a different machine resolves the same stock regardless of directory order.
+ Folded to 32 bits because params store it as uint32_t. */
+static uint32_t _name_hash(const char *s)
+{
+ const dt_hash_t h = dt_hash(DT_INITHASH, s, strlen(s));
+ const uint32_t h32 = (uint32_t)(h ^ (h >> 32));
+ return h32 ? h32 : 1; /* 0 is reserved for "first available" */
+}
+
+/* Where a hand-installed pack lives. Still the preferred location, and the one
+ named in the "no data pack" message, but no longer the only one: downloaded
+ packs live under the cache directory, one per spectral table. */
+static void _pack_dir(char *dst, size_t dstsz)
+{
+ char cfg[SF_PATH_LEN];
+ dt_loc_get_user_config_dir(cfg, sizeof cfg);
+ snprintf(dst, dstsz, "%s/spektrafilm", cfg);
+}
+
+/* Pick the pack directory for an edit that recorded wanted_lut_hash (0 = no
+ preference). Local lookup only -- no network, safe on the pixelpipe. Falls
+ back to the config directory so error text still names somewhere real. */
+static void _resolve_pack_dir(uint32_t wanted_lut_hash, char *dst, size_t dstsz)
+{
+ gboolean exact = FALSE;
+ const gboolean found = sf_fetch_resolve_pack_dir(wanted_lut_hash, dst, dstsz, &exact);
+ if(!found) _pack_dir(dst, dstsz);
+ /* Which directory was chosen, and why, is the first thing worth knowing when
+ the module renders nothing: it separates "no pack anywhere" from "found a
+ pack, but it failed to load". */
+ dt_print(DT_DEBUG_DEV,
+ "[spektrafilm] pack for table %08x -> %s (%s)\n", wanted_lut_hash, dst,
+ !found ? "nothing found, falling back"
+ : exact ? "exact match"
+ : !wanted_lut_hash ? "no table recorded, taking what is installed"
+ : "table mismatch, using anyway");
+}
+
+/* natural (human) string compare: embedded numbers compared numerically
+ so "Vision3 50D" < "Vision3 200T" < "Vision3 500T" */
+static int _nat_cmp(const char *a, const char *b)
+{
+ for(;;)
+ {
+ if(*a == 0) return *b == 0 ? 0 : -1;
+ if(*b == 0) return 1;
+ int da = (unsigned)*a - '0' < 10u;
+ int db = (unsigned)*b - '0' < 10u;
+ if(da && db)
+ {
+ unsigned long va = 0, vb = 0;
+ while((unsigned)*a - '0' < 10u) { va = va * 10 + (*a - '0'); a++; }
+ while((unsigned)*b - '0' < 10u) { vb = vb * 10 + (*b - '0'); b++; }
+ if(va != vb) return va < vb ? -1 : 1;
+ }
+ else if(da != db)
+ return da ? -1 : 1;
+ else
+ {
+ int ca = g_ascii_tolower(*a);
+ int cb = g_ascii_tolower(*b);
+ if(ca != cb) return ca < cb ? -1 : 1;
+ a++; b++;
+ }
+ }
+}
+
+/* Trim filler out of a profile's display name, in place.
+
+ The comboboxes are as wide as darktable's right panel and no wider, so a name
+ like "Kodak Professional Portra Endura" truncates to "Kodak Professional ..."
+ -- and four papers share that prefix, so the list became four identical
+ entries. Only genuinely redundant words go: "Professional" says nothing, and
+ "Negative Film" / "Reversal Film" duplicate the section heading the entry is
+ already filed under. Everything that identifies the stock stays, including
+ "Print Film 2302", where it is part of the name people know. */
+static void _shorten_name(char *s, const size_t sz)
+{
+ static const char *const filler[] = { " Professional", " Negative Film", " Reversal Film" };
+ for(size_t f = 0; f < sizeof(filler) / sizeof(*filler); f++)
+ {
+ char *at = strstr(s, filler[f]);
+ if(!at) continue;
+ const size_t flen = strlen(filler[f]);
+ memmove(at, at + flen, strlen(at + flen) + 1);
+ }
+ (void)sz;
+}
+
+static gint _entry_name_cmp(gconstpointer a, gconstpointer b)
+{
+ return _nat_cmp(((const sf_prof_entry_t *)a)->name, ((const sf_prof_entry_t *)b)->name);
+}
+
+/* scan /profiles/ (all .json files); reads only the info header of
+ each profile (stock / name / stage / target_print). Returns a newly allocated
+ list of sf_prof_entry_t, sorted by name, which the caller owns.
+
+ packdir may be NULL, which means "whichever pack is currently loaded, or the
+ one a fresh edit would resolve to". Profiles must come from the same pack as
+ the spectral table: a profile names a stock, and the pack holds that stock's
+ digested render defaults, so mixing the two silently drops per-film halation,
+ grain and coupler data for any stock the other side has never heard of. */
+static GList *_scan_profiles(const char *packdir)
+{
+ char dir[SF_PATH_LEN];
+ if(packdir && *packdir)
+ g_strlcpy(dir, packdir, sizeof dir);
+ else
+ {
+ dt_pthread_mutex_lock(&_pack_lock);
+ /* _pack_path also records directories that failed to load, so it only
+ answers "which pack is in use" when one is actually held. */
+ const gboolean have = _pack && _pack_path[0] != 0;
+ if(have) g_strlcpy(dir, _pack_path, sizeof dir);
+ dt_pthread_mutex_unlock(&_pack_lock);
+ if(!have) _resolve_pack_dir(0, dir, sizeof dir);
+ }
+ char profdir[SF_PATH_LEN + 16];
+ snprintf(profdir, sizeof profdir, "%s/profiles", dir);
+
+ GDir *gd = g_dir_open(profdir, 0, NULL);
+ if(!gd) return NULL;
+ GList *list = NULL;
+ const char *fn;
+ while((fn = g_dir_read_name(gd)))
+ {
+ if(!g_str_has_suffix(fn, ".json")) continue;
+ char path[SF_PATH_LEN + 300];
+ snprintf(path, sizeof path, "%s/%s", profdir, fn);
+ char *err = NULL;
+ sf_profile_t *prof = sf_profile_load(path, 0.5f, &err); /* info header only */
+ if(!prof)
+ {
+ free(err);
+ continue;
+ }
+ sf_prof_entry_t *e = g_malloc0(sizeof(sf_prof_entry_t));
+ g_strlcpy(e->stock, sf_profile_stock(prof) ? sf_profile_stock(prof) : fn, SF_NAME_LEN);
+ /* strip .json when falling back to the file name */
+ char *dot = strstr(e->stock, ".json");
+ if(dot) *dot = 0;
+ g_strlcpy(e->name, sf_profile_name(prof) ? sf_profile_name(prof) : e->stock, SF_NAME_LEN);
+ const char *stage = sf_profile_stage(prof);
+ e->printing = (stage && !strcmp(stage, "printing"));
+ const char *tp = sf_profile_target_print(prof);
+ if(tp) g_strlcpy(e->target_print, tp, SF_NAME_LEN);
+ const char *type = sf_profile_type(prof);
+ e->positive = (type && !strcmp(type, "positive"));
+ _shorten_name(e->name, sizeof e->name);
+ const char *cm = sf_profile_channel_model(prof);
+ e->bw = (cm && !strcmp(cm, "bw"));
+ e->n_dev = sf_profile_dev_times(prof, e->dev_times, SF_MAX_DEV_TIMES);
+ e->hash = _name_hash(e->stock);
+ sf_profile_free(prof);
+ list = g_list_prepend(list, e);
+ }
+ g_dir_close(gd);
+ /* natural order by display name (numbers compared numerically,
+ so "50D" < "200T" instead of lexicographic "200T" < "50D") */
+ return g_list_sort(list, _entry_name_cmp);
+}
+
+/* resolve a profile hash to its stock name. hash 0 -> default:
+ for films the first filming stock, for papers prefer the film's
+ target_print. Returns false when nothing matches. */
+static gboolean _resolve_stock(GList *entries, uint32_t hash, gboolean want_printing,
+ const char *prefer_stock, char *dst, size_t dstsz)
+{
+ if(hash)
+ for(GList *l = entries; l; l = l->next)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(e->hash == hash && e->printing == want_printing)
+ {
+ g_strlcpy(dst, e->stock, dstsz);
+ return TRUE;
+ }
+ }
+ if(prefer_stock && prefer_stock[0])
+ for(GList *l = entries; l; l = l->next)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(e->printing == want_printing && !strcmp(e->stock, prefer_stock))
+ {
+ g_strlcpy(dst, e->stock, dstsz);
+ return TRUE;
+ }
+ }
+ for(GList *l = entries; l; l = l->next)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(e->printing == want_printing)
+ {
+ g_strlcpy(dst, e->stock, dstsz);
+ return TRUE;
+ }
+ }
+ return FALSE;
+}
+
+/* ---------------------------------------------------------------------- */
+/* pipeline plumbing */
+/* ---------------------------------------------------------------------- */
+
+void init_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece)
+{
+ dt_iop_spektrafilm_data_t *d = calloc(1, sizeof(dt_iop_spektrafilm_data_t));
+ dt_pthread_mutex_init(&d->lock, NULL);
+ piece->data = d;
+}
+
+void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece)
+{
+ dt_iop_spektrafilm_data_t *d = (dt_iop_spektrafilm_data_t *)piece->data;
+ if(d)
+ {
+ if(d->gpu) sf_sim_gpu_free(d->gpu);
+ if(d->sim) sf_sim_free(d->sim);
+ if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax);
+ if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart);
+ if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin);
+ if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total);
+ if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve);
+ dt_pthread_mutex_destroy(&d->lock);
+ }
+ free(piece->data);
+ piece->data = NULL;
+}
+
+void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_t *pipe,
+ dt_dev_pixelpipe_iop_t *piece)
+{
+ dt_iop_spektrafilm_data_t *d = (dt_iop_spektrafilm_data_t *)piece->data;
+ d->p = *(dt_iop_spektrafilm_params_t *)p1;
+ /* the sim itself is (re)built lazily in process(), where the pipe's work
+ profile is reliably known; a stale sim is detected via sim_key there. */
+ /* exact-spectral quality has no GPU kernels: stay on the CPU path */
+ if(d->p.quality == DT_SPEKTRAFILM_Q_EXACT) piece->process_cl_ready = FALSE;
+}
+
+static uint64_t _mix64(uint64_t h, const void *data, size_t len)
+{
+ const unsigned char *p = data;
+ for(size_t i = 0; i < len; i++)
+ {
+ h ^= p[i];
+ h *= 0x100000001b3ULL; /* FNV-1a 64 */
+ }
+ return h;
+}
+
+static int _quality_steps(dt_iop_spektrafilm_quality_t q)
+{
+ switch(q)
+ {
+ case DT_SPEKTRAFILM_Q_DRAFT: return 17;
+ case DT_SPEKTRAFILM_Q_HIGH: return 49;
+ case DT_SPEKTRAFILM_Q_EXACT: return 0; /* exact spectral, no table */
+ case DT_SPEKTRAFILM_Q_STANDARD:
+ default: return 33;
+ }
+}
+
+/* make sure d->sim matches the current params + work profile; returns the sim
+ or NULL (passthrough). Called from process() under no assumption of being
+ single-threaded (full/preview pipes run concurrently). */
+static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d,
+ const dt_iop_order_iccprofile_info_t *work_profile)
+{
+ const dt_iop_spektrafilm_params_t *p = &d->p;
+
+ /* the work profile's RGB<->XYZ matrices feed the engine; include them in
+ the cache key so a work-profile change rebuilds the sim */
+ float m_in[9], m_out[9];
+ for(int i = 0; i < 3; i++)
+ for(int j = 0; j < 3; j++)
+ {
+ /* dt_colormatrix_t, row-major: XYZ_i = sum_j matrix_in[i][j] * RGB_j */
+ m_in[i * 3 + j] = work_profile->matrix_in[i][j];
+ m_out[i * 3 + j] = work_profile->matrix_out[i][j];
+ }
+
+ uint64_t key = 0xcbf29ce484222325ULL;
+ key = _mix64(key, &p->film_hash, sizeof p->film_hash);
+ key = _mix64(key, &p->lut_hash, sizeof p->lut_hash);
+ key = _mix64(key, &p->paper_hash, sizeof p->paper_hash);
+ key = _mix64(key, &p->exposure_ev, sizeof p->exposure_ev);
+ key = _mix64(key, &p->print_exposure_ev, sizeof p->print_exposure_ev);
+ key = _mix64(key, &p->print_auto_exposure, sizeof p->print_auto_exposure);
+ key = _mix64(key, &p->print_contrast, sizeof p->print_contrast);
+ key = _mix64(key, &p->filter_m, sizeof p->filter_m);
+ key = _mix64(key, &p->filter_y, sizeof p->filter_y);
+ key = _mix64(key, &p->couplers_amount, sizeof p->couplers_amount);
+ key = _mix64(key, &p->preflash_exposure, sizeof p->preflash_exposure);
+ key = _mix64(key, &p->preflash_m_shift, sizeof p->preflash_m_shift);
+ key = _mix64(key, &p->preflash_y_shift, sizeof p->preflash_y_shift);
+ key = _mix64(key, &p->scan_film, sizeof p->scan_film);
+ /* selects which member of a B&W development-time family the curve model is
+ built from, so it has to be part of the sim's cache key */
+ key = _mix64(key, &p->development_min, sizeof p->development_min);
+ key = _mix64(key, &p->print_development_min, sizeof p->print_development_min);
+ key = _mix64(key, &p->quality, sizeof p->quality);
+ /* both change the tc LUT, which is built once per sim */
+ key = _mix64(key, &p->adaptation_bandwidth, sizeof p->adaptation_bandwidth);
+ key = _mix64(key, &p->adaptation_surface, sizeof p->adaptation_surface);
+ key = _mix64(key, &p->output_luminance_boost, sizeof p->output_luminance_boost);
+ key = _mix64(key, &p->film_gamma_factor, sizeof p->film_gamma_factor);
+ key = _mix64(key, &p->film_gamma_factor_fast, sizeof p->film_gamma_factor_fast);
+ key = _mix64(key, &p->film_gamma_factor_slow, sizeof p->film_gamma_factor_slow);
+ key = _mix64(key, &p->film_developer_exhaustion, sizeof p->film_developer_exhaustion);
+ key = _mix64(key, &p->push_pull_stops, sizeof p->push_pull_stops);
+ key = _mix64(key, m_in, sizeof m_in);
+ key = _mix64(key, m_out, sizeof m_out);
+
+ dt_pthread_mutex_lock(&d->lock);
+ if(d->sim && d->sim_key == key)
+ {
+ sf_sim_t *s = d->sim;
+ dt_pthread_mutex_unlock(&d->lock);
+ return s;
+ }
+
+ /* (re)build */
+ if(d->gpu)
+ {
+ sf_sim_gpu_free(d->gpu);
+ d->gpu = NULL;
+ /* these were uploaded from this gpu's grain_layer_* tables; release them
+ now so process_cl() re-uploads fresh ones from the new gpu instead of
+ reusing now-stale content. */
+ if(d->grain_cl_dmax) { dt_opencl_release_mem_object(d->grain_cl_dmax); d->grain_cl_dmax = NULL; }
+ if(d->grain_cl_npart) { dt_opencl_release_mem_object(d->grain_cl_npart); d->grain_cl_npart = NULL; }
+ if(d->grain_cl_dmin) { dt_opencl_release_mem_object(d->grain_cl_dmin); d->grain_cl_dmin = NULL; }
+ if(d->grain_cl_total) { dt_opencl_release_mem_object(d->grain_cl_total); d->grain_cl_total = NULL; }
+ if(d->grain_cl_curve) { dt_opencl_release_mem_object(d->grain_cl_curve); d->grain_cl_curve = NULL; }
+ d->grain_cl_built_for = NULL;
+ }
+ if(d->sim)
+ {
+ sf_sim_free(d->sim);
+ d->sim = NULL;
+ }
+ d->sim_key = key;
+ d->sim_error[0] = 0;
+ d->sim_warning[0] = 0;
+
+ /* Global pack, loaded once per resolved directory.
+ Which directory that is depends on the spectral table this edit recorded,
+ so switching to an image developed against a different table reloads rather
+ than silently rendering it with the wrong data. One pack is held at a time:
+ the LUT is ~12 MB, and having two images from different table generations
+ open in the same session is rare enough that the reload costs less than
+ permanently carrying every pack the user has on disk. */
+ char want_dir[SF_PATH_LEN];
+ _resolve_pack_dir(p->lut_hash, want_dir, sizeof want_dir);
+
+ const guint gen = sf_fetch_generation();
+
+ dt_pthread_mutex_lock(&_pack_lock);
+ /* The loaded pack and the error from failing to load both belong to one
+ directory at one point in time, so both go stale on either axis: the
+ resolved directory changing, or a download changing what that directory
+ holds. Testing only the first, and only while a pack was actually loaded,
+ left _pack_error latched forever after the first failure -- so a pack
+ downloaded mid-session was never picked up and the image went on
+ rendering against a failure recorded before the pack existed. */
+ if(strcmp(_pack_path, want_dir) != 0 || _pack_gen != gen)
+ {
+ if(_pack) sf_pack_free(_pack);
+ _pack = NULL;
+ _pack_error[0] = 0;
+ _pack_path[0] = 0;
+ }
+ if(!_pack && !_pack_error[0])
+ {
+ char *err = NULL;
+ _pack = sf_pack_load(want_dir, &err);
+ /* Record the attempt on failure too: _pack_path is what the staleness
+ check above compares against, and leaving it empty after a failed load
+ would make every later call look stale and retry the same doomed load
+ once per pipe run. */
+ g_strlcpy(_pack_path, want_dir, sizeof _pack_path);
+ _pack_gen = gen;
+ if(!_pack)
+ {
+ g_strlcpy(_pack_error, err ? err : "unknown", sizeof _pack_error);
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] %s\n", _pack_error);
+ free(err);
+ }
+ else
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] loaded data pack %s (spektrafilm %s)\n",
+ want_dir, sf_pack_version(_pack));
+ }
+ sf_pack_t *pack = _pack;
+ char pack_dir[SF_PATH_LEN];
+ char pack_error[sizeof _pack_error];
+ g_strlcpy(pack_dir, _pack_path, sizeof pack_dir);
+ g_strlcpy(pack_error, _pack_error, sizeof pack_error);
+ dt_pthread_mutex_unlock(&_pack_lock);
+ if(!pack)
+ {
+ /* Without this the module renders nothing behind an empty trouble banner
+ and the reason only ever reaches the terminal. */
+ g_strlcpy(d->sim_error, pack_error[0] ? pack_error : "no data pack found",
+ sizeof d->sim_error);
+ dt_pthread_mutex_unlock(&d->lock);
+ return NULL;
+ }
+
+ /* resolve stocks */
+ GList *entries = _scan_profiles(pack_dir);
+ char film_stock[SF_NAME_LEN] = { 0 }, paper_stock[SF_NAME_LEN] = { 0 };
+ if(!_resolve_stock(entries, p->film_hash, FALSE, "kodak_portra_400", film_stock,
+ sizeof film_stock))
+ {
+ g_strlcpy(d->sim_error, "no filming profiles found", sizeof d->sim_error);
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] no filming profiles under %s/profiles\n",
+ pack_dir);
+ g_list_free_full(entries, g_free);
+ dt_pthread_mutex_unlock(&d->lock);
+ return NULL;
+ }
+ const char *target_print = NULL;
+ for(GList *l = entries; l; l = l->next)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(!e->printing && !strcmp(e->stock, film_stock)) target_print = e->target_print;
+ }
+ if(!p->scan_film
+ && !_resolve_stock(entries, p->paper_hash, TRUE, target_print, paper_stock,
+ sizeof paper_stock))
+ {
+ g_strlcpy(d->sim_error, "no printing profiles found", sizeof d->sim_error);
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] no printing profiles under %s/profiles\n",
+ pack_dir);
+ g_list_free_full(entries, g_free);
+ dt_pthread_mutex_unlock(&d->lock);
+ return NULL;
+ }
+
+ g_list_free_full(entries, g_free); /* stocks resolved; the list is done */
+
+ /* Load from the pack that was actually resolved, NOT from the config
+ directory. _scan_profiles() above lists the resolved pack's profiles, so
+ using a different directory here means the stock names resolve against one
+ pack and the files are read from another -- with a downloaded pack and
+ nothing hand-installed, every load simply misses and the module goes quiet
+ for want of a film. */
+ char path[SF_PATH_LEN + 300];
+ char *err = NULL;
+ snprintf(path, sizeof path, "%s/profiles/%s.json", pack_dir, film_stock);
+ sf_profile_t *film = sf_profile_load(path, p->development_min, &err);
+ if(!film)
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] cannot load film profile %s: %s\n", path,
+ err ? err : "unknown");
+ sf_profile_t *paper = NULL;
+ if(film && !p->scan_film)
+ {
+ snprintf(path, sizeof path, "%s/profiles/%s.json", pack_dir, paper_stock);
+ paper = sf_profile_load(path, p->print_development_min, &err);
+ if(!paper)
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] cannot load print profile %s: %s\n", path,
+ err ? err : "unknown");
+ }
+
+ /* A profile that will not load left sim_error empty and printed nothing: the
+ only branch that reports err sits inside the "film loaded" path below, so
+ this failure rendered as a silently disabled module. */
+ if(!film || (!paper && !p->scan_film))
+ g_strlcpy(d->sim_error, err ? err : "profile could not be loaded",
+ sizeof d->sim_error);
+
+ if(film && (paper || p->scan_film))
+ {
+ sf_sim_params_t sp;
+ sf_sim_params_defaults(&sp);
+ sp.exposure_comp_ev = p->exposure_ev - p->push_pull_stops;
+ sp.print_exposure = powf(2.0f, p->print_exposure_ev);
+ sp.print_exposure_compensation = p->print_auto_exposure; /* normalize_print_exposure
+ stays at sf_sim_params_defaults' true — that combination
+ is what gives f_mid (a fixed reference midgray density)
+ when this toggle is off, i.e. film exposure then has its
+ full, uncompensated effect on brightness; see
+ sf_sim_build's midgray_factor branches */
+ sp.m_filter_shift = p->filter_m;
+ sp.y_filter_shift = p->filter_y;
+ sp.couplers_active = (p->couplers_amount > 0.0f);
+ sp.couplers_amount = p->couplers_amount;
+ sp.preflash_exposure = p->preflash_exposure;
+ sp.preflash_m_shift = p->preflash_m_shift;
+ sp.preflash_y_shift = p->preflash_y_shift;
+ sp.scan_film = p->scan_film;
+ sp.adaptation_bandwidth = p->adaptation_bandwidth;
+ sp.adaptation_surface = p->adaptation_surface;
+ sp.lut_steps = _quality_steps(p->quality);
+ sp.out_luminance_boost = p->output_luminance_boost;
+ if(p->print_contrast != 1.0f)
+ {
+ sp.morph_active = true;
+ sp.morph_gamma = p->print_contrast;
+ }
+ if(p->film_gamma_factor != 1.0f || p->film_gamma_factor_fast != 1.0f
+ || p->film_gamma_factor_slow != 1.0f || p->film_developer_exhaustion != 0.0f
+ || p->push_pull_stops != 0.0f)
+ {
+ sp.film_morph_active = true;
+ sp.film_morph_gamma = p->film_gamma_factor
+ * powf(SF_PUSH_PULL_GAMMA_PER_STOP, p->push_pull_stops);
+ sp.film_morph_gamma_fast = p->film_gamma_factor_fast;
+ sp.film_morph_gamma_slow = p->film_gamma_factor_slow;
+ sp.film_morph_developer_exhaustion = p->film_developer_exhaustion;
+ }
+ /* darktable pipeline XYZ is D50-relative; the work profile matrices map
+ work RGB <-> that XYZ, so both engine whites are D50 */
+ static const double d50_xy[2] = { 0.3457, 0.3585 };
+ for(int i = 0; i < 9; i++)
+ {
+ sp.input_rgb_to_xyz[i] = m_in[i];
+ sp.output_rgb_to_xyz[i] = m_in[i];
+ sp.output_xyz_to_rgb[i] = m_out[i];
+ }
+ sp.input_white_xy[0] = sp.output_white_xy[0] = d50_xy[0];
+ sp.input_white_xy[1] = sp.output_white_xy[1] = d50_xy[1];
+
+ /* Compare what this edit was developed against with what is installed. Only
+ when the edit actually recorded one -- a 0 means the field predates the
+ edit, not that anything is wrong. */
+ const uint32_t pack_lut = sf_pack_lut_hash(pack);
+ if(p->lut_hash && pack_lut && p->lut_hash != pack_lut)
+ /* Print the hash as well as the name. The name carries the spektrafilm
+ version string, and that is not a reliable identifier: an editable dev
+ install reports whatever pyproject.toml says, so two materially
+ different checkouts can both call themselves the same thing. Without
+ the hash the message reads as nonsense when they do. */
+ snprintf(d->sim_warning, sizeof d->sim_warning,
+ _("developed with a different spectral table\n"
+ "recorded: %08x installed: %s (%08x)\n"
+ "the matching pack can be fetched with the button below"),
+ p->lut_hash, sf_pack_lut_id(pack), pack_lut);
+
+ d->sim = sf_sim_build(pack, film, paper, &sp, &err);
+ if(!d->sim && err)
+ {
+ g_strlcpy(d->sim_error, err, sizeof d->sim_error);
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] %s\n", err);
+ }
+ else if(d->sim)
+ {
+ /* float tables for the GPU path (NULL for exact-spectral quality,
+ which stays CPU-only) */
+ d->gpu = sf_sim_gpu_export(d->sim);
+ dt_print(DT_DEBUG_DEV, "[spektrafilm] built sim: %s -> %s (steps %d, gpu %s)\n",
+ film_stock, p->scan_film ? "(scan film)" : paper_stock, sp.lut_steps,
+ d->gpu ? "yes" : "no");
+ }
+ }
+ free(err);
+ if(film) sf_profile_free(film);
+ if(paper) sf_profile_free(paper);
+
+ sf_sim_t *s = d->sim;
+ dt_pthread_mutex_unlock(&d->lock);
+ return s;
+}
+
+/* ---------------------------------------------------------------------- */
+/* ROI / tiling: expand the input by the spatial-effect halo */
+/* ---------------------------------------------------------------------- */
+
+/* Widest Gaussian sigma (micrometres on film) one diffusion-filter stage will
+ dispatch, or 0 when the stage is off / a no-op. Built from the same
+ sf_diffusion_build_plan() the CPU and GPU paths run, so the ROI padding can
+ never drift from the bank that is actually convolved. */
+static float _diffusion_pad_sigma_um(const gboolean on, const int family, const float strength,
+ const float warmth, const float scale)
+{
+ if(!on) return 0.0f;
+ sf_diffusion_plan_t plan;
+ if(!sf_diffusion_build_plan(family, strength, warmth, &plan) || plan.p_s <= 0.0f) return 0.0f;
+ float smax = 0.0f;
+ for(int j = 0; j < plan.n; j++) smax = fmaxf(smax, plan.sigma_um[j]);
+ return smax * fmaxf(scale, 1e-3f);
+}
+
+static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_um)
+{
+ const float inv_um = 1.0f / fmaxf(pixel_um, 1e-3f);
+ /* halation stage: first-bounce radius, scaled by the user's halation_scale
+ (previously this padding ignored halation_scale entirely, silently
+ under-padding for anyone above the 1.0 default -- fixed here). */
+ const float hal_scale = fmaxf(p->halation_scale, 1e-3f);
+ const float hal = (p->halation_on && p->halation_amount > 0.0f)
+ ? SF_HALATION_FIRST_SIGMA_UM * SF_HALATION_PSF_SIGMAS * hal_scale * inv_um
+ : 0.0f;
+ /* scatter stage: widest core+tail component, scaled by its own
+ scatter_scale (independent from halation_scale since the scatter_amount/
+ scatter_scale split). */
+ const float scat_scale = fmaxf(p->scatter_scale, 1e-3f);
+ const float scat = (p->halation_on && p->scatter_amount > 0.0f)
+ /* SF_SCATTER_TAIL_MAX_UM is already the widest tail
+ component's sigma; SF_HALATION_PSF_SIGMAS is
+ sqrt(n_bounces) and belongs to the halation term
+ above, so applying it here over-padded scatter by
+ 1.73x. Harmless but wasteful. */
+ ? SF_SCATTER_TAIL_MAX_UM * scat_scale * inv_um
+ : 0.0f;
+ /* The widest of film-stage and print-stage diffusion determines the ROI
+ padding — both must fit in the expanded tile. Take the widest component of
+ the actual Gaussian bank each stage will dispatch rather than a single
+ constant: the bloom scale differs by 2.6x across the four families (BPM
+ 380*2.5 um vs cinebloom 1000*2.5 um), so one constant either under-pads the
+ wide families or over-pads the narrow ones. */
+ const float diff = fmaxf(_diffusion_pad_sigma_um(p->diffusion_on,
+ (int)p->diffusion_filter_family,
+ p->diffusion_strength, p->diffusion_warmth,
+ p->diffusion_scale),
+ _diffusion_pad_sigma_um(p->print_diffusion_on,
+ (int)p->print_diffusion_filter_family,
+ p->print_diffusion_strength,
+ p->print_diffusion_warmth,
+ p->print_diffusion_scale))
+ * inv_um;
+ const float grain = (p->grain_on && p->grain_amount > 0.0f)
+ ? SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM
+ * fmaxf(p->grain_size, SF_GRAIN_SIZE_MIN) * inv_um
+ : 0.0f;
+ /* coupler halo: gaussian core plus the widest exponential-tail component;
+ the per-film tail size is unknown before the sim exists, so assume the
+ stock value all current profiles use (200 um) whenever couplers are on */
+ const float coupler = (p->couplers_amount > 0.0f)
+ ? fmaxf((float)SF_COUPLER_BLUR_UM,
+ (float)(SF_EXPTAIL_R2 * 200.0)) * inv_um
+ : 0.0f;
+ /* scanner stage: already in pixels, so it does not go through inv_um */
+ const float scan = fmaxf(p->scan_blur,
+ (p->scan_usm_amount > 0.0f) ? p->scan_usm_sigma : 0.0f);
+ return fmaxf(fmaxf(fmaxf(hal, scat), fmaxf(diff, scan)), fmaxf(grain, coupler));
+}
+
+void modify_roi_in(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece,
+ const dt_iop_roi_t *roi_out, dt_iop_roi_t *roi_in)
+{
+ *roi_in = *roi_out;
+ const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data;
+ if(!d) return;
+ /* film_format_mm is the format's long-edge dimension; buf_in.width alone
+ is the SHORT edge for portrait-oriented images (post-orientation), so
+ using it directly here would under-scale pixel_um (and every halo/grain/
+ halation size derived from it) by the aspect ratio for portrait shots. */
+ const float full_long_edge
+ = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_out->scale, 1.0f);
+ const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
+ const int halo = (int)ceilf(SF_HALO_SIGMAS * _max_halo_sigma(&d->p, pixel_um));
+ if(halo <= 0) return;
+ const int img_w = (int)roundf((float)piece->buf_in.width * roi_out->scale);
+ const int img_h = (int)roundf((float)piece->buf_in.height * roi_out->scale);
+ int x0 = roi_out->x - halo, y0 = roi_out->y - halo;
+ int x1 = roi_out->x + roi_out->width + halo, y1 = roi_out->y + roi_out->height + halo;
+ if(x0 < 0) x0 = 0;
+ if(y0 < 0) y0 = 0;
+ if(img_w > 0 && x1 > img_w) x1 = img_w;
+ if(img_h > 0 && y1 > img_h) y1 = img_h;
+ roi_in->x = x0;
+ roi_in->y = y0;
+ roi_in->width = x1 - x0;
+ roi_in->height = y1 - y0;
+}
+
+void tiling_callback(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece,
+ const dt_iop_roi_t *roi_in, const dt_iop_roi_t *roi_out,
+ dt_develop_tiling_t *tiling)
+{
+ const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data;
+ /* see modify_roi_in: film_format_mm is the long-edge dimension */
+ const float full_long_edge
+ = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f);
+ const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
+ tiling->factor = 2.5f; /* 4 float4 buffers, but they alias in practice */
+ tiling->factor_cl = 4.0f; /* + gtmp4 (1 float4) + plane1 and gtmp1 (1ch each, 1/4 float4) */
+ tiling->maxbuf = 1.0f;
+ tiling->maxbuf_cl = 1.0f;
+ tiling->overhead = 0;
+ tiling->overlap = (unsigned)ceilf(SF_HALO_SIGMAS * _max_halo_sigma(&d->p, pixel_um));
+ tiling->align = 1;
+}
+
+/* ---------------------------------------------------------------------- */
+/* process */
+/* ---------------------------------------------------------------------- */
+
+static void _passthrough(const float *in, float *out, int w, int oh, int ow, int ox, int oy)
+{
+ for(int y = 0; y < oh; y++)
+ for(int x = 0; x < ow; x++)
+ {
+ const float *s = in + ((size_t)(y + oy) * w + (x + ox)) * 4;
+ float *o = out + ((size_t)y * ow + x) * 4;
+ o[0] = s[0];
+ o[1] = s[1];
+ o[2] = s[2];
+ o[3] = s[3];
+ }
+}
+
+void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *const ivoid,
+ void *const ovoid, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out)
+{
+ dt_iop_spektrafilm_data_t *const d = (dt_iop_spektrafilm_data_t *)piece->data;
+ /* process the FULL input ROI (expanded by modify_roi_in), then crop roi_out */
+ const int w = roi_in->width, h = roi_in->height;
+ const int ow = roi_out->width, oh = roi_out->height;
+ const int ox = roi_out->x - roi_in->x, oy = roi_out->y - roi_in->y;
+ const size_t npix = (size_t)w * h;
+ const float *const in = (const float *)ivoid;
+ float *const out = (float *)ovoid;
+
+ const dt_iop_order_iccprofile_info_t *const work_profile
+ = dt_ioppr_get_pipe_work_profile_info(piece->pipe);
+ sf_sim_t *sim = work_profile ? _ensure_sim(d, work_profile) : NULL;
+ if(!sim)
+ {
+ _passthrough(in, out, w, oh, ow, ox, oy);
+ return;
+ }
+
+ /* physical micrometres per pixel at this pipe resolution; film_format_mm
+ is the long-edge dimension, see modify_roi_in */
+ const float full_long_edge
+ = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f);
+ const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
+ /* Fixed pixel radii (grain clumps, the two unsharp masks, the glare veil) were
+ validated at export resolution; darktable's preview pipe renders the same
+ image smaller, where the same nominal radius covers much more real scene
+ detail. Shrink them there, but never grow them past 1:1. */
+ const float preview_scale = fminf(roi_in->scale, 1.0f);
+
+ float *plane = dt_alloc_align_float(npix * 3); /* raw / lograw / cmy, in place */
+ float *corr = dt_alloc_align_float(npix * 3); /* DIR coupler correction field */
+ float *scratch = dt_alloc_align_float(npix); /* 1ch blur scratch */
+ if(!plane || !corr || !scratch)
+ {
+ if(plane) dt_free_align(plane);
+ if(corr) dt_free_align(corr);
+ if(scratch) dt_free_align(scratch);
+ _passthrough(in, out, w, oh, ow, ox, oy);
+ return;
+ }
+
+ /* 1) camera exposure: work RGB -> spectral upsampling -> film raw exposure
+ (includes the film-exposure EV) */
+ sf_sim_expose(sim, in, plane, npix, 4, 3);
+
+ /* 2) pre-film spatial effects on LINEAR exposure, spektrafilm's order:
+ highlight boost -> diffusion filter -> halation */
+ sf_boost_highlights(plane, w, h, d->p.boost_ev, d->p.boost_range, d->p.protect_ev);
+ if(d->p.diffusion_on)
+ sf_diffusion_filter(plane, w, h, (double)pixel_um, (int)d->p.diffusion_filter_family,
+ d->p.diffusion_strength, d->p.diffusion_scale, d->p.diffusion_warmth);
+ if(d->p.halation_on && (d->p.scatter_amount > 0.0f || d->p.halation_amount > 0.0f))
+ {
+ double hal_strength[3], hal_sigma_um;
+ sf_sim_halation_params(sim, hal_strength, &hal_sigma_um);
+ /* modify_roi_in()/tiling_callback() already padded for at most
+ SF_HALATION_FIRST_SIGMA_UM (see _max_halo_sigma); clamp so a future
+ pack entry larger than that can't under-pad the halo. */
+ hal_sigma_um = fmin(hal_sigma_um, (double)SF_HALATION_FIRST_SIGMA_UM);
+ /* per-film scatter PSF; clamped so a pack cannot outrun the ROI padding */
+ double sc_core[3], sc_tail[3], sc_w[3];
+ sf_sim_scatter_params(sim, sc_core, sc_tail, sc_w);
+ for(int c = 0; c < 3; c++)
+ {
+ sc_core[c] = fmin(sc_core[c], (double)SF_SCATTER_CORE_CLAMP_UM);
+ sc_tail[c] = fmin(sc_tail[c], (double)SF_SCATTER_TAIL_CLAMP_UM);
+ }
+ sf_halation(plane, w, h, (double)pixel_um, sc_core, sc_tail, sc_w, d->p.scatter_amount,
+ d->p.scatter_scale, d->p.halation_amount, d->p.halation_scale, hal_strength,
+ hal_sigma_um);
+ }
+
+ /* 3) film development: log exposure, DIR coupler inhibition (the correction
+ field diffuses in the emulsion: gaussian, sigma 20 um as in the
+ reference), density curves */
+ sf_sim_lograw(plane, npix, 3);
+ const int couplers = (d->p.couplers_amount > 0.0f);
+ if(couplers)
+ {
+ sf_sim_develop_corr(sim, plane, corr, npix, 3);
+ double cdiff_um, ctail_um, ctail_w;
+ sf_sim_coupler_diffusion(sim, &cdiff_um, &ctail_um, &ctail_w);
+ const float csigma = (float)cdiff_um / fmaxf(pixel_um, 1e-3f);
+ if(ctail_w > 0.0)
+ {
+ /* corr = (1-w)*gauss(corr) + w*exptail(corr); exptail is upstream's
+ 3-gaussian mixture surrogate (fast_exponential_filter, n=3) */
+ const float amp[3] = { SF_EXPTAIL_A0, SF_EXPTAIL_A1, SF_EXPTAIL_A2 };
+ const float rat[3] = { SF_EXPTAIL_R0, SF_EXPTAIL_R1, SF_EXPTAIL_R2 };
+ const float tail_px = (float)ctail_um / fmaxf(pixel_um, 1e-3f);
+ float *mix = dt_alloc_align_float(npix * 3);
+ float *tmp = dt_alloc_align_float(npix * 3);
+ if(mix && tmp)
+ {
+ const float wbase = 1.0f - (float)ctail_w;
+ memcpy(tmp, corr, sizeof(float) * npix * 3);
+ if(csigma > 0.1f) sf_blur_plane3_fast(tmp, w, h, csigma, scratch);
+ for(size_t i = 0; i < npix * 3; i++) mix[i] = wbase * tmp[i];
+ for(int g3 = 0; g3 < 3; g3++)
+ {
+ memcpy(tmp, corr, sizeof(float) * npix * 3);
+ const float ts = rat[g3] * tail_px;
+ if(ts > 0.1f) sf_blur_plane3_fast(tmp, w, h, ts, scratch);
+ const float wk = (float)ctail_w * amp[g3];
+ for(size_t i = 0; i < npix * 3; i++) mix[i] += wk * tmp[i];
+ }
+ memcpy(corr, mix, sizeof(float) * npix * 3);
+ }
+ else if(csigma > 0.1f)
+ sf_blur_plane3_fast(corr, w, h, csigma, scratch); /* alloc failed: core only */
+ dt_free_align(mix);
+ dt_free_align(tmp);
+ }
+ else if(csigma > 0.1f)
+ sf_blur_plane3_fast(corr, w, h, csigma, scratch);
+ }
+ sf_sim_develop(sim, plane, couplers ? corr : NULL, plane, npix, 3, 3);
+
+ /* 4) grain on the developed CMY film density: sample a grained density,
+ take its difference from the clean one, scale by strength, add it
+ back, then clump-blur the combined field and recover acutance with
+ the multiplicative unsharp mask (upstream's blur/usm pair) */
+ if(d->p.grain_on && d->p.grain_amount > 0.0f)
+ {
+ float *gbuf = corr; /* corr is free now — reuse as the grain delta buffer */
+ const int roi_x = roi_in->x, roi_y = roi_in->y;
+ const float amount = d->p.grain_amount;
+ const int mono = sf_sim_film_bw(sim); /* B&W: achromatic grain */
+ /* sf_grain_delta_ml's layer_npart is precomputed at sf_sim_build time
+ against the fixed SF_GRAIN_REF_UM reference scale (it depends on
+ curve/coupler state baked in at build time, not just resolution);
+ rescale it live to the real pixel_um here. */
+ const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM);
+ /* SF_GRAIN_BLUR_FACTOR/SF_GRAIN_DYE_BLUR_UM/grain_usm_sigma are fixed
+ pixel radii, validated against upstream at whatever single
+ resolution each of its own renders happens to use -- upstream has
+ no notion of "the same image, but at a temporarily reduced preview
+ resolution for interactive editing speed" the way darktable's
+ preview pipe does. Without this, the SAME nominal pixel radius
+ covers a much larger fraction of real scene detail in a
+ downscaled preview than at full/export resolution (a real image
+ edge that spans ~30px at full res might span ~4px in a heavily
+ zoomed-out preview), producing visible over-sharpening ringing on
+ actual scene content, not just grain texture, that isn't present
+ at 1:1/export. Capped at 1.0 so zooming in PAST 100% doesn't grow
+ the radii beyond what was actually validated. Particle density
+ (npart_scale above) is NOT touched by this -- it's correctly
+ resolution-dependent via pixel_um already, confirmed against the
+ reference at multiple different resolutions. */
+ float grms[3], gunif[3], gdmin[3];
+ /* gdmin here is what the SAMPLER adds -- the sum of the per-sub-layer
+ floors, not the film's single density_min. They coincide for a
+ single-layer stock and differ for a multi-sub-layer one; using
+ density_min there gave the delta a constant positive mean. */
+ sf_sim_grain_dmin_total(sim, gdmin);
+ float gdmin_unused[3];
+ sf_sim_film_grain3(sim, grms, gunif, gdmin_unused); /* per-film catalogue grain
+ (rms-granularity, uniformity, density
+ floor) — Portra 400 no longer shares
+ Tri-X's grain signature */
+ sf_grain_layers_t layers;
+ sf_sim_grain_layers(sim, &layers); /* n==1 for a single-layer curve fit
+ is already valid data (see the
+ function's own comment): every stock
+ goes through this one mechanism. */
+ const int nsub = layers.n;
+
+ /* Upstream's per-sub-layer dye-cloud blur (layer_particle_model's
+ blur_particle, grain.py) runs INSIDE the particle sampler, on each
+ sub-layer's raw draw independently, before the main clump blur
+ below ever sees it. Its sigma depends only on that sub-layer's own
+ per-particle optical density (dmax/npart) -- constant across the
+ whole image for a given (channel, sub-layer), so compute it once
+ here rather than per pixel. */
+ float dye_sigma[3][SF_GRAIN_MAX_SUBLAYERS];
+ for(int c = 0; c < 3; c++)
+ for(int sl = 0; sl < nsub; sl++)
+ {
+ const float npart_c = (float)layers.layer_npart[sl][c] * npart_scale;
+ const float od_particle = (float)layers.layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f);
+ dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * sqrtf(fmaxf(od_particle, 0.0f)) * preview_scale;
+ }
+
+ float *raw[SF_GRAIN_MAX_SUBLAYERS] = { 0 };
+ gboolean raw_ok = TRUE;
+ for(int sl = 0; sl < nsub; sl++)
+ {
+ raw[sl] = dt_alloc_align_float(npix);
+ if(!raw[sl]) raw_ok = FALSE;
+ }
+
+ if(!raw_ok)
+ memset(gbuf, 0, npix * 3 * sizeof(float)); /* allocation failed: skip grain gracefully */
+ else
+ {
+ const int n_out_ch = mono ? 1 : 3;
+ for(int oc = 0; oc < n_out_ch; oc++)
+ {
+ const int channel_idx = mono ? 1 : oc; /* mono uses channel 1's curve/params for the
+ achromatic draw, matching sf_grain_delta_ml */
+ const int seed_ch = mono ? 0 : oc; /* mono seeds as sl*10 (no channel term), matching
+ sf_grain_delta_ml's own convention exactly */
+ const float unif_ch = gunif[channel_idx];
+#ifdef _OPENMP
+#pragma omp parallel for default(none) \
+ shared(plane, raw, layers) firstprivate(w, npix, roi_x, roi_y, mono, channel_idx, seed_ch, \
+ unif_ch, npart_scale, nsub) schedule(static)
+#endif
+ for(size_t k = 0; k < npix; k++)
+ {
+ const int x = (int)(k % (size_t)w), y = (int)(k / (size_t)w);
+ const float density = mono ? (plane[k * 3 + 0] + plane[k * 3 + 1] + plane[k * 3 + 2])
+ / 3.0f
+ : plane[k * 3 + channel_idx];
+ float samp[SF_GRAIN_MAX_SUBLAYERS];
+ sf_grain_raw_samples_ml(&layers, density, channel_idx, seed_ch, (uint32_t)(x + roi_x),
+ (uint32_t)(y + roi_y), unif_ch, npart_scale, samp);
+ for(int sl = 0; sl < nsub; sl++) raw[sl][k] = samp[sl];
+ }
+ /* dye-cloud blur: each sub-layer independently, no variance
+ restoration (matching upstream: layer_particle_model doesn't
+ renormalize after its blur_particle pass either). */
+ for(int sl = 0; sl < nsub; sl++)
+ sf_blur_plane1(raw[sl], w, h, dye_sigma[channel_idx][sl], NULL, scratch);
+ /* combine: sum sub-layers, subtract the density floor and the
+ original clean density, scale by strength. */
+#ifdef _OPENMP
+#pragma omp parallel for default(none) shared(plane, gbuf, raw) \
+ firstprivate(npix, nsub, channel_idx, mono, oc, amount, gdmin) schedule(static)
+#endif
+ for(size_t k = 0; k < npix; k++)
+ {
+ float total = 0.0f;
+ for(int sl = 0; sl < nsub; sl++) total += raw[sl][k];
+ const float g = total - gdmin[channel_idx];
+ const float density = mono ? (plane[k * 3 + 0] + plane[k * 3 + 1] + plane[k * 3 + 2])
+ / 3.0f
+ : plane[k * 3 + channel_idx];
+ const float delta = (g - density) * amount;
+ if(mono) gbuf[k * 3 + 0] = gbuf[k * 3 + 1] = gbuf[k * 3 + 2] = delta;
+ else gbuf[k * 3 + oc] = delta;
+ }
+ }
+ }
+ for(int sl = 0; sl < nsub; sl++)
+ if(raw[sl]) dt_free_align(raw[sl]);
+ /* No DC-centring pass. The delta is zero-mean by construction now: the
+ sampler draws an unbiased Poisson (spektra_core.h) and the combine above
+ takes back exactly the floors the sampler added -- see
+ sf_sim_grain_dmin_total(). Subtracting grain_density_min there instead
+ left a constant +(sum - density_min) per unit strength, which a per-ROI
+ mean was previously hiding. */
+ /* Add the still-UNBLURRED delta onto the clean density first, so the clump
+ blur below runs on the grained ABSOLUTE density -- image detail and grain
+ together -- which is what upstream blurs (_finalize_grain in grain.py
+ smooths density_cmy_out itself, not a separate grain layer).
+
+ Blurring only the delta and adding it to an untouched clean signal leaves
+ real image detail at full sharpness, and the multiplicative unsharp mask
+ further down then sharpens it anyway. That mask exists solely to recover
+ the acutance this blur takes away: the two are a tuned pair
+ (params_schema.py annotates the blur "optimized to go with the mult usm
+ below" and the usm "optimized to go with the blur above"). Running the
+ recovery half without the loss half is over-sharpening by construction,
+ and showed up as crunchy, over-defined edges on fine high-contrast
+ texture. Softening genuine detail here is intended, not a side effect --
+ it is what the emulsion does, and what upstream's own output shows. */
+#ifdef _OPENMP
+#pragma omp parallel for default(none) shared(plane, gbuf) firstprivate(npix) \
+ schedule(static)
+#endif
+ for(size_t k = 0; k < npix * 3; k++) plane[k] += gbuf[k];
+ /* Upstream's grain blur (GrainParams.blur, params_schema.py) is a
+ literal FIXED pixel sigma (0.8), independent of pixel_um/resolution/
+ film_format_mm -- confirmed empirically: measuring the real
+ reference's noise autocorrelation at two different resolutions
+ (87.5 and 35 um/px) gave near-identical radial profiles. Physical
+ scaling lives entirely in particle DENSITY (now pixel_um-driven
+ above), not in this smoothing pass. SF_GRAIN_SIZE_CAL is gone: it
+ was calibrated against the old pixel_um-scaled formula and no
+ longer applies. preview_scale is a SEPARATE, darktable-only
+ correction (see its own comment above): upstream always renders
+ one real resolution, but darktable's preview pipe renders the same
+ image at a temporarily reduced resolution for interactive speed,
+ so this fixed radius needs shrinking there or it over-affects real
+ scene detail relative to what 1:1/export shows. */
+ const float sigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN)
+ * preview_scale;
+ /* No variance-restoration renorm here -- upstream's own grain
+ finalization (_finalize_grain in grain.py) has none either; it just
+ blurs and lets the natural contrast reduction stand, matching real
+ optical clumping. Restoring full pre-blur variance made grain
+ visibly higher-contrast, and therefore visually coarser, than
+ upstream at any matching sigma. */
+ sf_blur_plane3(plane, w, h, sigma, scratch);
+ /* Acutance recovery for the blur above, and only meaningful because of it:
+ these two are tuned together (defaults sigma 0.7 / amount 1.5). */
+ if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f)
+ /* The reference's multiplicative USM (_finalize_grain in grain.py)
+ runs on the ABSOLUTE density -- the grain sampler's floor is still
+ present and is only removed afterwards (add_micro_structure -> blur
+ -> USM -> -= density_min). The delta combine above already took the
+ floor back out, so pass it back in here: without it the D/blur(D)
+ ratio is ill-conditioned in the deepest shadows and the USM
+ amplifies shadow noise the reference never does. */
+ sf_multiplicative_unsharp_mask3(plane, w, h, d->p.grain_usm_sigma * preview_scale,
+ d->p.grain_usm_amount, gdmin, corr, scratch);
+ }
+
+ /* 5) print exposure + development (skipped in scan-film mode) */
+ if(!d->p.scan_film)
+ {
+ sf_sim_print_expose(sim, plane, plane, npix, 3, 3);
+ if(d->p.print_diffusion_on)
+ sf_diffusion_filter(plane, w, h, (double)pixel_um, (int)d->p.print_diffusion_filter_family,
+ d->p.print_diffusion_strength, d->p.print_diffusion_scale,
+ d->p.print_diffusion_warmth);
+ sf_sim_print_develop(sim, plane, plane, npix, 3, 3);
+ }
+
+ /* 6) scanning: viewing light through the print/film -> XYZ -> work RGB with
+ OkLCh gamut compression. Write RGBA + carried alpha, then crop. */
+ sf_sim_scan(sim, plane, plane, npix, 3, 3);
+
+ /* 6b) scanner optics + viewing glare, on the scanned RGB over the full padded
+ ROI so the crop below never sees an edge artifact ([sc] ScannerParams
+ lens_blur / unsharp_mask, [gl] add_glare -- the reference skips glare
+ when the film itself is scanned rather than printed). */
+ if(d->p.scan_blur > 0.0f)
+ sf_blur_plane3(plane, w, h, d->p.scan_blur * preview_scale, scratch);
+ if(d->p.scan_usm_sigma > 0.0f && d->p.scan_usm_amount > 0.0f)
+ sf_unsharp_mask3(plane, w, h, d->p.scan_usm_sigma * preview_scale, d->p.scan_usm_amount,
+ corr, scratch);
+ if(!d->p.scan_film && d->p.glare_percent > 0.0f)
+ sf_glare(plane, w, h, d->p.glare_percent, SF_GLARE_ROUGHNESS,
+ SF_GLARE_BLUR_PX * preview_scale, roi_in->x, roi_in->y, scratch);
+
+#ifdef _OPENMP
+#pragma omp parallel for default(none) shared(plane) firstprivate(out, in, w, ow, oh, ox, oy) \
+ schedule(static)
+#endif
+ for(int y = 0; y < oh; y++)
+ for(int x = 0; x < ow; x++)
+ {
+ const size_t ks = (size_t)(y + oy) * w + (x + ox);
+ const float *pl = plane + ks * 3;
+ float *o = out + ((size_t)y * ow + x) * 4;
+ o[0] = pl[0];
+ o[1] = pl[1];
+ o[2] = pl[2];
+ o[3] = in[ks * 4 + 3];
+ }
+
+ dt_free_align(plane);
+ dt_free_align(corr);
+ dt_free_align(scratch);
+}
+
+#ifdef HAVE_OPENCL
+/* Separable Young-van Vliet pass on the device, the GPU half of
+ sf_gauss_yvv_coeffs / _sf_gauss_iir_1d. `tmp` is a scratch buffer of the same
+ shape as `buf`. This replaces dt_gaussian_mean_blur_cl, which was being fed
+ sigma * SF_GAUSS_SIGMA_CORRECTION -- a factor measured for the CPU's old
+ Deriche recursion and meaningless for darktable's iterated-box blur, so the
+ two paths were producing different halo widths for the same sigma. */
+static cl_int _sf_yvv_blur_cl(const int devid, dt_iop_spektrafilm_global_data_t *gd,
+ cl_mem buf, cl_mem tmp, const int w, const int h,
+ const float sigma, const int ch)
+{
+ float b[4];
+ sf_gauss_yvv_coeffs(sigma, b);
+ const int krow = (ch == 4) ? gd->kernel_yvv_row_4c : gd->kernel_yvv_row_1c;
+ const int kcol = (ch == 4) ? gd->kernel_yvv_col_4c : gd->kernel_yvv_col_1c;
+ cl_int e = dt_opencl_enqueue_kernel_2d_args(devid, krow, h, 1, CLARG(buf), CLARG(tmp),
+ CLARG(w), CLARG(h), CLARG(b[0]), CLARG(b[1]),
+ CLARG(b[2]), CLARG(b[3]));
+ if(e != CL_SUCCESS) return e;
+ return dt_opencl_enqueue_kernel_2d_args(devid, kcol, w, 1, CLARG(tmp), CLARG(buf),
+ CLARG(w), CLARG(h), CLARG(b[0]), CLARG(b[1]),
+ CLARG(b[2]), CLARG(b[3]));
+}
+
+/* GPU path: mirrors process(). Per-pixel stages run as kernels on the
+ validated float tables from sf_sim_gpu_export() (POCL-checked to ~1e-6 vs
+ the CPU engine); the Gaussian blurs (diffusion bank, halation bounces,
+ coupler correction diffusion, grain clumps) use this file's own direct
+ separable convolution (spektrafilm_gauss_row/col_*c, weights built
+ host-side by sf_gauss_kernel_1d -- see spektra_core.c/.h), exactly as the
+ CPU path uses sf_blur_plane3. */
+int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_in,
+ cl_mem dev_out, const dt_iop_roi_t *const roi_in,
+ const dt_iop_roi_t *const roi_out)
+{
+ dt_iop_spektrafilm_data_t *const d = (dt_iop_spektrafilm_data_t *)piece->data;
+ dt_iop_spektrafilm_global_data_t *gd = (dt_iop_spektrafilm_global_data_t *)self->global_data;
+ const int devid = piece->pipe->devid;
+ const int w = roi_in->width, h = roi_in->height;
+ const int ow = roi_out->width, oh = roi_out->height;
+ const int ox = roi_out->x - roi_in->x, oy = roi_out->y - roi_in->y;
+ const size_t npix = (size_t)w * h;
+ cl_int err = DT_OPENCL_DEFAULT_ERROR;
+#define SF_CL_STEP(label) \
+ do \
+ { \
+ if(err != CL_SUCCESS) \
+ { \
+ dt_print(DT_DEBUG_OPENCL, "[spektrafilm] GPU step FAILED: %s (err=%d)\n", (label), \
+ (int)err); \
+ goto cleanup; \
+ } \
+ } while(0)
+
+ const dt_iop_order_iccprofile_info_t *const work_profile
+ = dt_ioppr_get_pipe_work_profile_info(piece->pipe);
+ sf_sim_t *sim = work_profile ? _ensure_sim(d, work_profile) : NULL;
+ const sf_sim_gpu_t *g = d->gpu;
+
+ if(!sim) /* no data pack / profiles: crop passthrough */
+ return dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_passthrough, ow, oh,
+ CLARG(dev_in), CLARG(dev_out), CLARG(ow),
+ CLARG(oh), CLARG(ox), CLARG(oy));
+ if(!g) return DT_OPENCL_DEFAULT_ERROR; /* exact quality etc. -> CPU fallback */
+
+ /* film_format_mm is the long-edge dimension, see modify_roi_in */
+ const float full_long_edge
+ = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f);
+ const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
+ /* see the matching comment in process(): fixed pixel radii shrink with the
+ preview pipe's reduced resolution, but never grow past 1:1 */
+ const float preview_scale = fminf(roi_in->scale, 1.0f);
+
+ /* ---- table uploads (read-only buffers) -------------------------------- */
+ /* packed matrix block: layout must match the SF_M_* offsets in the .cl */
+ float mats[93]; /* SF_M_* layout in spektrafilm.cl */
+ memcpy(mats + 0, g->m_in, 9 * sizeof(float));
+ memcpy(mats + 9, g->m_out, 9 * sizeof(float));
+ memcpy(mats + 18, g->couplers_M, 9 * sizeof(float));
+ memcpy(mats + 27, g->out_rgb2xyz, 9 * sizeof(float));
+ memcpy(mats + 36, g->out_xyz2rgb, 9 * sizeof(float));
+ memcpy(mats + 45, g->oklab_m1, 9 * sizeof(float));
+ memcpy(mats + 54, g->oklab_m2, 9 * sizeof(float));
+ memcpy(mats + 63, g->oklab_m1inv, 9 * sizeof(float));
+ memcpy(mats + 72, g->oklab_m2inv, 9 * sizeof(float));
+ memcpy(mats + 81, g->couplers_donor_K, 3 * sizeof(float));
+ memcpy(mats + 84, g->couplers_donor_Dref, 3 * sizeof(float));
+ memcpy(mats + 87, g->couplers_recv_Kr, 3 * sizeof(float));
+ memcpy(mats + 90, g->couplers_recv_cref, 3 * sizeof(float));
+
+ const int steps = g->steps;
+ const size_t n3 = (size_t)steps * steps * steps * 3;
+ const size_t m3 = (size_t)(steps - 1) * (steps - 1) * (steps - 1) * 3;
+ const size_t f = sizeof(float);
+ cl_mem mats_cl = dt_opencl_copy_host_to_device_constant(devid, 93 * f, mats);
+ cl_mem tc_cl = dt_opencl_copy_host_to_device_constant(
+ devid, (size_t)g->tc_n * g->tc_n * 3 * f, g->tc_lut);
+ cl_mem cn_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f, g->curves_norm);
+ cl_mem cb_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f,
+ g->couplers_active ? g->curves_before
+ : g->curves_norm);
+ cl_mem el_cl = NULL, ex_cl = NULL, ey_cl = NULL, ez_cl = NULL, en_cl = NULL, em_cl = NULL;
+ cl_mem pc_cl = NULL;
+ if(g->has_print)
+ {
+ el_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_lut);
+ ex_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sx);
+ ey_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sy);
+ ez_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sz);
+ en_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->enl_cmin);
+ em_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->enl_cmax);
+ pc_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f, g->print_curves);
+ }
+ cl_mem sl_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_lut);
+ cl_mem sx_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sx);
+ cl_mem sy_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sy);
+ cl_mem sz_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sz);
+ cl_mem sn_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->scan_cmin);
+ cl_mem sm_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->scan_cmax);
+ /* cmax_table is only used in oklch mode but the kernel arg must be valid */
+ cl_mem cm_cl = dt_opencl_copy_host_to_device_constant(
+ devid, (g->cmax_table ? (size_t)g->cmax_nl * g->cmax_nh : 1) * f,
+ g->cmax_table ? (void *)g->cmax_table : (void *)mats);
+
+ cl_mem plane = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
+ cl_mem plane2 = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
+ cl_mem tmpa = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
+ cl_mem acc = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
+ /* single-channel scratch for the scatter stage's genuinely per-channel
+ blurs (spektrafilm_channel_extract + a 1ch Gaussian): 1/4 the size and
+ 1/4 the per-blur cost of running the equivalent work on a float4
+ buffer, see the scatter stage below. */
+ cl_mem plane1 = dt_opencl_alloc_device_buffer(devid, npix * f);
+ /* row-pass intermediates for the direct (exact) separable convolution
+ below: dedicated buffers, distinct from every buffer a blur might be
+ called on in place, so the row pass never aliases its own input. */
+ cl_mem gtmp4 = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
+ cl_mem gtmp1 = dt_opencl_alloc_device_buffer(devid, npix * f);
+ /* kernel weights (2*SF_GAUSS_MAX_RADIUS+1 taps, built host-side by
+ sf_gauss_kernel_1d and rewritten before each blur dispatch below) */
+ cl_mem gauss_w = dt_opencl_alloc_device_buffer(devid, sizeof(float) * (2 * SF_GAUSS_MAX_RADIUS + 1));
+ if(!mats_cl || !tc_cl || !cn_cl || !cb_cl || !sl_cl || !sx_cl || !sy_cl || !sz_cl || !sn_cl
+ || !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !acc || !plane1 || !gtmp4 || !gtmp1
+ || !gauss_w
+ || (g->has_print && (!el_cl || !ex_cl || !ey_cl || !ez_cl || !en_cl || !em_cl || !pc_cl)))
+ {
+ err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
+ goto cleanup;
+ }
+/* Direct (exact) separable Gaussian blur: builds the exact kernel
+ host-side (the same sf_gauss_kernel_1d() the CPU path convolves with),
+ uploads it, then dispatches a row pass into a dedicated scratch buffer
+ followed by a col pass into `dst` -- safe even when dst==buf (in-place),
+ since the row pass fully consumes buf into scratch before the col pass
+ writes buf. No sigma-correction factor: unlike a recursive/IIR
+ approximation, a direct truncated kernel has no sigma-dependent error to
+ correct for in the first place. */
+#define SF_GAUSS_BLUR4(buf, _sg, label) do { \
+ if(err == CL_SUCCESS) \
+ { \
+ float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
+ const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
+ err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
+ sizeof(float) * (2 * _kr + 1), TRUE); \
+ if(err == CL_SUCCESS) \
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \
+ CLARG(buf), CLARG(gtmp4), CLARG(w), CLARG(h), \
+ CLARG(gauss_w), CLARG(_kr)); \
+ if(err == CL_SUCCESS) \
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \
+ CLARG(gtmp4), CLARG(buf), CLARG(w), CLARG(h), \
+ CLARG(gauss_w), CLARG(_kr)); \
+ } \
+ SF_CL_STEP(label); \
+ } while(0)
+/* Same as SF_GAUSS_BLUR4, but above SF_GAUSS_EXACT_MAX_SIGMA falls back to
+ the shared Young-van Vliet recursion (_sf_yvv_blur_cl above)
+ instead of the exact row/col kernels: for callers with no downstream
+ dependency on the exact kernel's shape (unlike grain's SF_GAUSS_BLUR4
+ above, which stays on the exact path unconditionally -- the fast
+ recursive approximation's own known ~18% effective-width error would
+ reintroduce the same size mismatch against upstream that the exact
+ kernel was adopted to fix), this recovers most of the O(radius) cost the
+ exact kernel pays at large sigma. */
+#define SF_GAUSS_BLUR4_FAST(buf, _sg, label) do { \
+ if(err == CL_SUCCESS) \
+ { \
+ if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \
+ err = _sf_yvv_blur_cl(devid, gd, (buf), gtmp4, w, h, (_sg), 4); \
+ else \
+ { \
+ float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
+ const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
+ err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
+ sizeof(float) * (2 * _kr + 1), TRUE); \
+ if(err == CL_SUCCESS) \
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \
+ CLARG(buf), CLARG(gtmp4), CLARG(w), CLARG(h), \
+ CLARG(gauss_w), CLARG(_kr)); \
+ if(err == CL_SUCCESS) \
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \
+ CLARG(gtmp4), CLARG(buf), CLARG(w), CLARG(h), \
+ CLARG(gauss_w), CLARG(_kr)); \
+ } \
+ } \
+ SF_CL_STEP(label); \
+ } while(0)
+/* loop-safe variant: sets err, caller checks err/breaks; src/dst may differ
+ (e.g. accumulating several blurred copies of the same source). Falls back
+ to the fast recursive blur above SF_GAUSS_EXACT_MAX_SIGMA, same rationale
+ as SF_GAUSS_BLUR4_FAST -- none of this macro's callers (halation bounce,
+ coupler tail, both diffusion filters) renormalize against the exact
+ kernel's shape. */
+#define SF_GAUSS_BLUR4_OP_L(src, dst, _sg) do { \
+ if(err == CL_SUCCESS) \
+ { \
+ if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \
+ { \
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \
+ if(err == CL_SUCCESS) \
+ err = _sf_yvv_blur_cl(devid, gd, (dst), gtmp4, w, h, (_sg), 4); \
+ } \
+ else \
+ { \
+ float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
+ const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
+ err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
+ sizeof(float) * (2 * _kr + 1), TRUE); \
+ if(err == CL_SUCCESS) \
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \
+ CLARG(src), CLARG(gtmp4), CLARG(w), CLARG(h), \
+ CLARG(gauss_w), CLARG(_kr)); \
+ if(err == CL_SUCCESS) \
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \
+ CLARG(gtmp4), CLARG(dst), CLARG(w), CLARG(h), \
+ CLARG(gauss_w), CLARG(_kr)); \
+ } \
+ } \
+ } while(0)
+/* single-channel in-place blur (scatter stage only, on plane1). Same fast
+ fallback above SF_GAUSS_EXACT_MAX_SIGMA -- scatter's core/tail sigmas are
+ normally small (sub-few-px), but the fallback is here for whatever a user's
+ scatter_scale slider can push them to. */
+#define SF_GAUSS_BLUR1_L(buf, _sg) do { \
+ if(err == CL_SUCCESS) \
+ { \
+ if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \
+ err = _sf_yvv_blur_cl(devid, gd, (buf), gtmp1, w, h, (_sg), 1); \
+ else \
+ { \
+ float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
+ const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
+ err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
+ sizeof(float) * (2 * _kr + 1), TRUE); \
+ if(err == CL_SUCCESS) \
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_1c, w, h, \
+ CLARG(buf), CLARG(gtmp1), CLARG(w), CLARG(h), \
+ CLARG(gauss_w), CLARG(_kr)); \
+ if(err == CL_SUCCESS) \
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_1c, w, h, \
+ CLARG(gtmp1), CLARG(buf), CLARG(w), CLARG(h), \
+ CLARG(gauss_w), CLARG(_kr)); \
+ } \
+ } \
+ } while(0)
+
+ /* ---- 1) expose: input image -> linear film raw exposure ---------------- */
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_expose, w, h, CLARG(dev_in),
+ CLARG(plane), CLARG(w), CLARG(h), CLARG(mats_cl),
+ CLARG(tc_cl), CLARG(g->tc_n), CLARG(g->ev_scale));
+ SF_CL_STEP("expose");
+
+ /* ---- 2) pre-film spatial effects on linear exposure -------------------- */
+ if(d->p.boost_ev > 0.0f)
+ {
+ /* The frame-maximum reduction that used to run here is gone: the curve is
+ anchored to the exposure scale now, which is what makes the boost agree
+ between the preview pipe, the export pipe and every tile. */
+ const float b_ev = d->p.boost_ev, b_rng = d->p.boost_range, b_prot = d->p.protect_ev;
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_boost, w, h, CLARG(plane),
+ CLARG(w), CLARG(h), CLARG(b_ev), CLARG(b_rng),
+ CLARG(b_prot));
+ SF_CL_STEP("boost");
+ }
+
+ if(d->p.diffusion_on)
+ {
+ sf_diffusion_plan_t plan;
+ if(sf_diffusion_build_plan((int)d->p.diffusion_filter_family, d->p.diffusion_strength,
+ d->p.diffusion_warmth, &plan)
+ && plan.p_s > 0.0f)
+ {
+ const float dsc = fmaxf(d->p.diffusion_scale, 1e-6f);
+ for(int j = 0; j < plan.n; j++)
+ {
+ const float sigma = fmaxf(plan.sigma_um[j] * dsc / pixel_um, 1e-3f);
+ SF_GAUSS_BLUR4_OP_L(plane, tmpa, sigma);
+ if(err != CL_SUCCESS) break;
+ const int reset = (j == 0);
+ const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j];
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
+ CLARG(tmpa), CLARG(acc), CLARG(w), CLARG(h),
+ CLARG(wr), CLARG(wg), CLARG(wb), CLARG(reset));
+ if(err != CL_SUCCESS) break;
+ }
+ if(err == CL_SUCCESS)
+ {
+ const float ps = plan.p_s;
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_mix, w, h,
+ CLARG(plane), CLARG(acc), CLARG(w), CLARG(h),
+ CLARG(ps));
+ }
+ SF_CL_STEP("diffusion");
+ }
+ }
+
+ if(d->p.halation_on && (d->p.scatter_amount > 0.0f || d->p.halation_amount > 0.0f))
+ {
+ if(d->p.scatter_amount > 0.0f)
+ {
+ const float sscl = fmaxf(d->p.scatter_scale, 1e-3f);
+ /* per-channel scatter radii (um on film) and tail mixture, identical to
+ spektra_core.c's sf_halation() sc_core/sc_tail/tail_amp/tail_rat.
+ Each channel needs its OWN sigma (R/G/B differ): extract that
+ channel into the single-channel scratch buffer plane1, blur it
+ alone (1x the work of a same-size float4 blur, not 4x), then
+ kernel_channel_accum folds it into the target channel of tmpa/acc. */
+ /* per-film scatter PSF, same clamps as the CPU path */
+ float sc_core[3], sc_tail[3];
+ for(int c = 0; c < 3; c++)
+ {
+ sc_core[c] = fminf(g->scatter_core_um[c], SF_SCATTER_CORE_CLAMP_UM);
+ sc_tail[c] = fminf(g->scatter_tail_um[c], SF_SCATTER_TAIL_CLAMP_UM);
+ }
+ const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f };
+ for(int c = 0; c < 3; c++)
+ {
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_extract, w, h,
+ CLARG(plane), CLARG(plane1), CLARG(w), CLARG(h),
+ CLARG(c));
+ if(err != CL_SUCCESS) break;
+ SF_GAUSS_BLUR1_L(plane1, fmaxf(sc_core[c] * sscl / pixel_um, 1e-6f));
+ if(err != CL_SUCCESS) break;
+ const float core_weight = 1.0f;
+ const int core_reset = (c == 0);
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_accum, w, h,
+ CLARG(plane1), CLARG(tmpa), CLARG(w), CLARG(h),
+ CLARG(core_weight), CLARG(c), CLARG(core_reset));
+ SF_CL_STEP("scatter core blur");
+ }
+ for(int g3 = 0; g3 < 3 && err == CL_SUCCESS; g3++)
+ for(int c = 0; c < 3; c++)
+ {
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_extract, w, h,
+ CLARG(plane), CLARG(plane1), CLARG(w), CLARG(h),
+ CLARG(c));
+ if(err != CL_SUCCESS) break;
+ const float sigma = fmaxf(rat[g3] * sc_tail[c] * sscl / pixel_um, 1e-6f);
+ SF_GAUSS_BLUR1_L(plane1, sigma);
+ if(err != CL_SUCCESS) break;
+ const int reset = (g3 == 0 && c == 0);
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_accum, w, h,
+ CLARG(plane1), CLARG(acc), CLARG(w), CLARG(h),
+ CLARG(amp[g3]), CLARG(c), CLARG(reset));
+ SF_CL_STEP("scatter tail accum");
+ }
+ const float ws_r = g->scatter_tail_weight[0], ws_g = g->scatter_tail_weight[1],
+ ws_b = g->scatter_tail_weight[2];
+ /* (1-s)*raw + s*scattered, matching sf_halation()'s CPU blend; `plane`
+ doubles as both the pre-scatter `raw` input and the `out` write
+ target -- safe since this is a purely per-pixel elementwise op. */
+ /* convex blend weight -- see sf_halation() for why it cannot exceed 1 */
+ const float s_amount = CLAMP(d->p.scatter_amount, 0.0f, 1.0f);
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scatter_combine, w, h, CLARG(plane),
+ CLARG(tmpa), CLARG(acc), CLARG(plane), CLARG(w),
+ CLARG(h), CLARG(s_amount), CLARG(ws_r), CLARG(ws_g),
+ CLARG(ws_b));
+ SF_CL_STEP("scatter combine");
+ }
+
+ if(d->p.halation_amount > 0.0f)
+ {
+ const float hscl = fmaxf(d->p.halation_scale, 1e-3f);
+ const int N = 3;
+ /* per-film first-bounce radius (still ~65um / cine ~50um on real
+ stocks); clamped to what modify_roi_in()/tiling_callback() padded
+ for, see the matching comment in process(). */
+ const float first_sigma = fminf(g->halation_first_sigma_um, SF_HALATION_FIRST_SIGMA_UM);
+ const float dec[3] = { 1.0f/1.75f, 0.5f/1.75f, 0.25f/1.75f };
+ for(int k = 1; k <= N; k++)
+ {
+ SF_GAUSS_BLUR4_OP_L(plane, plane2, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f));
+ if(err != CL_SUCCESS) break;
+ const int reset = (k == 1);
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(plane2),
+ CLARG(acc), CLARG(w), CLARG(h), CLARG(dec[k - 1]),
+ CLARG(reset));
+ SF_CL_STEP("halation bounce accum");
+ }
+ /* halation_amount is a direct linear multiplier on strength, matching
+ upstream's a_tot = halation_strength * halation_amount (no curve). */
+ const float h_eff = d->p.halation_amount;
+ /* per-film halation strength (e.g. a strong-AH stock stays near-zero on
+ blue and much lower on red/green than a no-AH/redscale stock). */
+ const float a_r = g->halation_strength[0] * h_eff, a_g = g->halation_strength[1] * h_eff,
+ a_b = g->halation_strength[2] * h_eff;
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_halation_apply, w, h, CLARG(plane),
+ CLARG(acc), CLARG(w), CLARG(h), CLARG(a_r),
+ CLARG(a_g), CLARG(a_b));
+ SF_CL_STEP("halation apply");
+ }
+ }
+
+ /* ---- 3) film development ------------------------------------------------ */
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_lograw, w, h, CLARG(plane), CLARG(w),
+ CLARG(h));
+ SF_CL_STEP("lograw");
+
+ const int use_corr = g->couplers_active;
+ if(use_corr)
+ {
+ err = dt_opencl_enqueue_kernel_2d_args(
+ devid, gd->kernel_develop_corr, w, h, CLARG(plane), CLARG(acc), CLARG(w), CLARG(h),
+ CLARG(cn_cl), CLARG(mats_cl), CLARG(g->gamma[0]), CLARG(g->gamma[1]), CLARG(g->gamma[2]),
+ CLARG(g->le0), CLARG(g->le_step), CLARG(g->film_dmax[0]), CLARG(g->film_dmax[1]),
+ CLARG(g->film_dmax[2]), CLARG(g->film_positive));
+ SF_CL_STEP("develop_corr");
+ /* DIR coupler inhibitor diffusion, gaussian sigma 20 um (reference value) */
+ const float csigma = g->coupler_diff_um / fmaxf(pixel_um, 1e-3f);
+ if(g->coupler_tail_w > 0.0f)
+ {
+ const float amp[4] = { 1.0f - g->coupler_tail_w, g->coupler_tail_w * SF_EXPTAIL_A0,
+ g->coupler_tail_w * SF_EXPTAIL_A1, g->coupler_tail_w * SF_EXPTAIL_A2 };
+ const float sig[4] = { csigma, SF_EXPTAIL_R0 * g->coupler_tail_um / fmaxf(pixel_um, 1e-3f),
+ SF_EXPTAIL_R1 * g->coupler_tail_um / fmaxf(pixel_um, 1e-3f),
+ SF_EXPTAIL_R2 * g->coupler_tail_um / fmaxf(pixel_um, 1e-3f) };
+ for(int g3 = 0; g3 < 4; g3++)
+ {
+ if(sig[g3] > 0.1f)
+ {
+ SF_GAUSS_BLUR4_OP_L(acc, plane2, sig[g3]);
+ if(err != CL_SUCCESS) break;
+ }
+ else
+ {
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, acc, plane2, 0, 0, npix * f * 4);
+ if(err != CL_SUCCESS) break;
+ }
+ const int reset = (g3 == 0);
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
+ CLARG(plane2), CLARG(tmpa), CLARG(w), CLARG(h),
+ CLARG(amp[g3]), CLARG(amp[g3]), CLARG(amp[g3]),
+ CLARG(reset));
+ SF_CL_STEP("coupler tail accum");
+ }
+ }
+ else if(csigma > 0.1f)
+ SF_GAUSS_BLUR4_FAST(acc, csigma, "coupler blur");
+ }
+ cl_mem corr_buf = (g->coupler_tail_w > 0.0f) ? tmpa : acc;
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_develop, w, h, CLARG(plane),
+ CLARG(corr_buf), CLARG(use_corr), CLARG(plane2), CLARG(w),
+ CLARG(h), CLARG(cb_cl), CLARG(mats_cl), CLARG(g->gamma[0]),
+ CLARG(g->gamma[1]), CLARG(g->gamma[2]), CLARG(g->le0),
+ CLARG(g->le_step));
+ SF_CL_STEP("develop");
+
+ /* ---- 4) grain on the developed CMY density ----------------------------- */
+ if(d->p.grain_on && d->p.grain_amount > 0.0f)
+ {
+ const int roi_x = roi_in->x, roi_y = roi_in->y;
+ const float amount = d->p.grain_amount;
+ const int mono = g->film_bw; /* B&W: achromatic grain */
+ /* see the matching comment on the CPU path (process()) for the full
+ rationale: darktable's preview pipe renders at a temporarily
+ reduced resolution, unlike upstream which always renders one real
+ resolution, so these fixed pixel radii need shrinking there to
+ avoid over-affecting real scene detail. Capped at 1.0 so zooming
+ in past 100% doesn't grow radii beyond what was validated. Does
+ NOT apply to npart_scale below, which is correctly resolution-
+ dependent via pixel_um already. */
+ /* Unified through the multi-sublayer table for every stock (nsub can be
+ 1) rather than branching to a separate single-layer kernel -- see the
+ matching comment in process()'s CPU path for why that's valid: the
+ build-time layer table already has correct n==1 data for single-layer
+ stocks. Built once per d->gpu (see d->grain_cl_built_for above)
+ rather than re-uploaded on every process_cl() call: tiled processing
+ calls this once per tile, and this data never changes between tiles
+ of the same image, so re-uploading it per tile was pure overhead. */
+ const int nsub = g->grain_n_sublayers, nle = SF_NLE, maxsub = SF_GRAIN_MAX_SUBLAYERS;
+ if(d->grain_cl_built_for != g || d->grain_cl_devid != devid)
+ {
+ if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax);
+ if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart);
+ if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin);
+ if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total);
+ if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve);
+ d->grain_cl_dmax = dt_opencl_copy_host_to_device_constant(
+ devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmax);
+ d->grain_cl_npart = dt_opencl_copy_host_to_device_constant(
+ devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_npart);
+ d->grain_cl_dmin = dt_opencl_copy_host_to_device_constant(
+ devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmin);
+ d->grain_cl_total = dt_opencl_copy_host_to_device_constant(
+ devid, (size_t)nle * 3 * f, (void *)g->grain_layer_curve_total);
+ d->grain_cl_curve = dt_opencl_copy_host_to_device_constant(
+ devid, (size_t)nle * maxsub * 3 * f, (void *)g->grain_layer_curve);
+ d->grain_cl_built_for = g;
+ d->grain_cl_devid = devid;
+ }
+ if(!d->grain_cl_dmax || !d->grain_cl_npart || !d->grain_cl_dmin || !d->grain_cl_total
+ || !d->grain_cl_curve)
+ err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
+ else
+ {
+ /* layer_npart (uploaded above) is precomputed at sf_sim_build time
+ against the fixed SF_GRAIN_REF_UM reference scale; rescale it live
+ to the real pixel_um here. */
+ const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM);
+ /* Upstream's per-sub-layer dye-cloud blur (layer_particle_model's
+ blur_particle, grain.py): sigma depends only on that sub-layer's
+ own per-particle optical density (dmax/npart), which is constant
+ across the whole image -- computed host-side once here, same as
+ the CPU path, rather than per-pixel on the device. */
+ float dye_sigma[3][SF_GRAIN_MAX_SUBLAYERS];
+ for(int c = 0; c < 3; c++)
+ for(int sl = 0; sl < nsub; sl++)
+ {
+ const float npart_c = g->grain_layer_npart[sl][c] * npart_scale;
+ const float od_particle = g->grain_layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f);
+ dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * sqrtf(fmaxf(od_particle, 0.0f)) * preview_scale;
+ }
+
+ cl_mem raw_buf[SF_GRAIN_MAX_SUBLAYERS] = { NULL };
+ cl_mem acc_buf = dt_opencl_alloc_device_buffer(devid, npix * f);
+ gboolean raw_ok = acc_buf != NULL;
+ for(int sl = 0; sl < nsub; sl++)
+ {
+ raw_buf[sl] = dt_opencl_alloc_device_buffer(devid, npix * f);
+ if(!raw_buf[sl]) raw_ok = FALSE;
+ }
+ if(!raw_ok)
+ err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
+ else
+ {
+ const int n_out_ch = mono ? 1 : 3;
+ for(int oc = 0; oc < n_out_ch && err == CL_SUCCESS; oc++)
+ {
+ const int channel_idx = mono ? 1 : oc;
+ const int seed_ch = mono ? 0 : oc;
+ const float unif_ch = g->grain_uniformity[channel_idx];
+ for(int sl = 0; sl < nsub && err == CL_SUCCESS; sl++)
+ {
+ err = dt_opencl_enqueue_kernel_2d_args(
+ devid, gd->kernel_grain_gen_raw_sl, w, h, CLARG(plane2), CLARG(raw_buf[sl]),
+ CLARG(w), CLARG(h), CLARG(roi_x), CLARG(roi_y), CLARG(mono), CLARG(channel_idx),
+ CLARG(seed_ch), CLARG(sl), CLARG(nle), CLARG(maxsub), CLARG(unif_ch),
+ CLARG(npart_scale), CLARG(d->grain_cl_dmax), CLARG(d->grain_cl_npart),
+ CLARG(d->grain_cl_dmin), CLARG(d->grain_cl_total), CLARG(d->grain_cl_curve));
+ if(err == CL_SUCCESS && dye_sigma[channel_idx][sl] > 1e-6f)
+ SF_GAUSS_BLUR1_L(raw_buf[sl], dye_sigma[channel_idx][sl]);
+ }
+ for(int sl = 0; sl < nsub && err == CL_SUCCESS; sl++)
+ {
+ const int reset = sl == 0;
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_accumulate_1c, w, h,
+ CLARG(acc_buf), CLARG(raw_buf[sl]), CLARG(w),
+ CLARG(h), CLARG(reset));
+ }
+ if(err == CL_SUCCESS)
+ {
+ const float dmin_ch = g->grain_dmin[channel_idx];
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_finalize_channel, w, h,
+ CLARG(tmpa), CLARG(acc_buf), CLARG(plane2),
+ CLARG(w), CLARG(h), CLARG(mono),
+ CLARG(channel_idx), CLARG(oc), CLARG(dmin_ch),
+ CLARG(amount));
+ }
+ }
+ }
+ for(int sl = 0; sl < nsub; sl++)
+ if(raw_buf[sl]) dt_opencl_release_mem_object(raw_buf[sl]);
+ if(acc_buf) dt_opencl_release_mem_object(acc_buf);
+ }
+ SF_CL_STEP("grain gen");
+ /* The DC-centring reduction is gone along with its CPU counterpart: the
+ Poisson sampler is unbiased, so there is nothing to centre. That also
+ retires the full device->host readback it needed, which stalled the queue
+ once per grain stage. */
+ /* Add the still-UNBLURRED delta, so the blur below sees the grained
+ absolute density rather than an isolated grain layer -- same ordering as
+ process(), see the long comment there for why the blur and the unsharp
+ mask that follows it only make sense as a pair. */
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2),
+ CLARG(tmpa), CLARG(w), CLARG(h));
+ SF_CL_STEP("grain add");
+ /* fixed pixel sigma, matching process()'s CPU-side fix -- see comment
+ there for the empirical validation. */
+ const float gsigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN)
+ * preview_scale;
+ SF_GAUSS_BLUR4(plane2, gsigma, "grain blur");
+ if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f)
+ {
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane2, acc, 0, 0, npix * f * 4);
+ if(err != CL_SUCCESS) goto cleanup;
+ const float usig = d->p.grain_usm_sigma * preview_scale;
+ SF_GAUSS_BLUR4_FAST(plane2, usig, "grain USM blur");
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_usm, w, h, CLARG(plane2),
+ CLARG(acc), CLARG(w), CLARG(h),
+ CLARG(d->p.grain_usm_amount),
+ CLARG(g->grain_dmin[0]), CLARG(g->grain_dmin[1]),
+ CLARG(g->grain_dmin[2]));
+ SF_CL_STEP("grain USM");
+ }
+ }
+
+ /* ---- 5) print ----------------------------------------------------------- */
+ if(g->has_print)
+ {
+ err = dt_opencl_enqueue_kernel_2d_args(
+ devid, gd->kernel_print_expose, w, h, CLARG(plane2), CLARG(plane), CLARG(w), CLARG(h),
+ CLARG(el_cl), CLARG(ex_cl), CLARG(ey_cl), CLARG(ez_cl), CLARG(en_cl), CLARG(em_cl),
+ CLARG(steps), CLARG(g->enl_lo[0]), CLARG(g->enl_lo[1]), CLARG(g->enl_lo[2]),
+ CLARG(g->enl_hi[0]), CLARG(g->enl_hi[1]), CLARG(g->enl_hi[2]), CLARG(g->print_exposure));
+ SF_CL_STEP("print_expose");
+ /* ---- print diffusion (optional, on the exposed print density) ---- */
+ if(d->p.print_diffusion_on)
+ {
+ sf_diffusion_plan_t pplan;
+ if(sf_diffusion_build_plan((int)d->p.print_diffusion_filter_family,
+ d->p.print_diffusion_strength,
+ d->p.print_diffusion_warmth, &pplan)
+ && pplan.p_s > 0.0f)
+ {
+ const float pdsc = fmaxf(d->p.print_diffusion_scale, 1e-6f);
+ for(int j = 0; j < pplan.n; j++)
+ {
+ const float sigma = fmaxf(pplan.sigma_um[j] * pdsc / pixel_um, 1e-3f);
+ SF_GAUSS_BLUR4_OP_L(plane, tmpa, sigma);
+ if(err != CL_SUCCESS) break;
+ const int reset = (j == 0);
+ const float wr = pplan.wr[j], wg = pplan.wg[j], wb = pplan.wb[j];
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
+ CLARG(tmpa), CLARG(acc), CLARG(w), CLARG(h),
+ CLARG(wr), CLARG(wg), CLARG(wb), CLARG(reset));
+ if(err != CL_SUCCESS) break;
+ }
+ if(err == CL_SUCCESS)
+ {
+ const float ps = pplan.p_s;
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_mix, w, h,
+ CLARG(plane), CLARG(acc), CLARG(w), CLARG(h),
+ CLARG(ps));
+ }
+ SF_CL_STEP("print_diffusion");
+ }
+ }
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_print_develop, w, h, CLARG(plane),
+ CLARG(plane2), CLARG(w), CLARG(h), CLARG(pc_cl),
+ CLARG(g->le0), CLARG(g->le_step));
+ SF_CL_STEP("print_develop");
+ }
+
+ /* ---- 6) scan over the full padded ROI into `plane` (free since print) ---- */
+ err = dt_opencl_enqueue_kernel_2d_args(
+ devid, gd->kernel_scan, w, h, CLARG(plane2), CLARG(plane), CLARG(w), CLARG(h),
+ CLARG(sl_cl), CLARG(sx_cl), CLARG(sy_cl),
+ CLARG(sz_cl), CLARG(sn_cl), CLARG(sm_cl), CLARG(steps), CLARG(g->scan_lo[0]),
+ CLARG(g->scan_lo[1]), CLARG(g->scan_lo[2]), CLARG(g->scan_hi[0]), CLARG(g->scan_hi[1]),
+ CLARG(g->scan_hi[2]), CLARG(mats_cl), CLARG(cm_cl), CLARG(g->cmax_nl), CLARG(g->cmax_nh),
+ CLARG(g->out_compress), CLARG(g->out_luminance_boost), CLARG(g->scan_bw_on), CLARG(g->scan_bw_m),
+ CLARG(g->scan_bw_q));
+ SF_CL_STEP("scan");
+
+ /* ---- 6b) scanner optics + viewing glare (mirrors process()) -------------- */
+ if(d->p.scan_blur > 0.0f)
+ {
+ SF_GAUSS_BLUR4(plane, d->p.scan_blur * preview_scale, "scanner blur");
+ }
+ if(d->p.scan_usm_sigma > 0.0f && d->p.scan_usm_amount > 0.0f)
+ {
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, acc, 0, 0, npix * f * 4);
+ if(err != CL_SUCCESS) goto cleanup;
+ SF_GAUSS_BLUR4_FAST(plane, d->p.scan_usm_sigma * preview_scale, "scanner USM blur");
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scan_usm, w, h, CLARG(plane),
+ CLARG(acc), CLARG(w), CLARG(h),
+ CLARG(d->p.scan_usm_amount));
+ SF_CL_STEP("scanner USM");
+ }
+ if(!d->p.scan_film && d->p.glare_percent > 0.0f)
+ {
+ const float gmean = d->p.glare_percent * 0.01f;
+ const float sigma2 = logf(1.0f + SF_GLARE_ROUGHNESS * SF_GLARE_ROUGHNESS);
+ const float gs = sqrtf(sigma2), gbias = -0.5f * sigma2;
+ const int roi_x = roi_in->x, roi_y = roi_in->y;
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_glare_gen, w, h, CLARG(tmpa),
+ CLARG(w), CLARG(h), CLARG(roi_x), CLARG(roi_y),
+ CLARG(gmean), CLARG(gs), CLARG(gbias));
+ SF_CL_STEP("glare gen");
+ SF_GAUSS_BLUR4(tmpa, SF_GLARE_BLUR_PX * preview_scale, "glare blur");
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_glare_add, w, h, CLARG(plane),
+ CLARG(tmpa), CLARG(w), CLARG(h));
+ SF_CL_STEP("glare add");
+ }
+
+ /* ---- 7) crop the roi_out window into dev_out ----------------------------- */
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_crop_out, ow, oh, CLARG(plane),
+ CLARG(dev_in), CLARG(dev_out), CLARG(w), CLARG(ow),
+ CLARG(oh), CLARG(ox), CLARG(oy));
+ SF_CL_STEP("crop out");
+
+cleanup:
+ dt_opencl_release_mem_object(mats_cl);
+ dt_opencl_release_mem_object(tc_cl);
+ dt_opencl_release_mem_object(cn_cl);
+ dt_opencl_release_mem_object(cb_cl);
+ dt_opencl_release_mem_object(el_cl);
+ dt_opencl_release_mem_object(ex_cl);
+ dt_opencl_release_mem_object(ey_cl);
+ dt_opencl_release_mem_object(ez_cl);
+ dt_opencl_release_mem_object(en_cl);
+ dt_opencl_release_mem_object(em_cl);
+ dt_opencl_release_mem_object(pc_cl);
+ dt_opencl_release_mem_object(sl_cl);
+ dt_opencl_release_mem_object(sx_cl);
+ dt_opencl_release_mem_object(sy_cl);
+ dt_opencl_release_mem_object(sz_cl);
+ dt_opencl_release_mem_object(sn_cl);
+ dt_opencl_release_mem_object(sm_cl);
+ dt_opencl_release_mem_object(cm_cl);
+ dt_opencl_release_mem_object(plane);
+ dt_opencl_release_mem_object(plane2);
+ dt_opencl_release_mem_object(tmpa);
+ dt_opencl_release_mem_object(acc);
+ dt_opencl_release_mem_object(plane1);
+ dt_opencl_release_mem_object(gtmp4);
+ dt_opencl_release_mem_object(gtmp1);
+ dt_opencl_release_mem_object(gauss_w);
+ return err;
+}
+#endif /* HAVE_OPENCL */
+
+/* ---------------------------------------------------------------------- */
+/* GUI */
+/* ---------------------------------------------------------------------- */
+
+static void _rescan(dt_iop_module_t *self)
+{
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ g_list_free_full(g->entries, g_free);
+ g->entries = _scan_profiles(NULL);
+}
+
+/* Entry at a list position, or NULL. The comboboxes carry the position as their
+ data, so this is how a user selection resolves back to a profile. */
+static const sf_prof_entry_t *_entry_at(const dt_iop_spektrafilm_gui_data_t *g, const int pos)
+{
+ return (pos >= 0) ? g_list_nth_data(g->entries, pos) : NULL;
+}
+
+static void _update_print_sensitivity(dt_iop_module_t *self);
+static void _update_development_sensitivity(const dt_iop_spektrafilm_gui_data_t *g,
+ const dt_iop_spektrafilm_params_t *p);
+static float _development_default(const sf_prof_entry_t *e);
+
+/* forward: needs _entry_by_hash(), which is defined below with the rest of the
+ stock lookups */
+static void _update_paper_auto_entry(dt_iop_module_t *self);
+
+static void _film_changed(GtkWidget *w, dt_iop_module_t *self)
+{
+ if(darktable.gui->reset) return;
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
+ const sf_prof_entry_t *e
+ = _entry_at(g, GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film)));
+ if(!e) return;
+ p->film_hash = e->hash;
+ /* A development time from the previous stock means nothing here -- the times
+ differ per stock, and a stale value can sit above the new stock's longest
+ (Double-X at 12 min, then 2302's family topping out at 9). Land on the new
+ stock's own default, so the slider always shows a time it actually has. */
+ p->development_min = _development_default(e);
+ DT_ENTER_GUI_UPDATE();
+ dt_bauhaus_slider_set(g->development_min, p->development_min);
+ DT_LEAVE_GUI_UPDATE();
+ /* A positive/reversal stock has no print stage, so its natural mode is
+ scan_film. Point the widget's reset target at that, so a reset gesture on
+ the checkbox lands on what THIS film wants rather than the compiled
+ default. */
+ dt_bauhaus_toggle_set_default(g->scan_film, e->positive);
+ /* scan-film follows the film's natural mode on a film switch: slides and
+ reversal stocks are viewed directly (scan), negatives go through the
+ print stage. The user can still toggle freely afterwards -- this only
+ re-baselines when the film itself changes, like the paper auto-follow. */
+ if(p->scan_film != e->positive)
+ {
+ p->scan_film = e->positive;
+ DT_ENTER_GUI_UPDATE();
+ dt_bauhaus_toggle_set(g->scan_film, p->scan_film);
+ DT_LEAVE_GUI_UPDATE();
+ _update_print_sensitivity(self);
+ }
+ /* On "auto" (hash 0) the paper follows the film's target print, and the
+ combobox keeps reading "auto" rather than jumping to the resolved stock:
+ the state is the link, not the destination, and selecting a specific paper
+ while the hash says auto made the two disagree. The entry names the stock
+ it resolves to instead. Done on every film change, not only while auto is
+ selected, so the entry is already correct if the paper is set back to auto
+ later. The pipeline resolves it identically either way (_resolve_stock). */
+ _update_paper_auto_entry(self);
+ /* last, once scan_film and the auto-followed paper have settled: both
+ development sliders are gated on their own stock, and the print one also on
+ there being a print stage at all */
+ _update_development_sensitivity(g, p);
+ dt_dev_add_history_item(darktable.develop, self, TRUE);
+}
+
+static void _paper_changed(GtkWidget *w, dt_iop_module_t *self)
+{
+ if(darktable.gui->reset) return;
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
+ const int ppos = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->paper));
+ if(ppos < 0)
+ {
+ /* back to auto: drop the explicit choice so the film resolves it again */
+ p->paper_hash = 0;
+ p->print_development_min = 0.0f;
+ _update_development_sensitivity(g, p);
+ dt_dev_add_history_item(darktable.develop, self, TRUE);
+ return;
+ }
+ const sf_prof_entry_t *pe = _entry_at(g, ppos);
+ if(!pe) return;
+ p->paper_hash = pe->hash;
+ /* same as _film_changed: a time from the previous paper does not transfer */
+ p->print_development_min = _development_default(pe);
+ DT_ENTER_GUI_UPDATE();
+ dt_bauhaus_slider_set(g->print_development_min, p->print_development_min);
+ DT_LEAVE_GUI_UPDATE();
+ _update_development_sensitivity(g, p);
+ dt_dev_add_history_item(darktable.develop, self, TRUE);
+}
+
+/* Entry for a stock hash, or NULL. `printing` disambiguates, since the same
+ stock name can exist as both a film and a paper. */
+static const sf_prof_entry_t *_entry_by_hash(const dt_iop_spektrafilm_gui_data_t *g,
+ const uint32_t hash, const gboolean printing)
+{
+ for(const GList *l = g->entries; l; l = l->next)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(e->hash == hash && e->printing == printing) return e;
+ }
+ return NULL;
+}
+
+/* The film the pipeline is rendering with: the stock this edit names, or -- for
+ a hash of 0, or a stock that has since left the pack -- the same fallback
+ gui_update() selects the combobox on, so the label below never names a film
+ the combobox does not show. */
+static const sf_prof_entry_t *_current_film_entry(const dt_iop_spektrafilm_gui_data_t *g,
+ const dt_iop_spektrafilm_params_t *p)
+{
+ const sf_prof_entry_t *hit = _entry_by_hash(g, p->film_hash, FALSE);
+ if(p->film_hash && hit) return hit;
+ const sf_prof_entry_t *fallback = NULL;
+ for(const GList *l = g->entries; l; l = l->next)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(e->printing) continue;
+ if(!fallback || !strcmp(e->stock, "kodak_portra_400")) fallback = e;
+ }
+ return fallback;
+}
+
+/* The paper the pipeline actually prints on while "auto" is selected: the film's
+ own target print when the pack ships it as a paper, otherwise the first print
+ stock in the list. That second half mirrors _resolve_stock()'s own last
+ resort, and is what makes the label honest -- stopping at target_print left
+ two cases reading "follow film stock" while the pipeline was quietly printing
+ on a specific paper anyway: a film that names no target print, and Double-X,
+ whose target 2302 the pack exports as a film rather than as a paper. */
+static const sf_prof_entry_t *_auto_paper_entry(const dt_iop_spektrafilm_gui_data_t *g,
+ const sf_prof_entry_t *film)
+{
+ const sf_prof_entry_t *first = NULL;
+ for(const GList *l = g->entries; l; l = l->next)
+ {
+ const sf_prof_entry_t *pe = l->data;
+ if(!pe->printing) continue;
+ if(!first) first = pe;
+ if(film && film->target_print[0] && !strcmp(pe->stock, film->target_print)) return pe;
+ }
+ return first;
+}
+
+/* Name the resolved paper in the "auto" entry itself rather than only in the
+ tooltip. The combobox deliberately keeps reading "auto" while it follows a
+ film (the state is the link, not the destination), but that left the paper
+ actually being printed on invisible unless you hovered. Renaming the entry
+ shows both at once and keeps the link intact -- the selection does not move,
+ only its text changes, so paper_hash stays 0.
+
+ With scan_film there is no print stage at all, and the widget is insensitive:
+ the entry goes blank rather than naming a paper nothing will be printed on.
+ Called from _update_print_sensitivity() as well as on a film change, so it
+ follows that toggle. */
+static void _update_paper_auto_entry(dt_iop_module_t *self)
+{
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ const dt_iop_spektrafilm_params_t *p = (const dt_iop_spektrafilm_params_t *)self->params;
+ if(!g || !g->paper) return;
+ char label[SF_NAME_LEN + 32];
+ char tip[SF_NAME_LEN + 256];
+ const sf_prof_entry_t *re
+ = p->scan_film ? NULL : _auto_paper_entry(g, _current_film_entry(g, p));
+ if(p->scan_film)
+ {
+ label[0] = '\0';
+ g_strlcpy(tip, _("print paper. not used: the film is being scanned directly."),
+ sizeof tip);
+ }
+ else if(re)
+ {
+ snprintf(label, sizeof label, _("auto (%s)"), re->name);
+ snprintf(tip, sizeof tip,
+ _("print paper. \"auto\" follows the film stock's own target print,\n"
+ "currently %s. picking a paper pins it until you select auto again."),
+ re->name);
+ }
+ else
+ {
+ /* no print stock in the pack at all -- the list below reads "(none)" */
+ g_strlcpy(label, _("auto"), sizeof label);
+ g_strlcpy(tip, _("print paper. \"auto\" follows the film stock's own target print."),
+ sizeof tip);
+ }
+ /* Position 0: the auto entry is added before any section header, so its list
+ index is fixed whatever paper groups the pack turns out to contain. */
+ dt_bauhaus_combobox_set_entry_label(g->paper, 0, label);
+ /* The widget renders the active entry's label straight out of that array at
+ draw time, so a relabel needs nothing but a redraw -- and does need one. */
+ gtk_widget_queue_draw(g->paper);
+ gtk_widget_set_tooltip_text(g->paper, tip);
+}
+
+/* Default development time for a stock, in minutes: the representative middle
+ member of its family, which is what select_development_time(None) picks. 0 when
+ the stock is characterised at a single development. */
+static float _development_default(const sf_prof_entry_t *e)
+{
+ return (e && e->n_dev > 1) ? (float)e->dev_times[(e->n_dev - 1) / 2] : 0.0f;
+}
+
+/* Point one development slider at one stock: sensitive only where that stock is
+ characterised at more than one development time, spanning exactly the times it
+ offers, and naming them -- they differ per stock and the value snaps to them,
+ so a bare 0-15 range would be guesswork. */
+static void _development_widget_update(GtkWidget *w, const sf_prof_entry_t *e)
+{
+ if(!w) return;
+ const gboolean have = (e && e->n_dev > 1);
+ gtk_widget_set_sensitive(w, have);
+
+ float hi = 15.0f;
+ if(have)
+ {
+ hi = (float)e->dev_times[0];
+ for(int i = 1; i < e->n_dev; i++) hi = fmaxf(hi, (float)e->dev_times[i]);
+ }
+ /* 0 stays reachable at the bottom -- it means "this stock's own default" -- and
+ the hard 15 min ceiling covers the widest family in the release (Double-X,
+ 12 min) with room to spare. */
+ dt_bauhaus_slider_set_soft_range(w, 0.0f, hi);
+ /* Reset gestures (double-click, scroll-reset) go to the widget's own default,
+ which introspection set to the compiled 0. That renders correctly -- 0 means
+ "this stock's default" -- but leaves the slider reading 0 instead of the time
+ it resolved to, on the one discontinuity in the range. Point it at the real
+ number for the stock in hand, so a reset shows 6.5 min on Double-X and 5 min
+ on 2302 rather than 0. */
+ dt_bauhaus_slider_set_default(w, _development_default(e));
+
+ if(have)
+ {
+ char times[128] = { 0 };
+ for(int i = 0; i < e->n_dev; i++)
+ {
+ char one[24];
+ snprintf(one, sizeof one, "%s%.3g", i ? ", " : "", e->dev_times[i]);
+ g_strlcat(times, one, sizeof times);
+ }
+ char tip[320];
+ snprintf(tip, sizeof tip,
+ _("development time, in minutes. snaps to the nearest time %s is\n"
+ "characterised at: %s min. 0 uses the stock's own default (%.3g min)."),
+ e->name, times, (double)_development_default(e));
+ gtk_widget_set_tooltip_text(w, tip);
+ }
+ else
+ gtk_widget_set_tooltip_text(w,
+ _("development time. this stock is characterised at a single\n"
+ "development, so there is nothing to choose.\n\n"
+ "single-emulsion B&W stocks are the ones that carry a family:\n"
+ "Double-X at 4/5/6.5/9/12 min, print film 2302 at 2/3.5/5/7/9 min."));
+}
+
+/* Film and print are separate chemistries developed for separate times, so they
+ get a slider each, pointed at their own stock. Lives here rather than in
+ gui_update() because this is the one function every film / paper / scan_film
+ change already routes through; in gui_update() alone it went stale the moment a
+ stock was switched. */
+static void _update_development_sensitivity(const dt_iop_spektrafilm_gui_data_t *g,
+ const dt_iop_spektrafilm_params_t *p)
+{
+ _development_widget_update(g->development_min, _entry_by_hash(g, p->film_hash, FALSE));
+ _development_widget_update(g->print_development_min,
+ p->scan_film ? NULL : _entry_by_hash(g, p->paper_hash, TRUE));
+}
+
+static void _update_print_sensitivity(dt_iop_module_t *self)
+{
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
+ const gboolean printing = !p->scan_film;
+ gtk_widget_set_sensitive(g->paper, printing);
+ gtk_widget_set_sensitive(g->print_exposure_ev, printing);
+ gtk_widget_set_sensitive(g->print_auto_exposure, printing);
+ gtk_widget_set_sensitive(g->print_contrast, printing);
+ gtk_widget_set_sensitive(g->filter_m, printing);
+ gtk_widget_set_sensitive(g->filter_y, printing);
+ gtk_widget_set_sensitive(g->print_diffusion_on, printing);
+ gtk_widget_set_sensitive(g->print_diffusion_filter_family, printing && p->print_diffusion_on);
+ gtk_widget_set_sensitive(g->print_diffusion_strength, printing && p->print_diffusion_on);
+ gtk_widget_set_sensitive(g->print_diffusion_scale, printing && p->print_diffusion_on);
+ gtk_widget_set_sensitive(g->print_diffusion_warmth, printing && p->print_diffusion_on);
+ gtk_widget_set_sensitive(g->preflash_exposure, printing);
+ gtk_widget_set_sensitive(g->preflash_m_shift, printing);
+ gtk_widget_set_sensitive(g->preflash_y_shift, printing);
+ /* toggle_from_params checkboxes keep showing their tick even when made
+ insensitive -- GTK just dims the whole widget, so a checked-but-grayed
+ box can read as "this is still on" when it has no effect at all (no
+ print stage on positive/reversal film). Blank the tick while
+ insensitive and restore the real value once re-enabled. Wrapped in
+ DT_ENTER/LEAVE_GUI_UPDATE -- the same guard dt_iop_gui_update's own
+ programmatic widget syncs rely on -- so this is purely visual and
+ never writes back into the param. */
+ DT_ENTER_GUI_UPDATE();
+ dt_bauhaus_toggle_set(g->print_auto_exposure,
+ printing && p->print_auto_exposure);
+ dt_bauhaus_toggle_set(g->print_diffusion_on,
+ printing && p->print_diffusion_on);
+ DT_LEAVE_GUI_UPDATE();
+
+ /* The auto entry names the paper in use, and with no print stage there is
+ none: relabel from here, the one place every scan_film change passes. */
+ _update_paper_auto_entry(self);
+
+ /* Also from here: gui_changed() sends the scan_film toggle to this function and
+ not to _toggle_sensitivity(), so without this the print development slider
+ stayed live after switching to a scan-the-film workflow that has no print
+ stage at all. */
+ _update_development_sensitivity(g, p);
+}
+
+/* Grays out each effect's own sub-controls when its master "enable" toggle
+ is off -- previously only the print-related controls (scan_film ->
+ _update_print_sensitivity above) got this treatment; halation/grain/
+ diffusion sliders stayed clickable-but-inert when their own toggle was
+ unchecked, which reads as "these still do something" when they don't. */
+static void _toggle_sensitivity(dt_iop_spektrafilm_gui_data_t *g,
+ dt_iop_spektrafilm_params_t *p)
+{
+ const gboolean hal = p->halation_on;
+ gtk_widget_set_sensitive(g->scatter_amount, hal);
+ gtk_widget_set_sensitive(g->scatter_scale, hal);
+ gtk_widget_set_sensitive(g->halation_amount, hal);
+ gtk_widget_set_sensitive(g->halation_scale, hal);
+ gtk_widget_set_sensitive(g->boost_ev, hal);
+ gtk_widget_set_sensitive(g->boost_range, hal);
+ gtk_widget_set_sensitive(g->protect_ev, hal);
+
+ const gboolean grn = p->grain_on;
+ gtk_widget_set_sensitive(g->grain_amount, grn);
+ gtk_widget_set_sensitive(g->grain_size, grn);
+ gtk_widget_set_sensitive(g->grain_usm_sigma, grn);
+ gtk_widget_set_sensitive(g->grain_usm_amount, grn);
+
+ const gboolean dif = p->diffusion_on;
+ gtk_widget_set_sensitive(g->diffusion_filter_family, dif);
+ gtk_widget_set_sensitive(g->diffusion_strength, dif);
+ gtk_widget_set_sensitive(g->diffusion_scale, dif);
+ gtk_widget_set_sensitive(g->diffusion_warmth, dif);
+
+ const gboolean pdif = p->print_diffusion_on;
+ gtk_widget_set_sensitive(g->print_diffusion_filter_family, pdif);
+ gtk_widget_set_sensitive(g->print_diffusion_strength, pdif);
+ gtk_widget_set_sensitive(g->print_diffusion_scale, pdif);
+ gtk_widget_set_sensitive(g->print_diffusion_warmth, pdif);
+
+ /* last: the development sliders are gated on their own stock's family, and
+ must not be re-enabled by any of the plain `printing` toggles above */
+ _update_development_sensitivity(g, p);
+}
+
+void gui_reset(dt_iop_module_t *self)
+{
+ dt_iop_color_picker_reset(self, TRUE);
+}
+
+/* called by the core whenever a params-linked widget changed */
+void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous)
+{
+ /* Stamp the spectral table this edit is being made against. Done here because
+ gui_changed() runs after the widget has written the param and before the
+ history item is created, so the value lands in the same edit -- and only on
+ a real user change, so merely opening an image never dirties one.
+
+ Only stamp when there is nothing to lose: no table recorded yet, or the
+ loaded pack is already the one recorded. Overwriting a DIFFERENT recorded
+ hash would throw away the only record of which pack renders this edit as
+ it was made, and it would happen on any incidental slider touch while the
+ mismatch warning was on screen saying the data was wrong. That record is
+ what the download button uses to fetch the right pack, so losing it turns
+ a fixable mismatch into a permanent one. */
+ {
+ dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
+ dt_pthread_mutex_lock(&_pack_lock);
+ if(_pack)
+ {
+ const uint32_t cur = sf_pack_lut_hash(_pack);
+ if(!p->lut_hash || p->lut_hash == cur) p->lut_hash = cur;
+ }
+ dt_pthread_mutex_unlock(&_pack_lock);
+ }
+
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
+ if(!w || w == g->scan_film) _update_print_sensitivity(self);
+ if(!w || w == g->halation_on || w == g->grain_on || w == g->diffusion_on
+ || w == g->print_diffusion_on)
+ {
+ _toggle_sensitivity(g, p);
+ if(w == g->print_diffusion_on) _update_print_sensitivity(self);
+ }
+ if(w == g->print_auto_exposure && !*(gboolean *)previous && p->print_auto_exposure)
+ {
+ /* print_exposure_ev (manual) and print_auto_exposure (automatic) are
+ independent, always-additive factors -- matching the reference app's
+ own architecture (raw *= exposure_factor; raw *= enlarger.print_exposure,
+ two separate multiplications) rather than a mutually-exclusive pair.
+ Left alone, re-enabling auto stacks on top of whatever manual EV was
+ dialed in while it was off, which reads as "auto exposure is now
+ offset by the old manual value". Reset the manual slider on OFF->ON
+ so re-enabling auto gives a clean auto result to fine-tune from. */
+ p->print_exposure_ev = 0.0f;
+ dt_bauhaus_slider_set(g->print_exposure_ev, 0.0f);
+ }
+}
+
+/* ---------------------------------------------------------------------- */
+/* data pack status row */
+/* ---------------------------------------------------------------------- */
+
+static gboolean _data_poll_cb(gpointer user_data);
+
+/* Reflect pack state into the header row. Called whenever the module's trouble
+ state is refreshed and once per second while a download runs. */
+static void _update_data_row(dt_iop_module_t *self)
+{
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ const dt_iop_spektrafilm_params_t *p =
+ (const dt_iop_spektrafilm_params_t *)self->params;
+ if(!g || !g->data_box) return;
+
+ char msg[256] = { 0 };
+ double progress = 0.0;
+ const sf_fetch_state_t state = sf_fetch_status(msg, sizeof msg, &progress);
+
+ /* Is any pack usable at all, and is it the one this edit was made with?
+ Local-only, so this is cheap enough to answer on every refresh. */
+ char dir[SF_PATH_LEN];
+ gboolean exact = FALSE;
+ const gboolean have_any =
+ sf_fetch_resolve_pack_dir(p->lut_hash, dir, sizeof dir, &exact);
+
+ /* With no pack there is nothing any of the controls could act on: a film
+ list with no films, sliders driving a simulation that cannot be built.
+ Collapse the module to the one control that changes that, and bring the
+ rest back only once a pack is in place. */
+ if(g->main_box) gtk_widget_set_visible(g->main_box, have_any);
+
+ if(state == SF_FETCH_RUNNING)
+ {
+ g->data_last_state = state;
+ char line[320];
+ snprintf(line, sizeof line, "%s %d%%", msg, (int)(progress * 100.0 + 0.5));
+ gtk_label_set_text(GTK_LABEL(g->data_status), line);
+ gtk_button_set_label(GTK_BUTTON(g->data_button), _("cancel"));
+ gtk_widget_set_sensitive(g->data_button, TRUE);
+ gtk_widget_set_visible(g->data_box, TRUE);
+ if(!g->data_poll) g->data_poll = g_timeout_add(500, _data_poll_cb, self);
+ return;
+ }
+
+ if(g->data_poll)
+ {
+ g_source_remove(g->data_poll);
+ g->data_poll = 0;
+ }
+
+ /* A fetch just finished. Reprocessing alone is not enough to make the new
+ pack usable: the film and paper comboboxes were filled by _rescan() at a
+ time when no profiles existed, and nothing refills them on a pipe
+ reprocess. Without this the module renders but every stock list stays
+ empty, so no selection can be made and changing a slider appears to do
+ nothing. dt_iop_gui_update() re-runs gui_update(), which rescans. */
+ if(g->data_last_state == SF_FETCH_RUNNING && state == SF_FETCH_DONE)
+ {
+ g->data_last_state = state;
+ dt_iop_gui_update(self);
+
+ /* Drop the cached pipeline output from this module onwards before asking
+ for a reprocess.
+
+ The pixelpipe caches by a hash over module parameters, and installing a
+ pack changes none of them -- so the cacheline computed while the module
+ had no data (and therefore passed pixels through untouched) still
+ matches and gets reused. The symptom is a module that stays inert after
+ a successful download, starts working the moment any slider moves, and
+ goes inert again the instant that slider returns to its original value,
+ because that value hashes back onto the stale line. A restart looked
+ like a fix only because it started with an empty cache. */
+ dt_develop_t *dev = darktable.develop;
+ if(dev)
+ {
+ dt_dev_pixelpipe_t *pipes[]
+ = { dev->full.pipe, dev->preview_pipe, dev->preview2.pipe };
+ for(size_t i = 0; i < sizeof(pipes) / sizeof(pipes[0]); i++)
+ if(pipes[i])
+ dt_dev_pixelpipe_cache_invalidate_later(pipes[i], self->iop_order,
+ "spektrafilm data pack installed");
+ dt_dev_reprocess_all(dev);
+ }
+ return; /* gui_update() calls back into here with the settled state */
+ }
+ g->data_last_state = state;
+
+ /* Nothing installed at all, or installed but not the table this edit wants.
+ Those are the only two states worth offering a download for; anything else
+ leaves the row hidden. */
+ if(have_any && (exact || !p->lut_hash))
+ {
+ gtk_widget_set_visible(g->data_box, FALSE);
+ return;
+ }
+
+ g->data_wanted = have_any ? p->lut_hash : 0;
+ gtk_label_set_text(
+ GTK_LABEL(g->data_status),
+ have_any
+ ? _("the table this edit was developed with is not installed")
+ : _("no data pack installed -- the module's controls appear once one is"));
+ gtk_button_set_label(GTK_BUTTON(g->data_button), _("download data pack"));
+
+ /* The button stays visible but dead when downloads are switched off, so the
+ reason the module cannot render is discoverable rather than silent. */
+ const gboolean allowed = sf_fetch_downloads_enabled();
+ gtk_widget_set_sensitive(g->data_button, allowed);
+ gtk_widget_set_tooltip_text(
+ g->data_button,
+ allowed ? _("fetch the matching spectral data pack over the network")
+ : _("enable \"allow spektrafilm to download data\" in preferences first"));
+ gtk_widget_set_visible(g->data_box, TRUE);
+}
+
+static gboolean _data_poll_cb(gpointer user_data)
+{
+ dt_iop_module_t *self = (dt_iop_module_t *)user_data;
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ if(!g || !g->data_box) return G_SOURCE_REMOVE;
+ _update_data_row(self);
+ /* _update_data_row clears data_poll when the fetch is no longer running, and
+ that is also the signal to stop this timeout. */
+ return g->data_poll ? G_SOURCE_CONTINUE : G_SOURCE_REMOVE;
+}
+
+static void _data_button_clicked(GtkButton *button, dt_iop_module_t *self)
+{
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ if(!g) return;
+
+ if(sf_fetch_status(NULL, 0, NULL) == SF_FETCH_RUNNING)
+ sf_fetch_cancel();
+ else
+ sf_fetch_start(g->data_wanted);
+
+ _update_data_row(self);
+}
+
+/* sim_error and sim_warning were being recorded and never shown -- a missing
+ data pack, an unreadable profile or a spectral-table mismatch all produced a
+ silently wrong or blank render. Route them to the module's trouble banner,
+ which is darktable's own mechanism for exactly this. */
+static void _update_trouble_message(dt_iop_module_t *self)
+{
+ const dt_iop_spektrafilm_data_t *d = (const dt_iop_spektrafilm_data_t *)self->data;
+ _update_data_row(self);
+ if(!d) return;
+ if(d->sim_error[0])
+ dt_iop_set_module_trouble_message(self, _("cannot render"), d->sim_error, NULL);
+ else if(d->sim_warning[0])
+ dt_iop_set_module_trouble_message(self, _("data mismatch"), d->sim_warning, NULL);
+ else
+ dt_iop_set_module_trouble_message(self, NULL, NULL, NULL);
+}
+
+void gui_update(dt_iop_module_t *self)
+{
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
+
+ _rescan(self);
+
+ /* Films and papers share one list; e->printing separates them, and each
+ combobox entry carries its position in that list as its data. */
+ static const struct { int pos; int bw; const char *label; } groups[] = {
+ { 0, 0, N_("negative color") },
+ { 0, 1, N_("negative monochrome") },
+ { 1, 0, N_("positive color") },
+ { 1, 1, N_("positive monochrome") },
+ };
+ static const struct { int bw; const char *label; } pgroups[] = {
+ { 0, N_("color") },
+ { 1, N_("monochrome") },
+ };
+
+ dt_bauhaus_combobox_clear(g->film);
+ gboolean any_film = FALSE;
+ for(int gi = 0; gi < 4; gi++)
+ {
+ gboolean first = TRUE;
+ int pos = 0;
+ for(const GList *l = g->entries; l; l = l->next, pos++)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(e->printing || e->positive != groups[gi].pos || e->bw != groups[gi].bw) continue;
+ if(first) { dt_bauhaus_combobox_add_section(g->film, _(groups[gi].label)); first = FALSE; }
+ dt_bauhaus_combobox_add_full(g->film, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
+ GINT_TO_POINTER(pos), NULL, TRUE);
+ any_film = TRUE;
+ }
+ }
+ if(!any_film) dt_bauhaus_combobox_add(g->film, _("(no profiles found)"));
+
+ dt_bauhaus_combobox_clear(g->paper);
+ /* paper_hash 0 means "follow the film's target print" -- the state a fresh
+ edit starts in, and the one _film_changed() keeps updating. Picking a paper
+ replaced it with an explicit choice and the link was then unreachable, which
+ is what people have asked to get back. Give that state a name at the top of
+ the list so it is both visible and selectable, rather than adding a separate
+ reset button for something the combobox can already express. Data -1 keeps
+ it clear of the list positions used below. */
+ dt_bauhaus_combobox_add_full(g->paper, _("auto (follow film stock)"),
+ DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(-1), NULL, TRUE);
+ gboolean any_paper = FALSE;
+ for(int gi = 0; gi < 2; gi++)
+ {
+ gboolean first = TRUE;
+ int pos = 0;
+ for(const GList *l = g->entries; l; l = l->next, pos++)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(!e->printing || e->bw != pgroups[gi].bw) continue;
+ if(first) { dt_bauhaus_combobox_add_section(g->paper, _(pgroups[gi].label)); first = FALSE; }
+ dt_bauhaus_combobox_add_full(g->paper, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
+ GINT_TO_POINTER(pos), NULL, TRUE);
+ any_paper = TRUE;
+ }
+ }
+ if(!any_paper) dt_bauhaus_combobox_add(g->paper, _("(none)"));
+
+ /* Select the saved film. On no hash match -- a fresh param with film_hash 0,
+ or a stock that vanished from the pack -- mirror _resolve_stock's fallback
+ so the combobox agrees with what the pipeline actually renders, instead of
+ landing on whatever sorts first while the pipe renders the real default. */
+ int fpos = -1, fallback = -1, pos = 0;
+ const sf_prof_entry_t *fe = NULL;
+ for(const GList *l = g->entries; l; l = l->next, pos++)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(e->printing) continue;
+ if(fallback < 0 || !strcmp(e->stock, "kodak_portra_400")) fallback = pos;
+ if(p->film_hash && e->hash == p->film_hash) { fpos = pos; fe = e; }
+ }
+ if(fpos < 0) fpos = fallback;
+ if(fpos >= 0)
+ {
+ if(!fe) fe = _entry_at(g, fpos);
+ dt_bauhaus_combobox_set_from_value(g->film, fpos);
+ }
+
+ /* _film_changed() bails out under darktable.gui->reset, which gui_update runs
+ under, so its reset target never gets set on a plain module load. Do it here
+ too, or a reset gesture on a positive/reversal film would flip scan_film off.
+ p->scan_film itself is deliberately not touched: the loaded value may be an
+ intentional override and must survive the load. */
+ if(fe) dt_bauhaus_toggle_set_default(g->scan_film, fe->positive);
+
+ const char *target = fe ? fe->target_print : NULL;
+ int ppos = -1, pfirst = -1;
+ pos = 0;
+ for(const GList *l = g->entries; l; l = l->next, pos++)
+ {
+ const sf_prof_entry_t *e = l->data;
+ if(!e->printing) continue;
+ if(pfirst < 0) pfirst = pos;
+ if(p->paper_hash ? (e->hash == p->paper_hash) : (target && !strcmp(e->stock, target)))
+ ppos = pos;
+ }
+ /* an edit that never picked a paper shows "auto", not the stock it happens to
+ resolve to -- otherwise the link looks broken the moment it is displayed */
+ if(!p->paper_hash) ppos = -1;
+ else if(ppos < 0) ppos = pfirst;
+ if(ppos >= -1) dt_bauhaus_combobox_set_from_value(g->paper, ppos);
+ /* after the repopulation above, which reset the auto entry to its plain
+ label: the entry names the paper this film resolves to, so a module that
+ opens on auto shows the paper it is really printing on */
+ _update_paper_auto_entry(self);
+
+ {
+ const int fpreset = _format_mm_to_preset(p->film_format_mm);
+ dt_bauhaus_combobox_set_from_value(g->film_format_combo, fpreset);
+ gtk_widget_set_visible(g->film_format_mm_slider,
+ fpreset < 0 || fpreset >= FORMAT_PRESETS_N);
+ }
+
+ /* toggle_from_params check buttons are NOT auto-synced by
+ dt_bauhaus_update_from_field (it only handles sliders/combos), so set
+ them here or they drift from the params: a stale box makes the first
+ click a no-op (field already has that value -> no history item) and
+ module reset never updates them. */
+ dt_bauhaus_toggle_set(g->scan_film, p->scan_film);
+ dt_bauhaus_toggle_set(g->adaptation_bandwidth, p->adaptation_bandwidth);
+ dt_bauhaus_toggle_set(g->adaptation_surface, p->adaptation_surface);
+ dt_bauhaus_toggle_set(g->print_auto_exposure, p->print_auto_exposure);
+ dt_bauhaus_toggle_set(g->halation_on, p->halation_on);
+ dt_bauhaus_toggle_set(g->diffusion_on, p->diffusion_on);
+ dt_bauhaus_toggle_set(g->print_diffusion_on, p->print_diffusion_on);
+ dt_bauhaus_toggle_set(g->grain_on, p->grain_on);
+
+ _toggle_sensitivity(g, p);
+ _update_print_sensitivity(self);
+
+ _update_trouble_message(self);
+}
+
+/* Boost that puts the probe lightness of `rgb` at `target_L`.
+ *
+ * This used to be a closed form: L was taken to scale as boost^(1/3), so one
+ * probe plus new = current * (target/measured)^3 was supposedly exact. That
+ * identity holds only while the scan stage is a pure scale on XYZ. It is not:
+ * sf_sim_scan applies the scanner black/white-point correction AFTER the boost,
+ * and that correction is affine and clipped -- the delivered luminance is
+ * clamp(m * boost * Y + q, 0, 1). So L goes as boost^a with a < 1/3, the update
+ * degenerates to new = current^(1 - 3a) * const, and repeated picks crept
+ * toward the right value instead of landing on it. (Negatives are unaffected,
+ * scan_bw_on is only set for scan-film mode with positive stock -- which is
+ * exactly where this control gets used.)
+ *
+ * Solve against the real transfer instead. boost -> L is monotone
+ * non-decreasing and one probe is a single pixel through the sim, so a
+ * geometric bisection over the slider's own range is both exact and free:
+ * 20 steps pin the answer to ~2e-6 of the range. The result no longer depends
+ * on the current slider value at all, so picking twice gives the same number. */
+static float _solve_boost_for_lightness(const sf_sim_t *sim, const float rgb[3],
+ const float target_L)
+{
+ float lo = 0.5f, hi = 4.0f; /* the slider's own $MIN / $MAX */
+ if(sf_sim_probe_lightness(sim, rgb, lo) >= target_L) return lo;
+ if(sf_sim_probe_lightness(sim, rgb, hi) <= target_L) return hi;
+ for(int i = 0; i < 20; i++)
+ {
+ const float mid = sqrtf(lo * hi);
+ if(sf_sim_probe_lightness(sim, rgb, mid) < target_L) lo = mid;
+ else hi = mid;
+ }
+ return sqrtf(lo * hi);
+}
+
+void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpipe_t *pipe)
+{
+ dt_iop_spektrafilm_gui_data_t *g = self->gui_data;
+ if(picker != g->output_boost) return;
+
+ /* picked_color_min/max start at sentinel values (+FLT_MAX / -FLT_MAX)
+ until a real area pick has actually landed; if this callback fires
+ before that (e.g. some other programmatic trigger of the picker
+ path), max stays below min and using it directly would feed garbage
+ (-FLT_MAX for every channel) into the simulation -- that propagates
+ into a wildly out-of-range LUT lookup and segfaults. Standard
+ darktable idiom for this check, matching e.g. exposure.c's own
+ color_picker_apply. */
+ if(self->picked_color_max[0] < self->picked_color_min[0]) return;
+
+ const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_work_profile_info(pipe);
+ if(!work_profile) return;
+
+ /* Build a standalone sim from the module's current live params: the
+ picker runs independently of any specific piece's cached data, so this
+ is a one-off build for this measurement, not the pipe's own d->sim
+ (which _ensure_sim also caches on -- see there for what gets set). */
+ dt_iop_spektrafilm_data_t d_tmp;
+ memset(&d_tmp, 0, sizeof(d_tmp));
+ d_tmp.p = *(dt_iop_spektrafilm_params_t *)self->params;
+ dt_pthread_mutex_init(&d_tmp.lock, NULL);
+ sf_sim_t *sim = _ensure_sim(&d_tmp, work_profile);
+ if(!sim)
+ {
+ dt_pthread_mutex_destroy(&d_tmp.lock);
+ return;
+ }
+
+ /* the brightest tone in the picked area is what determines whether the
+ compressor's knee engages usefully; picked_color_max is already an
+ area-mode min/max/mean pick (see dt_color_picker_new(..., DT_COLOR_PICKER_AREA, ...)
+ above in gui_init). */
+ const float rgb_max[3] = { self->picked_color_max[0], self->picked_color_max[1],
+ self->picked_color_max[2] };
+ /* Target: land well past the compressor's knee threshold (SF_OUT_LIGHT_T
+ = 0.7 in spektra_sim.c), close to but not at its asymptotic limit
+ (1.0). 0.80 (only 0.10 above the threshold) turned out too
+ conservative in practice -- left visible unused headroom in the
+ histogram and read as noticeably dark, since the knee's own
+ compression only really starts doing useful work well above its
+ threshold. 0.90 still left some headroom on further testing; 0.95
+ (only 0.05 short of the limit) uses close to the full available
+ range. The knee handles any input gracefully by design, so there's no
+ hard-clipping risk in pushing this close to it. */
+ const float target_L = 0.95f;
+ const float new_boost = _solve_boost_for_lightness(sim, rgb_max, target_L);
+
+ if(d_tmp.gpu) sf_sim_gpu_free(d_tmp.gpu);
+ if(d_tmp.sim) sf_sim_free(d_tmp.sim);
+ dt_pthread_mutex_destroy(&d_tmp.lock);
+
+ dt_iop_spektrafilm_params_t *p = self->params;
+ p->output_luminance_boost = new_boost;
+ DT_ENTER_GUI_UPDATE();
+ dt_bauhaus_slider_set(g->output_boost, new_boost);
+ DT_LEAVE_GUI_UPDATE();
+ dt_dev_add_history_item(darktable.develop, self, TRUE);
+}
+
+/* Section heading with its own reset button.
+
+ Each tab holds several unrelated groups and darktable resets whole modules or
+ single widgets, nothing in between -- so trying one idea in "chemistry" means
+ either undoing every slider by hand or throwing away the rest of the tab. The
+ button resets exactly the widgets between this heading and the next one.
+
+ No bookkeeping: the widgets are packed into the page box in order, so the
+ callback walks that box from its own header to the following one. A heading
+ is marked with the "sf_section" data key rather than recognised by type. */
+static void _section_reset_clicked(GtkButton *button, dt_iop_module_t *self)
+{
+ if(darktable.gui->reset) return;
+ GtkWidget *hdr = gtk_widget_get_parent(GTK_WIDGET(button));
+ GtkWidget *box = hdr ? gtk_widget_get_parent(hdr) : NULL;
+ if(!box) return;
+
+ /* Deliberately NOT wrapped in darktable.gui->reset: each widget's own
+ value-changed handler is what writes the param, so suppressing it would
+ move the sliders without changing the render. The cost is one history entry
+ per widget rather than one per click -- correct, undoable, just chattier
+ than ideal. */
+ GList *kids = gtk_container_get_children(GTK_CONTAINER(box));
+ gboolean after = FALSE;
+ for(const GList *l = kids; l; l = l->next)
+ {
+ GtkWidget *w = l->data;
+ if(w == hdr) { after = TRUE; continue; }
+ if(!after) continue;
+ if(g_object_get_data(G_OBJECT(w), "sf_section")) break; /* next section */
+ if(DT_IS_BAUHAUS_WIDGET(w)) dt_bauhaus_widget_reset(w);
+ }
+ g_list_free(kids);
+}
+
+/* Pack a section heading carrying a reset button, and return it. */
+static GtkWidget *_section_add(dt_iop_module_t *self, const char *label)
+{
+ GtkWidget *hdr = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
+ g_object_set_data(G_OBJECT(hdr), "sf_section", GINT_TO_POINTER(1));
+
+ GtkWidget *lbl = dt_ui_section_label_new(label);
+ gtk_box_pack_start(GTK_BOX(hdr), lbl, TRUE, TRUE, 0);
+
+ GtkWidget *btn = dtgtk_button_new(dtgtk_cairo_paint_reset, 0, NULL);
+ gtk_widget_set_tooltip_text(btn, _("reset only this section"));
+ gtk_box_pack_end(GTK_BOX(hdr), btn, FALSE, FALSE, 0);
+ g_signal_connect(G_OBJECT(btn), "clicked", G_CALLBACK(_section_reset_clicked), self);
+
+ dt_gui_box_add(self->widget, hdr);
+ return hdr;
+}
+
+void gui_init(dt_iop_module_t *self)
+{
+ dt_iop_spektrafilm_gui_data_t *g = IOP_GUI_ALLOC(spektrafilm);
+ self->widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+
+ GtkWidget *sf_main_box = self->widget;
+
+ /* ---- data pack row (packed first, so it reads as a precondition) ----
+ Deliberately outside main_box: it is the one thing that must stay on
+ screen when there is no pack, since it is what gets you one.
+
+ Built with no_show_all set, because dt_iop_gui_init() runs a single
+ gtk_widget_show_all() over the whole module at creation time. Without the
+ flag that one call would reveal the row before _update_data_row() has had
+ any chance to decide whether it should be there. */
+ g->data_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_PIXEL_APPLY_DPI(2));
+ gtk_widget_set_no_show_all(g->data_box, TRUE);
+
+ g->data_status = gtk_label_new("");
+ gtk_label_set_line_wrap(GTK_LABEL(g->data_status), TRUE);
+ gtk_label_set_xalign(GTK_LABEL(g->data_status), 0.0);
+ gtk_widget_set_name(g->data_status, "spektrafilm-data-status");
+ gtk_box_pack_start(GTK_BOX(g->data_box), g->data_status, TRUE, TRUE, 0);
+
+ g->data_button = gtk_button_new_with_label(_("download data pack"));
+ g_signal_connect(G_OBJECT(g->data_button), "clicked",
+ G_CALLBACK(_data_button_clicked), self);
+ gtk_box_pack_start(GTK_BOX(g->data_box), g->data_button, TRUE, TRUE, 0);
+
+ gtk_widget_show(g->data_status);
+ gtk_widget_show(g->data_button);
+ gtk_box_pack_start(GTK_BOX(sf_main_box), g->data_box, TRUE, TRUE, 0);
+
+ /* Everything else lives under main_box so a single set_visible() hides the
+ lot. No no_show_all here: the creation-time show_all is what establishes
+ the correct visibility of every child, including the ones with their own
+ rules (the format slider is only shown for a custom format), and redoing
+ that by hand on reveal would quietly override them. Since that show_all
+ runs once at creation and gui_update() runs after it, hiding here sticks,
+ and revealing later restores exactly the state show_all left behind. */
+ g->main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+ dt_gui_box_add(sf_main_box, g->main_box);
+
+ /* ---- header ---- */
+ GtkWidget *header_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+ dt_gui_box_add(g->main_box, header_box);
+
+ /* Inline labels, like every other control in the module. These were section
+ headings for a while, to buy the value more width: the paper names used to
+ truncate to a shared prefix, so "Kodak Professional Portra Endura" and three
+ others all read alike. _shorten_name() has since stripped the filler words
+ they shared, and they now diverge at the seventh character ("Kodak Endura
+ Premier" / "Kodak Portra Endura" / "Kodak Supra Endura" / "Kodak Ultra
+ Endura"), so a label beside them no longer costs anything worth having.
+ A heading per single control also read as clutter once there were three. */
+ g->film = dt_bauhaus_combobox_new(self);
+ dt_bauhaus_widget_set_label(g->film, NULL, N_("film stock"));
+ gtk_widget_set_tooltip_text(g->film, _("film emulsion (spektrafilm filming profile)"));
+ g_signal_connect(G_OBJECT(g->film), "value-changed", G_CALLBACK(_film_changed), self);
+ gtk_box_pack_start(GTK_BOX(header_box), g->film, TRUE, TRUE, 0);
+
+ g->paper = dt_bauhaus_combobox_new(self);
+ dt_bauhaus_widget_set_label(g->paper, NULL, N_("print paper"));
+ gtk_widget_set_tooltip_text(g->paper,
+ _("print/paper stock; defaults to the film's target print"));
+ g_signal_connect(G_OBJECT(g->paper), "value-changed", G_CALLBACK(_paper_changed), self);
+ gtk_box_pack_start(GTK_BOX(header_box), g->paper, TRUE, TRUE, 0);
+
+ /* redirect self->widget so from_params widgets pack into header_box */
+ self->widget = header_box;
+
+ g->film_format_combo = dt_bauhaus_combobox_new(self);
+ dt_bauhaus_widget_set_label(g->film_format_combo, NULL, N_("format"));
+ gtk_widget_set_tooltip_text(g->film_format_combo,
+ _("common film/sensor gate presets; picking one sets the frame"
+ " long edge slider below (pick \"custom\" to dial in an exact"
+ " value).\nthe preset names a film gauge (35mm) while the"
+ " slider is the frame's long edge (36mm) -- both describe the"
+ " same format"));
+ _populate_format_combo(self);
+ g_signal_connect(G_OBJECT(g->film_format_combo), "value-changed",
+ G_CALLBACK(_format_changed), self);
+ gtk_box_pack_start(GTK_BOX(header_box), g->film_format_combo, TRUE, TRUE, 0);
+
+ g->film_format_mm_slider = dt_bauhaus_slider_from_params(self, "film_format_mm");
+ dt_bauhaus_slider_set_format(g->film_format_mm_slider, _(" mm"));
+ gtk_widget_set_tooltip_text(g->film_format_mm_slider,
+ _("physical frame size, long edge. sets the scale that grain, "
+ "scatter, halation and diffusion are all computed at, so a "
+ "smaller format shows every one of them proportionally larger "
+ "for the same print size"));
+ g_signal_connect(G_OBJECT(g->film_format_mm_slider), "value-changed",
+ G_CALLBACK(_format_slider_changed), self);
+
+ /* restore main widget for the notebook */
+ self->widget = sf_main_box;
+
+ /* ---- notebook / tabs ---- */
+ static struct dt_action_def_t notebook_def = { };
+ g->notebook = dt_ui_notebook_new(¬ebook_def);
+ dt_action_define_iop(self, NULL, N_("page"), GTK_WIDGET(g->notebook), ¬ebook_def);
+ dt_gui_box_add(g->main_box, GTK_WIDGET(g->notebook));
+
+ /* ---- tab 1: film (exposure + development) ---- */
+ self->widget = dt_ui_notebook_page(g->notebook, N_("film"), NULL);
+
+ _section_add(self, C_("section", "exposure"));
+
+ g->exposure_ev = dt_bauhaus_slider_from_params(self, "exposure_ev");
+ dt_bauhaus_slider_set_format(g->exposure_ev, _(" EV"));
+ gtk_widget_set_tooltip_text(
+ g->exposure_ev, _("film exposure compensation; with auto print exposure enabled, print"
+ " exposure follows automatically so this has no net brightness effect"
+ " (except on positive/reversal film, which has no print stage)"));
+ g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film");
+ gtk_widget_set_tooltip_text(g->scan_film,
+ _("view the developed film directly (no print stage)"));
+
+ g->push_pull_stops = dt_bauhaus_slider_from_params(self, "push_pull_stops");
+ dt_bauhaus_slider_set_format(g->push_pull_stops, _(" stops"));
+ gtk_widget_set_tooltip_text(
+ g->push_pull_stops,
+ _("push (positive) or pull (negative) processing: shoot at an effective ISO"
+ " different from box speed, then under- or over-develop to compensate --"
+ " combines an exposure shift with a derived contrast increase/decrease"
+ " (approximate: the exact relationship depends on the specific film/developer"
+ " combination, which isn't modeled here). Stacks with the granular gamma"
+ " controls below for further fine-tuning"));
+
+ _section_add(self, C_("section", "chemistry"));
+
+ g->development_min = dt_bauhaus_slider_from_params(self, "development_min");
+ dt_bauhaus_slider_set_format(g->development_min, _(" min"));
+ /* gui_update() replaces this with the selected stock's own times, and greys
+ the slider out for stocks characterised at a single development */
+ gtk_widget_set_tooltip_text(g->development_min,
+ _("development time. snaps to the nearest time the stock was\n"
+ "characterised at; 0 uses the stock's own default."));
+
+ g->film_gamma_factor = dt_bauhaus_slider_from_params(self, "film_gamma_factor");
+ dt_bauhaus_slider_set_soft_range(g->film_gamma_factor, 0.25f, 2.0f);
+ gtk_widget_set_tooltip_text(
+ g->film_gamma_factor,
+ _("overall development contrast (morphs the film's density curves) -- extended or"
+ " reduced development time, as in push/pull processing; 1.0 = normal development"));
+
+ g->film_gamma_factor_fast = dt_bauhaus_slider_from_params(self, "film_gamma_factor_fast");
+ dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_fast, 0.25f, 2.0f);
+ gtk_widget_set_tooltip_text(
+ g->film_gamma_factor_fast,
+ _("contrast of the fastest (most light-sensitive) emulsion sub-layer only --"
+ " independent of the slow layer, since push/pull processing doesn't always affect"
+ " every sub-layer equally"));
+
+ g->film_gamma_factor_slow = dt_bauhaus_slider_from_params(self, "film_gamma_factor_slow");
+ dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_slow, 0.25f, 2.0f);
+ gtk_widget_set_tooltip_text(
+ g->film_gamma_factor_slow,
+ _("contrast of the mid and slow emulsion sub-layers"));
+
+ g->film_developer_exhaustion = dt_bauhaus_slider_from_params(self, "film_developer_exhaustion");
+ gtk_widget_set_tooltip_text(
+ g->film_developer_exhaustion,
+ _("local developer depletion in dense (highly-exposed) areas: blends the highlight"
+ " shoulder toward a self-limiting rolloff without shifting midgray (0 = off)"));
+
+ /* "couplers and quality" named its first two controls, which stopped
+ describing the section once the adaptation switches joined them -- and
+ enumerating members does not scale anyway. What all four have in common is
+ that they are the knobs you reach for last: the coupler strength and the
+ two adaptation halves change how faithful the model is rather than what the
+ look is, and the quality setting trades accuracy for speed. */
+ _section_add(self, C_("section", "advanced"));
+
+ g->couplers_amount = dt_bauhaus_slider_from_params(self, "couplers_amount");
+ gtk_widget_set_tooltip_text(g->couplers_amount,
+ _("DIR coupler strength: inter-layer inhibition drives saturation"
+ " and edge effects (1.0 = film-accurate, 0 = off)"));
+
+ g->quality = dt_bauhaus_combobox_from_params(self, "quality");
+ gtk_widget_set_tooltip_text(g->quality,
+ _("spectral accuracy vs speed: the colour model is evaluated"
+ " on a table of this size and PCHIP-interpolated between the"
+ " points, so a finer table lands closer to the exact answer"
+ " and costs more to build.\n\"exact spectral\" skips the table"
+ " and runs the model per pixel -- CPU only, and slow"));
+
+ g->adaptation_bandwidth = dt_bauhaus_toggle_from_params(self, "adaptation_bandwidth");
+ gtk_widget_set_tooltip_text(
+ g->adaptation_bandwidth,
+ _("first half of the film's sensitivity adaptation: a spectral bandpass\n"
+ "applied to the stock's own sensitivities, rolling off the UV and IR\n"
+ "ends of each channel while preserving white balance.\non by default,\n"
+ "and best left on: it is part of how the stock is characterised rather\n"
+ "than a look.\nno effect on stocks whose profile carries no bandpass"));
+
+ g->adaptation_surface = dt_bauhaus_toggle_from_params(self, "adaptation_surface");
+ gtk_widget_set_tooltip_text(
+ g->adaptation_surface,
+ _("second half of the film's sensitivity adaptation: a per-colour exposure\n"
+ "correction of up to two stops, zero at the film's own white point and\n"
+ "growing with distance from it.\noff by default: it shifts saturated\n"
+ "colours substantially.\nno effect on stocks whose profile carries no\n"
+ "surface (the monochrome films and every print paper)"));
+
+ /* ---- tab 2: print ---- */
+ self->widget = dt_ui_notebook_page(g->notebook, N_("print"), NULL);
+
+ GtkWidget *print_page = self->widget;
+
+ g->print_exposure_ev = dt_bauhaus_slider_from_params(self, "print_exposure_ev");
+ dt_bauhaus_slider_set_format(g->print_exposure_ev, _(" EV"));
+ gtk_widget_set_tooltip_text(g->print_exposure_ev, _("print brightness (enlarger exposure)"));
+
+ g->print_auto_exposure = dt_bauhaus_toggle_from_params(self, "print_auto_exposure");
+ gtk_widget_set_tooltip_text(
+ g->print_auto_exposure,
+ _("automatically compensate print exposure for film exposure changes, as a real"
+ " printer would print to a fixed density; disable for film exposure to affect"
+ " brightness directly, same as a fixed enlarger exposure time"));
+
+ g->print_contrast = dt_bauhaus_slider_from_params(self, "print_contrast");
+ gtk_widget_set_tooltip_text(g->print_contrast,
+ _("print contrast (morphs the paper's density curves)"));
+
+ _section_add(self, C_("section", "chemistry"));
+
+ g->print_development_min = dt_bauhaus_slider_from_params(self, "print_development_min");
+ dt_bauhaus_slider_set_format(g->print_development_min, _(" min"));
+ /* _update_development_sensitivity() replaces this with the selected paper's own
+ times, and greys it out for papers characterised at a single development */
+ gtk_widget_set_tooltip_text(g->print_development_min,
+ _("print development time. snaps to the nearest time the paper\n"
+ "was characterised at; 0 uses its own default."));
+
+ _section_add(self, C_("section", "filtration"));
+
+ g->filter_m = dt_bauhaus_slider_from_params(self, "filter_m");
+ dt_bauhaus_slider_set_format(g->filter_m, _(" CC"));
+ gtk_widget_set_tooltip_text(g->filter_m,
+ _("magenta enlarger filtration, Kodak CC units from neutral"));
+
+ g->filter_y = dt_bauhaus_slider_from_params(self, "filter_y");
+ dt_bauhaus_slider_set_format(g->filter_y, _(" CC"));
+ gtk_widget_set_tooltip_text(g->filter_y,
+ _("yellow enlarger filtration, Kodak CC units from neutral"));
+
+ self->widget = print_page;
+
+ _section_add(self, C_("section", "preflash"));
+
+ g->preflash_exposure = dt_bauhaus_slider_from_params(self, "preflash_exposure");
+ /* The effect is strong well before 0.5, so spreading 0..2 across the panel
+ put every usable setting in the first quarter of the travel and made the
+ step from off to barely-on larger than the whole range people work in.
+ Higher values stay reachable by right-click, as elsewhere in the module. */
+ dt_bauhaus_slider_set_soft_range(g->preflash_exposure, 0.0f, 0.5f);
+ gtk_widget_set_tooltip_text(
+ g->preflash_exposure,
+ _("preflash exposure: a brief, uniform pre-exposure of the print through"
+ " the film's base density, before the main print exposure -- lifts"
+ " shadows and reduces contrast (0 = off). drag up to 0.5, right-click"
+ " to enter higher values"));
+
+ g->preflash_m_shift = dt_bauhaus_slider_from_params(self, "preflash_m_shift");
+ dt_bauhaus_slider_set_format(g->preflash_m_shift, _(" CC"));
+ gtk_widget_set_tooltip_text(g->preflash_m_shift,
+ _("magenta filtration for the preflash exposure only, Kodak CC"
+ " units from neutral -- independent of the main enlarger"
+ " filtration above"));
+
+ g->preflash_y_shift = dt_bauhaus_slider_from_params(self, "preflash_y_shift");
+ dt_bauhaus_slider_set_format(g->preflash_y_shift, _(" CC"));
+ gtk_widget_set_tooltip_text(g->preflash_y_shift,
+ _("yellow filtration for the preflash exposure only, Kodak CC"
+ " units from neutral -- independent of the main enlarger"
+ " filtration above"));
+
+ /* ---- tab 3: grain ---- */
+ self->widget = dt_ui_notebook_page(g->notebook, N_("grain"), NULL);
+
+ g->grain_on = dt_bauhaus_toggle_from_params(self, "grain_on");
+
+ g->grain_amount = dt_bauhaus_slider_from_params(self, "grain_amount");
+ dt_bauhaus_slider_set_soft_range(g->grain_amount, 0.0f, 2.0f);
+ gtk_widget_set_tooltip_text(g->grain_amount,
+ _("grain strength (1.0 = film-accurate; drag up to 2,"
+ " right-click to enter higher values -- useful for pushing"
+ " naturally fine-grained stocks further than their"
+ " catalogue amount allows)"));
+
+ g->grain_size = dt_bauhaus_slider_from_params(self, "grain_size");
+ gtk_widget_set_tooltip_text(g->grain_size,
+ _("grain particle size (1.0 = film default; higher = coarser)"));
+
+ _section_add(self, C_("section", "acutance recovery"));
+
+ g->grain_usm_sigma = dt_bauhaus_slider_from_params(self, "grain_usm_sigma");
+ dt_bauhaus_slider_set_soft_range(g->grain_usm_sigma, 0.0f, 3.0f);
+ gtk_widget_set_tooltip_text(g->grain_usm_sigma,
+ _("sharpening radius (0 = off). "
+ "higher = wider halos, lower = finer detail"));
+
+ g->grain_usm_amount = dt_bauhaus_slider_from_params(self, "grain_usm_amount");
+ dt_bauhaus_slider_set_soft_range(g->grain_usm_amount, 0.0f, 2.0f);
+ gtk_widget_set_tooltip_text(g->grain_usm_amount,
+ _("sharpening strength (0 = off). "
+ "restores crispness that the grain blur softened; "
+ "overdo it and grain starts to look crunchy"));
+
+ /* ---- tab 4: halation ---- */
+ self->widget = dt_ui_notebook_page(g->notebook, N_("halation"), NULL);
+
+ g->halation_on = dt_bauhaus_toggle_from_params(self, "halation_on");
+
+ g->scatter_amount = dt_bauhaus_slider_from_params(self, "scatter_amount");
+ gtk_widget_set_tooltip_text(g->scatter_amount,
+ _("fraction of light that scatters inside the emulsion,\n"
+ "before the halation bounce. 1.0 is film-accurate and is\n"
+ "also the maximum -- it means all of it, so nothing of the\n"
+ "unscattered image remains.\n\n"
+ "this is why the whole frame softens rather than just high\n"
+ "contrast edges: the scatter radius is small (a few um on\n"
+ "film) but it applies everywhere. lower this if you want a\n"
+ "sharper result than the film itself would give."));
+
+ g->scatter_scale = dt_bauhaus_slider_from_params(self, "scatter_scale");
+ /* Upstream fixes scatter_spatial_scale at 1.0 -- it is a schema field with no
+ UI, no preset and no per-stock override. Exposing it is a darktable
+ addition, so keep the drag range close to the value the film model actually
+ claims and leave the rest reachable by right-click. At 4.0 on a 50 MP frame
+ the effective blur is ~14 px across the whole image, far outside anything
+ the reference produces. */
+ dt_bauhaus_slider_set_soft_range(g->scatter_scale, 0.2f, 1.5f);
+ gtk_widget_set_tooltip_text(g->scatter_scale,
+ _("scales the in-emulsion scatter radius. 1.0 is\n"
+ "film-accurate: the radius the film model itself\n"
+ "works at, and not normally something to change.\n\n"
+ "above 1.0 you are past what the film model claims, and the\n"
+ "whole frame softens quickly: the radius scales with the\n"
+ "value, so 4.0 is a four times wider blur everywhere.\n"
+ "drag up to 1.5, right-click to enter higher values"));
+
+ g->halation_amount = dt_bauhaus_slider_from_params(self, "halation_amount");
+ dt_bauhaus_slider_set_soft_range(g->halation_amount, 0.0f, 2.0f);
+ gtk_widget_set_tooltip_text(g->halation_amount,
+ _("halation strength (1.0 = film-accurate; drag up to 2,"
+ " right-click to enter higher values)"));
+
+ g->halation_scale = dt_bauhaus_slider_from_params(self, "halation_scale");
+ gtk_widget_set_tooltip_text(g->halation_scale,
+ _("halation size: scales the glow radius (1.0 = film-accurate)"));
+
+ _section_add(self, C_("section", "threshold"));
+
+ g->boost_ev = dt_bauhaus_slider_from_params(self, "boost_ev");
+ dt_bauhaus_slider_set_format(g->boost_ev, _(" EV"));
+ gtk_widget_set_tooltip_text(g->boost_ev,
+ _("highlight boost: reconstructs clipped highlights so they bloom"
+ " into halation/diffusion (0 = off)"));
+
+ g->boost_range = dt_bauhaus_slider_from_params(self, "boost_range");
+ gtk_widget_set_tooltip_text(
+ g->boost_range,
+ _("widens or narrows the band of tones the highlight boost acts on. "
+ "lower confines it to the brightest clipped highlights, higher pulls "
+ "more of the upper midtones into the bloom"));
+
+ g->protect_ev = dt_bauhaus_slider_from_params(self, "protect_ev");
+ dt_bauhaus_slider_set_format(g->protect_ev, _(" EV"));
+ gtk_widget_set_tooltip_text(g->protect_ev,
+ _("protect tones below this many stops over mid-grey from the boost"));
+
+ /* ---- tab 5: diffusion ---- */
+ self->widget = dt_ui_notebook_page(g->notebook, N_("diffusion"), NULL);
+
+ g->diffusion_on = dt_bauhaus_toggle_from_params(self, "diffusion_on");
+
+ g->diffusion_filter_family = dt_bauhaus_combobox_from_params(self, "diffusion_filter_family");
+ gtk_widget_set_tooltip_text(
+ g->diffusion_filter_family,
+ _("diffusion filter type: black pro-mist (concentrated, punchy halo, deep"
+ " blacks) / glimmerglass (tight, subtle, sharp-preserving) / pro-mist"
+ " (broader, pastel, atmospheric) / cinebloom (frame-wide, slow-decaying"
+ " veil)"));
+
+ g->diffusion_strength = dt_bauhaus_slider_from_params(self, "diffusion_strength");
+ gtk_widget_set_tooltip_text(
+ g->diffusion_strength,
+ _("sets how much light is diverted into the diffusion halo (0 = off). "
+ "the halo is added on top of the unfiltered image, so raising this "
+ "lifts shadows and lowers contrast as well as glowing the highlights"));
+
+ g->diffusion_scale = dt_bauhaus_slider_from_params(self, "diffusion_scale");
+ gtk_widget_set_tooltip_text(
+ g->diffusion_scale,
+ _("scales the radius of the diffusion halo. spreads the same amount of "
+ "light further from each highlight rather than adding more of it -- "
+ "use diffusion strength for that"));
+
+ g->diffusion_warmth = dt_bauhaus_slider_from_params(self, "diffusion_warmth");
+ gtk_widget_set_tooltip_text(g->diffusion_warmth,
+ _("diffusion halo warmth: >0 warm outer halo, <0 cool"
+ " (added on top of the selected filter's own warmth bias)"));
+
+ g->print_diffusion_on = dt_bauhaus_toggle_from_params(self, "print_diffusion_on");
+
+ g->print_diffusion_filter_family
+ = dt_bauhaus_combobox_from_params(self, "print_diffusion_filter_family");
+ gtk_widget_set_tooltip_text(
+ g->print_diffusion_filter_family,
+ _("print diffusion filter type (same presets as the film-stage filter)"));
+
+ g->print_diffusion_strength = dt_bauhaus_slider_from_params(self, "print_diffusion_strength");
+ gtk_widget_set_tooltip_text(
+ g->print_diffusion_strength,
+ _("sets how much light is diverted into the print diffusion halo "
+ "(0 = off). acts at the enlarger rather than the camera, so it blooms "
+ "the printed image instead of the scene"));
+
+ g->print_diffusion_scale = dt_bauhaus_slider_from_params(self, "print_diffusion_scale");
+ gtk_widget_set_tooltip_text(
+ g->print_diffusion_scale,
+ _("scales the radius of the print diffusion halo. spreads the same "
+ "amount of light further from each highlight rather than adding more "
+ "of it -- use print diffusion strength for that"));
+
+ g->print_diffusion_warmth = dt_bauhaus_slider_from_params(self, "print_diffusion_warmth");
+ gtk_widget_set_tooltip_text(g->print_diffusion_warmth,
+ _("print diffusion halo warmth: >0 warm outer halo, <0 cool"
+ " (added on top of the selected filter's own warmth bias)"));
+
+ /* ---- scanner tab ---- */
+ self->widget = dt_ui_notebook_page(g->notebook, N_("scanner"), NULL);
+
+ /* Pre-compression boost lives here, not in the header. It acts in the scan
+ stage, immediately before the OkLCh gamut compressor -- the last thing the
+ module does, not the first. Its old position at the top implied an input
+ control, which is why the picker "reading the processed look" was reported
+ as a bug: the picker is right, the placement was misleading. */
+ g->output_boost = dt_bauhaus_slider_from_params(self, "output_luminance_boost");
+ gtk_widget_set_tooltip_text(g->output_boost,
+ _("multiplies XYZ luminance just before the OkLCh gamut\n"
+ "compressor, pushing the histogram right while preserving\n"
+ "the film's natural shoulder rolloff.\n\n"
+ "this acts at the END of the module, so the picker measures\n"
+ "the processed image rather than the input"));
+ dt_color_picker_new(self, DT_COLOR_PICKER_AREA, g->output_boost);
+ dt_bauhaus_widget_set_quad_tooltip(g->output_boost,
+ _("pick brightest tone in the selected area and set the"
+ " boost so it lands just past the compressor's knee"));
+
+ g->scan_blur = dt_bauhaus_slider_from_params(self, "scan_blur");
+ gtk_widget_set_tooltip_text(g->scan_blur,
+ _("scanner lens softness, in pixels (0 = off)"));
+
+ g->scan_usm_sigma = dt_bauhaus_slider_from_params(self, "scan_usm_sigma");
+ gtk_widget_set_tooltip_text(g->scan_usm_sigma,
+ _("scanner sharpening radius, in pixels"));
+
+ g->scan_usm_amount = dt_bauhaus_slider_from_params(self, "scan_usm_amount");
+ gtk_widget_set_tooltip_text(g->scan_usm_amount,
+ _("scanner sharpening strength (0 = off). "
+ "0.7 is what a scan of the film normally gets; "
+ "leave at 0 if you prefer to sharpen downstream"));
+
+ g->glare_percent = dt_bauhaus_slider_from_params(self, "glare_percent");
+ gtk_widget_set_tooltip_text(g->glare_percent,
+ _("viewing glare: a faint veil of the viewing light "
+ "reflected off the print surface, in percent. "
+ "lifts the deepest blacks slightly. "
+ "not applied when scanning the film directly"));
+
+ /* restore root widget */
+ self->widget = sf_main_box;
+}
+
+void gui_cleanup(dt_iop_module_t *self)
+{
+ dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
+ if(g)
+ {
+ /* The poll timeout closes over self and reads gui_data. Leaving it armed
+ past teardown is a use-after-free on the next tick. */
+ if(g->data_poll)
+ {
+ g_source_remove(g->data_poll);
+ g->data_poll = 0;
+ }
+ g_list_free_full(g->entries, g_free);
+ g->entries = NULL;
+ }
+}
+
+// clang-format off
+// modelines
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// clang-format on
diff --git a/src/libs/modulegroups.c b/src/libs/modulegroups.c
index f69932eced75..e92973b0d80e 100644
--- a/src/libs/modulegroups.c
+++ b/src/libs/modulegroups.c
@@ -1640,6 +1640,7 @@ void init_presets(dt_lib_module_t *self)
AM("rgbcurve");
AM("rgblevels");
AM("sigmoid");
+ AM("spektrafilm");
AM("tonecurve");
SMG(C_("modulegroup", "color"), "color");
diff --git a/src/tests/unittests/CMakeLists.txt b/src/tests/unittests/CMakeLists.txt
index 239c0a0ebd3f..d87e1c73dd14 100644
--- a/src/tests/unittests/CMakeLists.txt
+++ b/src/tests/unittests/CMakeLists.txt
@@ -1,3 +1,4 @@
+add_subdirectory(common)
add_subdirectory(iop)
if(USE_AI)
diff --git a/src/tests/unittests/common/CMakeLists.txt b/src/tests/unittests/common/CMakeLists.txt
new file mode 100644
index 000000000000..314c380a4bdb
--- /dev/null
+++ b/src/tests/unittests/common/CMakeLists.txt
@@ -0,0 +1,22 @@
+add_cmocka_test(test_spektra_sim
+ SOURCES test_spektra_sim.c
+ LINK_LIBRARIES lib_darktable cmocka)
+
+# The pack-backed half of the coverage. SPEKTRA_PACK_DIR names the data pack
+# darktable ships, either as the directory holding pack.json or as one holding a
+# single versioned subdirectory that does -- the suite resolves both, so it does
+# not have to be taught the current spektrafilm release. With no pack there,
+# every test in it skips: a build machine that cannot answer the question should
+# not report the engine as broken.
+add_cmocka_test(test_spektra_pack
+ SOURCES test_spektra_pack.c
+ LINK_LIBRARIES lib_darktable cmocka)
+
+target_compile_definitions(test_spektra_pack PRIVATE
+ SPEKTRA_PACK_DIR="${CMAKE_SOURCE_DIR}/data/spektrafilm")
+
+# Windows: libs have to be copied next to the executable
+if(WIN32)
+ _copy_required_library(test_spektra_sim lib_darktable)
+ _copy_required_library(test_spektra_pack lib_darktable)
+endif(WIN32)
diff --git a/src/tests/unittests/common/test_spektra_pack.c b/src/tests/unittests/common/test_spektra_pack.c
new file mode 100644
index 000000000000..16ab3ca978f5
--- /dev/null
+++ b/src/tests/unittests/common/test_spektra_pack.c
@@ -0,0 +1,769 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+/*
+ * cmocka tests for common/spektra_sim.c against the data pack darktable ships.
+ *
+ * The companion suite, test_spektra_sim.c, covers the algorithms that need no
+ * data. These are the ones that do: profile contents, the enlarger's dichroic
+ * filters and neutral filter database, and the assembled pipeline. They are
+ * ports of the upstream python tests that use the same fixtures --
+ * tests/test_profiles.py, tests/test_enlarger_filters.py and
+ * tests/test_pipeline_smoke.py -- and each test names the one it came from.
+ *
+ * The pack is found at SPEKTRA_PACK_DIR, handed over by CMake (see
+ * CMakeLists.txt in this directory), either as a directory holding pack.json
+ * directly or as one holding a single versioned subdirectory that does. Only
+ * edits pinned to an older pack fetch anything at runtime, so the current pack
+ * being on disk is the normal case -- but when it is not there, every test
+ * here skips rather than fails. A missing pack means this build cannot answer
+ * the question, which is not the same as the engine being wrong, and a build
+ * that goes red for the wrong reason is a build people learn to ignore.
+ *
+ * The tolerances are deliberately loose. What is being pinned down is that the
+ * pipeline stays finite, bounded, ordered and reproducible on real data, not
+ * that particular numbers come out -- exact values belong in a regression
+ * baseline against a pinned pack, where a deliberate change to the model can be
+ * re-blessed in one place instead of scattering magic numbers through here.
+ *
+ * Please see ../README.md for more detailed documentation.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "../util/assert.h"
+#include "../util/tracing.h"
+
+#include "common/spektra_sim.c"
+
+#ifdef _WIN32
+#include "win/main_wrapper.h"
+#endif
+
+/*
+ * DEFINITIONS
+ */
+
+#ifndef SPEKTRA_PACK_DIR
+#define SPEKTRA_PACK_DIR ""
+#endif
+
+/* See test_spektra_sim.c: cmocka's assert_float_equal() collapses everything to
+ float, and from 1.1.2 on it shadows the fallback in ../util/assert.h. */
+#ifndef assert_double_close
+#define assert_double_close(a, b, epsilon) \
+ assert_true(fabs((double)(a) - (double)(b)) <= (double)(epsilon))
+#endif
+
+/* The stocks upstream's own fixtures use (conftest.py). Absent from a pack,
+ the first film and the first paper stand in, so a pack that renames or drops
+ them still gets exercised rather than skipped. */
+#define FILM_STOCK "kodak_portra_400"
+#define PRINT_STOCK "kodak_portra_endura"
+
+/* Output slack. The scanner's gamut compressor is what bounds the render, and
+ it approaches its limit asymptotically rather than clamping, so the bound is
+ checked with room for the last ulps rather than as a hard [0, 1]. */
+#define OUT_SLACK 1e-3
+
+typedef struct fixture_t
+{
+ sf_pack_t *pack;
+ sf_profile_t *film, *print;
+ char pack_dir[PATH_MAX];
+} fixture_t;
+
+/* Every test starts with this: no pack on disk, no verdict to give. */
+#define REQUIRE_PACK(f) \
+ do \
+ { \
+ if(!(f) || !(f)->pack || !(f)->film) skip(); \
+ } while(0)
+
+/*
+ * FIXTURE
+ */
+
+/* SPEKTRA_PACK_DIR itself when it holds pack.json, else its one subdirectory
+ that does. Written this way so the tests need not know which spektrafilm
+ release is currently shipped, and so they keep working if the layout is
+ flattened later. Returns false when there is no pack to be had. */
+static gboolean _resolve_pack_dir(char *dst, size_t dstsz)
+{
+ const char *root = SPEKTRA_PACK_DIR;
+ if(!root || !root[0]) return FALSE;
+
+ char *direct = g_build_filename(root, "pack.json", NULL);
+ const gboolean here = g_file_test(direct, G_FILE_TEST_IS_REGULAR);
+ g_free(direct);
+ if(here)
+ {
+ g_strlcpy(dst, root, dstsz);
+ return TRUE;
+ }
+
+ GDir *gd = g_dir_open(root, 0, NULL);
+ if(!gd) return FALSE;
+ gboolean found = FALSE;
+ const char *fn;
+ while((fn = g_dir_read_name(gd)))
+ {
+ char *sub = g_build_filename(root, fn, "pack.json", NULL);
+ if(g_file_test(sub, G_FILE_TEST_IS_REGULAR))
+ {
+ char *dir = g_build_filename(root, fn, NULL);
+ g_strlcpy(dst, dir, dstsz);
+ g_free(dir);
+ found = TRUE;
+ }
+ g_free(sub);
+ if(found) break;
+ }
+ g_dir_close(gd);
+ return found;
+}
+
+/* Load the profile for `stock` from the pack, or -- when the pack has no such
+ stock -- the first profile of the right kind, so the suite follows the pack
+ it is given instead of a stock list baked in here. */
+static sf_profile_t *_load_stock(const char *dir, const char *stock, const gboolean printing)
+{
+ char *profdir = g_build_filename(dir, "profiles", NULL);
+ GDir *gd = g_dir_open(profdir, 0, NULL);
+ if(!gd)
+ {
+ g_free(profdir);
+ return NULL;
+ }
+ sf_profile_t *wanted = NULL, *fallback = NULL;
+ const char *fn;
+ while((fn = g_dir_read_name(gd)) && !wanted)
+ {
+ if(!g_str_has_suffix(fn, ".json")) continue;
+ char *path = g_build_filename(profdir, fn, NULL);
+ char *err = NULL;
+ sf_profile_t *p = sf_profile_load(path, 0.0f, &err);
+ g_free(path);
+ free(err);
+ if(!p) continue;
+
+ const char *stage = sf_profile_stage(p);
+ const gboolean is_print = stage && !strcmp(stage, "printing");
+ if(is_print != printing)
+ {
+ sf_profile_free(p);
+ continue;
+ }
+ const char *s = sf_profile_stock(p);
+ if(s && !strcmp(s, stock))
+ wanted = p;
+ else if(!fallback)
+ fallback = p;
+ else
+ sf_profile_free(p);
+ }
+ g_dir_close(gd);
+ g_free(profdir);
+
+ if(wanted)
+ {
+ if(fallback) sf_profile_free(fallback);
+ return wanted;
+ }
+ if(fallback)
+ TR_NOTE("pack has no %s; using %s instead", stock,
+ sf_profile_stock(fallback) ? sf_profile_stock(fallback) : "(unnamed)");
+ return fallback;
+}
+
+static int group_setup(void **state)
+{
+ fixture_t *f = calloc(1, sizeof(fixture_t));
+ if(!f) return -1;
+ *state = f;
+
+ if(!_resolve_pack_dir(f->pack_dir, sizeof f->pack_dir))
+ {
+ TR_NOTE("no data pack under \"%s\" -- every test in this suite will skip", SPEKTRA_PACK_DIR);
+ return 0;
+ }
+ TR_NOTE("data pack: %s", f->pack_dir);
+
+ char *err = NULL;
+ f->pack = sf_pack_load(f->pack_dir, &err);
+ if(!f->pack)
+ {
+ TR_NOTE("pack failed to load: %s", err ? err : "(no message)");
+ free(err);
+ return 0; /* the first test reports it; the rest skip */
+ }
+ f->film = _load_stock(f->pack_dir, FILM_STOCK, FALSE);
+ f->print = _load_stock(f->pack_dir, PRINT_STOCK, TRUE);
+ return 0;
+}
+
+static int group_teardown(void **state)
+{
+ fixture_t *f = *state;
+ if(f)
+ {
+ if(f->film) sf_profile_free(f->film);
+ if(f->print) sf_profile_free(f->print);
+ if(f->pack) sf_pack_free(f->pack);
+ free(f);
+ }
+ return 0;
+}
+
+/*
+ * HELPERS
+ */
+
+/* Build a sim over the fixture's stocks. `configure` may be NULL. */
+static sf_sim_t *_build(const fixture_t *f, void (*configure)(sf_sim_params_t *))
+{
+ sf_sim_params_t p;
+ sf_sim_params_defaults(&p);
+ if(configure) configure(&p);
+
+ char *err = NULL;
+ sf_sim_t *sim = sf_sim_build(f->pack, f->film, p.scan_film ? NULL : f->print, &p, &err);
+ if(!sim) TR_NOTE("sim build failed: %s", err ? err : "(no message)");
+ free(err);
+ return sim;
+}
+
+/* The whole per-pixel chain, the way spektrafilm.c runs it minus the spatial
+ effects (which are the caller's, not the engine's). In-place throughout,
+ which the API allows and which keeps this readable. */
+static void _render(const sf_sim_t *sim, const float *rgb_in, float *rgb_out, const size_t npix)
+{
+ float *work = malloc(npix * 3 * sizeof(float));
+ float *corr = malloc(npix * 3 * sizeof(float));
+ assert_non_null(work);
+ assert_non_null(corr);
+
+ sf_sim_expose(sim, rgb_in, work, npix, 3, 3);
+ sf_sim_lograw(work, npix, 3);
+ sf_sim_develop_corr(sim, work, corr, npix, 3);
+ sf_sim_develop(sim, work, corr, work, npix, 3, 3);
+ if(sim->has_print)
+ {
+ sf_sim_print_expose(sim, work, work, npix, 3, 3);
+ sf_sim_print_develop(sim, work, work, npix, 3, 3);
+ }
+ sf_sim_scan(sim, work, rgb_out, npix, 3, 3);
+
+ free(work);
+ free(corr);
+}
+
+static void _fill_grey(float *rgb, const size_t npix, const float level)
+{
+ for(size_t i = 0; i < npix; i++)
+ {
+ rgb[i * 3 + 0] = level;
+ rgb[i * 3 + 1] = level;
+ rgb[i * 3 + 2] = level;
+ }
+}
+
+static double _mean(const float *rgb, const size_t npix)
+{
+ double acc = 0.0;
+ for(size_t i = 0; i < npix * 3; i++) acc += rgb[i];
+ return acc / (double)(npix * 3);
+}
+
+/* upstream tests/test_pipeline_smoke.py::_assert_valid_output */
+static void _assert_valid_output(const float *rgb, const size_t npix, const gboolean bounded)
+{
+ for(size_t i = 0; i < npix * 3; i++)
+ {
+ assert_true(isfinite(rgb[i]));
+ if(bounded)
+ {
+ assert_true(rgb[i] >= -OUT_SLACK);
+ assert_true(rgb[i] <= 1.0 + OUT_SLACK);
+ }
+ }
+}
+
+/*
+ * TEST FUNCTIONS: the pack itself
+ * (upstream tests/test_profiles.py, tests/test_lut.py)
+ */
+
+static void test_pack_loads_and_identifies_itself(void **state)
+{
+ fixture_t *f = *state;
+ if(!f || !f->pack_dir[0]) skip();
+ TR_STEP("the shipped pack loads and names its version and spectral table");
+ /* deliberately not REQUIRE_PACK: a pack that is present but will not load is
+ the one failure this suite must report rather than skip past */
+ assert_non_null(f->pack);
+ assert_non_null(sf_pack_version(f->pack));
+ assert_non_null(sf_pack_lut_id(f->pack));
+ assert_true(sf_pack_lut_hash(f->pack) != 0);
+ TR_DEBUG("version=%s lut=%s hash=%u", sf_pack_version(f->pack), sf_pack_lut_id(f->pack),
+ sf_pack_lut_hash(f->pack));
+}
+
+static void test_pack_ships_both_a_film_and_a_paper(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ TR_STEP("the pack carries at least one filming and one printing profile");
+ assert_non_null(f->film);
+ assert_non_null(f->print);
+ assert_non_null(sf_profile_stage(f->film));
+ assert_non_null(sf_profile_stage(f->print));
+ assert_string_equal(sf_profile_stage(f->film), "filming");
+ assert_string_equal(sf_profile_stage(f->print), "printing");
+}
+
+/* upstream test_profiles.py::test_profile_data_shapes_are_consistent -- the
+ array shapes it checks are fixed by the struct here, so what is left to check
+ is that the arrays actually carry data and that the optional blocks are
+ either absent or complete. */
+static void test_profile_arrays_are_populated_and_finite(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ TR_STEP("profile spectra and curves are fully populated and finite");
+ const sf_profile_t *profiles[2] = { f->film, f->print };
+ for(int i = 0; i < 2; i++)
+ {
+ const sf_profile_t *p = profiles[i];
+ if(!p) continue;
+ /* A profile carries NaN (null in the JSON, see upstream's _json_safe)
+ wherever a stock was never measured -- Portra 400's base density is
+ undefined at both ends of the visible range, and its channel densities
+ likewise. So the check is not that every sample is finite but that none
+ is infinite, which would be a parse or arithmetic fault rather than a
+ gap, and that real data is present at all. */
+ int sens_nonzero = 0, dens_finite = 0, base_finite = 0;
+ for(int l = 0; l < SF_NWL; l++)
+ for(int c = 0; c < 3; c++)
+ {
+ assert_false(isinf(p->channel_density[l][c]));
+ assert_false(isinf(p->log_sensitivity[l][c]));
+ if(isfinite(p->channel_density[l][c])) dens_finite++;
+ if(isfinite(p->log_sensitivity[l][c]) && p->log_sensitivity[l][c] != 0.0) sens_nonzero++;
+ }
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ assert_false(isinf(p->base_density[l]));
+ if(isfinite(p->base_density[l])) base_finite++;
+ }
+ TR_DEBUG("%s: %d sensitivity, %d channel density, %d base density samples",
+ sf_profile_stock(p) ? sf_profile_stock(p) : "(unnamed)", sens_nonzero, dens_finite,
+ base_finite);
+ assert_true(sens_nonzero > 0);
+ assert_true(dens_finite > 0);
+ assert_true(base_finite > 0);
+
+ /* the log-exposure grid must be strictly increasing: everything downstream
+ indexes it as uniform and would silently misread it otherwise */
+ for(int k = 1; k < SF_NLE; k++) assert_true(p->log_exposure[k] > p->log_exposure[k - 1]);
+ for(int k = 0; k < SF_NLE; k++)
+ for(int c = 0; c < 3; c++) assert_true(isfinite(p->density_curves[k][c]));
+
+ /* optional blocks are all-or-nothing */
+ assert_true(p->window_n == 0 || p->window_n == 4);
+ assert_true(p->surface_n == 0 || p->surface_n == SF_SURFACE_NCOEF);
+ }
+}
+
+/* upstream test_profiles.py checks the adaptation surface is either empty or
+ three channels wide. Here the interesting half is which stocks carry one:
+ a paper never does, and the module's adaptation switch is a no-op for it. */
+static void test_print_profile_carries_no_adaptation_surface(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ if(!f->print) skip();
+ TR_STEP("print stocks carry no sensitivity adaptation surface");
+ assert_int_equal(f->print->surface_n, 0);
+}
+
+static void test_film_target_print_resolves_to_a_paper(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ if(!f->film) skip();
+ TR_STEP("a film's target print names a paper the pack actually ships");
+ const char *target = sf_profile_target_print(f->film);
+ if(!target || !target[0]) skip(); /* positive stocks name none */
+ TR_DEBUG("%s targets %s", sf_profile_stock(f->film), target);
+
+ sf_profile_t *paper = _load_stock(f->pack_dir, target, TRUE);
+ assert_non_null(paper);
+ assert_non_null(sf_profile_stock(paper));
+ assert_string_equal(sf_profile_stock(paper), target);
+ sf_profile_free(paper);
+}
+
+/*
+ * TEST FUNCTIONS: enlarger filters
+ * (upstream tests/test_enlarger_filters.py)
+ */
+
+/* upstream test_color_enlarger_cc_scale_matches_density_definition: a CC value
+ is defined as density, so the deepest attenuation a filter reaches is
+ 10^(-cc/100) of the source. */
+static void test_dichroic_cc_scale_matches_the_density_definition(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ const double *filters = g_hash_table_lookup(f->pack->dichroics, "custom");
+ assert_non_null(filters);
+ TR_STEP("a CC value attenuates by its own density, 10^(-cc/100)");
+
+ const double cc_values[] = { 30.0, 60.0, 100.0 };
+ for(size_t i = 0; i < sizeof(cc_values) / sizeof(cc_values[0]); i++)
+ {
+ double flat[SF_NWL], out[SF_NWL];
+ for(int l = 0; l < SF_NWL; l++) flat[l] = 1.0;
+ const double cc[3] = { 0.0, 0.0, cc_values[i] };
+ apply_dichroic_cc(out, flat, filters, cc);
+
+ double lowest = INFINITY;
+ for(int l = 0; l < SF_NWL; l++) lowest = fmin(lowest, out[l]);
+ const double expected = pow(10.0, -cc_values[i] / 100.0);
+ TR_DEBUG("cc=%.0f min=%e expected=%e", cc_values[i], lowest, expected);
+ assert_double_close(lowest, expected, 1e-3);
+ }
+}
+
+/* upstream test_color_enlarger_cc_filters_target_expected_spectral_bands:
+ cyan takes out red, magenta green, yellow blue, and each leaves the other
+ two bands substantially alone. */
+static void test_dichroic_filters_attenuate_their_own_band(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ const double *filters = g_hash_table_lookup(f->pack->dichroics, "custom");
+ assert_non_null(filters);
+ TR_STEP("each dichroic takes out its own band and spares the others");
+
+ /* SF_NWL samples from 380 nm in 5 nm steps; bands 0 = blue, 1 = green, 2 = red */
+ for(int filter = 0; filter < 3; filter++)
+ {
+ double flat[SF_NWL], out[SF_NWL];
+ for(int l = 0; l < SF_NWL; l++) flat[l] = 1.0;
+ double cc[3] = { 0.0, 0.0, 0.0 };
+ cc[filter] = 100.0;
+ apply_dichroic_cc(out, flat, filters, cc);
+
+ double sum[3] = { 0.0, 0.0, 0.0 };
+ int n[3] = { 0, 0, 0 };
+ for(int l = 0; l < SF_NWL; l++)
+ {
+ const double wl = 380.0 + 5.0 * l;
+ int band = -1;
+ if(wl < 480.0)
+ band = 0;
+ else if(wl >= 500.0 && wl < 600.0)
+ band = 1;
+ else if(wl >= 620.0)
+ band = 2;
+ if(band < 0) continue;
+ sum[band] += out[l];
+ n[band]++;
+ }
+ /* cyan (index 0) attenuates red, magenta green, yellow blue */
+ const int hit = 2 - filter;
+ for(int b = 0; b < 3; b++)
+ {
+ assert_true(n[b] > 0);
+ const double mean = sum[b] / n[b];
+ TR_DEBUG("filter %d band %d mean=%e", filter, b, mean);
+ if(b == hit)
+ assert_true(mean < 0.2);
+ else
+ assert_true(mean > 0.7);
+ }
+ }
+}
+
+static void test_neutral_filters_are_calibrated_for_the_shipped_pair(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ if(!f->film || !f->print) skip();
+ TR_STEP("the pack knows the neutral filtration for its own film/paper pair");
+ double cmy[3] = { -1.0, -1.0, -1.0 };
+ const gboolean have = sf_pack_neutral_filters(f->pack, sf_profile_stock(f->print), "TH-KG3",
+ sf_profile_stock(f->film), cmy);
+ if(!have) skip(); /* not every pairing is calibrated */
+ TR_DEBUG("cmy = %.1f %.1f %.1f", cmy[0], cmy[1], cmy[2]);
+ for(int c = 0; c < 3; c++)
+ {
+ assert_true(isfinite(cmy[c]));
+ /* Kodak CC units: a dichroic head cannot dial past 200 */
+ assert_true(cmy[c] >= 0.0 && cmy[c] <= 200.0);
+ }
+}
+
+/*
+ * TEST FUNCTIONS: the assembled pipeline
+ * (upstream tests/test_pipeline_smoke.py)
+ */
+
+static void test_pipeline_renders_valid_output(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ if(!f->print) skip();
+ TR_STEP("the full chain returns finite, bounded output for ordinary input");
+ sf_sim_t *sim = _build(f, NULL);
+ assert_non_null(sim);
+
+ enum { N = 16 };
+ float in[N * 3], out[N * 3];
+ for(int i = 0; i < N; i++)
+ {
+ const float level = 0.01f + 0.99f * (float)i / (float)(N - 1);
+ in[i * 3 + 0] = level;
+ in[i * 3 + 1] = level * 0.6f;
+ in[i * 3 + 2] = level * 0.3f;
+ }
+ _render(sim, in, out, N);
+ _assert_valid_output(out, N, TRUE);
+ sf_sim_free(sim);
+}
+
+/* upstream: pure black, and a wildly over-range input, must not produce NaN */
+static void test_pipeline_survives_extreme_input(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ if(!f->print) skip();
+ TR_STEP("black and blown-out input stay finite and in range");
+ sf_sim_t *sim = _build(f, NULL);
+ assert_non_null(sim);
+
+ enum { N = 4 };
+ float in[N * 3], out[N * 3];
+ _fill_grey(in, N, 0.0f);
+ _render(sim, in, out, N);
+ _assert_valid_output(out, N, TRUE);
+
+ _fill_grey(in, N, 10000.0f);
+ _render(sim, in, out, N);
+ _assert_valid_output(out, N, TRUE);
+ sf_sim_free(sim);
+}
+
+/* upstream test_uniform_gray_output_is_stable_and_artifact_free */
+static void test_uniform_patch_renders_uniform_and_repeatably(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ if(!f->print) skip();
+ TR_STEP("a flat patch renders flat, and twice over renders identically");
+ sf_sim_t *sim = _build(f, NULL);
+ assert_non_null(sim);
+
+ enum { N = 9 };
+ float in[N * 3], first[N * 3], second[N * 3];
+ _fill_grey(in, N, 0.184f);
+ _render(sim, in, first, N);
+ _render(sim, in, second, N);
+ _assert_valid_output(first, N, TRUE);
+
+ for(int i = 0; i < N * 3; i++)
+ {
+ assert_true(first[i] == second[i]); /* bit-identical: no hidden state */
+ assert_double_close(first[i], first[i % 3], 1e-6);
+ }
+ sf_sim_free(sim);
+}
+
+/* upstream test_exposure_controls_behave_consistently, first half */
+static void test_brighter_input_renders_brighter(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ if(!f->print) skip();
+ TR_STEP("output brightness follows input brightness, monotonically");
+ sf_sim_t *sim = _build(f, NULL);
+ assert_non_null(sim);
+
+ const float levels[] = { 0.02f, 0.05f, 0.18f, 0.5f, 0.9f };
+ enum { N = 4 };
+ double previous = -INFINITY;
+ for(size_t i = 0; i < sizeof(levels) / sizeof(levels[0]); i++)
+ {
+ float in[N * 3], out[N * 3];
+ _fill_grey(in, N, levels[i]);
+ _render(sim, in, out, N);
+ const double mean = _mean(out, N);
+ TR_DEBUG("level=%.2f mean=%e", levels[i], mean);
+ assert_true(mean > previous);
+ previous = mean;
+ }
+ sf_sim_free(sim);
+}
+
+static void _cfg_plus_two_ev(sf_sim_params_t *p)
+{
+ p->exposure_comp_ev = 2.0;
+ p->print_exposure_compensation = false;
+}
+static void _cfg_minus_two_ev(sf_sim_params_t *p)
+{
+ p->exposure_comp_ev = -2.0;
+ p->print_exposure_compensation = false;
+}
+static void _cfg_no_print_comp(sf_sim_params_t *p)
+{
+ p->print_exposure_compensation = false;
+}
+
+/* upstream test_exposure_controls_behave_consistently, second half */
+static void test_exposure_compensation_moves_the_render(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ if(!f->print) skip();
+ TR_STEP("exposure compensation brightens and darkens as its sign says");
+ enum { N = 4 };
+ float in[N * 3], out[N * 3];
+ _fill_grey(in, N, 0.18f);
+
+ double means[3];
+ void (*configs[3])(sf_sim_params_t *) = { _cfg_minus_two_ev, _cfg_no_print_comp, _cfg_plus_two_ev };
+ for(int i = 0; i < 3; i++)
+ {
+ sf_sim_t *sim = _build(f, configs[i]);
+ assert_non_null(sim);
+ _render(sim, in, out, N);
+ _assert_valid_output(out, N, TRUE);
+ means[i] = _mean(out, N);
+ sf_sim_free(sim);
+ }
+ TR_DEBUG("-2EV=%e 0EV=%e +2EV=%e", means[0], means[1], means[2]);
+ assert_true(means[0] < means[1]);
+ assert_true(means[1] < means[2]);
+}
+
+static void _cfg_scan_film(sf_sim_params_t *p)
+{
+ p->scan_film = true;
+}
+
+static void test_scan_film_builds_without_a_paper(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ TR_STEP("scanning the negative directly needs no print stock at all");
+ sf_sim_t *sim = _build(f, _cfg_scan_film);
+ assert_non_null(sim);
+ assert_int_equal(sim->has_print, 0);
+
+ enum { N = 4 };
+ float in[N * 3], out[N * 3];
+ _fill_grey(in, N, 0.18f);
+ _render(sim, in, out, N);
+ _assert_valid_output(out, N, TRUE);
+ sf_sim_free(sim);
+}
+
+static void _cfg_lut_quality(sf_sim_params_t *p)
+{
+ p->lut_steps = 33;
+}
+
+/* upstream tests/test_lut_mode.py: the tabulated path is an approximation of
+ the exact spectral one and has to stay close to it. A 33-step table on the
+ 0.3.3 pack lands within 6e-4 of exact across a colour ramp; the bound below
+ leaves an order of magnitude of headroom for other stocks and other
+ platforms' rounding, because what would be a regression is the two paths
+ diverging visibly, not the interpolation error moving in its last digits. */
+static void test_lut_path_tracks_exact_spectral(void **state)
+{
+ fixture_t *f = *state;
+ REQUIRE_PACK(f);
+ if(!f->print) skip();
+ TR_STEP("the quality LUT stays close to the exact spectral path");
+ enum { N = 8 };
+ float in[N * 3], exact[N * 3], lut[N * 3];
+ for(int i = 0; i < N; i++)
+ {
+ const float level = 0.02f + 0.9f * (float)i / (float)(N - 1);
+ in[i * 3 + 0] = level;
+ in[i * 3 + 1] = level * 0.75f;
+ in[i * 3 + 2] = level * 0.45f;
+ }
+
+ sf_sim_t *sim = _build(f, NULL);
+ assert_non_null(sim);
+ _render(sim, in, exact, N);
+ sf_sim_free(sim);
+
+ sim = _build(f, _cfg_lut_quality);
+ assert_non_null(sim);
+ _render(sim, in, lut, N);
+ sf_sim_free(sim);
+
+ for(int i = 0; i < N * 3; i++)
+ {
+ TR_DEBUG("exact=%e lut=%e", exact[i], lut[i]);
+ assert_double_close(lut[i], exact[i], 0.005);
+ }
+}
+
+/*
+ * MAIN
+ */
+
+int main(int argc, char *argv[])
+{
+ const struct CMUnitTest tests[] = {
+ cmocka_unit_test(test_pack_loads_and_identifies_itself),
+ cmocka_unit_test(test_pack_ships_both_a_film_and_a_paper),
+ cmocka_unit_test(test_profile_arrays_are_populated_and_finite),
+ cmocka_unit_test(test_print_profile_carries_no_adaptation_surface),
+ cmocka_unit_test(test_film_target_print_resolves_to_a_paper),
+ cmocka_unit_test(test_dichroic_cc_scale_matches_the_density_definition),
+ cmocka_unit_test(test_dichroic_filters_attenuate_their_own_band),
+ cmocka_unit_test(test_neutral_filters_are_calibrated_for_the_shipped_pair),
+ cmocka_unit_test(test_pipeline_renders_valid_output),
+ cmocka_unit_test(test_pipeline_survives_extreme_input),
+ cmocka_unit_test(test_uniform_patch_renders_uniform_and_repeatably),
+ cmocka_unit_test(test_brighter_input_renders_brighter),
+ cmocka_unit_test(test_exposure_compensation_moves_the_render),
+ cmocka_unit_test(test_scan_film_builds_without_a_paper),
+ cmocka_unit_test(test_lut_path_tracks_exact_spectral),
+ };
+
+ return cmocka_run_group_tests(tests, group_setup, group_teardown);
+}
+// clang-format off
+// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
+// clang-format on
diff --git a/src/tests/unittests/common/test_spektra_sim.c b/src/tests/unittests/common/test_spektra_sim.c
new file mode 100644
index 000000000000..e3c92b1a742b
--- /dev/null
+++ b/src/tests/unittests/common/test_spektra_sim.c
@@ -0,0 +1,581 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ darktable is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with darktable. If not, see .
+*/
+/*
+ * cmocka unit tests for the spektrafilm simulation engine, common/spektra_sim.c.
+ *
+ * These are ports of the unit tests the upstream python implementation runs
+ * against the same algorithms (its tests/test_couplers.py,
+ * test_morph_curves.py, test_parametric.py and test_gamut_compression.py).
+ * Each test below names the upstream test it comes from, so that when the two
+ * drift apart it is clear which side moved.
+ *
+ * What they cover is the pure math, plus the per-pixel entry points fed from a
+ * hand-built sf_sim_t. Not covered is anything that needs a loaded profile --
+ * the spectral upsampling tables, the enlarger's neutral filters, the grain
+ * sublayer build. That is a matter of scope rather than availability: the
+ * current pack ships with darktable under data/spektrafilm/, and only edits
+ * pinned to an older one fetch anything at runtime, so a fixture is there for
+ * the taking. But a test built on it reads real profile data off disk, which
+ * makes it an integration test with a data dependency and not a unit test.
+ * Those live next door, in test_spektra_pack.c, which CMake points at the
+ * shipped pack and which skips itself when there is none on disk.
+ *
+ * Including the .c rather than the .h is what the module tests already do (see
+ * ../iop/test_filmicrgb.c): most of the algorithms are file-static helpers, and
+ * they are the interesting part.
+ *
+ * Please see ../README.md for more detailed documentation.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "../util/assert.h"
+#include "../util/tracing.h"
+
+#include "common/spektra_sim.c"
+
+#ifdef _WIN32
+#include "win/main_wrapper.h"
+#endif
+
+/*
+ * DEFINITIONS
+ */
+
+/* Comparison epsilon. The engine works in double, and every invariant tested
+ here is exact in exact arithmetic, so this only has to absorb rounding. */
+#define E 1e-9
+
+/* Compare in double, whatever cmocka happens to be installed.
+ cmocka's own assert_double_close() casts both operands AND the epsilon to
+ float, so from 1.1.2 on -- where its macro shadows the fallback in
+ ../util/assert.h -- an epsilon of 1e-9 sits below the float resolution of
+ values around 1 and the comparison stops meaning anything. Its
+ assert_double_equal() would be the right tool, but 1.1.0 is the minimum the
+ build accepts and has neither macro, so neither can be relied on. */
+#ifndef assert_double_close
+#define assert_double_close(a, b, epsilon) \
+ assert_true(fabs((double)(a) - (double)(b)) <= (double)(epsilon))
+#endif
+
+/* The knee parameters upstream's own gamut-compression tests use. They are not
+ the ones spektrafilm ships (SF_TC_KNEE_* / SF_OUT_KNEE_* have threshold 0),
+ which is precisely why the knee is tested through its parameters: the
+ threshold behaviour has to hold for any threshold, not just the shipped one. */
+#define KNEE_T 0.815
+#define KNEE_L 1.0
+#define KNEE_P 1.2
+
+/* A square standing in for the spectral locus. The real locus needs the pack's
+ colour matching functions; every property tested through it here is a
+ property of the radial compressor, not of the polygon, so a closed convex
+ polygon with white inside it is enough -- and it keeps the expected values
+ hand-checkable. Closed: last vertex repeats the first. */
+static const double LOCUS_SQUARE[5][2] = { { 0.0, 0.0 },
+ { 1.0, 0.0 },
+ { 1.0, 1.0 },
+ { 0.0, 1.0 },
+ { 0.0, 0.0 } };
+static const double WHITE_E[2] = { 1.0 / 3.0, 1.0 / 3.0 };
+
+/* One channel of a three-sublayer emulsion fit, taken from the first column of
+ the model upstream's test_morph_curves.py builds. */
+static const double CENTERS[3] = { -1.2, -1.1, -1.0 };
+static const double AMPS[3] = { 0.25, 0.22, 0.20 };
+static const double SIGMAS[3] = { 0.30, 0.28, 0.26 };
+
+/* Log-exposure grid for the hand-built sims below: SF_NLE points over
+ [-3, 1], the range the profiles themselves are fitted on. */
+#define LE0 (-3.0)
+#define LE_SPAN 4.0
+#define LE_STEP (LE_SPAN / (double)(SF_NLE - 1))
+
+/*
+ * HELPERS
+ */
+
+/* Distance from white in the chromaticity plane. */
+static double _dist_from_white(const double xy[2])
+{
+ return hypot(xy[0] - WHITE_E[0], xy[1] - WHITE_E[1]);
+}
+
+/* A sim carrying nothing but what the film-develop entry points read: a linear
+ density ramp from 0 at log-exposure LE0 to dmax at LE0 + LE_SPAN, unit gamma,
+ negative film, linear (non-Langmuir) couplers. Linear so that the expected
+ density at any exposure is a closed form the test can state on its own rather
+ than borrowing the interpolator it is checking. Caller frees. */
+static sf_sim_t *_sim_linear_ramp(const double dmax[3])
+{
+ sf_sim_t *sim = calloc(1, sizeof(sf_sim_t));
+ assert_non_null(sim);
+ sim->le0 = LE0;
+ sim->le_step = LE_STEP;
+ sim->inv_le_step = (float)(1.0 / LE_STEP);
+ for(int c = 0; c < 3; c++)
+ {
+ sim->gamma[c] = 1.0;
+ sim->film_dmax[c] = dmax[c];
+ for(int i = 0; i < SF_NLE; i++)
+ {
+ const double d = dmax[c] * (double)i / (double)(SF_NLE - 1);
+ sim->curves_norm[i][c] = d;
+ sim->curves_norm_f[i][c] = (float)d;
+ }
+ }
+ sim->film_positive = 0;
+ sim->couplers_active = 1;
+ sim->couplers_donor_lm = 0;
+ sim->couplers_recv_lm = 0;
+ return sim;
+}
+
+/* The density the ramp above holds at log-exposure x, stated independently of
+ the interpolator under test. */
+static double _ramp_density(double x, double dmax)
+{
+ const double t = (x - LE0) / LE_SPAN;
+ return dmax * (t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t));
+}
+
+/*
+ * TEST FUNCTIONS: reinhard knee
+ * (upstream tests/test_gamut_compression.py::TestReinhardKnee)
+ */
+
+static void test_knee_below_threshold_is_identity(void **state)
+{
+ TR_STEP("knee leaves distances below the threshold exactly alone");
+ const double d[] = { 0.0, 0.2, 0.5, 0.8 };
+ for(size_t i = 0; i < sizeof(d) / sizeof(d[0]); i++)
+ {
+ const double out = reinhard_knee(d[i], KNEE_T, KNEE_L, KNEE_P);
+ TR_DEBUG("d=%e => %e", d[i], out);
+ assert_double_close(out, d[i], E);
+ }
+}
+
+static void test_knee_above_threshold_compresses(void **state)
+{
+ TR_STEP("knee compresses rather than stretches above the threshold");
+ const double d[] = { 0.9, 1.5, 5.0, 100.0 };
+ for(size_t i = 0; i < sizeof(d) / sizeof(d[0]); i++)
+ {
+ const double out = reinhard_knee(d[i], KNEE_T, KNEE_L, KNEE_P);
+ TR_DEBUG("d=%e => %e", d[i], out);
+ assert_true(out < d[i]);
+ }
+}
+
+static void test_knee_asymptotes_at_limit(void **state)
+{
+ TR_STEP("knee approaches the limit as the distance grows without bound");
+ const double out = reinhard_knee(1e9, KNEE_T, KNEE_L, KNEE_P);
+ assert_double_close(out, KNEE_L, 1e-6);
+}
+
+static void test_knee_is_continuous_at_threshold(void **state)
+{
+ TR_STEP("knee has no step at the threshold");
+ const double eps = 1e-9;
+ const double below = reinhard_knee(KNEE_T - eps, KNEE_T, KNEE_L, KNEE_P);
+ const double above = reinhard_knee(KNEE_T + eps, KNEE_T, KNEE_L, KNEE_P);
+ assert_double_close(above, below, 1e-6);
+}
+
+/*
+ * TEST FUNCTIONS: output gamut compression, ACES RGC
+ * (upstream tests/test_gamut_compression.py::TestCompressRgbAcesRgc)
+ */
+
+static void test_aces_neutral_is_unchanged(void **state)
+{
+ TR_STEP("achromatic pixels pass through the output compressor untouched");
+ double rgb[3] = { 0.5, 0.5, 0.5 };
+ compress_rgb_aces(rgb);
+ for(int c = 0; c < 3; c++) assert_double_close(rgb[c], 0.5, E);
+}
+
+static void test_aces_black_is_identity(void **state)
+{
+ TR_STEP("pixels at or below black keep their values -- no chromaticity to compress");
+ double rgb[3] = { 0.0, 0.0, 0.0 };
+ compress_rgb_aces(rgb);
+ for(int c = 0; c < 3; c++) assert_double_close(rgb[c], 0.0, E);
+}
+
+static void test_aces_leaves_the_max_channel_alone(void **state)
+{
+ TR_STEP("the achromatic max is the anchor and never moves");
+ double rgb[3] = { 2.0, -0.1, 0.3 };
+ compress_rgb_aces(rgb);
+ assert_double_close(rgb[0], 2.0, E);
+}
+
+static void test_aces_pulls_negatives_back_inside(void **state)
+{
+ TR_STEP("out-of-gamut negatives come back non-negative, and by a real margin");
+ double rgb[3] = { 1.5, -0.1, -0.05 };
+ compress_rgb_aces(rgb);
+ assert_double_close(rgb[0], 1.5, E);
+ assert_true(rgb[1] >= 0.0);
+ assert_true(rgb[2] >= 0.0);
+ /* not merely clipped to a hair above zero: the knee lands them well inside */
+ assert_true(rgb[1] < 0.2 * 1.5);
+ assert_true(rgb[2] < 0.2 * 1.5);
+}
+
+static void test_aces_compresses_stronger_excursions_harder(void **state)
+{
+ TR_STEP("the further out of gamut, the closer to the boundary the result lands");
+ double mild[3] = { 1.0, -0.05, -0.05 };
+ double hard[3] = { 1.0, -1.0, -1.0 };
+ compress_rgb_aces(mild);
+ compress_rgb_aces(hard);
+ TR_DEBUG("mild=%e hard=%e", mild[1], hard[1]);
+ assert_true(hard[1] < mild[1]);
+ assert_true(hard[2] < mild[2]);
+}
+
+/*
+ * TEST FUNCTIONS: input chromaticity compression
+ * (upstream tests/test_gamut_compression.py::TestCompressXy)
+ */
+
+static void test_compress_xy_leaves_white_alone(void **state)
+{
+ TR_STEP("white has no direction to be pulled along and must not move");
+ double out[2];
+ compress_xy_radial(out, WHITE_E, WHITE_E, LOCUS_SQUARE, 5);
+ assert_double_close(out[0], WHITE_E[0], E);
+ assert_double_close(out[1], WHITE_E[1], E);
+}
+
+static void test_compress_xy_leaves_well_inside_alone(void **state)
+{
+ /* Upstream asserts exact identity here, which holds for its own threshold of
+ 0.815. spektrafilm ships SF_TC_KNEE_T = 0, so nothing is formally exempt
+ and the assertion becomes the one that survives either threshold: deep
+ inside the locus the knee is the identity to within rounding. */
+ TR_STEP("chromaticities well inside the locus come back where they went in");
+ const double xy[2] = { WHITE_E[0] + 0.02, WHITE_E[1] + 0.01 };
+ double out[2];
+ compress_xy_radial(out, xy, WHITE_E, LOCUS_SQUARE, 5);
+ assert_double_close(out[0], xy[0], 1e-6);
+ assert_double_close(out[1], xy[1], 1e-6);
+}
+
+static void test_compress_xy_pulls_oog_inside(void **state)
+{
+ TR_STEP("a chromaticity far outside the locus is pulled in, and stays in");
+ const double xy[2] = { 1.4, -0.3 };
+ double out[2];
+ compress_xy_radial(out, xy, WHITE_E, LOCUS_SQUARE, 5);
+ TR_DEBUG("in=(%e,%e) out=(%e,%e)", xy[0], xy[1], out[0], out[1]);
+ assert_true(_dist_from_white(out) < _dist_from_white(xy));
+ /* the limit is the boundary itself, so the result lands within the square */
+ assert_true(out[0] >= 0.0 && out[0] <= 1.0);
+ assert_true(out[1] >= 0.0 && out[1] <= 1.0);
+}
+
+static void test_compress_xy_preserves_direction(void **state)
+{
+ TR_STEP("compression is radial: only the distance from white changes");
+ const double xy[2] = { 1.4, -0.3 };
+ double out[2];
+ compress_xy_radial(out, xy, WHITE_E, LOCUS_SQUARE, 5);
+ const double in_dx = xy[0] - WHITE_E[0], in_dy = xy[1] - WHITE_E[1];
+ const double out_dx = out[0] - WHITE_E[0], out_dy = out[1] - WHITE_E[1];
+ /* cross product of the two offsets vanishes when they are collinear */
+ assert_double_close(in_dx * out_dy - in_dy * out_dx, 0.0, E);
+ /* and the pull is inward, never a reflection through white */
+ assert_true(in_dx * out_dx + in_dy * out_dy > 0.0);
+}
+
+/*
+ * TEST FUNCTIONS: density curve model
+ * (upstream tests/test_parametric.py::TestParametricDensityCurvesModel)
+ */
+
+static void test_density_curve_is_monotonic(void **state)
+{
+ TR_STEP("a negative stock's density never falls as exposure rises");
+ enum { N = 200 };
+ double le[N], density[N];
+ for(int i = 0; i < N; i++) le[i] = -3.0 + 5.0 * (double)i / (double)(N - 1);
+ for(int sept = 0; sept < 2; sept++)
+ {
+ eval_cdfs_channel(density, le, N, CENTERS, AMPS, SIGMAS, NULL, sept, 3, 0);
+ for(int i = 1; i < N; i++)
+ {
+ TR_DEBUG("le=%e d=%e", le[i], density[i]);
+ assert_true(density[i] - density[i - 1] >= -E);
+ }
+ }
+}
+
+static void test_density_curve_is_near_zero_at_low_exposure(void **state)
+{
+ TR_STEP("density vanishes far below the toe");
+ enum { N = 200 };
+ double le[N], density[N];
+ for(int i = 0; i < N; i++) le[i] = -6.0 + 8.0 * (double)i / (double)(N - 1);
+ for(int sept = 0; sept < 2; sept++)
+ {
+ eval_cdfs_channel(density, le, N, CENTERS, AMPS, SIGMAS, NULL, sept, 3, 0);
+ for(int i = 0; i < 5; i++) assert_true(density[i] < 0.01);
+ }
+}
+
+static void test_density_curve_is_inverted_for_positive_stock(void **state)
+{
+ TR_STEP("a positive stock's density falls where a negative's rises");
+ enum { N = 64 };
+ double le[N], neg[N], pos[N];
+ for(int i = 0; i < N; i++) le[i] = -3.0 + 5.0 * (double)i / (double)(N - 1);
+ eval_cdfs_channel(neg, le, N, CENTERS, AMPS, SIGMAS, NULL, 0, 3, 0);
+ eval_cdfs_channel(pos, le, N, CENTERS, AMPS, SIGMAS, NULL, 0, 3, 1);
+ const double total = AMPS[0] + AMPS[1] + AMPS[2];
+ for(int i = 0; i < N; i++) assert_double_close(neg[i] + pos[i], total, E);
+}
+
+/*
+ * TEST FUNCTIONS: developer exhaustion morph
+ * (upstream tests/test_morph_curves.py)
+ */
+
+static void test_developer_exhaustion_preserves_midgray(void **state)
+{
+ /* Exhaustion blends the layer sigmoids toward a matched gumbel shoulder,
+ which would drag the whole curve sideways; the solver's job is to find the
+ centre offset that puts density at log-exposure 0 back where it was. Both
+ polarities, because the solver negates z for a positive stock and a sign
+ error there would only show on one of them. */
+ TR_STEP("developer exhaustion reshapes the shoulder without moving midgray");
+ for(int positive = 0; positive < 2; positive++)
+ {
+ double c_base[3], s_base[3], g_base[3];
+ double c_exh[3], s_exh[3], g_exh[3];
+ _sf_morph_channel(CENTERS, SIGMAS, NULL, 0, 3, positive, 1.0, 1.0, 1.0, 0.0, AMPS,
+ c_base, s_base, g_base);
+ _sf_morph_channel(CENTERS, SIGMAS, NULL, 0, 3, positive, 1.0, 1.0, 1.0, 0.35, AMPS,
+ c_exh, s_exh, g_exh);
+
+ const double d_base
+ = _sf_channel_density_at(0.0, c_base, AMPS, s_base, NULL, 0, 3, g_base, positive);
+ const double d_exh
+ = _sf_channel_density_at(0.0, c_exh, AMPS, s_exh, NULL, 0, 3, g_exh, positive);
+ TR_DEBUG("positive=%d D(0) %e -> %e", positive, d_base, d_exh);
+ assert_double_close(d_exh, d_base, E);
+
+ /* and it did something: the blend is on, and the centres actually moved */
+ for(int l = 0; l < 3; l++) assert_double_close(g_exh[l], 0.35, E);
+ assert_true(fabs(c_exh[0] - c_base[0]) > E);
+ }
+}
+
+static void test_zero_exhaustion_leaves_the_curve_alone(void **state)
+{
+ TR_STEP("with no exhaustion and unit gammas the morph is the identity");
+ double centers[3], sigmas[3], gmix[3];
+ _sf_morph_channel(CENTERS, SIGMAS, NULL, 0, 3, 0, 1.0, 1.0, 1.0, 0.0, AMPS, centers, sigmas,
+ gmix);
+ for(int l = 0; l < 3; l++)
+ {
+ assert_double_close(centers[l], CENTERS[l], E);
+ assert_double_close(sigmas[l], SIGMAS[l], E);
+ assert_double_close(gmix[l], 0.0, E);
+ }
+}
+
+static void test_morph_gamma_scales_centers_and_sigmas(void **state)
+{
+ /* The coupled-gamma morph divides each sublayer's centre and width by its
+ own gamma, which is what makes a higher-contrast development steepen the
+ curve rather than merely shift it. */
+ TR_STEP("the chemistry gamma divides centres and widths alike");
+ const double gamma = 1.25;
+ double centers[3], sigmas[3], gmix[3];
+ _sf_morph_channel(CENTERS, SIGMAS, NULL, 0, 3, 0, gamma, 1.0, 1.0, 0.0, AMPS, centers, sigmas,
+ gmix);
+ for(int l = 0; l < 3; l++)
+ {
+ assert_double_close(centers[l], CENTERS[l] / gamma, E);
+ assert_double_close(sigmas[l], SIGMAS[l] / gamma, E);
+ }
+}
+
+/*
+ * TEST FUNCTIONS: DIR couplers
+ * (upstream tests/test_couplers.py::TestDirCouplers)
+ */
+
+static void test_couplers_inactive_gives_no_correction(void **state)
+{
+ TR_STEP("with the couplers off the exposure correction is identically zero");
+ const double dmax[3] = { 2.4, 2.2, 2.0 };
+ sf_sim_t *sim = _sim_linear_ramp(dmax);
+ sim->couplers_active = 0;
+ sim->couplers_M[0][0] = sim->couplers_M[1][1] = sim->couplers_M[2][2] = 0.5;
+
+ const float lograw[3] = { -1.0f, -1.2f, -1.4f };
+ float corr[3] = { 9.0f, 9.0f, 9.0f };
+ sf_sim_develop_corr(sim, lograw, corr, 1, 3);
+ for(int c = 0; c < 3; c++) assert_double_close(corr[c], 0.0, 1e-6);
+ free(sim);
+}
+
+static void test_couplers_zero_density_gives_no_correction(void **state)
+{
+ /* No developed silver means no inhibitor released, whatever the matrix says. */
+ TR_STEP("zero density releases no inhibitor, so the exposure is untouched");
+ const double dmax[3] = { 0.0, 0.0, 0.0 };
+ sf_sim_t *sim = _sim_linear_ramp(dmax);
+ for(int i = 0; i < 3; i++)
+ for(int j = 0; j < 3; j++) sim->couplers_M[i][j] = 0.3 + 0.1 * (i + j);
+
+ const float lograw[3] = { -1.0f, -0.5f, 0.0f };
+ float corr[3] = { 9.0f, 9.0f, 9.0f };
+ sf_sim_develop_corr(sim, lograw, corr, 1, 3);
+ for(int c = 0; c < 3; c++) assert_double_close(corr[c], 0.0, 1e-6);
+ free(sim);
+}
+
+static void test_couplers_diagonal_matrix_keeps_channels_independent(void **state)
+{
+ /* Upstream states this as "no interlayer inhibition is diagonal", checking
+ the matrix the coupler parameters build. Here the matrix is built inside
+ sf_sim_build() from a loaded profile, so the same property is asserted one
+ step further down, where it is what actually matters: with no off-diagonal
+ term, a channel's correction depends on its own density and nothing else. */
+ TR_STEP("a diagonal coupler matrix leaves each channel to itself");
+ const double dmax[3] = { 2.4, 2.2, 2.0 };
+ const double self_inhibition[3] = { 0.5, 0.4, 0.3 };
+ sf_sim_t *sim = _sim_linear_ramp(dmax);
+ for(int c = 0; c < 3; c++) sim->couplers_M[c][c] = self_inhibition[c];
+
+ const float lograw_a[3] = { -1.0f, -1.2f, -1.4f };
+ float corr_a[3];
+ sf_sim_develop_corr(sim, lograw_a, corr_a, 1, 3);
+ for(int c = 0; c < 3; c++)
+ {
+ const double expected = _ramp_density(lograw_a[c], dmax[c]) * self_inhibition[c];
+ TR_DEBUG("channel %d corr=%e expected=%e", c, corr_a[c], expected);
+ assert_double_close(corr_a[c], expected, 1e-5);
+ }
+
+ /* move one channel's exposure: the other two corrections must not budge */
+ const float lograw_b[3] = { 0.5f, -1.2f, -1.4f };
+ float corr_b[3];
+ sf_sim_develop_corr(sim, lograw_b, corr_b, 1, 3);
+ assert_true(fabsf(corr_b[0] - corr_a[0]) > 1e-4f);
+ assert_double_close(corr_b[1], corr_a[1], 1e-6);
+ assert_double_close(corr_b[2], corr_a[2], 1e-6);
+ free(sim);
+}
+
+static void test_couplers_interlayer_crosses_channels(void **state)
+{
+ /* The other side of the same coin, and the reason the test above is worth
+ having: an off-diagonal term must reach across, or the matrix is being
+ applied in the wrong orientation. Donor row -> receiver column, so
+ M[0][1] carries red's density into green's correction. */
+ TR_STEP("an off-diagonal coupler term carries one channel into another");
+ const double dmax[3] = { 2.4, 2.2, 2.0 };
+ sf_sim_t *sim = _sim_linear_ramp(dmax);
+ sim->couplers_M[0][1] = 0.25;
+
+ const float lograw[3] = { -1.0f, -1.2f, -1.4f };
+ float corr[3];
+ sf_sim_develop_corr(sim, lograw, corr, 1, 3);
+ const double expected = _ramp_density(lograw[0], dmax[0]) * 0.25;
+ assert_double_close(corr[1], expected, 1e-5);
+ assert_double_close(corr[0], 0.0, 1e-6);
+ assert_double_close(corr[2], 0.0, 1e-6);
+ free(sim);
+}
+
+static void test_couplers_correction_scales_with_density(void **state)
+{
+ TR_STEP("more developed silver releases proportionally more inhibitor");
+ const double dmax[3] = { 2.4, 2.2, 2.0 };
+ sf_sim_t *sim = _sim_linear_ramp(dmax);
+ for(int c = 0; c < 3; c++) sim->couplers_M[c][c] = 0.5;
+
+ const float low[3] = { -2.0f, -2.0f, -2.0f };
+ const float high[3] = { 0.0f, 0.0f, 0.0f };
+ float corr_low[3], corr_high[3];
+ sf_sim_develop_corr(sim, low, corr_low, 1, 3);
+ sf_sim_develop_corr(sim, high, corr_high, 1, 3);
+ for(int c = 0; c < 3; c++)
+ {
+ assert_true(corr_high[c] > corr_low[c]);
+ /* the ramp is linear, so the ratio is the exposure ratio exactly */
+ const double expected = _ramp_density(high[c], dmax[c]) / _ramp_density(low[c], dmax[c]);
+ assert_double_close((double)corr_high[c] / corr_low[c], expected, 1e-4);
+ }
+ free(sim);
+}
+
+/*
+ * MAIN
+ */
+
+int main(int argc, char *argv[])
+{
+ const struct CMUnitTest tests[] = {
+ cmocka_unit_test(test_knee_below_threshold_is_identity),
+ cmocka_unit_test(test_knee_above_threshold_compresses),
+ cmocka_unit_test(test_knee_asymptotes_at_limit),
+ cmocka_unit_test(test_knee_is_continuous_at_threshold),
+ cmocka_unit_test(test_aces_neutral_is_unchanged),
+ cmocka_unit_test(test_aces_black_is_identity),
+ cmocka_unit_test(test_aces_leaves_the_max_channel_alone),
+ cmocka_unit_test(test_aces_pulls_negatives_back_inside),
+ cmocka_unit_test(test_aces_compresses_stronger_excursions_harder),
+ cmocka_unit_test(test_compress_xy_leaves_white_alone),
+ cmocka_unit_test(test_compress_xy_leaves_well_inside_alone),
+ cmocka_unit_test(test_compress_xy_pulls_oog_inside),
+ cmocka_unit_test(test_compress_xy_preserves_direction),
+ cmocka_unit_test(test_density_curve_is_monotonic),
+ cmocka_unit_test(test_density_curve_is_near_zero_at_low_exposure),
+ cmocka_unit_test(test_density_curve_is_inverted_for_positive_stock),
+ cmocka_unit_test(test_developer_exhaustion_preserves_midgray),
+ cmocka_unit_test(test_zero_exhaustion_leaves_the_curve_alone),
+ cmocka_unit_test(test_morph_gamma_scales_centers_and_sigmas),
+ cmocka_unit_test(test_couplers_inactive_gives_no_correction),
+ cmocka_unit_test(test_couplers_zero_density_gives_no_correction),
+ cmocka_unit_test(test_couplers_diagonal_matrix_keeps_channels_independent),
+ cmocka_unit_test(test_couplers_interlayer_crosses_channels),
+ cmocka_unit_test(test_couplers_correction_scales_with_density),
+ };
+
+ return cmocka_run_group_tests(tests, NULL, NULL);
+}
+// clang-format off
+// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
+// clang-format on