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..94685e77880b
--- /dev/null
+++ b/data/kernels/spektrafilm.cl
@@ -0,0 +1,702 @@
+/*
+ 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;
+}
+static inline float sf_nrm(uint s)
+{
+ float u1 = fmax(sf_u01(s), 1e-7f), u2 = sf_u01(s * 2654435761u + 1u);
+ return native_sqrt(-2.f * native_log(u1)) * native_cos(6.2831853f * u2);
+}
+static inline uint sf_pixel_seed(uint xi, uint yi, uint chan)
+{
+ return xi * 73856093u ^ yi * 19349663u ^ chan * 83492791u;
+}
+static float sf_layer_particle(float density, float dmax, float npart, float unif, uint seed)
+{
+ float p = sf_clampf(density / dmax, 1e-6f, 1.f - 1e-6f), od = dmax / npart,
+ sat = 1.f - p * unif * (1.f - 1e-6f), lam = npart / sat;
+ float seeds = lam + native_sqrt(fmax(lam, 0.f)) * sf_nrm(seed * 0x9e3779b9u + 1u);
+ seeds = fmax(seeds, 0.f);
+ float mean = seeds * p, var = seeds * p * (1.f - p),
+ g = mean + native_sqrt(fmax(var, 0.f)) * sf_nrm(seed * 0x85ebca6bU + 7u);
+ g = fmax(g, 0.f);
+ g = fmin(g, seeds);
+ return g * 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);
+}
+
+/* stage 4: grain delta on the developed CMY density (spektra_core model);
+ delta is blurred host-side, then added back by spektrafilm_grain_add */
+__kernel void spektrafilm_grain_gen(__global const float4 *dens, __global float4 *grain_buf,
+ const int w, const int h, const float grain_amount,
+ const int roi_x, const int roi_y, const int mono,
+ const float dmax0, const float dmax1, const float dmax2,
+ const float dmin0, const float dmin1, const float dmin2,
+ const float rms0, const float rms1, const float rms2,
+ const float unf0, const float unf1, const float unf2)
+{
+ 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 dmn[3] = { dmin0, dmin1, dmin2 };
+ /* per-film emulsion D-max: a hardcoded colour-negative 2.2 saturates the
+ particle model in dense slide areas and tints them channel-dependently */
+ const float dmc[3] = { fmax(dmax0, 1e-3f), fmax(dmax1, 1e-3f), fmax(dmax2, 1e-3f) };
+ /* per-film catalogue grain (rms-granularity, uniformity) from
+ film_render_defaults[stock].grain — replaces the earlier one-size-fits-all
+ constants so e.g. Portra 400 and Tri-X render distinct grain */
+ const float rms[3] = { rms0, rms1, rms2 }, unf[3] = { unf0, unf1, unf2 };
+ const float A48 = 3.14159265f * 24.0f * 24.0f;
+ const float ref_um = 10.0f, pix = ref_um * ref_um;
+ const float dd[3] = { d4.x, d4.y, d4.z };
+ float gd[3];
+ if(mono) /* B&W stock: one achromatic grain realisation for all channels */
+ {
+ const float dm = (dd[0] + dd[1] + dd[2]) / 3.0f;
+ const float dmax = dmc[1] + dmn[1], d_ref = 1.0f + dmn[1];
+ const float sig = rms[1] / 1000.0f;
+ const float denom = fmax(d_ref * (dmax - unf[1] * d_ref), 1e-6f);
+ const float a_grain = sig * sig * A48 / denom;
+ const float npart = pix / fmax(a_grain, 1e-4f);
+ const float din = dm + dmn[1];
+ uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), 0u);
+ const float gval = sf_layer_particle(din, dmax, npart, unf[1], seed) - dmn[1];
+ const float dl = (gval - dm) * grain_amount;
+ grain_buf[k] = (float4)(dl, dl, dl, 0.f);
+ return;
+ }
+ for(int c = 0; c < 3; c++)
+ {
+ float dmax = dmc[c] + dmn[c], din = dd[c] + dmn[c];
+ float d_ref = 1.0f + dmn[c], sig = rms[c] / 1000.0f;
+ float denom = fmax(d_ref * (dmax - unf[c] * d_ref), 1e-6f);
+ float a_grain = sig * sig * A48 / denom;
+ float npart = pix / fmax(a_grain, 1e-4f);
+ uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), (uint)c);
+ float g = sf_layer_particle(din, dmax, npart, unf[c], seed) - dmn[c];
+ gd[c] = (g - dd[c]) * grain_amount;
+ }
+ grain_buf[k] = (float4)(gd[0], gd[1], gd[2], 0.f);
+}
+
+__kernel void spektrafilm_grain_add(__global float4 *dens_buf, __global const float4 *grain_buf,
+ const int w, const int h, const float renorm)
+{
+ 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 d = dens_buf[k];
+ float4 g = grain_buf[k];
+ dens_buf[k] = (float4)(d.x + g.x * renorm, d.y + g.y * renorm, d.z + g.z * renorm, d.w);
+}
+
+/* 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). */
+__kernel void spektrafilm_scan(__global const float4 *cmy, __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,
+ __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 >= ow || y >= oh) return;
+ const size_t k = (size_t)(y + oy) * w + (x + ox);
+ 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]);
+ }
+ }
+
+ const float4 px = read_imagef(in, sampleri, (int2)(x + ox, y + oy));
+ write_imagef(out, (int2)(x, y), (float4)(rgb.x, rgb.y, rgb.z, px.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) */
+/* ======================================================================== */
+
+__kernel void spektrafilm_scatter_combine(__global const float4 *core, __global const float4 *tail,
+ __global float4 *out, const int w, const int h,
+ 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 c = core[k], t = tail[k], o;
+ o.x = (1.f - ws_r) * c.x + ws_r * t.x;
+ o.y = (1.f - ws_g) * c.y + ws_g * t.y;
+ o.z = (1.f - ws_b) * c.z + ws_b * t.z;
+ o.w = c.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;
+}
+
+__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;
+}
+
+__kernel void spektrafilm_max_partials(__global const float4 *plane, const int npix,
+ __global float *partials, const int npartials)
+{
+ const int gid = get_global_id(0);
+ if(gid >= npartials) return;
+ float m = 0.0f;
+ for(int i = gid; i < npix; i += npartials)
+ {
+ float4 p = plane[i];
+ m = fmax(m, fmax(p.x, fmax(p.y, p.z)));
+ }
+ partials[gid] = m;
+}
+
+__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 float maxv)
+{
+ 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 || maxv <= 0.0f) return;
+
+ const float midgray = 0.184f;
+ const float rng = fmin(fmax(boost_range, 0.0f), 1.0f);
+ const float raw_x0 = midgray * exp2(fmax(protect_ev, 0.0f));
+ if(raw_x0 >= maxv) return;
+ 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 40edc6397a3c..5c047ab10578 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -88,6 +88,8 @@ FILE(GLOB SOURCE_FILES
"common/ratings.c"
"common/resource_limits.c"
"common/selection.c"
+ "common/spektra_core.c"
+ "common/spektra_sim.c"
"common/splines.cpp"
"common/styles.c"
"common/system_signal_handling.c"
diff --git a/src/common/iop_order.c b/src/common/iop_order.c
index 1cb9effaa6f9..fa1703ae2e3d 100644
--- a/src/common/iop_order.c
+++ b/src/common/iop_order.c
@@ -144,6 +144,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},
@@ -258,6 +259,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
@@ -377,6 +379,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
@@ -497,6 +500,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
@@ -619,6 +623,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
@@ -735,6 +740,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..70354902d0ce
--- /dev/null
+++ b/src/common/spektra_core.c
@@ -0,0 +1,468 @@
+/*
+ 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 use darktable's Gaussian
+ * blur (dt_gaussian) instead of a hand-rolled kernel. The math is unchanged from
+ * the spektrafilm / agx-emulsion reference; only the blur backend differs, so
+ * edge handling now follows dt_gaussian (DT_IOP_GAUSSIAN_ZERO) rather than the
+ * previous edge-replicate.
+ */
+
+#include "common/darktable.h"
+#include "common/gaussian.h"
+#include "common/imagebuf.h"
+
+#include
+#include
+
+/* The bundle-loader half of spektra_core.h needs file-IO/locale helpers. darktable
+ poisons bare libc fopen, so map them to glib here (this .c does not itself read
+ bundles, but the shared header must compile in this translation unit). */
+#include
+/* whole-file slurp via glib (g_file_get_contents returns a NUL-terminated,
+ g_free-owned buffer); maps the header's SF_READ_FILE/SF_FREE_FILE. */
+#define SF_READ_FILE(path, out, len) \
+ (g_file_get_contents((path), (out), (len), NULL) ? 0 : -1)
+#define SF_FREE_FILE(buf) g_free(buf)
+#define SF_STRTOD(s, end) g_ascii_strtod((s), (end))
+#include "spektra_core.h"
+
+/* Blur one channel `c` of a packed w*h*3 float buffer in place, with the given
+ * sigma (in pixels), using darktable's Gaussian. The channel is de-interleaved
+ * into a scratch single-channel plane, blurred, and written back. Per-channel
+ * sigmas (halation uses a different sigma per R/G/B) are handled by calling this
+ * once per channel. */
+static void _blur_channel(float *const buf, const int w, const int h, const int c,
+ const float sigma, float *const plane)
+{
+ 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];
+
+ const float range = 1.0e9f; /* grain delta / linear light: effectively unbounded */
+ const float vmax = range, vmin = -range;
+ dt_gaussian_t *g = dt_gaussian_init(w, h, 1, &vmax, &vmin, sigma, DT_IOP_GAUSSIAN_ZERO);
+ if(g)
+ {
+ dt_gaussian_blur(g, plane, plane);
+ dt_gaussian_free(g);
+ }
+ for(size_t i = 0; i < npix; i++) buf[i * 3 + c] = plane[i];
+}
+
+/* Blur all three channels of a packed buffer with the same sigma (grain). */
+void sf_blur_plane3(float *const buf, const int w, const int h, const float sigma, float *const plane)
+{
+ if(sigma < 0.3f) return;
+ for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma, plane);
+}
+
+/* Blur a packed buffer with per-channel sigma (scatter / halation passes). */
+static void _blur_per_channel(float *const buf, const int w, const int h, const float sigma[3],
+ float *const plane)
+{
+ for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma[c], plane);
+}
+
+/* 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;
+ float maxv = 0.0f;
+ for(size_t i = 0; i < nn; i++) maxv = fmaxf(maxv, raw[i]);
+ if(maxv <= 0.0f) return;
+
+ const float midgray = 0.184f;
+ const float rng = fminf(fmaxf(boost_range, 0.0f), 1.0f);
+ float raw_x0 = midgray * exp2f(fmaxf(protect_ev, 0.0f));
+ if(raw_x0 > maxv) return; /* threshold above peak: nothing to boost */
+ 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 float amount,
+ const float spatial_scale)
+{
+ if(amount <= 0.0f) return;
+
+ /* per-channel scatter radii (um on film) and core/tail mix weights */
+ static const double sc_core[3] = { 2.2, 2.0, 1.6 };
+ static const double sc_tail[3] = { 9.3, 9.7, 9.1 };
+ static const double w_s[3] = { 0.78, 0.65, 0.67 };
+ /* 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 };
+ /* per-channel halation strength: red/green only, blue has none on real film */
+ const double eff = pow((double)amount, 1.3);
+ const double a_tot[3] = { 0.05 * eff, 0.015 * eff, 0.0 };
+ const double first_sigma_um = 65.0; /* base bounce radius */
+ const double scl = fmax((double)spatial_scale, 1e-3); /* halation size multiplier */
+ 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); /* scratch single-channel plane */
+ if(!plane) return;
+
+ /* --- stage 1: scatter PSF (core + 3-component tail) --- */
+ {
+ 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);
+
+ 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);
+ 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;
+ raw[i] = (float)((1.0 - w_s[c]) * core[i] + w_s[c] * tail[i]);
+ }
+ }
+ dt_free_align(core);
+ dt_free_align(tail);
+ dt_free_align(comp);
+ }
+
+ /* --- stage 2: multi-bounce halation --- */
+ if(a_tot[0] > 0.0 || a_tot[1] > 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 * scl / pixel_um) * sqrt((double)k)), 1e-6f);
+ const float sig3[3] = { sk, sk, sk };
+ _blur_per_channel(comp, w, h, sig3, plane);
+ 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);
+}
+
+/* ---------------- 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
+ * radial RMS lambda*sqrt(2); we approximate each as a Gaussian of that sigma so
+ * the whole PSF becomes a weighted bank of Gaussian blurs (dt_gaussian), 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);
+
+ const double L2 = 1.4142135623730951; /* exp(-r/lambda) ~ Gaussian sigma=lambda*sqrt(2) */
+ 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);
+ if(!acc || !comp || !plane1)
+ {
+ dt_free_align(acc);
+ dt_free_align(comp);
+ dt_free_align(plane1);
+ 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);
+ 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);
+}
diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h
new file mode 100644
index 000000000000..90c477d0718f
--- /dev/null
+++ b/src/common/spektra_core.h
@@ -0,0 +1,689 @@
+/*
+ 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
+#include
+#include
+#include
+#include
+
+#ifndef SPEKTRA_INLINE
+#define SPEKTRA_INLINE static inline
+#endif
+
+/* Spatial effects implemented in spektra_core.c (they use dt_gaussian and so
+ need darktable linkage; everything else in this header is inline). */
+void sf_blur_plane3(float *buf, int w, int h, float sigma, float *plane);
+void sf_halation(float *raw, int w, int h, double pixel_um, float amount, float spatial_scale);
+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);
+
+
+/* Whole-file reader for the bundle loader (bundle.json and the .cube LUTs are
+ small enough to slurp). Inside darktable the including .c maps these to glib
+ (g_file_get_contents / g_free); darktable poisons bare libc fopen, so no libc
+ fallback is emitted in a darktable translation unit. The standalone unit test
+ (-DSF_STANDALONE) gets a small stdio-based fallback.
+
+ SF_READ_FILE(path, char **out_buf, size_t *out_len) -> 0 on success, the buffer
+ is NUL-terminated and owned by the caller, freed with SF_FREE_FILE. */
+#ifndef SF_READ_FILE
+#ifdef SF_STANDALONE
+#include
+SPEKTRA_INLINE int sf_read_file_stdio(const char *path, char **out, size_t *len)
+{
+ FILE *f = fopen(path, "rb");
+ if(!f) return -1;
+ fseek(f, 0, SEEK_END);
+ long sz = ftell(f);
+ fseek(f, 0, SEEK_SET);
+ if(sz < 0) { fclose(f); return -1; }
+ char *b = (char *)malloc((size_t)sz + 1);
+ if(!b) { fclose(f); return -1; }
+ if(fread(b, 1, (size_t)sz, f) != (size_t)sz) { free(b); fclose(f); return -1; }
+ b[sz] = 0;
+ fclose(f);
+ *out = b;
+ if(len) *len = (size_t)sz;
+ return 0;
+}
+#define SF_READ_FILE(path, out, len) sf_read_file_stdio((path), (out), (len))
+#define SF_FREE_FILE(buf) free(buf)
+#else
+#error "SF_READ_FILE must be defined (map to g_file_get_contents) before including spektra_core.h"
+#endif
+#endif
+
+/* Locale-independent ASCII float parse. darktable runs under the user locale
+ (e.g. de_DE uses ',' as decimal), but .cube / bundle.json always use '.'.
+ sscanf("%f")/strtod honour LC_NUMERIC, so we must not use them. The module
+ maps SF_STRTOD to g_ascii_strtod; standalone uses a small C-locale parser. */
+#ifndef SF_STRTOD
+SPEKTRA_INLINE double sf_ascii_strtod(const char *s, char **end)
+{
+ while(*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
+ double sign = 1.0;
+ if(*s == '+')
+ s++;
+ else if(*s == '-')
+ {
+ sign = -1.0;
+ s++;
+ }
+ double val = 0.0;
+ int any = 0;
+ while(*s >= '0' && *s <= '9')
+ {
+ val = val * 10.0 + (*s - '0');
+ s++;
+ any = 1;
+ }
+ if(*s == '.')
+ {
+ s++;
+ double f = 0.0, sc = 1.0;
+ while(*s >= '0' && *s <= '9')
+ {
+ f = f * 10.0 + (*s - '0');
+ sc *= 10.0;
+ s++;
+ any = 1;
+ }
+ val += f / sc;
+ }
+ if(any && (*s == 'e' || *s == 'E'))
+ {
+ s++;
+ int es = 1, e = 0;
+ if(*s == '+')
+ s++;
+ else if(*s == '-')
+ {
+ es = -1;
+ s++;
+ }
+ while(*s >= '0' && *s <= '9')
+ {
+ e = e * 10 + (*s - '0');
+ s++;
+ }
+ double m = 1.0;
+ for(int i = 0; i < e; i++) m *= 10.0;
+ val = es > 0 ? val * m : val / m;
+ }
+ if(end) *end = (char *)s;
+ return any ? sign * val : 0.0;
+}
+#define SF_STRTOD(s, end) sf_ascii_strtod((s), (end))
+#endif
+
+SPEKTRA_INLINE float sf_clampf(float x, float lo, float hi)
+{
+ return x < lo ? lo : (x > hi ? hi : x);
+}
+
+/* ---------------- .cube + bundle ---------------- */
+typedef struct
+{
+ int n;
+ float *data;
+} sf_cube_t; /* n^3 * 3, R fastest */
+typedef struct
+{
+ sf_cube_t film, print;
+ float d_min[3], d_max[3]; /* cmy_film wire */
+ char name[256]; /* bundle dir name */
+ int valid;
+ int is_positive; /* slide/reversal film: film cube has inverted density slope */
+ int is_combined; /* 1-LUT (combined rgb_in->rgb_out) bundle: one cube in `film`,
+ no density split, no `print`. Used for B&W and any 1lut bake. */
+ float input_gain; /* bundle.json input_exposure.gain: the cube was baked so that
+ film_pipeline(decode(coord) * gain). At runtime we sample at
+ coord = srgb_oetf(linear / input_gain). Default 1.0. */
+} sf_bundle_t;
+
+SPEKTRA_INLINE int sf_load_cube(const char *path, sf_cube_t *c)
+{
+ char *buf = NULL;
+ size_t len = 0;
+ if(SF_READ_FILE(path, &buf, &len) != 0 || !buf)
+ {
+#ifdef SF_DIAG_LOG
+ SF_DIAG_LOG("[spektrafilm] read cube FAILED: %s\n", path);
+#endif
+ return -1;
+ }
+
+ c->n = 0;
+ c->data = NULL;
+ int idx = 0, cap = 0;
+ /* Walk the file buffer line by line (the .cube grammar is line-oriented):
+ header keywords (LUT_3D_SIZE, DOMAIN_*, TITLE) and one "r g b" triplet per
+ data line, with R varying fastest. */
+ char *p = buf;
+ while(*p)
+ {
+ char *eol = p;
+ while(*eol && *eol != '\n') eol++;
+ const char hold = *eol;
+ *eol = 0; /* terminate this line for the parsers below */
+
+ char *s = p;
+ while(*s == ' ' || *s == '\t') s++;
+ if(*s == '#' || *s == '\r' || *s == 0)
+ {
+ /* comment or blank: skip */
+ }
+ else if(!strncmp(s, "LUT_3D_SIZE", 11))
+ {
+ c->n = atoi(s + 11);
+ cap = c->n * c->n * c->n * 3;
+ c->data = (float *)malloc(sizeof(float) * cap);
+ if(!c->data)
+ {
+ SF_FREE_FILE(buf);
+ return -1;
+ }
+ }
+ else if(!strncmp(s, "DOMAIN_", 7) || !strncmp(s, "TITLE", 5) || (*s >= 'A' && *s <= 'Z'))
+ {
+ /* other header keyword: skip */
+ }
+ else
+ {
+ char *e1 = NULL, *e2 = NULL, *e3 = NULL;
+ const float r = (float)SF_STRTOD(s, &e1);
+ const float g = (float)SF_STRTOD(e1, &e2);
+ const float b = (float)SF_STRTOD(e2, &e3);
+ if(e1 != s && e2 != e1 && e3 != e2 && idx + 3 <= cap)
+ {
+ c->data[idx++] = r;
+ c->data[idx++] = g;
+ c->data[idx++] = b;
+ }
+ }
+
+ if(hold == 0) break;
+ p = eol + 1;
+ }
+ SF_FREE_FILE(buf);
+
+ if(c->n <= 0 || idx != c->n * c->n * c->n * 3)
+ {
+#ifdef SF_DIAG_LOG
+ SF_DIAG_LOG("[spektrafilm] cube row/size mismatch n=%d got=%d expect=%d: %s\n", c->n, idx,
+ c->n * c->n * c->n * 3, path);
+#endif
+ free(c->data);
+ c->data = NULL;
+ return -1;
+ }
+ return 0;
+}
+SPEKTRA_INLINE void sf_cube_free(sf_cube_t *c)
+{
+ free(c->data);
+ c->data = NULL;
+ c->n = 0;
+}
+
+SPEKTRA_INLINE void sf_cube_sample(const sf_cube_t *c, const float in[3], float out[3])
+{
+ const int n = c->n;
+ float fx = sf_clampf(in[0], 0, 1) * (n - 1), fy = sf_clampf(in[1], 0, 1) * (n - 1),
+ fz = sf_clampf(in[2], 0, 1) * (n - 1);
+ int x0 = (int)fx, y0 = (int)fy, z0 = (int)fz, x1 = x0 < n - 1 ? x0 + 1 : x0,
+ y1 = y0 < n - 1 ? y0 + 1 : y0, z1 = z0 < n - 1 ? z0 + 1 : z0;
+ float dx = fx - x0, dy = fy - y0, dz = fz - z0;
+#define SFI(X, Y, Z) (((size_t)(Z) * n * n + (size_t)(Y) * n + (X)) * 3)
+ for(int ch = 0; ch < 3; ch++)
+ {
+ float a = c->data[SFI(x0, y0, z0) + ch] * (1 - dx) + c->data[SFI(x1, y0, z0) + ch] * dx;
+ float b = c->data[SFI(x0, y1, z0) + ch] * (1 - dx) + c->data[SFI(x1, y1, z0) + ch] * dx;
+ float cc = c->data[SFI(x0, y0, z1) + ch] * (1 - dx) + c->data[SFI(x1, y0, z1) + ch] * dx;
+ float d = c->data[SFI(x0, y1, z1) + ch] * (1 - dx) + c->data[SFI(x1, y1, z1) + ch] * dx;
+ float e = a * (1 - dy) + b * dy, g = cc * (1 - dy) + d * dy;
+ out[ch] = e * (1 - dz) + g * dz;
+ }
+#undef SFI
+}
+
+/* tiny JSON scrapes (sufficient for the fixed spektrafilm bundle.json schema) */
+SPEKTRA_INLINE int sf_scrape_float(const char *b, const char *k, float *out)
+{
+ /* find key k (e.g. "\"gain\"") then the number after the following ':' */
+ const char *p = strstr(b, k);
+ if(!p) return -1;
+ p = strchr(p, ':');
+ if(!p) return -1;
+ p++;
+ while(*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
+ char *end = NULL;
+ double d = SF_STRTOD(p, &end);
+ if(end == p) return -1;
+ *out = (float)d;
+ return 0;
+}
+
+SPEKTRA_INLINE int sf_scrape_vec3(const char *b, const char *k, float v[3])
+{
+ const char *p = strstr(b, k);
+ if(!p) return -1;
+ p = strchr(p, '[');
+ if(!p) return -1;
+ p++;
+ for(int i = 0; i < 3; i++)
+ {
+ while(*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
+ char *end = NULL;
+ double d = SF_STRTOD(p, &end);
+ if(end == p) return -1;
+ v[i] = (float)d;
+ p = end;
+ }
+ return 0;
+}
+SPEKTRA_INLINE int sf_scrape_path(const char *buf, const char *role, char *out, int sz)
+{
+ const char *p = buf;
+ while((p = strstr(p, "\"role\"")))
+ {
+ const char *c = strchr(p, ':'), *q1 = c ? strchr(c, '"') : 0,
+ *q2 = q1 ? strchr(q1 + 1, '"') : 0;
+ if(!q2)
+ {
+ p += 5;
+ continue;
+ }
+ int len = (int)(q2 - q1 - 1);
+ if((int)strlen(role) == len && !strncmp(q1 + 1, role, len))
+ {
+ const char *nr = strstr(q2, "\"role\""), *pa = strstr(q2, "\"path\"");
+ if(!pa || (nr && pa > nr))
+ {
+ p = q2;
+ continue;
+ }
+ pa = strchr(pa, ':');
+ pa = strchr(pa, '"');
+ if(!pa) return -1;
+ pa++;
+ const char *e = strchr(pa, '"');
+ if(!e || e - pa >= sz) return -1;
+ memcpy(out, pa, e - pa);
+ out[e - pa] = 0;
+ return 0;
+ }
+ p = q2;
+ }
+ return -1;
+}
+/* load a bundle dir (containing bundle.json + the two cubes) */
+SPEKTRA_INLINE int sf_load_bundle(const char *dir, sf_bundle_t *b)
+{
+ memset(b, 0, sizeof *b);
+ char jp[1024];
+ snprintf(jp, sizeof jp, "%s/bundle.json", dir);
+ char *buf = NULL;
+ size_t sz = 0;
+ if(SF_READ_FILE(jp, &buf, &sz) != 0 || !buf || sz == 0)
+ {
+ SF_FREE_FILE(buf);
+ return -1;
+ }
+ /* input exposure gain (bundle.json input_exposure.gain). The cube maps
+ output(coord) = film_pipeline(decode(coord) * gain), so at runtime we sample
+ at coord = srgb_oetf(linear / gain). Default 1.0 when absent (older bundles
+ or stops_above_midgray=null). Scrape the "gain" key inside "input_exposure". */
+ b->input_gain = 1.0f;
+ {
+ const char *ie = strstr(buf, "\"input_exposure\"");
+ if(ie)
+ {
+ float g = 1.0f;
+ if(!sf_scrape_float(ie, "\"gain\"", &g) && g > 1e-4f) b->input_gain = g;
+ }
+ }
+ /* 1-LUT (combined) bundle? It has a single lut with role "combined" and no
+ film/print density wire. Load that one cube into `film` and mark combined. */
+ char cp[256] = {0};
+ if(!sf_scrape_path(buf, "combined", cp, sizeof cp))
+ {
+ SF_FREE_FILE(buf);
+ char full[2048];
+ snprintf(full, sizeof full, "%s/%s", dir, cp);
+ if(sf_load_cube(full, &b->film)) return -1;
+ b->is_combined = 1;
+ b->valid = 1;
+ return 0; /* no density wire / print / positive-detection for combined */
+ }
+
+ int ok =
+ !sf_scrape_vec3(buf, "\"d_max\"", b->d_max) && !sf_scrape_vec3(buf, "\"d_min\"", b->d_min);
+ char fp[256] = {0}, pp[256] = {0};
+ ok = ok && !sf_scrape_path(buf, "film", fp, sizeof fp) &&
+ !sf_scrape_path(buf, "print", pp, sizeof pp);
+ SF_FREE_FILE(buf);
+ if(!ok)
+ {
+#ifdef SF_DIAG_LOG
+ SF_DIAG_LOG("[spektrafilm] bundle.json parse failed (wire/paths) in %s\n", dir);
+#endif
+ return -1;
+ }
+ char full[2048];
+ snprintf(full, sizeof full, "%s/%s", dir, fp);
+ if(sf_load_cube(full, &b->film)) return -1;
+ snprintf(full, sizeof full, "%s/%s", dir, pp);
+ if(sf_load_cube(full, &b->print))
+ {
+ sf_cube_free(&b->film);
+ return -1;
+ }
+ b->valid = 1;
+
+ /* Detect positive (slide/reversal) film: sample the film cube at black and
+ white, convert to cmy_film density via the wire, and compare. Negative
+ films -> density rises with input; positive films -> density falls. This
+ needs no metadata (bundle.json omits film type) and no name matching. */
+ {
+ float blk[3] = {0.f, 0.f, 0.f}, wht[3] = {1.f, 1.f, 1.f}, fo_b[3], fo_w[3];
+ sf_cube_sample(&b->film, blk, fo_b);
+ sf_cube_sample(&b->film, wht, fo_w);
+ float d_b = 0.f, d_w = 0.f;
+ for(int c = 0; c < 3; c++)
+ {
+ d_b += b->d_min[c] + fo_b[c] * (b->d_max[c] - b->d_min[c]);
+ d_w += b->d_min[c] + fo_w[c] * (b->d_max[c] - b->d_min[c]);
+ }
+ b->is_positive = (d_w < d_b) ? 1 : 0; /* white darker than black => slide */
+ }
+ return 0;
+}
+SPEKTRA_INLINE void sf_bundle_free(sf_bundle_t *b)
+{
+ sf_cube_free(&b->film);
+ sf_cube_free(&b->print);
+ b->valid = 0;
+}
+
+SPEKTRA_INLINE void sf_to_density(const sf_bundle_t *b, const float v[3], float d[3])
+{
+ for(int c = 0; c < 3; c++) d[c] = b->d_min[c] + v[c] * (b->d_max[c] - b->d_min[c]);
+}
+SPEKTRA_INLINE void sf_from_density(const sf_bundle_t *b, const float d[3], float v[3])
+{
+ for(int c = 0; c < 3; c++)
+ v[c] = sf_clampf((d[c] - b->d_min[c]) / (b->d_max[c] - b->d_min[c]), 0, 1);
+}
+
+/* ---------------- sRGB transfer (module is scene-linear; cubes are sRGB) ---------------- */
+SPEKTRA_INLINE float sf_srgb_oetf(float x)
+{
+ x = x < 0 ? 0 : x;
+ return x <= 0.0031308f ? 12.92f * x : 1.055f * powf(x, 1.0f / 2.4f) - 0.055f;
+}
+SPEKTRA_INLINE float sf_srgb_eotf(float x)
+{
+ x = sf_clampf(x, 0, 1);
+ return x <= 0.04045f ? x / 12.92f : powf((x + 0.055f) / 1.055f, 2.4f);
+}
+
+/* ---------------- 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: two uniforms -> one standard-normal sample via the Box-Muller
+ transform. 6.2831853 is 2*pi; 2654435761 is Knuth's golden-ratio multiplier
+ (2^32 / phi), used only to decorrelate the second uniform from the first. */
+SPEKTRA_INLINE float sf_nrm(uint32_t s)
+{
+ float u1 = fmaxf(sf_u01(s), 1e-7f), u2 = sf_u01(s * 2654435761u + 1u);
+ return sqrtf(-2.f * logf(u1)) * cosf(6.2831853f * u2);
+}
+/* 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;
+}
+
+/* Print-stage grading applied to the CMY film density before the print cube
+ (2lut path only). Mirrors the spektrafilm app's print controls, approximated on
+ the baked density rather than by re-running the paper model:
+ - print_exposure (stops): a uniform density shift (brighter print = less
+ density); dchange = -print_exposure * SF_PRINT_EV_TO_DENSITY.
+ - print_contrast: pivots density about a mid-grey Dp so slopes steepen/flatten.
+ - filtration_m / filtration_y: subtractive printing filters. Magenta rides the
+ green record, yellow the blue record; each adds a small per-channel density.
+ density[] is modified in place; d_ref is a representative mid density (mean of
+ the film's d_min/d_max) used as the contrast pivot. */
+#define SF_PRINT_EV_TO_DENSITY 0.30103f /* log10(2): one stop == 0.301 density */
+#define SF_FILTRATION_TO_DENSITY 0.30f /* full filtration slider == 0.30 density */
+SPEKTRA_INLINE void sf_apply_print_grading(float density[3], float d_ref, float print_exposure,
+ float print_contrast, float filtration_m,
+ float filtration_y)
+{
+ const float ev = -print_exposure * SF_PRINT_EV_TO_DENSITY;
+ for(int c = 0; c < 3; c++)
+ {
+ float v = density[c] + ev; /* print exposure */
+ v = d_ref + (v - d_ref) * print_contrast; /* print contrast (pivot) */
+ density[c] = v;
+ }
+ /* subtractive filters: M -> green channel (index 1), Y -> blue channel (index 2) */
+ density[1] += filtration_m * SF_FILTRATION_TO_DENSITY;
+ density[2] += filtration_y * SF_FILTRATION_TO_DENSITY;
+}
+
+SPEKTRA_INLINE float sf_layer_particle(float density, float dmax, float npart, float unif,
+ uint32_t seed)
+{
+ float p = sf_clampf(density / dmax, 1e-6f, 1 - 1e-6f), od = dmax / npart,
+ sat = 1.f - p * unif * (1 - 1e-6f), lam = npart / sat;
+ float seeds = lam + sqrtf(fmaxf(lam, 0)) * sf_nrm(seed * 0x9e3779b9u + 1u);
+ if(seeds < 0) seeds = 0;
+ float mean = seeds * p, var = seeds * p * (1 - p),
+ g = mean + sqrtf(fmaxf(var, 0)) * sf_nrm(seed * 0x85ebca6bU + 7u);
+ if(g < 0) g = 0;
+ if(g > seeds) g = seeds;
+ return g * 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
+/* grain on one CMY-density pixel; strength scales particle count effect via amount */
+SPEKTRA_INLINE void sf_grain_px(float dens[3], float pixel_um, float amount, float size,
+ uint32_t xi, uint32_t yi)
+{
+ const float dmin[3] = {0.03f, 0.03f, 0.03f}, dmaxc[3] = {2.2f, 2.2f, 2.2f};
+ const float pscale[3] = {1.6f, 1.6f, 3.2f}, unif[3] = {0.97f, 0.99f, 0.97f};
+ const int nsub = 1;
+ /* Grain is rendered at a FIXED reference scale (like spektrafilm's
+ pixel_size_um=10), NOT the live pipe pixel_um, so grain character stays
+ constant across zoom. The size slider scales this reference: larger size =>
+ larger effective grain pixel => fewer particles per pixel => coarser grain.
+ size=1.0 reproduces the app's default look. pixel_um is unused for grain
+ (still used by halation). */
+ const float parea = 0.2f;
+ const float ref_um = SF_GRAIN_REF_UM / fmaxf(size, 0.05f); /* size up => coarser grain */
+ float pix = ref_um * ref_um;
+ (void)pixel_um;
+ for(int c = 0; c < 3; c++)
+ {
+ float npart = pix / (parea * pscale[c]), dmax = dmaxc[c] + dmin[c];
+ float din = dens[c] + dmin[c], acc = 0;
+ for(int sl = 0; sl < nsub; sl++)
+ acc += sf_layer_particle(
+ din, dmax, npart, unif[c],
+ sf_pixel_seed(xi, yi, (uint32_t)(c + sl * 10)));
+ acc /= nsub;
+ acc -= dmin[c];
+ dens[c] = dens[c] + (acc - dens[c]) * amount; /* amount=1 -> full spektrafilm grain */
+ }
+}
+
+/* apply halation+scatter to a w*h*3 LINEAR plane in place (amount scales both passes) */
+/* Compute the grain DELTA (grained density - clean density) for one pixel into
+ out_delta[3]. Generation matches the validated per-pixel particle model; the
+ visible film STRUCTURE comes from blurring this delta buffer afterwards (as
+ spektrafilm blurs its grain by grain.blur). Generated at a fine fixed scale so
+ the subsequent blur produces organic clumps rather than 1px speckle. */
+/* dmax_c: the emulsion's actual per-channel maximum density (base-subtracted).
+ Using a too-small value saturates the particle model in dense areas (slide
+ shadows) and produces a channel-dependent -- i.e. coloured -- bias.
+ dmin_c/rms_c/unif_c: the stock's own catalogue grain characteristics
+ (film_render_defaults[stock].grain in the pack — rms_granularity,
+ uniformity, density_min), so e.g. Portra 400 and Tri-X no longer share one
+ hardcoded grain signature. Callers without per-film data may pass the
+ SF_GRAIN_LEGACY_* arrays below to reproduce the earlier fixed look. */
+/* SF_GRAIN_REF_UM (defined above, with sf_grain_px) is reused here for the
+ same fine-generation reference scale. */
+SPEKTRA_INLINE void sf_grain_delta_dmax(const float dens[3], float amount, float out_delta[3],
+ uint32_t xi, uint32_t yi, int mono,
+ const float dmax_c[3], const float dmin_c[3],
+ const float rms_c[3], const float unif_c[3])
+{
+ const float dmin[3] = { dmin_c[0], dmin_c[1], dmin_c[2] };
+ const float dmaxc[3] = { fmaxf(dmax_c[0], 1e-3f), fmaxf(dmax_c[1], 1e-3f),
+ fmaxf(dmax_c[2], 1e-3f) };
+ /* Latest spektrafilm grain model (study a90): per-channel particle area from
+ catalogue RMS-granularity (sigma_48 through a 48um aperture, ISO 6328):
+ a_grain = (rms/1000)^2 * A48 / (D_ref (Dmax - u D_ref)), D_ref = 1 + d_min.
+ N = pixel_area / a_grain. Generated at a fine fixed reference scale; the blur
+ afterwards sets visible clump size. rms/unif come from the film stock's own
+ catalogue data (see header comment) rather than one shared constant. */
+ const float rms[3] = { rms_c[0], rms_c[1], rms_c[2] };
+ const float unif[3] = { unif_c[0], unif_c[1], unif_c[2] };
+ const float A48 = 3.14159265f * 24.0f * 24.0f;
+ const float ref_um = SF_GRAIN_REF_UM, pix = ref_um * ref_um;
+ /* mono (B&W / combined): the three channels carry the same value, so grain must
+ be ACHROMATIC — one grain realisation applied identically to all channels.
+ Per-channel independent grain (the colour path) would otherwise paint colour
+ speckle onto a grey image. Use channel 1's parameters and the mean density. */
+ if(mono)
+ {
+ const float dm = (dens[0] + dens[1] + dens[2]) / 3.0f;
+ const float dmax = dmaxc[1] + dmin[1];
+ const float d_ref = 1.0f + dmin[1];
+ const float sig = rms[1] / 1000.0f;
+ const float denom = fmaxf(d_ref * (dmax - unif[1] * d_ref), 1e-6f);
+ const float a_grain = sig * sig * A48 / denom;
+ const float npart = pix / fmaxf(a_grain, 1e-4f);
+ const float din = dm + dmin[1];
+ float g = sf_layer_particle(din, dmax, npart, unif[1],
+ sf_pixel_seed(xi, yi, 0u)) - dmin[1];
+ const float d = (g - dm) * amount;
+ out_delta[0] = out_delta[1] = out_delta[2] = d; /* identical -> grey grain */
+ return;
+ }
+ for(int c = 0; c < 3; c++)
+ {
+ const float dmax = dmaxc[c] + dmin[c];
+ const float d_ref = 1.0f + dmin[c];
+ const float sig = rms[c] / 1000.0f;
+ const float denom = fmaxf(d_ref * (dmax - unif[c] * d_ref), 1e-6f);
+ const float a_grain = sig * sig * A48 / denom;
+ const float npart = pix / fmaxf(a_grain, 1e-4f);
+ const float din = dens[c] + dmin[c];
+ float g = sf_layer_particle(din, dmax, npart, unif[c],
+ sf_pixel_seed(xi, yi, (uint32_t)c));
+ g -= dmin[c];
+ out_delta[c] = (g - dens[c]) * amount; /* delta to be blurred then added */
+ }
+}
+
+/* Fallback catalogue values (spektrafilm's original single fixed profile) for
+ callers with no per-film pack data (see sf_pack_film_grain / sf_sim_film_grain3
+ for the real per-stock values). */
+#define SF_GRAIN_LEGACY_DMAX { 2.2f, 2.2f, 2.2f }
+#define SF_GRAIN_LEGACY_DMIN { 0.03f, 0.03f, 0.03f }
+#define SF_GRAIN_LEGACY_RMS { 6.0f, 8.0f, 10.0f }
+#define SF_GRAIN_LEGACY_UNIFORMITY { 0.97f, 0.97f, 0.97f }
+
+SPEKTRA_INLINE void sf_grain_delta(const float dens[3], float amount, float out_delta[3],
+ uint32_t xi, uint32_t yi, int mono)
+{
+ const float legacy_dmax[3] = SF_GRAIN_LEGACY_DMAX;
+ const float legacy_dmin[3] = SF_GRAIN_LEGACY_DMIN;
+ const float legacy_rms[3] = SF_GRAIN_LEGACY_RMS;
+ const float legacy_unif[3] = SF_GRAIN_LEGACY_UNIFORMITY;
+ sf_grain_delta_dmax(dens, amount, out_delta, xi, yi, mono, legacy_dmax, legacy_dmin,
+ legacy_rms, legacy_unif);
+}
diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c
new file mode 100644
index 000000000000..cd4d12d8a37a
--- /dev/null
+++ b/src/common/spektra_sim.c
@@ -0,0 +1,2480 @@
+/*
+ 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
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#define SF_LOG_EPS 1e-10
+#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
+
+/* ------------------------------------------------------------------------ */
+/* 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 */
+ /* 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;
+ double centers[3][8], amplitudes[3][8], sigmas[3][8];
+} 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 log_exposure[SF_NLE];
+ double density_curves[SF_NLE][3];
+ int window_n;
+ double window_params[8];
+ 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 */
+ double ev_scale;
+ int tc_n;
+ double *tc_lut; /* tc_n*tc_n*3 raw CMY exposure */
+
+ /* film develop */
+ double le0, le_step; /* uniform log exposure grid */
+ double curves_norm[SF_NLE][3];
+ double curves_before[SF_NLE][3];
+ 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];
+
+ /* 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];
+
+ /* 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;
+
+ /* 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 */
+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 */
+/* ------------------------------------------------------------------------ */
+
+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");
+
+ 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");
+
+ /* hanatos2025 spectra LUT */
+ {
+ 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 dims[3];
+ if(fread(magic, 1, 4, fh) != 4 || memcmp(magic, "SFSL", 4) != 0
+ || fread(dims, 4, 3, fh) != 3 || dims[0] != dims[1] || dims[2] != SF_NWL)
+ {
+ set_error(errmsg, "spektra_sim: bad spectra_lut header in %s", lut_path);
+ fclose(fh);
+ goto fail;
+ }
+ 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 || fread(pack->spectra, sizeof(float), count, fh) != count)
+ {
+ 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 — see spektrafilm_export_data.py's _grain_export).
+ Any output pointer may be NULL. Returns false and leaves outputs untouched
+ if the stock has no "grain" entry (older packs, or the stock predates
+ per-film grain), so the caller can keep its fallback constants. */
+bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock,
+ double rms[3], double uniformity[3], double density_min[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, "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;
+ 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, 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);
+ ok &= json_read_darray(data, "base_density", p->base_density, SF_NWL);
+ ok &= json_read_darray(data, "log_exposure", p->log_exposure, SF_NLE);
+ 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, "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;
+ /* The reference package's own DensityCurvesModel stores centers/
+ amplitudes/sigmas as (n_channels=3, n_layers) -- confirmed directly
+ against its bundled kodak_portra_endura.json, byte-identical to our
+ copy, and against apply_print_curves_morph's own
+ "n_channels = model.centers.shape[0]". That's the normal,
+ channel-major case (outer array length 3) and every profile checked
+ against the reference package matches it.
+ kodak_2302.json (not part of the reference package's own bundled
+ profiles -- a separately-authored addition) stores it transposed:
+ (n_layers=5, n_channels=3), outer length 5. Rather than assume one
+ convention universally (an earlier version of this fix did, and
+ broke every normal profile by mis-transposing correctly-shaped
+ data), detect orientation per-profile from the one invariant that
+ always holds: there are exactly 3 channels. */
+ const int outer_len = centers ? MIN((int)json_array_get_length(centers), 8) : 0;
+ JsonArray *row0 = (outer_len > 0) ? json_array_get_array_element(centers, 0) : NULL;
+ const int inner_len = row0 ? (int)json_array_get_length(row0) : 0;
+ const gboolean channel_major = (outer_len == 3); /* centers[channel][layer]: normal */
+ const gboolean layer_major = (!channel_major && inner_len == 3); /* centers[layer][channel]: e.g. kodak_2302 */
+ if(channel_major || layer_major)
+ {
+ const int nl = channel_major ? inner_len : outer_len;
+ p->curves_model.n_layers = nl;
+ double c[24], a[24], s[24];
+ 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", s, outer_len, inner_len))
+ {
+ for(int ch = 0; ch < 3; ch++)
+ for(int l = 0; l < nl; l++)
+ {
+ const int idx = channel_major ? (ch * nl + l) : (l * 3 + ch);
+ p->curves_model.centers[ch][l] = c[idx];
+ p->curves_model.amplitudes[ch][l] = a[idx];
+ p->curves_model.sigmas[ch][l] = s[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; }
+
+/* ------------------------------------------------------------------------ */
+/* 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->scan_film = 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);
+}
+
+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;
+}
+
+/* ------------------------------------------------------------------------ */
+/* [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];
+}
+
+/* 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;
+ }
+}
+
+/* ------------------------------------------------------------------------ */
+/* [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]);
+}
+
+/* ------------------------------------------------------------------------ */
+/* [mc] cdfs density curve model + s023 morph */
+/* ------------------------------------------------------------------------ */
+
+static inline double norm_cdf(double z) { return 0.5 * (1.0 + erf(z * M_SQRT1_2)); }
+
+/* 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, int n_layers,
+ int positive)
+{
+ for(int i = 0; i < nle; i++) out[i] = 0.0;
+ for(int l = 0; l < n_layers; l++)
+ {
+ for(int i = 0; i < nle; i++)
+ {
+ double z = (le[i] - centers[l]) / sigmas[l];
+ if(positive) z = -z;
+ out[i] += amps[l] * norm_cdf(z);
+ }
+ }
+}
+
+#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. */
+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];
+ memcpy(centers, m->centers[c], sizeof(centers));
+ memcpy(amps, m->amplitudes[c], sizeof(amps));
+ memcpy(sigmas, m->sigmas[c], sizeof(sigmas));
+
+ 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, 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);
+ }
+}
+
+/* ------------------------------------------------------------------------ */
+/* 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;
+}
+
+/* [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->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->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);
+ }
+ /* 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;
+ }
+ if(film->window_n == 4) /* erf4 spectral bandpass, white-balance preserving */
+ {
+ 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];
+ }
+ /* [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);
+ }
+ }
+
+ /* ----- film develop ---------------------------------------------------- */
+ s->le0 = film->log_exposure[0];
+ s->le_step = (film->log_exposure[SF_NLE - 1] - film->log_exposure[0]) / (SF_NLE - 1);
+ for(int c = 0; c < 3; c++) s->gamma[c] = p->density_curve_gamma;
+ 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++) s->curves_norm[i][c] = film->density_curves[i][c] - mn;
+ s->film_dmax[c] = mx - mn;
+ s->film_dmin[c] = mn;
+ }
+ /* [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];
+ }
+ sf_pack_film_grain(pack, film->stock, s->grain_rms, s->grain_uniformity,
+ p->grain_density_min);
+ }
+ /* [cp] coupler matrix: donor row -> receiver column, scaled by amount */
+ s->couplers_active = p->couplers_active;
+ {
+ double M[3][3] = { { 0 } };
+ 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)
+ {
+ 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;
+ }
+ }
+ }
+ else
+ memcpy(s->curves_before, s->curves_norm, sizeof(s->curves_before));
+
+ /* ----- 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;
+ }
+ build_print_curves(s->print_curves, print, p);
+ }
+
+ /* ----- 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;
+ }
+ /* 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);
+ 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);
+ }
+
+ /* ----- 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)
+{
+#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;
+ const double rgb[3] = { in[0], in[1], in[2] };
+ double r[3];
+ expose_pixel(sim->m_in, sim->tc_lut, sim->tc_n, rgb, r);
+ for(int c = 0; c < 3; c++) out[c] = (float)(r[c] * sim->ev_scale);
+ }
+}
+
+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] = (float)log10(fmax((double)v[c], 0.0) + 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;
+ double silver[3];
+ for(int c = 0; c < 3; c++)
+ {
+ const double d = interp_curve_uniform(in[c], sim->gamma[c], sim->le0, sim->le_step,
+ sim->curves_norm, c);
+ silver[c] = sim->film_positive ? sim->film_dmax[c] - d : d;
+ if(sim->couplers_donor_lm)
+ silver[c] = silver[c] * (sim->couplers_donor_K[c] + sim->couplers_donor_Dref[c])
+ / (sim->couplers_donor_K[c] + silver[c]);
+ }
+ for(int m = 0; m < 3; m++)
+ {
+ double acc = 0.0;
+ for(int k = 0; k < 3; k++) acc += silver[k] * sim->couplers_M[k][m];
+ out[m] = (float)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 double(*curves)[3] = use_corr ? sim->curves_before : sim->curves_norm;
+#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++)
+ {
+ double crv = cr ? (double)cr[c] : 0.0;
+ /* 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 * (sim->couplers_recv_Kr[c] + sim->couplers_recv_cref[c])
+ / (sim->couplers_recv_Kr[c] + crv);
+ const double x = (double)in[c] - crv;
+ out[c] = (float)interp_curve_uniform(x, sim->gamma[c], sim->le0, sim->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;
+ const sf_pchip3d_t P = { steps, sim->enl_lut, sim->enl_sx, sim->enl_sy, sim->enl_sz,
+ sim->enl_cmin, sim->enl_cmax };
+#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;
+ double l1[3];
+ if(steps >= 2)
+ {
+ const double scale = (double)(steps - 1);
+ const double r = (in[0] - sim->enl_lo[0]) / (sim->enl_hi[0] - sim->enl_lo[0]) * scale;
+ const double g = (in[1] - sim->enl_lo[1]) / (sim->enl_hi[1] - sim->enl_lo[1]) * scale;
+ const double b = (in[2] - sim->enl_lo[2]) / (sim->enl_hi[2] - sim->enl_lo[2]) * scale;
+ pchip3d_interp(&P, r, g, b, l1);
+ }
+ else
+ {
+ const double c[3] = { in[0], in[1], in[2] };
+ cmy_to_print_lograw(sim, c, l1);
+ }
+ /* [st] raw = 10^l1 * print_exposure; back to log10 */
+ for(int m = 0; m < 3; m++)
+ {
+ const double r = pow(10.0, l1[m]) * sim->print_exposure;
+ out[m] = (float)log10(fmax(r, 0.0) + SF_LOG_EPS);
+ }
+ }
+}
+
+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] = (float)interp_curve_uniform(in[c], 1.0, sim->le0, sim->le_step,
+ sim->print_curves, 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;
+ const sf_pchip3d_t P = { steps, sim->scan_lut, sim->scan_sx, sim->scan_sy, sim->scan_sz,
+ sim->scan_cmin, sim->scan_cmax };
+#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;
+ double lx[3];
+ if(steps >= 2)
+ {
+ const double scale = (double)(steps - 1);
+ const double r = (in[0] - sim->scan_lo[0]) / (sim->scan_hi[0] - sim->scan_lo[0]) * scale;
+ const double g = (in[1] - sim->scan_lo[1]) / (sim->scan_hi[1] - sim->scan_lo[1]) * scale;
+ const double b = (in[2] - sim->scan_lo[2]) / (sim->scan_hi[2] - sim->scan_lo[2]) * scale;
+ pchip3d_interp(&P, r, g, b, lx);
+ }
+ else
+ {
+ const double c[3] = { in[0], in[1], in[2] };
+ cmy_to_log_xyz(sim, c, lx);
+ }
+ double xyz[3], rgb[3];
+ for(int m = 0; m < 3; m++) xyz[m] = pow(10.0, 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)
+ {
+ /* scanner black/white point (positive film): scale toward Y in [0,1] */
+ 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];
+ }
+}
+
+void sf_sim_process(const sf_sim_t *sim, const float *rgb_in, float *rgb_out, size_t npix,
+ int nch_in, int nch_out)
+{
+ float *tmp = malloc(npix * 3 * sizeof(float));
+ float *corr = sim->couplers_active ? malloc(npix * 3 * sizeof(float)) : NULL;
+ sf_sim_expose(sim, rgb_in, tmp, npix, nch_in, 3);
+ sf_sim_lograw(tmp, npix, 3);
+ if(corr) sf_sim_develop_corr(sim, tmp, corr, npix, 3);
+ sf_sim_develop(sim, tmp, corr, tmp, npix, 3, 3);
+ if(sim->has_print)
+ {
+ sf_sim_print_expose(sim, tmp, tmp, npix, 3, 3);
+ sf_sim_print_develop(sim, tmp, tmp, npix, 3, 3);
+ }
+ sf_sim_scan(sim, tmp, rgb_out, npix, 3, nch_out);
+ free(corr);
+ free(tmp);
+}
+
+/* ------------------------------------------------------------------------ */
+/* 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];
+ /* self-consistent with g->film_dmax: see sf_sim_film_grain3 */
+ g->grain_dmin[c] = (float)s->film_dmin[c];
+ }
+ g->film_positive = s->film_positive;
+ g->couplers_active = s->couplers_active;
+
+ 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_film_dmax3(const sf_sim_t *sim, float dmax[3])
+{
+ for(int c = 0; c < 3; c++) dmax[c] = sim ? (float)sim->film_dmax[c] : 2.2f;
+}
+
+void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3])
+{
+ /* matches SF_GRAIN_LEGACY_* in spektra_core.h */
+ 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 — see sf_grain_delta_dmax. */
+ dmin[c] = sim ? (float)sim->film_dmin[c] : legacy_dmin[c];
+ }
+}
+
diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h
new file mode 100644
index 000000000000..20ea9222eb35
--- /dev/null
+++ b/src/common/spektra_sim.h
@@ -0,0 +1,327 @@
+/*
+ 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
+
+#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 */
+
+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);
+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 -- */
+
+sf_profile_t *sf_profile_load(const char *path, 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];
+ /* 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);
+void sf_sim_film_dmax3(const sf_sim_t *sim, float dmax[3]);
+/* 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]);
+bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock,
+ double rms[3], double uniformity[3], double density_min[3]);
+#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);
+
+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 */
+
+/* -------------------------------------------------------------- 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 */
+
+ /* 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;
+
+ /* 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);
+
+/* convenience: the full deterministic chain without spatial effects */
+void sf_sim_process(const sf_sim_t *sim, const float *rgb_in, float *rgb_out,
+ size_t npix, int nch_in, int nch_out);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/iop/CMakeLists.txt b/src/iop/CMakeLists.txt
index 92a69c1037f9..48bd2ce94eef 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..50049ae62949
--- /dev/null
+++ b/src/iop/spektrafilm.c
@@ -0,0 +1,1713 @@
+/*
+ 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 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/tiling.h"
+#include "develop/imageop_gui.h"
+#include "develop/imageop_math.h"
+#include "common/imagebuf.h"
+#include "common/iop_profile.h"
+#include "common/opencl.h"
+#include "common/gaussian.h"
+#include "gui/accelerators.h"
+#include "gui/gtk.h"
+#include "iop/iop_api.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define SPEKTRA_INLINE static inline
+#define SF_READ_FILE(path, out, len) \
+ (g_file_get_contents((path), (out), (len), NULL) ? 0 : -1)
+#define SF_FREE_FILE(buf) g_free(buf)
+#define SF_DIAG_LOG(...) dt_print(DT_DEBUG_DEV, __VA_ARGS__)
+#define SF_STRTOD(s, end) g_ascii_strtod((s), (end))
+#include "common/spektra_core.h"
+#include "common/spektra_sim.h"
+
+DT_MODULE_INTROSPECTION(2, 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) */
+#define SF_GRAIN_BLUR_FACTOR 0.8f
+#define SF_GRAIN_SIZE_MIN 0.05f
+#define SF_HALO_SIGMAS 4.0f
+#define SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM 950.0f
+/* DIR coupler inhibitor diffusion; spektrafilm params_schema
+ dir_couplers.diffusion_size_um default (a plain gaussian in the reference) */
+
+#define SF_MAX_PROFILES 128
+#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)
+ 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"
+ float print_exposure_ev; // $MIN: -3.0 $MAX: 3.0 $DEFAULT: 0.0 $DESCRIPTION: "print exposure"
+ gboolean print_auto_exposure; // $DEFAULT: TRUE $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: 2.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 halation_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "halation"
+ 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 grain_on; // $DEFAULT: TRUE $DESCRIPTION: "enable grain"
+ float grain_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain"
+ float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size"
+ float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "film format"
+ float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost"
+} 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) */
+ uint32_t hash;
+} sf_prof_entry_t;
+
+typedef struct dt_iop_spektrafilm_gui_data_t
+{
+ GtkWidget *film, *paper;
+ GtkWidget *exposure_ev, *print_exposure_ev, *print_auto_exposure, *print_contrast, *filter_m, *filter_y;
+ GtkWidget *couplers_amount, *scan_film, *quality;
+ GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift;
+ GtkWidget *halation_on, *halation_amount, *halation_scale;
+ GtkWidget *boost_ev, *boost_range, *protect_ev;
+ GtkWidget *diffusion_on, *diffusion_filter_family, *diffusion_strength, *diffusion_scale, *diffusion_warmth;
+ GtkWidget *grain_on, *grain_amount, *grain_size, *film_format_mm, *output_luminance_boost;
+ sf_prof_entry_t entries[SF_MAX_PROFILES];
+ int n_entries;
+ int film_entry[SF_MAX_PROFILES], n_films; /* indices into entries[] */
+ int paper_entry[SF_MAX_PROFILES], n_papers;
+ GtkNotebook *notebook;
+} dt_iop_spektrafilm_gui_data_t;
+
+/* 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];
+} 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, kernel_grain_add;
+ int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough;
+ int kernel_scatter_combine, kernel_accum, kernel_halation_apply;
+ int kernel_max_partials, 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;
+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);
+ 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 = dt_opencl_create_kernel(program, "spektrafilm_grain_gen");
+ gd->kernel_grain_add = dt_opencl_create_kernel(program, "spektrafilm_grain_add");
+ 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_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_halation_apply = dt_opencl_create_kernel(program, "spektrafilm_halation_apply");
+ gd->kernel_max_partials = dt_opencl_create_kernel(program, "spektrafilm_max_partials");
+ 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);
+ dt_opencl_free_kernel(gd->kernel_grain_add);
+ 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_passthrough);
+ dt_opencl_free_kernel(gd->kernel_scatter_combine);
+ dt_opencl_free_kernel(gd->kernel_accum);
+ dt_opencl_free_kernel(gd->kernel_halation_apply);
+ dt_opencl_free_kernel(gd->kernel_max_partials);
+ 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;
+ }
+ dt_pthread_mutex_lock(&_pack_lock);
+ if(_pack)
+ {
+ sf_pack_free(_pack);
+ _pack = NULL;
+ }
+ _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)
+{
+ return IOP_GROUP_COLOR | IOP_GROUP_GRADING;
+}
+int flags(void)
+{
+ return IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_INCLUDE_IN_STYLES;
+}
+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;
+}
+
+int legacy_params(dt_iop_module_t *self, const void *const old_params, const int old_version,
+ void **new_params, int32_t *new_params_size, int *new_version)
+{
+ /* v1 -> v2: added preflash_exposure/preflash_m_shift/preflash_y_shift,
+ diffusion_filter_family, and output_luminance_boost (Arecsu's
+ pre-compression boost patch). v1 here is the module's true original params
+ shape -- confirmed directly against the live, unmodified upstream
+ source, not reconstructed from memory -- covering every params layout
+ this module has shipped with so far: the introspection version was
+ never bumped through several earlier field additions (print_auto_exposure
+ among them) during this module's fast-moving initial development, so
+ this migration also recovers any history saved against those earlier
+ shapes, same reasoning as the previous v1->v2 migration this one
+ replaces: the struct has only ever grown by appending fields, so an
+ old, smaller saved blob still matches the leading fields of the
+ current struct, and the trailing fields (this migration's job) are
+ exactly what's missing. From this version onward, any further params
+ struct change should bump the version and add another case here rather
+ than silently drift again. */
+ typedef struct dt_iop_spektrafilm_params_v1_t
+ {
+ uint32_t film_hash;
+ uint32_t paper_hash;
+ float exposure_ev;
+ float print_exposure_ev;
+ gboolean print_auto_exposure;
+ float print_contrast;
+ float filter_m;
+ float filter_y;
+ float couplers_amount;
+ gboolean scan_film;
+ dt_iop_spektrafilm_quality_t quality;
+ gboolean halation_on;
+ float halation_amount;
+ float halation_scale;
+ float boost_ev;
+ float boost_range;
+ float protect_ev;
+ gboolean diffusion_on;
+ float diffusion_strength;
+ float diffusion_scale;
+ float diffusion_warmth;
+ gboolean grain_on;
+ float grain_amount;
+ float grain_size;
+ float film_format_mm;
+ } dt_iop_spektrafilm_params_v1_t;
+
+ if(old_version == 1)
+ {
+ const dt_iop_spektrafilm_params_v1_t *o = (dt_iop_spektrafilm_params_v1_t *)old_params;
+ dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t));
+
+ n->film_hash = o->film_hash;
+ n->paper_hash = o->paper_hash;
+ n->exposure_ev = o->exposure_ev;
+ n->print_exposure_ev = o->print_exposure_ev;
+ n->print_auto_exposure = o->print_auto_exposure;
+ n->print_contrast = o->print_contrast;
+ n->filter_m = o->filter_m;
+ n->filter_y = o->filter_y;
+ n->couplers_amount = o->couplers_amount;
+ n->preflash_exposure = 0.0f; /* new field: neutral default, no-op (matches upstream) */
+ n->preflash_m_shift = 0.0f; /* new field: neutral default, no-op */
+ n->preflash_y_shift = 0.0f; /* new field: neutral default, no-op */
+ n->scan_film = o->scan_film;
+ n->quality = o->quality;
+ n->halation_on = o->halation_on;
+ n->halation_amount = o->halation_amount;
+ n->halation_scale = o->halation_scale;
+ n->boost_ev = o->boost_ev;
+ n->boost_range = o->boost_range;
+ n->protect_ev = o->protect_ev;
+ n->diffusion_on = o->diffusion_on;
+ /* new field: the engine was hardcoded to Black Pro-Mist before the
+ family selector existed, so this exactly reproduces old saved
+ diffusion settings rather than just picking a neutral default. */
+ n->diffusion_filter_family = DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST;
+ n->diffusion_strength = o->diffusion_strength;
+ n->diffusion_scale = o->diffusion_scale;
+ n->diffusion_warmth = o->diffusion_warmth;
+ n->grain_on = o->grain_on;
+ n->grain_amount = o->grain_amount;
+ n->grain_size = o->grain_size;
+ n->film_format_mm = o->film_format_mm;
+ n->output_luminance_boost = 1.0f; /* new field: neutral default, no-op (matches upstream) */
+
+ *new_params = n;
+ *new_params_size = sizeof(dt_iop_spektrafilm_params_t);
+ *new_version = 2;
+ return 0;
+ }
+ return 1;
+}
+
+/* ---------------------------------------------------------------------- */
+/* profile discovery */
+/* ---------------------------------------------------------------------- */
+
+/* stable string hash for profile identity in params (same as the LUT module
+ used for bundles, so behaviour across machines/rescans is order-free) */
+static uint32_t sf_name_hash(const char *s)
+{
+ uint32_t h = 2166136261u; /* FNV-1a */
+ for(const unsigned char *p = (const unsigned char *)s; *p; p++)
+ {
+ h ^= *p;
+ h *= 16777619u;
+ }
+ return h ? h : 1; /* 0 is reserved for "first available" */
+}
+
+static void sf_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);
+}
+
+/* scan /spektrafilm/profiles/ (all .json files); reads only the info header of
+ each profile (stock / name / stage / target_print) */
+static int sf_scan_profiles(sf_prof_entry_t *out, int maxn)
+{
+ char dir[SF_PATH_LEN];
+ sf_pack_dir(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 0;
+ int n = 0;
+ const char *fn;
+ while(n < maxn && (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, &err);
+ if(!prof)
+ {
+ free(err);
+ continue;
+ }
+ sf_prof_entry_t *e = &out[n];
+ memset(e, 0, sizeof(*e));
+ 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"));
+ e->hash = sf_name_hash(e->stock);
+ sf_profile_free(prof);
+ n++;
+ }
+ g_dir_close(gd);
+ /* stable alphabetical order by display name */
+ for(int i = 0; i < n; i++)
+ for(int j = i + 1; j < n; j++)
+ if(g_ascii_strcasecmp(out[j].name, out[i].name) < 0)
+ {
+ sf_prof_entry_t t = out[i];
+ out[i] = out[j];
+ out[j] = t;
+ }
+ return n;
+}
+
+/* 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 sf_resolve_stock(const sf_prof_entry_t *entries, int n, uint32_t hash,
+ gboolean want_printing, const char *prefer_stock,
+ char *dst, size_t dstsz)
+{
+ if(hash)
+ for(int i = 0; i < n; i++)
+ if(entries[i].hash == hash && entries[i].printing == want_printing)
+ {
+ g_strlcpy(dst, entries[i].stock, dstsz);
+ return TRUE;
+ }
+ if(prefer_stock && prefer_stock[0])
+ for(int i = 0; i < n; i++)
+ if(entries[i].printing == want_printing && !strcmp(entries[i].stock, prefer_stock))
+ {
+ g_strlcpy(dst, entries[i].stock, dstsz);
+ return TRUE;
+ }
+ for(int i = 0; i < n; i++)
+ if(entries[i].printing == want_printing)
+ {
+ g_strlcpy(dst, entries[i].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);
+ 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->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);
+ key = _mix64(key, &p->quality, sizeof p->quality);
+ key = _mix64(key, &p->output_luminance_boost, sizeof p->output_luminance_boost);
+ 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;
+ }
+ if(d->sim)
+ {
+ sf_sim_free(d->sim);
+ d->sim = NULL;
+ }
+ d->sim_key = key;
+ d->sim_error[0] = 0;
+
+ /* global pack, loaded once */
+ dt_pthread_mutex_lock(&_pack_lock);
+ if(!_pack && !_pack_error[0])
+ {
+ char dir[SF_PATH_LEN];
+ sf_pack_dir(dir, sizeof dir);
+ char *err = NULL;
+ _pack = sf_pack_load(dir, &err);
+ 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", dir,
+ sf_pack_version(_pack));
+ }
+ sf_pack_t *pack = _pack;
+ dt_pthread_mutex_unlock(&_pack_lock);
+ if(!pack)
+ {
+ dt_pthread_mutex_unlock(&d->lock);
+ return NULL;
+ }
+
+ /* resolve stocks */
+ sf_prof_entry_t entries[SF_MAX_PROFILES];
+ const int n = sf_scan_profiles(entries, SF_MAX_PROFILES);
+ char film_stock[SF_NAME_LEN] = { 0 }, paper_stock[SF_NAME_LEN] = { 0 };
+ if(!sf_resolve_stock(entries, n, 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_pthread_mutex_unlock(&d->lock);
+ return NULL;
+ }
+ const char *target_print = NULL;
+ for(int i = 0; i < n; i++)
+ if(!entries[i].printing && !strcmp(entries[i].stock, film_stock))
+ target_print = entries[i].target_print;
+ if(!p->scan_film
+ && !sf_resolve_stock(entries, n, 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_pthread_mutex_unlock(&d->lock);
+ return NULL;
+ }
+
+ char dir[SF_PATH_LEN], path[SF_PATH_LEN + 300];
+ sf_pack_dir(dir, sizeof dir);
+ char *err = NULL;
+ snprintf(path, sizeof path, "%s/profiles/%s.json", dir, film_stock);
+ sf_profile_t *film = sf_profile_load(path, &err);
+ sf_profile_t *paper = NULL;
+ if(film && !p->scan_film)
+ {
+ snprintf(path, sizeof path, "%s/profiles/%s.json", dir, paper_stock);
+ paper = sf_profile_load(path, &err);
+ }
+
+ if(film && (paper || p->scan_film))
+ {
+ sf_sim_params_t sp;
+ sf_sim_params_defaults(&sp);
+ sp.exposure_comp_ev = p->exposure_ev;
+ 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.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;
+ }
+ /* 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];
+
+ 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 */
+/* ---------------------------------------------------------------------- */
+
+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);
+ const float hal = (p->halation_on && p->halation_amount > 0.0f)
+ ? SF_HALATION_FIRST_SIGMA_UM * SF_HALATION_PSF_SIGMAS * inv_um
+ : 0.0f;
+ const float diff = p->diffusion_on ? SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM * 1.41421356f
+ * p->diffusion_scale * inv_um
+ : 0.0f;
+ 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;
+ return fmaxf(fmaxf(hal, diff), 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;
+ const float full_w = fmaxf((float)piece->buf_in.width * roi_out->scale, 1.0f);
+ const float pixel_um = d->p.film_format_mm * 1000.0f / full_w;
+ 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;
+ const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f);
+ const float pixel_um = d->p.film_format_mm * 1000.0f / full_w;
+ tiling->factor = 4.5f; /* in + out + 3ch working plane + grain scratch */
+ tiling->factor_cl = 4.5f;
+ 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 */
+ const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f);
+ const float pixel_um = d->p.film_format_mm * 1000.0f / full_w;
+
+ 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.halation_amount > 0.0f)
+ sf_halation(plane, w, h, (double)pixel_um, d->p.halation_amount, d->p.halation_scale);
+
+ /* 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(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(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(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(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 (delta model + clump blur,
+ renormalised so strength is stable across clump sizes) */
+ 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 */
+ float gdmax[3], grms[3], gunif[3], gdmin[3];
+ sf_sim_film_dmax3(sim, gdmax); /* the emulsion's own D-max: slide film
+ exceeds the colour-negative 2.2 default,
+ which would bias (tint) dense areas */
+ sf_sim_film_grain3(sim, grms, gunif, gdmin); /* per-film catalogue grain
+ (rms-granularity, uniformity, density
+ floor) — Portra 400 no longer shares
+ Tri-X's grain signature */
+#ifdef _OPENMP
+#pragma omp parallel for default(none) shared(plane, gbuf, gdmax, grms, gunif, gdmin) \
+ firstprivate(w, npix, roi_x, roi_y, amount, mono) 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);
+ sf_grain_delta_dmax(plane + k * 3, amount, gbuf + k * 3, (uint32_t)(x + roi_x),
+ (uint32_t)(y + roi_y), mono, gdmax, gdmin, grms, gunif);
+ }
+ const float sigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM
+ * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f);
+ sf_blur_plane3(gbuf, w, h, sigma, scratch);
+ const float renorm = sqrtf(2.0f * sqrtf((float)M_PI) * fmaxf(sigma, 0.3f));
+#ifdef _OPENMP
+#pragma omp parallel for default(none) shared(plane, gbuf) firstprivate(npix, renorm) \
+ schedule(static)
+#endif
+ for(size_t k = 0; k < npix * 3; k++) plane[k] += gbuf[k] * renorm;
+ }
+
+ /* 5) print exposure + development (skipped in scan-film mode) */
+ if(!d->p.scan_film)
+ {
+ sf_sim_print_expose(sim, plane, plane, npix, 3, 3);
+ 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);
+
+#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
+/* 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 dt_gaussian on the float4
+ buffers, 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 */
+
+ const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f);
+ const float pixel_um = d->p.film_format_mm * 1000.0f / full_w;
+
+ /* ---- 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 tmpb = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
+ cl_mem acc = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
+ 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 || !tmpb || !acc
+ || (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;
+ }
+
+ /* ---- 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)
+ {
+ const int npartials = 256;
+ cl_mem partials = dt_opencl_alloc_device_buffer(devid, npartials * sizeof(float));
+ if(partials)
+ {
+ const int npix_i = (int)npix;
+ err = dt_opencl_enqueue_kernel_1d_args(devid, gd->kernel_max_partials, npartials,
+ CLARG(plane), CLARG(npix_i), CLARG(partials),
+ CLARG(npartials));
+ if(err == CL_SUCCESS)
+ {
+ float hp[256];
+ if(dt_opencl_read_buffer_from_device(devid, hp, partials, 0, npartials * sizeof(float),
+ CL_TRUE)
+ == CL_SUCCESS)
+ {
+ float maxv = 0.0f;
+ for(int i = 0; i < npartials; i++) maxv = fmaxf(maxv, hp[i]);
+ 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), CLARG(maxv));
+ }
+ }
+ dt_opencl_release_mem_object(partials);
+ 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);
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4);
+ if(err != CL_SUCCESS) break;
+ err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, 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.halation_amount > 0.0f)
+ {
+ const float hscl = fmaxf(d->p.halation_scale, 1e-3f);
+ const float core_um = (2.2f + 2.0f + 1.6f) / 3.0f, tail_um = (9.3f + 9.7f + 9.1f) / 3.0f;
+ const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f };
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4);
+ SF_CL_STEP("halation core copy");
+ err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4,
+ fmaxf(core_um * hscl / pixel_um, 1e-3f));
+ SF_CL_STEP("halation core blur");
+ for(int g3 = 0; g3 < 3; g3++)
+ {
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpb, 0, 0, npix * f * 4);
+ SF_CL_STEP("halation tail copy");
+ err = dt_gaussian_mean_blur_cl(devid, tmpb, w, h, 4,
+ fmaxf(rat[g3] * tail_um * hscl / pixel_um, 1e-3f));
+ SF_CL_STEP("halation tail blur");
+ const int reset = (g3 == 0);
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(tmpb),
+ CLARG(acc), CLARG(w), CLARG(h), CLARG(amp[g3]),
+ CLARG(reset));
+ SF_CL_STEP("halation tail accum");
+ }
+ const float ws_r = 0.78f, ws_g = 0.65f, ws_b = 0.67f;
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scatter_combine, w, h, CLARG(tmpa),
+ CLARG(acc), CLARG(plane), CLARG(w), CLARG(h),
+ CLARG(ws_r), CLARG(ws_g), CLARG(ws_b));
+ SF_CL_STEP("scatter combine");
+
+ const float first_sigma = SF_HALATION_FIRST_SIGMA_UM;
+ const int N = 3;
+ const float rho = 0.5f;
+ float dsum = 0.f, dec[3];
+ for(int k = 1; k <= N; k++)
+ {
+ dec[k - 1] = powf(rho, (float)(k - 1));
+ dsum += dec[k - 1];
+ }
+ for(int k = 0; k < N; k++) dec[k] /= dsum;
+ for(int k = 1; k <= N; k++)
+ {
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpb, 0, 0, npix * f * 4);
+ SF_CL_STEP("halation bounce copy");
+ err = dt_gaussian_mean_blur_cl(
+ devid, tmpb, w, h, 4, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f));
+ SF_CL_STEP("halation bounce blur");
+ const int reset = (k == 1);
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(tmpb),
+ CLARG(acc), CLARG(w), CLARG(h), CLARG(dec[k - 1]),
+ CLARG(reset));
+ SF_CL_STEP("halation bounce accum");
+ }
+ const float h_eff = powf(d->p.halation_amount, 1.3f);
+ const float a_r = 0.05f * h_eff, a_g = 0.015f * h_eff, a_b = 0.0f;
+ 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)
+ {
+ /* corr = (1-w)*gauss + w*3-gaussian exponential-tail surrogate;
+ accumulate the weighted passes into tmpa, then copy back to acc */
+ 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++)
+ {
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, acc, tmpb, 0, 0, npix * f * 4);
+ SF_CL_STEP("coupler tail copy");
+ if(sig[g3] > 0.1f)
+ {
+ err = dt_gaussian_mean_blur_cl(devid, tmpb, w, h, 4, sig[g3]);
+ SF_CL_STEP("coupler tail blur");
+ }
+ const int reset = (g3 == 0);
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
+ CLARG(tmpb), CLARG(tmpa), CLARG(w), CLARG(h),
+ CLARG(amp[g3]), CLARG(amp[g3]), CLARG(amp[g3]),
+ CLARG(reset));
+ SF_CL_STEP("coupler tail accum");
+ }
+ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, tmpa, acc, 0, 0, npix * f * 4);
+ SF_CL_STEP("coupler tail writeback");
+ }
+ else if(csigma > 0.1f)
+ {
+ err = dt_gaussian_mean_blur_cl(devid, acc, w, h, 4, csigma);
+ SF_CL_STEP("coupler blur");
+ }
+ }
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_develop, w, h, CLARG(plane),
+ CLARG(acc), 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 */
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_gen, w, h, CLARG(plane2),
+ CLARG(tmpa), CLARG(w), CLARG(h), CLARG(amount),
+ CLARG(roi_x), CLARG(roi_y), CLARG(mono),
+ CLARG(g->film_dmax[0]), CLARG(g->film_dmax[1]),
+ CLARG(g->film_dmax[2]), CLARG(g->grain_dmin[0]),
+ CLARG(g->grain_dmin[1]), CLARG(g->grain_dmin[2]),
+ CLARG(g->grain_rms[0]), CLARG(g->grain_rms[1]),
+ CLARG(g->grain_rms[2]), CLARG(g->grain_uniformity[0]),
+ CLARG(g->grain_uniformity[1]),
+ CLARG(g->grain_uniformity[2]));
+ SF_CL_STEP("grain gen");
+ const float gsigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM
+ * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f);
+ err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, gsigma);
+ SF_CL_STEP("grain blur");
+ const float grenorm = sqrtf(2.0f * sqrtf((float)M_PI) * fmaxf(gsigma, 0.3f));
+ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2),
+ CLARG(tmpa), CLARG(w), CLARG(h), CLARG(grenorm));
+ SF_CL_STEP("grain add");
+ }
+
+ /* ---- 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");
+ 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: crop the roi_out window straight into dev_out ------------- */
+ err = dt_opencl_enqueue_kernel_2d_args(
+ devid, gd->kernel_scan, ow, oh, CLARG(plane2), CLARG(dev_in), CLARG(dev_out), CLARG(w),
+ CLARG(ow), CLARG(oh), CLARG(ox), CLARG(oy), 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");
+
+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(tmpb);
+ dt_opencl_release_mem_object(acc);
+ 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->n_entries = sf_scan_profiles(g->entries, SF_MAX_PROFILES);
+ g->n_films = g->n_papers = 0;
+ for(int i = 0; i < g->n_entries; i++)
+ {
+ if(g->entries[i].printing)
+ g->paper_entry[g->n_papers++] = i;
+ else
+ g->film_entry[g->n_films++] = i;
+ }
+}
+
+static void _update_print_sensitivity(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 int fi = dt_bauhaus_combobox_get(g->film);
+ if(fi < 0 || fi >= g->n_films) return;
+ const sf_prof_entry_t *e = &g->entries[g->film_entry[fi]];
+ p->film_hash = e->hash;
+ /* 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;
+ ++darktable.gui->reset;
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->scan_film), p->scan_film);
+ --darktable.gui->reset;
+ _update_print_sensitivity(self);
+ }
+ /* if the paper is still on "auto" (hash 0) keep it following the film's
+ target print; otherwise leave the explicit user choice alone */
+ if(p->paper_hash == 0 && e->target_print[0])
+ for(int k = 0; k < g->n_papers; k++)
+ if(!strcmp(g->entries[g->paper_entry[k]].stock, e->target_print))
+ {
+ ++darktable.gui->reset;
+ dt_bauhaus_combobox_set(g->paper, k);
+ --darktable.gui->reset;
+ break;
+ }
+ 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 pi = dt_bauhaus_combobox_get(g->paper);
+ if(pi < 0 || pi >= g->n_papers) return;
+ p->paper_hash = g->entries[g->paper_entry[pi]].hash;
+ dt_dev_add_history_item(darktable.develop, self, 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);
+ /* 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();
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_auto_exposure),
+ printing && p->print_auto_exposure);
+ DT_LEAVE_GUI_UPDATE();
+}
+
+/* called by the core whenever a params-linked widget changed */
+void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous)
+{
+ 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 == 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);
+ }
+}
+
+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);
+ dt_bauhaus_combobox_clear(g->film);
+ if(g->n_films == 0)
+ dt_bauhaus_combobox_add(g->film, _("(no profiles found)"));
+ else
+ for(int f = 0; f < g->n_films; f++)
+ dt_bauhaus_combobox_add(g->film, g->entries[g->film_entry[f]].name);
+ dt_bauhaus_combobox_clear(g->paper);
+ if(g->n_papers == 0)
+ dt_bauhaus_combobox_add(g->paper, _("(none)"));
+ else
+ for(int k = 0; k < g->n_papers; k++)
+ dt_bauhaus_combobox_add(g->paper, g->entries[g->paper_entry[k]].name);
+
+ int fi = 0;
+ gboolean film_matched = FALSE;
+ for(int f = 0; f < g->n_films; f++)
+ if(g->entries[g->film_entry[f]].hash == p->film_hash) { fi = f; film_matched = TRUE; }
+ if(!film_matched)
+ {
+ /* no hash match (fresh param with film_hash==0, or the saved stock
+ vanished from the pack) -- mirror sf_resolve_stock's fallback so the
+ combobox agrees with what the pixel pipeline actually renders, instead
+ of silently landing on whatever sorts first (e.g. "Fujifilm C200"
+ alphabetically before "Kodak Portra 400") while the pipe renders the
+ real default. */
+ for(int f = 0; f < g->n_films; f++)
+ if(!strcmp(g->entries[g->film_entry[f]].stock, "kodak_portra_400")) fi = f;
+ }
+ dt_bauhaus_combobox_set(g->film, fi);
+ int pi = 0;
+ const char *target = (fi < g->n_films) ? g->entries[g->film_entry[fi]].target_print : NULL;
+ for(int k = 0; k < g->n_papers; k++)
+ {
+ const sf_prof_entry_t *e = &g->entries[g->paper_entry[k]];
+ if(p->paper_hash ? (e->hash == p->paper_hash)
+ : (target && !strcmp(e->stock, target)))
+ pi = k;
+ }
+ dt_bauhaus_combobox_set(g->paper, pi);
+
+ /* 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. */
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->scan_film), p->scan_film);
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_auto_exposure), p->print_auto_exposure);
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->halation_on), p->halation_on);
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->diffusion_on), p->diffusion_on);
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->grain_on), p->grain_on);
+ _update_print_sensitivity(self);
+}
+
+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);
+
+ 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(self->widget), 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(self->widget), g->paper, TRUE, TRUE, 0);
+
+ /* filmic-style tabbed notebook: everything else lives in tabs instead of a
+ single flat, ever-growing list of sliders. */
+ GtkWidget *sf_main_box = self->widget; /* restored after all tab pages below */
+ 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(sf_main_box, GTK_WIDGET(g->notebook));
+
+ /* ---- tab: film and print ---- */
+ self->widget = dt_ui_notebook_page(g->notebook, N_("film and print"), NULL);
+ dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "film")));
+ 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)"));
+ dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print")));
+ 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)"));
+ 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"));
+ 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)"));
+ dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "preflash")));
+ g->preflash_exposure = dt_bauhaus_slider_from_params(self, "preflash_exposure");
+ 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)"));
+ 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"));
+ dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "format")));
+ g->film_format_mm = dt_bauhaus_slider_from_params(self, "film_format_mm");
+ dt_bauhaus_slider_set_format(g->film_format_mm, _(" mm"));
+ gtk_widget_set_tooltip_text(g->film_format_mm,
+ _("physical film width; sets the scale of grain and halation"));
+
+ /* ---- tab: 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)"));
+
+ /* ---- tab: halation ---- */
+ self->widget = dt_ui_notebook_page(g->notebook, N_("halation"), NULL);
+ g->halation_on = dt_bauhaus_toggle_from_params(self, "halation_on");
+ 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)"));
+ 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, _("range of the highlight boost curve"));
+ 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: 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, _("diffusion filter strength"));
+ g->diffusion_scale = dt_bauhaus_slider_from_params(self, "diffusion_scale");
+ gtk_widget_set_tooltip_text(g->diffusion_scale, _("diffusion halo/bloom size"));
+ 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)"));
+
+ /* ---- tab: advanced ---- */
+ self->widget = dt_ui_notebook_page(g->notebook, N_("advanced"), NULL);
+ g->quality = dt_bauhaus_combobox_from_params(self, "quality");
+ gtk_widget_set_tooltip_text(g->quality,
+ _("spectral accuracy vs speed; the tables are PCHIP-interpolated"
+ " and validated against the reference"));
+ g->output_luminance_boost = dt_bauhaus_slider_from_params(self, "output_luminance_boost");
+ gtk_widget_set_tooltip_text(g->output_luminance_boost,
+ _("pre-compression boost: multiplies XYZ luminance before the"
+ " OkLCh gamut compressor, pushing the histogram right while"
+ " preserving the film's natural shoulder rolloff"));
+
+ self->widget = sf_main_box;
+}
+
+// clang-format off
+// modelines
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// clang-format on