src/iop/spektrafilm.c
Rama master · réplica de lectura del repositorio original.
/*
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 <http://www.gnu.org/licenses/>.
*/
/* spektrafilm — native spectral film simulation.
*
* Film modeling powered by spektrafilm (https://github.com/andreavolpato/spektrafilm),
* GPLv3, © Andrea Volpato. Film/paper profile data CC BY-SA 4.0.
*
* This module computes the full spektrafilm colour pipeline natively per pixel:
*
* scene-linear work RGB
* -> CAT16 to the film's reference illuminant, xy -> spectral upsampling
* (hanatos2025 tc LUT) x film sensitivity = camera exposure
* -> highlight boost / diffusion / halation (linear, spatial)
* -> log exposure -> DIR coupler correction (blurred) = film development
* -> CMY film density -> grain (density, spatial)
* -> enlarger (dichroic-filtered light through the negative,
* print paper sensitivity, midgray-balanced) = print exposure
* -> print diffusion filter (optional) (linear print exposure, spatial)
* -> print density curves (with optional contrast morph)
* -> viewing illuminant through the print, CMFs -> XYZ
* -> CAT02 -> work RGB -> OkLCh gamut compression = scanning
*
* The per-pixel colour science lives in spektra_sim.c (a validated port of
* spektrafilm 0.3.x, max deviation < 1e-4 vs the Python reference); the
* spatial effects (grain / halation / diffusion / highlight boost) live in
* spektra_core.h/.c, both shared with the OpenCL-side ports.
*
* Spectral data ships separately, from
* https://github.com/darktable-org/darktable-spektrafilm: that repository holds
* the data packs, the manifest the download button reads, and the exporter
* (tools/spektrafilm_export_data.py) that turns a spektrafilm release into a
* pack. The module reads a pack from one of two places, the first winning:
* <user data>/darktable/spektrafilm/ (installed by hand)
* <user data>/darktable/spektrafilm/packs/<hash>/ (downloaded, one per table)
* Either directory holds pack.json + spectra_lut.f32 and a profiles/
* subdirectory of film and paper *.json profiles.
*
* 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.
*/
#include "bauhaus/bauhaus.h"
#include "common/darktable.h"
#include "common/file_location.h"
#include "control/conf.h"
#include "control/control.h"
#include "develop/imageop.h"
#include "develop/pixelpipe_cache.h"
#include "develop/tiling.h"
#include "develop/imageop_gui.h"
#include "develop/imageop_math.h"
#include "common/imagebuf.h"
#include "common/iop_profile.h"
#include "common/spektra_fetch.h"
#include "common/opencl.h"
#include "common/gaussian.h"
#include "gui/accelerators.h"
#include "dtgtk/button.h"
#include "dtgtk/expander.h"
#include "dtgtk/paint.h"
#include "gui/color_picker_proxy.h"
#include "gui/gtk.h"
#include "gui/presets.h"
#include "iop/iop_api.h"
#include <glib.h>
#include <glib/gstdio.h>
#include <gtk/gtk.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
#define GRAIN_INLINE static inline
#include "common/gaussian.h"
#include "common/spektra_core.h"
#include "common/spektra_sim.h"
DT_MODULE_INTROSPECTION(3, dt_iop_spektrafilm_params_t)
/* Spatial-scale constants, micrometres on film unless noted (see the LUT
module for the full rationale; these are shared with modify_roi_in() and
tiling_callback() so the halo math stays in sync). */
#define SF_HALATION_FIRST_SIGMA_UM 65.0f
#define SF_HALATION_PSF_SIGMAS 1.7320508f /* sqrt(3) */
/* widest stage-1 scatter component: max(sc_tail)*max(tail_rat) from
spektra_core.c's sf_halation() = 9.7 * 2.7684 um, rounded up */
#define SF_SCATTER_TAIL_MAX_UM 27.0f
/* Per-channel ceilings the ROI padding above is sized for. The scatter PSF is
per-film pack data now, and modify_roi_in() runs before the sim exists, so it
cannot measure the real values -- clamp them to what the padding covers
instead, exactly as hal_sigma_um is clamped to SF_HALATION_FIRST_SIGMA_UM.
9.7 * SF_EXPTAIL_R2 = 26.85, which is where the 27.0 above comes from. */
#define SF_SCATTER_CORE_CLAMP_UM 2.2f
#define SF_SCATTER_TAIL_CLAMP_UM 9.7f
/* [gl] GlareParams.roughness / .blur -- the reference exposes these but leaves
them at these values for every profile, so only the amount gets a slider. */
#define SF_GLARE_ROUGHNESS 0.7f
#define SF_GLARE_BLUR_PX 0.5f
/* the spectral table of the first data pack ever published (0.3.3's
hanatos2025, irradiance_xy_tc). An edit predating the table field can only
have been rendered with it, that pack having been the only one in
existence */
#define SF_FIRST_PUBLISHED_LUT_HASH 0x565f4ec4u
/* and the method it is, which that pack does not declare: pack_format 2 named
no tables, so its header says only the kind ("irradiance_xy_tc@0.3.3"). */
#define SF_FIRST_PUBLISHED_LUT_NAME "hanatos2025"
#define SF_GRAIN_BLUR_MIN 0.05f
/* Upstream's GrainParams.blur_dye_clouds_um (params_schema.py): a SECOND,
* per-sub-layer blur applied to the raw particle draw INSIDE the particle
* sampler itself (layer_particle_model in grain.py), before the main
* clump blur above ever runs -- sigma = SF_GRAIN_DYE_BLUR_UM *
* sqrt(od_particle), where od_particle = dmax/npart is that sub-layer's
* own per-particle optical density. Passed through verbatim (no
* pixel_um conversion anywhere in the reference's own call chain,
* despite the "_um" name) -- ported as literally as upstream computes it,
* naming included. No variance restoration afterward either, same as the main
* clump blur. */
#define SF_GRAIN_DYE_BLUR_UM 2.0f
/* Push/pull processing is really two things happening together: shooting
* at an effective ISO different from box speed (already modeled via
* exposure_ev), plus extended/reduced development time, which increases
* or decreases contrast -- the gamma knob. There's no single fixed
* physical constant for how much contrast one stop of push buys (it
* depends on the specific film/developer combination, which isn't
* modeled here), so this is a documented approximation: each stop
* multiplies gamma by this factor, a commonly-cited rule of thumb
* (roughly a 15% contrast increase per stop). Compounds naturally across
* multiple stops (push 2 = factor^2), which suits gamma being a
* multiplicative quantity in this model to begin with. */
#define SF_PUSH_PULL_GAMMA_PER_STOP 1.15f
#define SF_HALO_SIGMAS 4.0f
/* DIR coupler inhibitor diffusion; spektrafilm params_schema
dir_couplers.diffusion_size_um default (a plain gaussian in the reference) */
#define SF_NAME_LEN 128
#define SF_PATH_LEN 1024
/* Combobox data values that are NOT positions in g->entries. Both stock
comboboxes carry a list position as their entry data and resolve it through
_entry_at(), so any entry that names no stock needs a value outside that
range -- negative, since positions start at 0.
Every such entry must set its data explicitly through
dt_bauhaus_combobox_add_full(). dt_bauhaus_combobox_add() attaches NULL, and
GPOINTER_TO_INT(NULL) is 0, which is a perfectly valid position: the two
lists are one list, so a data-less entry in the film combobox resolves to
whichever paper sorts first, and one in the paper combobox to a film.
Bauhaus does auto-number NULL data, but only when entry 0 is also data-less,
which neither combobox satisfies. */
#define SF_COMBO_PAPER_AUTO (-1) /* follow the film stock's target print */
#define SF_COMBO_SCAN_FILM (-2) /* no print stage: view the developed film */
#define SF_COMBO_NO_PROFILES (-3) /* placeholder: the pack lists none of this kind */
/* grid size per axis of the table the color model is evaluated on and
PCHIP-interpolated from */
#define SF_LUT_STEPS 33
/* order must match SF_DIFF_FAMILIES[] in spektra_core.c */
typedef enum dt_iop_spektrafilm_diffusion_family_t
{
DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST = 0, // $DESCRIPTION: "black pro-mist"
DT_SPEKTRAFILM_DIFF_GLIMMERGLASS = 1, // $DESCRIPTION: "glimmerglass"
DT_SPEKTRAFILM_DIFF_PRO_MIST = 2, // $DESCRIPTION: "pro-mist"
DT_SPEKTRAFILM_DIFF_CINEBLOOM = 3, // $DESCRIPTION: "cinebloom"
} dt_iop_spektrafilm_diffusion_family_t;
typedef struct dt_iop_spektrafilm_params_t
{
uint32_t film_hash; // $DEFAULT: 0 (0 = first available filming stock)
/* Identity of the spectral upsampling table this edit was developed against.
Upstream revises it often and every revision changes the render, so an edit
reopened against a different one is reported. 0 means
"not recorded" (an edit older than this field, or one
made while no pack was loaded) and never warns. Diagnostic only -- nothing
downstream reads it. */
uint32_t lut_hash; // $DEFAULT: 0
uint32_t paper_hash; // $DEFAULT: 0 (0 = the film's target print stock)
float exposure_ev; // $MIN: -12.0 $MAX: 12.0 $DEFAULT: 0.0 $DESCRIPTION: "film exposure"
/* An offset either way, which is what "compensation" in the name records:
with auto print exposure off it is the enlarger exposure itself, and with
it on it shifts the automatic result rather than replacing it. */
float print_exposure_ev; // $MIN: -3.0 $MAX: 3.0 $DEFAULT: 0.0 $DESCRIPTION: "print exposure compensation"
gboolean print_auto_exposure; // $DEFAULT: FALSE $DESCRIPTION: "auto print exposure"
/* the reference's gamma_factor for the print curves; the field name predates
the label and is kept, being what presets and styles refer to */
float print_contrast; // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print gamma"
float filter_m; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration M"
float filter_y; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration Y"
float couplers_amount; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 1.0 $DESCRIPTION: "DIR couplers"
/* Inhibitor spread, in micrometres on the film. Absolute, not a scale
on the stock's value, so the number means the same thing whatever film is
loaded and survives a film change. Ranges follow the reference's GUI
manifest (minimum 0 throughout, tail weight up to 1), and the defaults are
its DirCouplersParams; _sync_coupler_diffusion() moves the sliders' reset
targets onto whatever the loaded stock ships. */
float couplers_diffusion_um; // $MIN: 0.0 $MAX: 60.0 $DEFAULT: 20.0 $DESCRIPTION: "inhibitor spread"
float couplers_tail_um; // $MIN: 0.0 $MAX: 400.0 $DEFAULT: 200.0 $DESCRIPTION: "spread tail"
float couplers_tail_weight; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.03 $DESCRIPTION: "tail weight"
float couplers_inhibition_same; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 1.0 $DESCRIPTION: "same-layer inhibition"
float couplers_inhibition_inter; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 1.0 $DESCRIPTION: "interlayer inhibition"
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)"
gboolean halation_on; // $DEFAULT: TRUE $DESCRIPTION: "enable halation and scatter"
float scatter_amount; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter amount"
float scatter_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter size"
float halation_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "halation strength"
float halation_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "halation size"
float boost_ev; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 0.0 $DESCRIPTION: "highlight boost"
float boost_range; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.3 $DESCRIPTION: "boost range"
float protect_ev; // $MIN: 0.0 $MAX: 6.0 $DEFAULT: 4.0 $DESCRIPTION: "boost protect"
gboolean diffusion_on; // $DEFAULT: FALSE $DESCRIPTION: "enable diffusion filter"
dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; // $DEFAULT: DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST $DESCRIPTION: "diffusion filter type"
float diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "diffusion strength"
float diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "diffusion size"
float diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "diffusion halo warmth"
gboolean print_diffusion_on; // $DEFAULT: FALSE $DESCRIPTION: "enable print diffusion"
dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; // $DEFAULT: DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST $DESCRIPTION: "print diffusion filter type"
float print_diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "print diffusion strength"
float print_diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "print diffusion size"
float print_diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "print diffusion halo warmth"
gboolean grain_on; // $DEFAULT: TRUE $DESCRIPTION: "enable grain"
float grain_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain strength"
/* Blur applied to the sampled grain, in pixels (reference: GrainParams.blur).
Softens grain without touching the particle population, so it makes grain
less distinct, not coarser; grain_granularity below is what changes
the crystals. Measured on the output, not on the film, so the right
value depends on magnification. */
float grain_blur; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain blur"
/* The reference's GrainParams.rms_granularity, as a scale on the stock's own
datasheet figure, not an absolute: it is per-channel there (6/8/10
for the default profile), and a single multiplier keeps a film's measured
colour balance while moving all three together. Particle area goes as the
square of it, so this is the control that actually makes grain coarser --
bigger particles, fewer of them, more fluctuation at every density. */
float grain_granularity; // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "granularity"
/* GrainParams.uniformity, again as a scale. HIGHER bends the noise toward
the Selwyn bell -- grain that grows and then falls away again with
density. sf_layer_particle draws against a saturation term 1 - p*unif
and the variance scales with it, so raising uniformity suppresses the
fluctuation at high density. The scale reaches the sampler as a plain
multiplier on the stock's own figure (grain_uniformity_scale in
spektra_sim.c), so nothing inverts it on the way. At 0.5 the variance
climbs monotonically to Dmax: no bell. */
float grain_uniformity; // $MIN: 0.5 $MAX: 1.03 $DEFAULT: 1.0 $DESCRIPTION: "uniformity"
/* GrainParams.particle_scale_sublayers, as a scale on every entry but the
coarsest. Real emulsions layer coarse crystals over fine ones; this moves
the finer sub-layers relative to the coarsest, which stays the reference
at 1. */
float grain_sublayer_scale; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "sublayer particle scale"
/* GrainParams.density_min, as a scale on the stock's own measured floor. The
floor each grain particle sits at, and the reason grain does not vanish
entirely in clear film. Shared with the enlarger and scan table ranges, so
it is a property of the emulsion, not of the drawing.
A scale rather than the reference's absolute value, matching
grain_granularity and grain_uniformity above: the pack's floors are per
channel and three of them cannot be spelled with one slider, so an absolute
control would flatten kodak_vision3_500t's 0.12/0.10/0.35 the moment it was
touched. Scaling keeps the ratios and still reaches any overall floor. */
float grain_density_min; // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "density floor"
/* GrainParams.blur_dye_clouds_um, as a scale on the reference's own 2 um.
Each developed crystal produces a small cloud of dye, not a hard
dot; this is how far that cloud spreads, in real emulsion units, so it only
becomes visible when a micrometre covers more than a pixel. */
float grain_dye_cloud; // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "dye cloud size"
/* The format combobox above picks a film GAUGE (35mm); this is the frame's
LONG EDGE (36 mm). Both describe the same format, so the two carry
different labels -- "format" and "frame long edge" -- to keep the preset
from reading as a contradiction of the slider beneath it. */
float film_format_mm; // $MIN: 5.0 $MAX: 250.0 $DEFAULT: 36.0 $DESCRIPTION: "frame long edge"
float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost"
/* Gain on the finished colour, after the gamut compressor -- what a tone
curve moving its white point does, and the other half of the pair with the
boost above. The boost drives more into the compressor, which lifts the
picture and desaturates whatever was near the gamut edge; this only changes
the level and leaves the channel ratios alone. Nothing bounds the result,
so it can send values past 1.0 for the rest of the pipe to deal with, which
is why it is the second tool and not the first. */
float output_scale; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "post-compression scale"
float grain_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "recovery radius"
float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.5 $DESCRIPTION: "recovery strength"
float film_gamma_factor; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "development gamma"
float film_gamma_factor_fast; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "fast layer gamma"
float film_gamma_factor_slow; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "slow layer gamma"
float film_developer_exhaustion; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "developer exhaustion"
float push_pull_stops; // $MIN: -4.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "push/pull"
float scan_blur; // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "scanner blur"
float scan_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpen radius"
float scan_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpen strength"
/* Off by default. Glare is a viewing-condition simulation, not a film
property, added in the scan as the viewing light: leaving it on lifts the
black point of every render, which hides the real per-paper black points
while you are still judging them. The reference defaults it on because it
renders finished images; this is an editing tool. Note that this is NOT
the same reasoning as scan_usm_amount, which does default to the
reference's own 0.7 -- sharpening is part of what a scan looks like,
whereas glare is a property of the room the print is viewed in. */
float glare_percent; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "viewing glare"
float development_min; // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "development time"
float print_development_min; // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "development time"
/* The two halves of the hanatos2025 sensitivity adaptation, each defaulting
the way the reference resolves it. Bandwidth on: the filming stage agrees
with the reference to 4e-15 that way. Surface off: the film profiles carry
the correction surface and enable it, but the reference runtime's own
setting disables it and wins, so its renders are made without it. Both are
left switchable because the coefficients are part of the profile data and
the upstream defaults may flip; see spektra_sim.h. */
gboolean adaptation_bandwidth; // $DEFAULT: TRUE $DESCRIPTION: "bandwidth adaptation"
gboolean adaptation_surface; // $DEFAULT: FALSE $DESCRIPTION: "surface adaptation"
/* Whether the finished colour goes through the output gamut compressor. The
reference has the same switch (io.output_gamut_compress.algorithm, which
takes "off"), so this is the model's own setting and not an addition.
Note that the compressor is two knees, not one, and this switch gates
both: a chroma knee towards the output profile's boundary, and a
lightness knee on OkLab L from 0.7 upwards (SF_OUT_LIGHT_T/_L/_P in
spektra_sim.c). Switching it off therefore lets the highlights run
brighter as well as letting saturated colours leave the profile, which
is worth spelling out wherever this control is described -- the
brightness half is the one users do not expect from the name.
Appended at the end of the struct: legacy_params() copies an older prefix
and leaves the tail at its default, so anything added later goes here. */
gboolean gamut_compress; // $DEFAULT: TRUE $DESCRIPTION: "gamut compression"
/* per-channel multipliers on print_contrast, the reference's own
gamma_rgb beside its scalar gamma (utils/morph_curves.py). The paper's
three density curves are morphed independently, so raising one channel
steepens that dye's response alone: contrast and color cross here, and a
channel split is how a print is graded for a crossed-over negative, where
one layer's contrast is wrong rather than all three.
Multiplicative on print_contrast rather than replacing it, matching how
the engine combines them (morph_gamma * gamma_ch), so the scalar stays the
overall control and these three are a trim on top of it */
float print_gamma_r; // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print gamma red"
float print_gamma_g; // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print gamma green"
float print_gamma_b; // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print gamma blue"
/* upstream's GrainParams.blur: the clump-blur radius in pixels that
grain_blur scales, rather than a second control beside it: hence no
widget. 0.89 is the value study b80 fitted jointly with the multiplicative
density unsharp mask, whose own halves are already here as
grain_usm_sigma (0.7) and grain_usm_amount (1.5); the blur softens grain
and the mask takes the resolution back, so the three only reproduce
upstream together.
A parameter and not a constant because it decides how every existing edit
with grain renders. legacy_params() holds migrated edits at the 0.8 they
were developed at, which a constant could not do */
float grain_blur_base; // $MIN: 0.5 $MAX: 1.5 $DEFAULT: 0.89
/* which of the pack's spectral upsampling tables this edit renders with, by
content hash: the same identity lut_hash above records, and the same
reason: a pack can revise a method's table without renaming it. 0 is the
pack's default, which is what every edit made before packs carried more
than one table gets, and the only thing a pack_format 2 pack offers.
A hash the installed pack does not carry is reported rather than silently
rendered with another table, exactly as a lut_hash mismatch is */
uint32_t upsampling_hash; // $DEFAULT: 0
/* the pack this edit was developed against. lut_hash above names only the
spectral table, and a release can carry a table forward byte-identical
while its profiles move, so two packs answer to one lut_hash and render
differently. 0 on an edit made before packs carried an identity, and on a
pack that declares none; both fall back to matching the table */
uint32_t pack_hash; // $DEFAULT: 0
/* black/white point, the reference's scanner.black_correction /
white_correction and their levels. Fitted for a scan of positive film,
whose slide carries base density and never reaches D-max, and for a print
on negative paper, whose range comes from the film behind it; a scan of a
negative and a print on positive paper carry their own ends and are never
fitted.
Off by default, as upstream has them. Choosing a positive stock switches
them on with scan_film (_film_changed()), which is upstream's
scan-for-print mode, and legacy_params() gives them the value scan_film
had: the module fitted positive scans and nothing else before these
existed */
gboolean scan_black_correction; // $DEFAULT: FALSE $DESCRIPTION: "black point correction"
gboolean scan_white_correction; // $DEFAULT: FALSE $DESCRIPTION: "white point correction"
/* below 0 the fit sends the densest tone under black, where the scan stage
clips it: shadow detail traded away deliberately, which is what crushing
a print means and what the reference's own 0 floor cannot express. Above
about a tenth the densest tone is already a mid gray and the render is
flat, so the useful span is a fraction of the range; the hard limits stay
wide and the soft range carries the part worth dragging through */
float scan_black_level; // $MIN: -0.1 $MAX: 1.0 $DEFAULT: 0.01 $DESCRIPTION: "black level"
float scan_white_level; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.98 $DESCRIPTION: "white level"
} dt_iop_spektrafilm_params_t;
/* one discovered profile: stock (= file base name), display name, stage */
typedef struct sf_prof_entry_t
{
char stock[SF_NAME_LEN];
char name[SF_NAME_LEN];
char target_print[SF_NAME_LEN];
gboolean printing; /* stage == "printing" */
gboolean positive; /* info.type == "positive" (slide / reversal) */
gboolean bw; /* channel_model == "bw" */
/* development times this stock is characterised at; n_dev == 0 means a single
characterisation, i.e. nothing for the development slider to choose */
int n_dev;
double dev_times[SF_MAX_DEV_TIMES];
uint32_t hash;
} sf_prof_entry_t;
typedef struct dt_iop_spektrafilm_gui_data_t
{
GtkWidget *film, *paper;
GtkWidget *output_boost, *output_scale;
GtkWidget *film_format_combo, *film_format_mm_slider;
GtkWidget *exposure_ev;
GtkWidget *push_pull_stops, *film_gamma_factor;
GtkWidget *film_gamma_factor_fast, *film_gamma_factor_slow, *film_developer_exhaustion;
GtkWidget *adaptation_bandwidth, *adaptation_surface;
GtkWidget *upsampling;
GList *tables; /* sf_table_entry_t*, owned; the loaded pack's tables */
GtkWidget *gamut_compress;
/* Last status the preview pipe reported, copied out of piece->data at the
end of commit_params(). The GUI cannot read piece->data itself: it has no
piece, and the pipes are torn down and rebuilt underneath it. */
char status_error[256], status_warning[256];
GtkWidget *print_exposure_ev, *print_auto_exposure, *print_contrast;
GtkWidget *print_gamma_r, *print_gamma_g, *print_gamma_b;
GtkWidget *filter_m, *filter_y, *couplers_amount;
GtkWidget *couplers_diffusion_um, *couplers_tail_um, *couplers_tail_weight;
GtkWidget *couplers_inhibition_same, *couplers_inhibition_inter;
GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift;
GtkWidget *grain_on, *grain_amount, *grain_blur;
GtkWidget *grain_granularity, *grain_uniformity, *grain_sublayer_scale;
GtkWidget *grain_density_min, *grain_dye_cloud;
GtkWidget *scan_blur, *scan_usm_sigma, *scan_usm_amount, *glare_percent;
GtkWidget *scan_black_correction, *scan_white_correction;
GtkWidget *scan_black_level, *scan_white_level;
GtkWidget *development_min, *print_development_min;
GtkWidget *grain_usm_sigma, *grain_usm_amount;
GtkWidget *halation_on, *scatter_amount, *scatter_scale, *halation_amount, *halation_scale;
GtkWidget *boost_ev, *boost_range, *protect_ev;
GtkWidget *diffusion_on, *diffusion_filter_family, *diffusion_strength, *diffusion_scale, *diffusion_warmth;
GtkWidget *print_diffusion_on, *print_diffusion_filter_family;
GtkWidget *print_diffusion_strength, *print_diffusion_scale, *print_diffusion_warmth;
/* every profile found on disk, sorted, films and papers together --
e->printing separates them. Owns its sf_prof_entry_t nodes. */
GList *entries;
GtkNotebook *notebook;
/* data-pack row in the header: a button and a status line, both hidden while
the installed pack already satisfies the edit. Shown only when there is
something to do, so the common case stays a clean two-combobox header. */
/* main_box holds everything except the data-pack row, so the whole module can
be collapsed to just that row while no usable pack exists. */
GtkWidget *main_box;
GtkWidget *data_box, *data_button, *data_status;
GtkWidget *update_box, *update_button, *update_status, *update_progress;
gboolean update_fetching; /* the running fetch was started from this row */
/* why the last fetch started from the update row failed, "" when it did not */
char update_error[256];
/* Sections repoint self->widget at their own container while a page is being
built, so the page box has to be remembered separately. section_container
is the last one handed out, which is how a fresh page is recognised. */
GtkWidget *section_page, *section_container;
GList *sections; /* dt_gui_collapsible_section_t*, owned */
/* List index of the "auto" entry in the paper combobox, whose label is
rewritten to name the paper it resolves to. Recorded when the list is
built: entries ahead of it would otherwise silently shift it. */
int paper_auto_pos;
guint data_poll; /* g_timeout id while a download runs, 0 otherwise */
/* g_idle id of a posted banner refresh, 0 when none is pending. The source
closes over the module, so it has to be removable at teardown. Written by
the pixelpipe thread that posts it and by the GTK thread that runs or
cancels it, so it lives under the module's GUI critical section. */
guint trouble_idle;
uint32_t data_wanted; /* spectral table the button will ask for */
uint32_t data_wanted_pack; /* and the pack, which outranks the table */
gboolean data_checked; /* a check has run, so "up to date" means something */
/* sf_fetch_generation() when the GUI last refreshed for an install. A check
finishes like a download but installs nothing, and must not rebuild the
module or reprocess the image */
guint data_gen;
sf_fetch_state_t data_last_state; /* to spot the moment a fetch finishes */
} dt_iop_spektrafilm_gui_data_t;
static const struct { const char *label; float mm; } _format_presets[] = {
{ "half-frame", 24.0f }, { "35mm", 36.0f }, { "6x6", 56.0f },
{ "6x7", 69.0f }, { "6x9", 84.0f }, { "4x5", 120.0f },
{ "8x10", 244.0f },
{ "Super 8", 5.79f }, { "16mm", 10.26f }, { "Super 16", 12.52f },
{ "Super 35", 24.89f }, { "VistaVision", 37.72f },
{ "65mm 5-perf", 52.63f }, { "IMAX 15-perf", 69.6f },
};
#define FORMAT_PRESETS_N ((int)(sizeof(_format_presets) / sizeof(_format_presets[0])))
#define FORMAT_PRESET_CUSTOM FORMAT_PRESETS_N
static int _format_mm_to_preset(float mm)
{
for(int i = 0; i < FORMAT_PRESETS_N; i++)
if(fabsf(_format_presets[i].mm - mm) < 0.01f) return i;
return FORMAT_PRESET_CUSTOM;
}
static void _format_changed(GtkWidget *combo,
gpointer user_data)
{
dt_iop_module_t *self = (dt_iop_module_t *)user_data;
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
const int pi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film_format_combo));
if(pi >= 0 && pi < FORMAT_PRESETS_N)
{
DT_ENTER_GUI_UPDATE();
dt_bauhaus_slider_set(g->film_format_mm_slider, _format_presets[pi].mm);
DT_LEAVE_GUI_UPDATE();
p->film_format_mm = _format_presets[pi].mm;
dt_dev_add_history_item(darktable.develop, self, TRUE);
}
/* The millimetre slider only means anything on "custom" -- on a preset it is
a read-only echo, and one that reads as a contradiction ("35mm" setting
"36 mm") because the preset names a film GAUGE while the slider is the
frame's LONG EDGE. Showing it only when it is editable removes both the
row and the confusion. */
gtk_widget_set_visible(g->film_format_mm_slider, pi < 0 || pi >= FORMAT_PRESETS_N);
}
static void _populate_format_combo(dt_iop_module_t *self)
{
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
dt_bauhaus_combobox_clear(g->film_format_combo);
dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "still"));
for(int i = 0; i < 7; i++)
dt_bauhaus_combobox_add_full(g->film_format_combo, _( _format_presets[i].label ),
DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(i), NULL, TRUE);
dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "cine"));
for(int i = 7; i < FORMAT_PRESETS_N; i++)
dt_bauhaus_combobox_add_full(g->film_format_combo, _( _format_presets[i].label ),
DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(i), NULL, TRUE);
dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "custom"));
dt_bauhaus_combobox_add_full(g->film_format_combo, _("custom"),
DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(FORMAT_PRESETS_N), NULL, TRUE);
}
/* per-piece data: parameter snapshot + a lazily (re)built simulation.
The sim depends on the pipe's work profile, which is only reliably known in
process(), so the build happens there guarded by a mutex. */
typedef struct dt_iop_spektrafilm_data_t
{
dt_iop_spektrafilm_params_t p;
/* engine cache */
dt_pthread_mutex_t lock;
sf_sim_t *sim;
sf_sim_gpu_t *gpu; /* float tables for process_cl */
uint64_t sim_key; /* hash of everything the sim build depends on */
/* the build for sim_key got as far as a loaded pack. With sim NULL it then
failed on a profile or in the engine, which the same key will fail on
again, so it is not retried until the key changes. */
gboolean sim_tried;
char sim_error[256];
char sim_warning[256];
/* Set by commit_params() for the preview pipe only, NULL everywhere else, so
that exactly one pipe publishes its status to the GUI (see _ensure_sim).
Letting every pipe write would have them racing over the same buffers for
no gain -- they all reach the same verdict from the same params. */
dt_iop_module_t *self;
/* multi-sublayer grain GPU constant buffers (see process_cl's grain
stage): built from d->gpu's grain_layer_* tables, which only change
when d->gpu itself is rebuilt (a new film/paper choice), never
per-tile. Cached here and keyed on the `gpu` pointer they were built
from AND on the device they were built on, so they survive across
process_cl() calls -- tiled processing calls process_cl() once per tile,
and re-uploading per tile would cost one transfer per tile for data that
is identical across every tile of the same image.
The device is part of the key because a cl_mem belongs to the context
that created it. piece->data lives as long as the pipe, but pipe->devid
is reassigned by dt_opencl_lock_device() on every run -- that is what the
device pool is for -- so on a machine with more than one OpenCL device a
pipe can upload these on one device and, next run, hand them to a kernel
on another. The handles are still valid, just foreign, and clSetKernelArg
dereferences them inside the driver: it segfaults there rather than
returning an error, so there is nothing to check after the fact. */
const sf_sim_gpu_t *grain_cl_built_for;
int grain_cl_devid;
#ifdef HAVE_OPENCL
cl_mem grain_cl_dmax, grain_cl_npart, grain_cl_dmin, grain_cl_total, grain_cl_curve;
#endif
} dt_iop_spektrafilm_data_t;
typedef struct dt_iop_spektrafilm_global_data_t
{
int kernel_expose, kernel_lograw, kernel_develop_corr, kernel_develop;
int kernel_grain_gen_raw_sl, kernel_grain_accumulate_1c, kernel_grain_finalize_channel,
kernel_grain_add, kernel_grain_usm;
int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough;
int kernel_crop_out, kernel_scan_usm, kernel_glare_gen;
int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply;
int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c;
int kernel_yvv_row_4c, kernel_yvv_col_4c, kernel_yvv_row_1c, kernel_yvv_col_1c;
int kernel_wide_rows, kernel_wide_down, kernel_wide_up;
int kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix;
int kernel_pow10, kernel_log10;
} 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 so every pipe sees the same pack. */
static sf_pack_t *_pack = NULL;
/* Directory _pack was loaded from. Which directory that is depends on the edit:
an image developed against an older spectral table resolves to a different
pack than one developed against the current one. Keeping the path lets
_ensure_sim() notice the resolved directory has changed and reload, and lets
_scan_profiles() read profiles out of the SAME pack, not always out of
the pack directory -- profiles and spectral table have to come from one
place or the film list will not match the data behind it. */
static char _pack_path[SF_PATH_LEN] = { 0 };
/* Value of sf_fetch_generation() when _pack / _pack_error were last decided.
A download can install a pack into a directory this module already probed
and rejected, which leaves the resolved path unchanged -- so a path
comparison alone cannot tell that the answer has changed. */
static guint _pack_gen = 0;
static char _pack_error[256] = { 0 };
static dt_pthread_mutex_t _pack_lock;
/* The last profile scan, kept so that resolving a stock hash does not cost a
parse of every profile in the pack. _ensure_sim() scans on every rebuild,
once per pipe, and the scan loads each profile in full to read its header:
about 67 ms for the 31 profiles of the 0.3.3 pack against roughly 130 ms
for the build it precedes.
Valid for one profiles directory at one modification time and one fetch
generation. The generation covers a download, which can replace a pack in
place; the directory's mtime covers a hand-installed pack whose files are
added or removed underneath a running darktable. Callers get their own
copy of the list and free it as before. */
static GMutex _prof_cache_lock;
static GList *_prof_cache = NULL;
static char _prof_cache_dir[SF_PATH_LEN + 16] = { 0 };
static gint64 _prof_cache_mtime = -1;
static guint _prof_cache_gen = 0;
void init_global(dt_iop_module_so_t *self)
{
dt_pthread_mutex_init(&_pack_lock);
sf_fetch_init();
const int program = 43; /* 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;
if(!gd) return; /* cleanup_global() tolerates a NULL self->data */
gd->kernel_expose = dt_opencl_create_kernel(program, "spektrafilm_expose");
gd->kernel_lograw = dt_opencl_create_kernel(program, "spektrafilm_lograw");
gd->kernel_develop_corr = dt_opencl_create_kernel(program, "spektrafilm_develop_corr");
gd->kernel_develop = dt_opencl_create_kernel(program, "spektrafilm_develop");
gd->kernel_grain_gen_raw_sl = dt_opencl_create_kernel(program, "spektrafilm_grain_gen_raw_sl");
gd->kernel_grain_accumulate_1c = dt_opencl_create_kernel(program, "spektrafilm_grain_accumulate_1c");
gd->kernel_grain_finalize_channel
= dt_opencl_create_kernel(program, "spektrafilm_grain_finalize_channel");
gd->kernel_grain_add = dt_opencl_create_kernel(program, "spektrafilm_grain_add");
gd->kernel_grain_usm = dt_opencl_create_kernel(program, "spektrafilm_grain_usm");
gd->kernel_print_expose = dt_opencl_create_kernel(program, "spektrafilm_print_expose");
gd->kernel_print_develop = dt_opencl_create_kernel(program, "spektrafilm_print_develop");
gd->kernel_scan = dt_opencl_create_kernel(program, "spektrafilm_scan");
gd->kernel_crop_out = dt_opencl_create_kernel(program, "spektrafilm_crop_out");
gd->kernel_scan_usm = dt_opencl_create_kernel(program, "spektrafilm_scan_usm");
gd->kernel_glare_gen = dt_opencl_create_kernel(program, "spektrafilm_glare_gen");
gd->kernel_passthrough = dt_opencl_create_kernel(program, "spektrafilm_passthrough");
gd->kernel_scatter_combine = dt_opencl_create_kernel(program, "spektrafilm_scatter_combine");
gd->kernel_accum = dt_opencl_create_kernel(program, "spektrafilm_accum");
gd->kernel_channel_extract = dt_opencl_create_kernel(program, "spektrafilm_channel_extract");
gd->kernel_yvv_row_4c = dt_opencl_create_kernel(program, "spektrafilm_yvv_row_4c");
gd->kernel_yvv_col_4c = dt_opencl_create_kernel(program, "spektrafilm_yvv_col_4c");
gd->kernel_yvv_row_1c = dt_opencl_create_kernel(program, "spektrafilm_yvv_row_1c");
gd->kernel_yvv_col_1c = dt_opencl_create_kernel(program, "spektrafilm_yvv_col_1c");
gd->kernel_wide_rows = dt_opencl_create_kernel(program, "spektrafilm_wide_rows");
gd->kernel_wide_down = dt_opencl_create_kernel(program, "spektrafilm_wide_down");
gd->kernel_wide_up = dt_opencl_create_kernel(program, "spektrafilm_wide_up");
gd->kernel_gauss_row_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_4c");
gd->kernel_gauss_col_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_4c");
gd->kernel_gauss_row_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_1c");
gd->kernel_gauss_col_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_1c");
gd->kernel_channel_accum = dt_opencl_create_kernel(program, "spektrafilm_channel_accum");
gd->kernel_halation_apply = dt_opencl_create_kernel(program, "spektrafilm_halation_apply");
gd->kernel_boost = dt_opencl_create_kernel(program, "spektrafilm_boost");
gd->kernel_diffusion_accum = dt_opencl_create_kernel(program, "spektrafilm_diffusion_accum");
gd->kernel_diffusion_mix = dt_opencl_create_kernel(program, "spektrafilm_diffusion_mix");
gd->kernel_pow10 = dt_opencl_create_kernel(program, "spektrafilm_pow10");
gd->kernel_log10 = dt_opencl_create_kernel(program, "spektrafilm_log10");
}
void cleanup_global(dt_iop_module_so_t *self)
{
dt_iop_spektrafilm_global_data_t *gd = (dt_iop_spektrafilm_global_data_t *)self->data;
if(gd)
{
dt_opencl_free_kernel(gd->kernel_expose);
dt_opencl_free_kernel(gd->kernel_lograw);
dt_opencl_free_kernel(gd->kernel_develop_corr);
dt_opencl_free_kernel(gd->kernel_develop);
dt_opencl_free_kernel(gd->kernel_grain_gen_raw_sl);
dt_opencl_free_kernel(gd->kernel_grain_accumulate_1c);
dt_opencl_free_kernel(gd->kernel_grain_finalize_channel);
dt_opencl_free_kernel(gd->kernel_grain_add);
dt_opencl_free_kernel(gd->kernel_grain_usm);
dt_opencl_free_kernel(gd->kernel_print_expose);
dt_opencl_free_kernel(gd->kernel_print_develop);
dt_opencl_free_kernel(gd->kernel_scan);
dt_opencl_free_kernel(gd->kernel_crop_out);
dt_opencl_free_kernel(gd->kernel_scan_usm);
dt_opencl_free_kernel(gd->kernel_glare_gen);
dt_opencl_free_kernel(gd->kernel_passthrough);
dt_opencl_free_kernel(gd->kernel_scatter_combine);
dt_opencl_free_kernel(gd->kernel_accum);
dt_opencl_free_kernel(gd->kernel_channel_extract);
dt_opencl_free_kernel(gd->kernel_yvv_row_4c);
dt_opencl_free_kernel(gd->kernel_yvv_col_4c);
dt_opencl_free_kernel(gd->kernel_yvv_row_1c);
dt_opencl_free_kernel(gd->kernel_yvv_col_1c);
dt_opencl_free_kernel(gd->kernel_wide_rows);
dt_opencl_free_kernel(gd->kernel_wide_down);
dt_opencl_free_kernel(gd->kernel_wide_up);
dt_opencl_free_kernel(gd->kernel_gauss_row_4c);
dt_opencl_free_kernel(gd->kernel_gauss_col_4c);
dt_opencl_free_kernel(gd->kernel_gauss_row_1c);
dt_opencl_free_kernel(gd->kernel_gauss_col_1c);
dt_opencl_free_kernel(gd->kernel_channel_accum);
dt_opencl_free_kernel(gd->kernel_halation_apply);
dt_opencl_free_kernel(gd->kernel_boost);
dt_opencl_free_kernel(gd->kernel_diffusion_accum);
dt_opencl_free_kernel(gd->kernel_diffusion_mix);
dt_opencl_free_kernel(gd->kernel_pow10);
dt_opencl_free_kernel(gd->kernel_log10);
free(self->data);
self->data = NULL;
}
/* Before the pack lock goes away: a running fetch reprocesses the pipe on
completion, so it has to be joined while there is still a pipe to touch. */
sf_fetch_cleanup();
dt_pthread_mutex_lock(&_pack_lock);
if(_pack)
{
sf_pack_free(_pack);
_pack = NULL;
}
_pack_path[0] = 0;
_pack_gen = 0;
_pack_error[0] = 0;
dt_pthread_mutex_unlock(&_pack_lock);
dt_pthread_mutex_destroy(&_pack_lock);
g_mutex_lock(&_prof_cache_lock);
g_list_free_full(_prof_cache, g_free);
_prof_cache = NULL;
_prof_cache_dir[0] = 0;
_prof_cache_mtime = -1;
g_mutex_unlock(&_prof_cache_lock);
}
const char *name(void)
{
return _("spektrafilm");
}
const char *aliases(void)
{
return _("film simulation|analog|spectral|grain|halation|print");
}
const char **description(dt_iop_module_t *self)
{
return dt_iop_set_description(
self,
_("simulates the physical process of developing and printing analog film,\n"
"using spectral emulsion and paper data from the spektrafilm project"),
_("creative"), _("linear, RGB, scene-referred"), _("non-linear, RGB"),
_("non-linear, RGB, display-referred"));
}
int default_group(void)
{
/* Same grouping as the other display transforms (filmicrgb, sigmoid, agx),
not the grading modules. It matters beyond tidiness: "only use one display
transform" is the module's first piece of advice, and a group of its own
would separate it from every module it conflicts with. */
return IOP_GROUP_TONE | IOP_GROUP_TECHNICAL;
}
int flags(void)
{
return IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_INCLUDE_IN_STYLES | IOP_FLAGS_ALLOW_TILING;
}
/* v1 -> v2: grain_density_min changes meaning from an absolute density to a
scale on the stock's own measured floor. Same type and offset, so the struct
is unchanged and the copy is a straight one -- only the value has to move,
from v1's 0.03 default to a neutral 1.0.
Every v1 edit rendered on the film's own floor whatever that field said:
sf_pack_film_grain() wrote the pack's value over it before anything read it.
So 1.0 does not approximate a v1 edit, it reproduces it exactly, and a v1
slider position carries no information worth carrying forward. */
/* v2 -> v3: quality dropped (every edit renders on the SF_LUT_STEPS table),
print_gamma_r/_g/_b and the fields after them appended. v1 and v2 share this
layout, so one struct reads both; the copy below takes the fields either
side of quality, which v3 keeps in the same order */
typedef struct dt_iop_spektrafilm_params_v2_t
{
uint32_t film_hash;
uint32_t lut_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;
float couplers_diffusion_um;
float couplers_tail_um;
float couplers_tail_weight;
float couplers_inhibition_same;
float couplers_inhibition_inter;
float preflash_exposure;
float preflash_m_shift;
float preflash_y_shift;
gboolean scan_film;
int quality;
gboolean halation_on;
float scatter_amount;
float scatter_scale;
float halation_amount;
float halation_scale;
float boost_ev;
float boost_range;
float protect_ev;
gboolean diffusion_on;
dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family;
float diffusion_strength;
float diffusion_scale;
float diffusion_warmth;
gboolean print_diffusion_on;
dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family;
float print_diffusion_strength;
float print_diffusion_scale;
float print_diffusion_warmth;
gboolean grain_on;
float grain_amount;
float grain_blur;
float grain_granularity;
float grain_uniformity;
float grain_sublayer_scale;
float grain_density_min;
float grain_dye_cloud;
float film_format_mm;
float output_luminance_boost;
float output_scale;
float grain_usm_sigma;
float grain_usm_amount;
float film_gamma_factor;
float film_gamma_factor_fast;
float film_gamma_factor_slow;
float film_developer_exhaustion;
float push_pull_stops;
float scan_blur;
float scan_usm_sigma;
float scan_usm_amount;
float glare_percent;
float development_min;
float print_development_min;
gboolean adaptation_bandwidth;
gboolean adaptation_surface;
gboolean gamut_compress;
} dt_iop_spektrafilm_params_v2_t;
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)
{
if(old_version != 1 && old_version != 2) return 1;
dt_iop_spektrafilm_params_t *n = calloc(1, sizeof(dt_iop_spektrafilm_params_t));
if(!n) return 1;
const size_t head = offsetof(dt_iop_spektrafilm_params_v2_t, quality);
const size_t tail = offsetof(dt_iop_spektrafilm_params_v2_t, halation_on);
memcpy(n, old_params, head);
memcpy((char *)n + head, (const char *)old_params + tail,
sizeof(dt_iop_spektrafilm_params_v2_t) - tail);
if(old_version == 1) n->grain_density_min = 1.0f;
n->print_gamma_r = n->print_gamma_g = n->print_gamma_b = 1.0f;
/* not the 0.89 default: these edits were developed against 0.8 */
n->grain_blur_base = 0.8f;
/* the pack's default table, which is the only one these edits ever had */
n->upsampling_hash = 0u;
/* named no pack, and could not have: none declared an identity yet */
n->pack_hash = 0u;
/* the endpoints the module placed for these edits before they were exposed:
a positive scan was fitted, a print never was, and a negative scan
ignores the switches */
n->scan_black_correction = n->scan_white_correction = n->scan_film;
n->scan_black_level = 0.01f;
n->scan_white_level = 0.98f;
/* an edit of this vintage that recorded no table was made when exactly one
pack existed, so that is the table it used: there was nothing else to
render with. Left at 0 it would instead resolve to whatever happens to be
installed, which on a machine that has since moved to a later pack is a
different set of profiles and a different render.
Only here, where the record's own version proves it predates the choice. A
fresh edit also starts at 0 and must keep resolving to the default pack,
which is why this cannot be done in the resolver */
if(!n->lut_hash) n->lut_hash = SF_FIRST_PUBLISHED_LUT_HASH;
*new_params = n;
*new_params_size = sizeof(dt_iop_spektrafilm_params_t);
*new_version = 3;
return 0;
}
dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self,
dt_dev_pixelpipe_t *p,
dt_dev_pixelpipe_iop_t *pi)
{
return IOP_CS_RGB;
}
/* ---------------------------------------------------------------------- */
/* profile discovery */
/* ---------------------------------------------------------------------- */
/* Stable identity for a profile in params: the stock name hashed, so a rescan
or a different machine resolves the same stock regardless of directory order.
Folded to 32 bits because params store it as uint32_t. */
static uint32_t _name_hash(const char *s)
{
const dt_hash_t h = dt_hash(DT_INITHASH, s, strlen(s));
const uint32_t h32 = (uint32_t)(h ^ (h >> 32));
return h32 ? h32 : 1; /* 0 is reserved for "first available" */
}
/* <user data>/darktable/spektrafilm -- the folder a user can put a pack in by
hand, safe from the fetcher, which only ever writes to the `packs`
subdirectory below it (see spektra_fetch.c). A pack here wins over every
downloaded one, so it is also the answer to "how do I pin a specific
version", and it is the folder named when no pack can be found at all. */
static void _pack_dir(char *dst,
size_t dstsz)
{
char *dir = g_build_filename(g_get_user_data_dir(), "darktable", "spektrafilm", NULL);
g_strlcpy(dst, dir, dstsz);
g_free(dir);
}
/* the installed pack carrying this spectral table, or FALSE. Peeks the same
declaration the GUI lists from, so a table the user can pick is a table the
pipeline can find. 0 asks for nothing and always answers FALSE */
static gboolean _dir_has_table(const char *dir,
const uint32_t table_hash)
{
sf_table_info_t info[SF_MAX_TABLES];
const int n = sf_pack_peek_tables(dir, info, SF_MAX_TABLES);
for(int i = 0; i < n; i++)
if(info[i].lut_hash == table_hash) return TRUE;
return FALSE;
}
static gboolean _pack_dir_with_table(const uint32_t table_hash,
char *dst,
const size_t dstsz)
{
if(!table_hash) return FALSE;
gboolean found = FALSE;
GPtrArray *packs = sf_fetch_list_packs();
for(guint pi = 0; packs && pi < packs->len && !found; pi++)
{
const sf_fetch_pack_t *fp = g_ptr_array_index(packs, pi);
if(_dir_has_table(fp->dir, table_hash))
{
g_strlcpy(dst, fp->dir, dstsz);
found = TRUE;
}
}
if(packs) g_ptr_array_unref(packs);
return found;
}
/* the installed pack an edit that names no pack was made with: one carrying its
table AND declaring no identity of its own.
An edit predating pack_hash was necessarily made against a pack predating
pack_hash, that being the only kind that existed. Without this, such an edit
resolves on its table alone, and a later pack that carries the same table
forward byte-identical answers to it just as well, while its profiles render
differently. That is the whole failure pack_hash exists to prevent, and the
one case pack_hash cannot express, because the pack it wants has none.
FALSE when no such pack is installed, which leaves the edit to resolve on
its table as before; there is nothing better available then */
static gboolean _legacy_pack_dir_with_table(const uint32_t table_hash,
char *dst,
const size_t dstsz)
{
if(!table_hash) return FALSE;
gboolean found = FALSE;
GPtrArray *packs = sf_fetch_list_packs();
for(guint pi = 0; packs && pi < packs->len && !found; pi++)
{
const sf_fetch_pack_t *fp = g_ptr_array_index(packs, pi);
if(sf_fetch_peek_pack_hash(fp->dir)) continue; /* identified: not this one */
if(_dir_has_table(fp->dir, table_hash))
{
g_strlcpy(dst, fp->dir, dstsz);
found = TRUE;
}
}
if(packs) g_ptr_array_unref(packs);
return found;
}
/* how an edit resolves to a pack on this machine */
typedef enum sf_edit_pack_t
{
SF_EDIT_PACK_OK = 0, /* dir holds the pack this edit asks for */
SF_EDIT_PACK_SUBSTITUTE, /* dir holds a pack, but not the one asked for */
SF_EDIT_PACK_LEGACY_GONE,/* pre-identity edit on an identified pack */
SF_EDIT_PACK_NONE, /* nothing installed; dir names the hand-install folder */
} sf_edit_pack_t;
/* the pack this edit needs, and whether it is here.
*
* Three hashes answer three different questions:
*
* pack_hash the pack the edit was developed against. Two installed
* packs can carry one table and render differently, so this
* outranks the table.
* upsampling_hash a table the edit was explicitly moved to. Choosing it
* clears the other two, so until the next stamp it names
* the only thing the edit asks for; once stamped, the
* recorded pack has to carry it.
* lut_hash the pack's default table as stamped, the weakest claim
* and the only one a pre-identity edit has.
*
* dir always receives a directory: the matching pack for OK, the pack that
* renders in its place for SUBSTITUTE and LEGACY_GONE, and the hand-install
* folder for NONE so an error can name somewhere real. Anything but NONE
* renders; the mismatch is reported, not refused, as a table mismatch always
* was. out_table receives the table a download should ask for */
static sf_edit_pack_t _edit_pack_dir(const dt_iop_spektrafilm_params_t *p,
char *dir,
const size_t dstsz,
uint32_t *out_table)
{
const uint32_t want_table = p->upsampling_hash ? p->upsampling_hash : p->lut_hash;
if(out_table) *out_table = want_table;
if(p->pack_hash && sf_fetch_pack_dir_for_pack_hash(p->pack_hash, dir, dstsz)
&& (!p->upsampling_hash || _dir_has_table(dir, p->upsampling_hash)))
return SF_EDIT_PACK_OK;
/* an edit carrying a table but no pack was last rendered on a pack that
declares no identity: either the edit predates pack_hash, or the pack it
used does. Only such a pack is its own */
const gboolean unidentified = !p->pack_hash && !p->upsampling_hash && p->lut_hash;
if(unidentified && _legacy_pack_dir_with_table(p->lut_hash, dir, dstsz))
return SF_EDIT_PACK_OK;
gboolean exact = FALSE;
if(p->upsampling_hash && _pack_dir_with_table(p->upsampling_hash, dir, dstsz))
exact = TRUE;
else if(!sf_fetch_resolve_pack_dir(p->lut_hash, dir, dstsz, &exact))
{
_pack_dir(dir, dstsz);
return SF_EDIT_PACK_NONE;
}
/* the recorded pack is not here, so whatever was found stands in for it */
if(p->pack_hash) return SF_EDIT_PACK_SUBSTITUTE;
/* reaching an identified pack is a substitution: the table matches, so
nothing objects, but a release can carry a table forward unchanged while
its profiles move */
if(unidentified && sf_fetch_peek_pack_hash(dir)) return SF_EDIT_PACK_LEGACY_GONE;
/* an edit that records no table has no preference a pack could violate, so
whatever the resolver found, a downloaded pack included, is the right one */
return (exact || !want_table) ? SF_EDIT_PACK_OK : SF_EDIT_PACK_SUBSTITUTE;
}
/* Pick the pack directory for an edit that recorded wanted_lut_hash (0 = no
preference). Local lookup only -- no network, safe on the pixelpipe. Falls
back to the hand-install directory so error text still names somewhere real. */
static void _resolve_pack_dir(uint32_t wanted_lut_hash,
char *dst,
size_t dstsz)
{
gboolean exact = FALSE;
const gboolean found = sf_fetch_resolve_pack_dir(wanted_lut_hash, dst, dstsz, &exact);
if(!found) _pack_dir(dst, dstsz);
/* Which directory was chosen, and why, is the first thing worth knowing when
the module renders nothing: it separates "no pack anywhere" from "found a
pack, but it failed to load". */
dt_print(DT_DEBUG_DEV,
"[spektrafilm] pack for table %08x -> %s (%s)\n", wanted_lut_hash, dst,
!found ? "nothing found, falling back"
: exact ? "exact match"
: !wanted_lut_hash ? "no table recorded, taking what is installed"
: "table mismatch, using anyway");
}
/* natural (human) string compare: embedded numbers compared numerically
so "Vision3 50D" < "Vision3 200T" < "Vision3 500T" */
static int _nat_cmp(const char *a,
const char *b)
{
for(;;)
{
if(*a == 0) return *b == 0 ? 0 : -1;
if(*b == 0) return 1;
int da = (unsigned)*a - '0' < 10u;
int db = (unsigned)*b - '0' < 10u;
if(da && db)
{
unsigned long va = 0, vb = 0;
while((unsigned)*a - '0' < 10u) { va = va * 10 + (*a - '0'); a++; }
while((unsigned)*b - '0' < 10u) { vb = vb * 10 + (*b - '0'); b++; }
if(va != vb) return va < vb ? -1 : 1;
}
else if(da != db)
return da ? -1 : 1;
else
{
int ca = g_ascii_tolower(*a);
int cb = g_ascii_tolower(*b);
if(ca != cb) return ca < cb ? -1 : 1;
a++; b++;
}
}
}
/* Trim filler out of a profile's display name, in place.
The comboboxes are as wide as darktable's right panel and no wider, so a name
like "Kodak Professional Portra Endura" truncates to "Kodak Professional ..."
-- and four papers share that prefix, so the list became four identical
entries. Only genuinely redundant words go: "Professional" says nothing, and
"Negative Film" / "Reversal Film" duplicate the section heading the entry is
already filed under. Everything that identifies the stock stays, including
"Print Film 2302", where it is part of the name people know. */
static void _shorten_name(char *s,
const size_t sz)
{
static const char *const filler[] = { " Professional", " Negative Film", " Reversal Film" };
for(size_t f = 0; f < sizeof(filler) / sizeof(*filler); f++)
{
char *at = strstr(s, filler[f]);
if(!at) continue;
const size_t flen = strlen(filler[f]);
memmove(at, at + flen, strlen(at + flen) + 1);
}
(void)sz;
}
static gint _entry_name_cmp(gconstpointer a,
gconstpointer b)
{
return _nat_cmp(((const sf_prof_entry_t *)a)->name, ((const sf_prof_entry_t *)b)->name);
}
/* scan <packdir>/profiles/ (all .json files); reads only the info header of
each profile (stock / name / stage / target_print). Returns a newly allocated
list of sf_prof_entry_t, sorted by name, which the caller owns.
packdir may be NULL, which means "whichever pack is currently loaded, or the
one a fresh edit would resolve to". Profiles must come from the same pack as
the spectral table: a profile names a stock, and the pack holds that stock's
digested render defaults, so mixing the two silently drops per-film halation,
grain and coupler data for any stock the other side has never heard of. */
/* one spectral upsampling table, as the combobox needs it. Snapshotted out of
the pack rather than read live: the GUI has no pack reference of its own and
the pack can be reloaded underneath it when the resolved directory changes */
typedef struct sf_table_entry_t
{
char label[64]; /* the method name shown in the combobox */
char dir[SF_PATH_LEN];/* the pack that carries it */
uint32_t hash;
gboolean reflectance;
gboolean named; /* label came from a declaration, not a header id */
} sf_table_entry_t;
/* every spectral upsampling table on this machine, across all installed packs.
Not only the pack this edit resolves to: an edit made against 0.3.3 resolves
to a pack carrying one table, and listing just that one would leave it no way
to reach a method a later pack added: the control would be permanently
inert on exactly the edits most likely to want it. Choosing a table from
another pack moves the edit to that pack, which _upsampling_changed() does.
Read from the directories and not from the loaded pack, for the same reason
_scan_profiles() scans directories: _pack is assigned only by _ensure_sim(),
which runs in the pixelpipe, so it is still NULL while the GUI is built.
Peeking costs a pack.json parse and a 32-byte header per table */
static GList *_scan_tables(void)
{
GList *list = NULL;
GPtrArray *packs = sf_fetch_list_packs();
for(guint pi = 0; packs && pi < packs->len; pi++)
{
const sf_fetch_pack_t *fp = g_ptr_array_index(packs, pi);
sf_table_info_t info[SF_MAX_TABLES];
const int n = sf_pack_peek_tables(fp->dir, info, SF_MAX_TABLES);
dt_print(DT_DEBUG_DEV, "[spektrafilm] %d spectral upsampling table(s) in %s\n",
n, fp->dir);
for(int i = 0; i < n; i++)
{
/* one entry per table, not per pack: packs overlap, and 0.3.3's table is
byte-identical to the one 0.3.4 carries. Two entries for it would read
as two methods and pick between the packs arbitrarily. First pack
wins, which is the precedence sf_fetch_list_packs() already reports */
sf_table_entry_t *dup = NULL;
for(GList *l = list; l && !dup; l = l->next)
if(((sf_table_entry_t *)l->data)->hash == info[i].lut_hash)
dup = l->data;
if(dup)
{
/* A pack that names the table teaches the entry its method even when
an earlier pack supplies the bytes: same table, and "hanatos2025"
is what the method is called everywhere else. The directory stays
the earlier pack's, precedence being about which files to read */
if(!dup->named && info[i].identifier[0])
{
g_strlcpy(dup->label, info[i].identifier, sizeof dup->label);
dup->named = TRUE;
}
continue;
}
sf_table_entry_t *e = g_malloc0(sizeof(*e));
/* A format 2 pack's single table declared no identifier; its header id is
the only name it has, and it is the one the mismatch banner and the
data repository both use */
e->named = info[i].identifier[0] != 0;
g_strlcpy(e->label, e->named ? info[i].identifier : info[i].lut_id,
sizeof e->label);
g_strlcpy(e->dir, fp->dir, sizeof e->dir);
e->hash = info[i].lut_hash;
e->reflectance = info[i].kind == SF_LUT_REFLECTANCE;
list = g_list_append(list, e);
dt_print(DT_DEBUG_DEV, "[spektrafilm] %08x %s %s\n", e->hash, e->label,
info[i].lut_id);
}
}
if(packs) g_ptr_array_unref(packs);
/* nothing installed names the first published table, so fall back to what it
is: only one pack ever declared no tables, and this is the one it held */
for(GList *l = list; l; l = l->next)
{
sf_table_entry_t *e = l->data;
if(!e->named && e->hash == SF_FIRST_PUBLISHED_LUT_HASH)
{
g_strlcpy(e->label, SF_FIRST_PUBLISHED_LUT_NAME, sizeof e->label);
e->named = TRUE;
}
}
return list;
}
static const sf_table_entry_t *_table_at(const dt_iop_spektrafilm_gui_data_t *g,
const int pos)
{
return g ? g_list_nth_data(g->tables, pos) : NULL;
}
static GList *_entries_copy(GList *src)
{
GList *out = NULL;
for(GList *l = src; l; l = l->next)
{
sf_prof_entry_t *e = g_malloc(sizeof(sf_prof_entry_t));
memcpy(e, l->data, sizeof(sf_prof_entry_t));
out = g_list_prepend(out, e);
}
return g_list_reverse(out);
}
static GList *_scan_profiles(const char *packdir)
{
char dir[SF_PATH_LEN];
if(packdir && *packdir)
g_strlcpy(dir, packdir, sizeof dir);
else
{
dt_pthread_mutex_lock(&_pack_lock);
/* _pack_path also records directories that failed to load, so it only
answers "which pack is in use" when one is actually held. */
const gboolean have = _pack && _pack_path[0] != 0;
if(have) g_strlcpy(dir, _pack_path, sizeof dir);
dt_pthread_mutex_unlock(&_pack_lock);
if(!have) _resolve_pack_dir(0, dir, sizeof dir);
}
char profdir[SF_PATH_LEN + 16];
snprintf(profdir, sizeof profdir, "%s/profiles", dir);
GStatBuf pst;
const gint64 prof_mtime = (g_stat(profdir, &pst) == 0) ? (gint64)pst.st_mtime : -1;
const guint prof_gen = sf_fetch_generation();
g_mutex_lock(&_prof_cache_lock);
if(_prof_cache && prof_mtime >= 0 && _prof_cache_mtime == prof_mtime
&& _prof_cache_gen == prof_gen && strcmp(_prof_cache_dir, profdir) == 0)
{
GList *copy = _entries_copy(_prof_cache);
g_mutex_unlock(&_prof_cache_lock);
return copy;
}
g_mutex_unlock(&_prof_cache_lock);
GDir *gd = g_dir_open(profdir, 0, NULL);
if(!gd) return NULL;
GList *list = NULL;
const char *fn;
while((fn = g_dir_read_name(gd)))
{
if(!g_str_has_suffix(fn, ".json")) continue;
char path[SF_PATH_LEN + 300];
snprintf(path, sizeof path, "%s/%s", profdir, fn);
char *err = NULL;
sf_profile_t *prof = sf_profile_load(path, 0.5f, &err); /* info header only */
if(!prof)
{
free(err);
continue;
}
sf_prof_entry_t *e = g_malloc0(sizeof(sf_prof_entry_t));
g_strlcpy(e->stock, sf_profile_stock(prof) ? sf_profile_stock(prof) : fn, SF_NAME_LEN);
/* strip .json when falling back to the file name */
char *dot = strstr(e->stock, ".json");
if(dot) *dot = 0;
g_strlcpy(e->name, sf_profile_name(prof) ? sf_profile_name(prof) : e->stock, SF_NAME_LEN);
const char *stage = sf_profile_stage(prof);
e->printing = (stage && !strcmp(stage, "printing"));
const char *tp = sf_profile_target_print(prof);
if(tp) g_strlcpy(e->target_print, tp, SF_NAME_LEN);
const char *type = sf_profile_type(prof);
e->positive = (type && !strcmp(type, "positive"));
_shorten_name(e->name, sizeof e->name);
const char *cm = sf_profile_channel_model(prof);
e->bw = (cm && !strcmp(cm, "bw"));
e->n_dev = sf_profile_dev_times(prof, e->dev_times, SF_MAX_DEV_TIMES);
e->hash = _name_hash(e->stock);
sf_profile_free(prof);
list = g_list_prepend(list, e);
}
g_dir_close(gd);
/* natural order by display name (numbers compared numerically,
so "50D" < "200T" instead of lexicographic "200T" < "50D") */
list = g_list_sort(list, _entry_name_cmp);
/* an empty result is not kept: it is what a download in progress or a
half-deleted pack looks like, and the next call should look again */
if(list && prof_mtime >= 0)
{
g_mutex_lock(&_prof_cache_lock);
g_list_free_full(_prof_cache, g_free);
_prof_cache = _entries_copy(list);
g_strlcpy(_prof_cache_dir, profdir, sizeof _prof_cache_dir);
_prof_cache_mtime = prof_mtime;
_prof_cache_gen = prof_gen;
g_mutex_unlock(&_prof_cache_lock);
}
return list;
}
/* 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. */
/* prefer_bw: 0 or 1 to prefer a stock with that channel model when the named
one is absent, -1 for no preference. It is the tier _auto_paper_entry()
applies in the GUI, and it has to be applied here too or the two disagree:
target_print is optional in the pack, the entry list is sorted by display
name, and so the last-resort first printing entry bears no relation to the
film: for a black-and-white negative it is a color paper */
static gboolean _resolve_stock(GList *entries,
uint32_t hash,
gboolean want_printing,
const char *prefer_stock,
int prefer_bw,
char *dst,
size_t dstsz)
{
if(hash)
for(GList *l = entries; l; l = l->next)
{
const sf_prof_entry_t *e = l->data;
if(e->hash == hash && e->printing == want_printing)
{
g_strlcpy(dst, e->stock, dstsz);
return TRUE;
}
}
if(prefer_stock && prefer_stock[0])
for(GList *l = entries; l; l = l->next)
{
const sf_prof_entry_t *e = l->data;
if(e->printing == want_printing && !strcmp(e->stock, prefer_stock))
{
g_strlcpy(dst, e->stock, dstsz);
return TRUE;
}
}
if(prefer_bw >= 0)
for(GList *l = entries; l; l = l->next)
{
const sf_prof_entry_t *e = l->data;
if(e->printing == want_printing && e->bw == (prefer_bw != 0))
{
g_strlcpy(dst, e->stock, dstsz);
return TRUE;
}
}
for(GList *l = entries; l; l = l->next)
{
const sf_prof_entry_t *e = l->data;
if(e->printing == want_printing)
{
g_strlcpy(dst, e->stock, dstsz);
return TRUE;
}
}
return FALSE;
}
/* ---------------------------------------------------------------------- */
/* pipeline plumbing */
/* ---------------------------------------------------------------------- */
void init_pipe(dt_iop_module_t *self,
dt_dev_pixelpipe_t *pipe,
dt_dev_pixelpipe_iop_t *piece)
{
dt_iop_spektrafilm_data_t *d = calloc(1, sizeof(dt_iop_spektrafilm_data_t));
/* NULL on failure: every consumer of piece->data tests for it and passes
the image through */
if(d) dt_pthread_mutex_init(&d->lock);
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);
#ifdef HAVE_OPENCL
if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax);
if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart);
if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin);
if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total);
if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve);
#endif
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;
if(!d) return;
d->p = *(dt_iop_spektrafilm_params_t *)p1;
/* Only the preview pipe reports status to the module banner; see the field's
declaration. Re-set on every commit because a pipe can be rebuilt. */
d->self = dt_pipe_is_preview(pipe) ? self : NULL;
/* 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. */
}
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;
}
/* 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). */
/* forward: _ensure_sim() posts the banner refresh from the pipe thread */
static gboolean _trouble_idle_cb(gpointer user_data);
static void _update_trouble_message(dt_iop_module_t *self);
/* Hand this run's verdict to the GUI. Called at every exit from _ensure_sim(),
including the early ones: a failure returns before the build and its message
is exactly the one worth showing, so every pack and profile failure has to
publish from where it is detected.
Only the preview pipe carries d->self, so only one pipe writes. The banner is
redrawn from gui_update(), which a pipe run does not call, so a change posts a
refresh through the main loop -- and only on a change, leaving a steady error
free. Call with d->lock held.
The status strings live in gui_data and are read on the GTK thread, so
d->lock -- which is per piece -- does not order those accesses against each
other. Take the module's GUI critical section for the compare-and-copy; the
readers take the same one. */
static void _publish_status(const dt_iop_spektrafilm_data_t *d)
{
dt_iop_module_t *self = d->self;
if(!self) return;
/* gui_data is read inside the section, which gui_cleanup() also takes, so
the whole test-and-write is ordered against teardown: the module cannot be
dismantled between finding a GUI here and writing into it. */
dt_iop_gui_enter_critical_section(self);
dt_iop_spektrafilm_gui_data_t *g = self->gui_data;
if(g)
{
const gboolean changed = strcmp(g->status_error, d->sim_error) != 0
|| strcmp(g->status_warning, d->sim_warning) != 0;
if(changed)
{
g_strlcpy(g->status_error, d->sim_error, sizeof g->status_error);
g_strlcpy(g->status_warning, d->sim_warning, sizeof g->status_warning);
}
if(changed && !g->trouble_idle)
g->trouble_idle = g_idle_add(_trouble_idle_cb, self);
}
dt_iop_gui_leave_critical_section(self);
}
/* Bring the cached sim's two output gains, and their float mirrors in the GPU
export, up to the current params. They are not part of the sim key. Call
with d->lock held; the piece's own pipe is the only thing rendering with
d->sim, and it is the caller. */
static void _apply_output_gain(dt_iop_spektrafilm_data_t *d)
{
if(!d->sim) return;
sf_sim_set_output_gain(d->sim, d->p.output_luminance_boost, d->p.output_scale);
if(d->gpu)
{
d->gpu->out_luminance_boost = d->p.output_luminance_boost;
d->gpu->out_scale = d->p.output_scale;
}
}
static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d,
const dt_iop_order_iccprofile_info_t *work_profile)
{
const dt_iop_spektrafilm_params_t *p = &d->p;
/* the work profile's RGB<->XYZ matrices feed the engine; include them in
the cache key so a work-profile change rebuilds the sim */
float m_in[9], m_out[9];
for(int i = 0; i < 3; i++)
for(int j = 0; j < 3; j++)
{
/* dt_colormatrix_t, row-major: XYZ_i = sum_j matrix_in[i][j] * RGB_j */
m_in[i * 3 + j] = work_profile->matrix_in[i][j];
m_out[i * 3 + j] = work_profile->matrix_out[i][j];
}
uint64_t key = 0xcbf29ce484222325ULL;
key = _mix64(key, &p->film_hash, sizeof p->film_hash);
key = _mix64(key, &p->lut_hash, sizeof p->lut_hash);
key = _mix64(key, &p->pack_hash, sizeof p->pack_hash);
/* picks the spectral table the tc LUT is built from, so the sim is a
different one entirely */
key = _mix64(key, &p->upsampling_hash, sizeof p->upsampling_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);
/* morph the paper curves at build time, exactly as print_contrast does */
key = _mix64(key, &p->print_gamma_r, sizeof p->print_gamma_r);
key = _mix64(key, &p->print_gamma_g, sizeof p->print_gamma_g);
key = _mix64(key, &p->print_gamma_b, sizeof p->print_gamma_b);
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->couplers_diffusion_um, sizeof p->couplers_diffusion_um);
key = _mix64(key, &p->couplers_tail_um, sizeof p->couplers_tail_um);
key = _mix64(key, &p->couplers_tail_weight, sizeof p->couplers_tail_weight);
key = _mix64(key, &p->couplers_inhibition_same, sizeof p->couplers_inhibition_same);
key = _mix64(key, &p->couplers_inhibition_inter, sizeof p->couplers_inhibition_inter);
key = _mix64(key, &p->preflash_exposure, sizeof p->preflash_exposure);
key = _mix64(key, &p->preflash_m_shift, sizeof p->preflash_m_shift);
key = _mix64(key, &p->preflash_y_shift, sizeof p->preflash_y_shift);
key = _mix64(key, &p->scan_film, sizeof p->scan_film);
/* selects which member of a B&W development-time family the curve model is
built from, so it has to be part of the sim's cache key */
key = _mix64(key, &p->development_min, sizeof p->development_min);
key = _mix64(key, &p->print_development_min, sizeof p->print_development_min);
/* these three rebuild the grain layer tables, which live in the sim */
key = _mix64(key, &p->grain_granularity, sizeof p->grain_granularity);
key = _mix64(key, &p->grain_uniformity, sizeof p->grain_uniformity);
key = _mix64(key, &p->grain_sublayer_scale, sizeof p->grain_sublayer_scale);
key = _mix64(key, &p->grain_density_min, sizeof p->grain_density_min);
/* both change the tc LUT, which is built once per sim */
key = _mix64(key, &p->adaptation_bandwidth, sizeof p->adaptation_bandwidth);
key = _mix64(key, &p->adaptation_surface, sizeof p->adaptation_surface);
/* output_luminance_boost and output_scale are deliberately absent: both are
plain multipliers applied at scan time and enter no table, so they are
written into the cached sim by _apply_output_gain() instead of costing a
rebuild on every slider step */
/* the endpoint fit is solved at build time, not per pixel */
key = _mix64(key, &p->scan_black_correction, sizeof p->scan_black_correction);
key = _mix64(key, &p->scan_white_correction, sizeof p->scan_white_correction);
key = _mix64(key, &p->scan_black_level, sizeof p->scan_black_level);
key = _mix64(key, &p->scan_white_level, sizeof p->scan_white_level);
key = _mix64(key, &p->gamut_compress, sizeof p->gamut_compress);
key = _mix64(key, &p->film_gamma_factor, sizeof p->film_gamma_factor);
key = _mix64(key, &p->film_gamma_factor_fast, sizeof p->film_gamma_factor_fast);
key = _mix64(key, &p->film_gamma_factor_slow, sizeof p->film_gamma_factor_slow);
key = _mix64(key, &p->film_developer_exhaustion, sizeof p->film_developer_exhaustion);
key = _mix64(key, &p->push_pull_stops, sizeof p->push_pull_stops);
key = _mix64(key, m_in, sizeof m_in);
key = _mix64(key, m_out, sizeof m_out);
/* Installing a data pack changes no parameter, but it changes the data the sim
was built from and any mismatch warning it recorded, so the generation has
to be part of the key. It is bumped once per successful install, which
makes this force a rebuild exactly when new data arrived and never
otherwise. */
const guint fetch_gen = sf_fetch_generation();
key = _mix64(key, &fetch_gen, sizeof fetch_gen);
dt_pthread_mutex_lock(&d->lock);
/* a hit, or a build this key already failed: sim_error still holds why */
if(d->sim_key == key && (d->sim || d->sim_tried))
{
sf_sim_t *s = d->sim;
_apply_output_gain(d);
_publish_status(d);
dt_pthread_mutex_unlock(&d->lock);
return s;
}
/* (re)build */
if(d->gpu)
{
sf_sim_gpu_free(d->gpu);
d->gpu = NULL;
/* these were uploaded from this gpu's grain_layer_* tables; release them
now so process_cl() re-uploads fresh ones from the new gpu instead of
reusing now-stale content. */
#ifdef HAVE_OPENCL
if(d->grain_cl_dmax) { dt_opencl_release_mem_object(d->grain_cl_dmax); d->grain_cl_dmax = NULL; }
if(d->grain_cl_npart) { dt_opencl_release_mem_object(d->grain_cl_npart); d->grain_cl_npart = NULL; }
if(d->grain_cl_dmin) { dt_opencl_release_mem_object(d->grain_cl_dmin); d->grain_cl_dmin = NULL; }
if(d->grain_cl_total) { dt_opencl_release_mem_object(d->grain_cl_total); d->grain_cl_total = NULL; }
if(d->grain_cl_curve) { dt_opencl_release_mem_object(d->grain_cl_curve); d->grain_cl_curve = NULL; }
d->grain_cl_built_for = NULL;
#endif
}
if(d->sim)
{
sf_sim_free(d->sim);
d->sim = NULL;
}
d->sim_key = key;
d->sim_tried = FALSE;
d->sim_error[0] = 0;
d->sim_warning[0] = 0;
/* Global pack, loaded once per resolved directory.
Which directory that is depends on the spectral table this edit recorded,
so switching to an image developed against a different table reloads. One pack is held at a
time:
the LUT is ~12 MB, and having two images from different table generations
open in the same session is rare enough that the reload costs less than
permanently carrying every pack the user has on disk. */
char want_dir[SF_PATH_LEN];
/* one lookup, shared with the module's data row: the two disagreeing about
which pack an edit needs is what left an edit unrenderable while the row
reported nothing missing */
const sf_edit_pack_t edit_pack = _edit_pack_dir(p, want_dir, sizeof want_dir, NULL);
if(edit_pack != SF_EDIT_PACK_OK)
dt_print(DT_DEBUG_DEV, "[spektrafilm] edit's own pack is not installed (%d); using %s\n",
(int)edit_pack, want_dir);
const guint gen = fetch_gen;
dt_pthread_mutex_lock(&_pack_lock);
/* The loaded pack and the error from failing to load both belong to one
directory at one point in time, so both go stale on either axis: the
resolved directory changing, or a download changing what that directory
holds. Both axes are tested, and the test runs whether or not a pack is
held -- otherwise _pack_error would latch after the first failure and a
pack installed mid-session would never be picked up. */
if(strcmp(_pack_path, want_dir) != 0 || _pack_gen != gen)
{
if(_pack) sf_pack_free(_pack);
_pack = NULL;
_pack_error[0] = 0;
_pack_path[0] = 0;
}
if(!_pack && !_pack_error[0])
{
char *err = NULL;
sf_pack_status_t status = SF_PACK_ERR_UNREADABLE;
_pack = sf_pack_load(want_dir, &err, &status);
/* Record the attempt on failure too: _pack_path is what the staleness
check above compares against, and leaving it empty after a failed load
would make every later call look stale and retry the same doomed load
once per pipe run. */
g_strlcpy(_pack_path, want_dir, sizeof _pack_path);
_pack_gen = gen;
if(!_pack)
{
/* The engine's own text names files and internal functions, which helps
in a log and not at all in the module header. Say what happened and
what to do about it there, and keep the detail for -d dev. */
const char *msg =
status == SF_PACK_ERR_TOO_NEW
? _("this data pack was made for a newer version of darktable\n"
"update darktable to use it")
: status == SF_PACK_ERR_TOO_OLD
? _("this data pack is too old for this version of darktable\n"
"use the download button to fetch a current one")
: _("the data pack could not be read\n"
"it may have downloaded incompletely -- use the download button"
" to fetch it again");
g_strlcpy(_pack_error, msg, sizeof _pack_error);
dt_print(DT_DEBUG_DEV, "[spektrafilm] %s\n", err ? err : "unknown");
free(err);
}
else
dt_print(DT_DEBUG_DEV, "[spektrafilm] loaded data pack %s (spektrafilm %s)\n",
want_dir, sf_pack_version(_pack));
}
/* Take a reference for the rest of this call. _pack is process-global and
another thread entering here -- a second pixelpipe, or the colour picker
from the GUI thread -- frees it whenever it resolves a different pack
directory or sees a newer fetch generation, both of which are ordinary
configurations rather than corner cases. The lock excludes only threads
inside the critical section, not one that has already left holding the
pointer, and the build below reads the spectra, the film defaults and the
illuminants for its whole multi-hundred-millisecond duration. */
sf_pack_t *pack = sf_pack_ref(_pack);
char pack_dir[SF_PATH_LEN];
char pack_error[sizeof _pack_error];
g_strlcpy(pack_dir, _pack_path, sizeof pack_dir);
g_strlcpy(pack_error, _pack_error, sizeof pack_error);
dt_pthread_mutex_unlock(&_pack_lock);
if(!pack)
{
/* Without this the module renders nothing behind an empty trouble banner
and the reason only ever reaches the terminal. */
g_strlcpy(d->sim_error,
pack_error[0] ? pack_error
: _("no data pack installed\n"
"use the download button in the module to fetch one"),
sizeof d->sim_error);
_publish_status(d);
dt_pthread_mutex_unlock(&d->lock);
return NULL;
}
/* From here on a failure belongs to this key: the pack is loaded, so what
can still go wrong is a profile or the build, and neither changes until a
parameter or the installed data does -- both of which change the key. A
missing pack above is left retrying on purpose: one copied in by hand
bumps no generation, and that path returns before anything expensive. */
d->sim_tried = TRUE;
/* resolve stocks */
GList *entries = _scan_profiles(pack_dir);
char film_stock[SF_NAME_LEN] = { 0 }, paper_stock[SF_NAME_LEN] = { 0 };
if(!_resolve_stock(entries, p->film_hash, FALSE, "kodak_gold_200", -1, film_stock,
sizeof film_stock))
{
g_strlcpy(d->sim_error,
_("the installed data pack contains no film stocks\n"
"it may have downloaded incompletely -- try fetching it again"),
sizeof d->sim_error);
dt_print(DT_DEBUG_DEV, "[spektrafilm] no filming profiles under %s/profiles\n",
pack_dir);
g_list_free_full(entries, g_free);
sf_pack_free(pack);
_publish_status(d);
dt_pthread_mutex_unlock(&d->lock);
return NULL;
}
const char *target_print = NULL;
int film_bw = -1;
for(GList *l = entries; l; l = l->next)
{
const sf_prof_entry_t *e = l->data;
if(!e->printing && !strcmp(e->stock, film_stock))
{
target_print = e->target_print;
film_bw = e->bw ? 1 : 0;
}
}
if(!p->scan_film
&& !_resolve_stock(entries, p->paper_hash, TRUE, target_print, film_bw,
paper_stock, sizeof paper_stock))
{
g_strlcpy(d->sim_error,
_("the installed data pack contains no print papers\n"
"it may have downloaded incompletely -- try fetching it again"),
sizeof d->sim_error);
dt_print(DT_DEBUG_DEV, "[spektrafilm] no printing profiles under %s/profiles\n",
pack_dir);
g_list_free_full(entries, g_free);
sf_pack_free(pack);
_publish_status(d);
dt_pthread_mutex_unlock(&d->lock);
return NULL;
}
g_list_free_full(entries, g_free); /* stocks resolved; the list is done */
/* Load from the pack that was actually resolved, NOT from the config
directory. _scan_profiles() above lists the resolved pack's profiles, so
using a different directory here means the stock names resolve against one
pack and the files are read from another -- with a downloaded pack and
nothing hand-installed, every load simply misses and the module goes quiet
for want of a film. */
char path[SF_PATH_LEN + 300];
char *err = NULL;
snprintf(path, sizeof path, "%s/profiles/%s.json", pack_dir, film_stock);
sf_profile_t *film = sf_profile_load(path, p->development_min, &err);
if(!film)
dt_print(DT_DEBUG_DEV, "[spektrafilm] cannot load film profile %s: %s\n", path,
err ? err : "unknown");
sf_profile_t *paper = NULL;
if(film && !p->scan_film)
{
snprintf(path, sizeof path, "%s/profiles/%s.json", pack_dir, paper_stock);
paper = sf_profile_load(path, p->print_development_min, &err);
if(!paper)
dt_print(DT_DEBUG_DEV, "[spektrafilm] cannot load print profile %s: %s\n", path,
err ? err : "unknown");
}
/* Report a profile that will not load from here. The build below only runs
with a film in hand, so a load failure never reaches its own error
reporting and would otherwise render as a silently disabled module. */
if(!film || (!paper && !p->scan_film))
g_strlcpy(d->sim_error, err ? err : _("this film or paper could not be loaded"),
sizeof d->sim_error);
if(film && (paper || p->scan_film))
{
sf_sim_params_t sp;
sf_sim_params_defaults(&sp);
sp.exposure_comp_ev = p->exposure_ev - p->push_pull_stops;
sp.print_exposure = powf(2.0f, p->print_exposure_ev);
sp.print_exposure_compensation = p->print_auto_exposure; /* normalize_print_exposure
stays at sf_sim_params_defaults' true — that combination
is what gives f_mid (a fixed reference midgray density)
when this toggle is off, i.e. film exposure then has its
full, uncompensated effect on brightness; see
sf_sim_build's midgray_factor branches */
sp.m_filter_shift = p->filter_m;
sp.y_filter_shift = p->filter_y;
sp.couplers_active = (p->couplers_amount > 0.0f);
sp.couplers_amount = p->couplers_amount;
sp.grain_rms_scale = p->grain_granularity;
sp.grain_uniformity_scale = p->grain_uniformity;
sp.grain_particle_scale = p->grain_sublayer_scale;
sp.grain_density_min_scale = p->grain_density_min;
sp.coupler_diffusion_um = p->couplers_diffusion_um;
sp.coupler_tail_um = p->couplers_tail_um;
sp.coupler_tail_weight = p->couplers_tail_weight;
sp.inhibition_samelayer = p->couplers_inhibition_same;
sp.inhibition_interlayer = p->couplers_inhibition_inter;
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.spectral_lut_hash = p->upsampling_hash;
sp.scan_black_correction = p->scan_black_correction;
sp.scan_white_correction = p->scan_white_correction;
sp.scan_black_level = p->scan_black_level;
sp.scan_white_level = p->scan_white_level;
sp.adaptation_bandwidth = p->adaptation_bandwidth;
sp.adaptation_surface = p->adaptation_surface;
sp.lut_steps = SF_LUT_STEPS;
sp.out_luminance_boost = p->output_luminance_boost;
if(!p->gamut_compress) sp.output_compress = SF_OUTPUT_COMPRESS_OFF;
sp.out_scale = p->output_scale;
if(p->print_contrast != 1.0f || p->print_gamma_r != 1.0f
|| p->print_gamma_g != 1.0f || p->print_gamma_b != 1.0f)
{
sp.morph_active = true;
sp.morph_gamma = p->print_contrast;
sp.morph_gamma_r = p->print_gamma_r;
sp.morph_gamma_g = p->print_gamma_g;
sp.morph_gamma_b = p->print_gamma_b;
}
if(p->film_gamma_factor != 1.0f || p->film_gamma_factor_fast != 1.0f
|| p->film_gamma_factor_slow != 1.0f || p->film_developer_exhaustion != 0.0f
|| p->push_pull_stops != 0.0f)
{
sp.film_morph_active = true;
sp.film_morph_gamma = p->film_gamma_factor
* powf(SF_PUSH_PULL_GAMMA_PER_STOP, p->push_pull_stops);
sp.film_morph_gamma_fast = p->film_gamma_factor_fast;
sp.film_morph_gamma_slow = p->film_gamma_factor_slow;
sp.film_morph_developer_exhaustion = p->film_developer_exhaustion;
}
/* darktable pipeline XYZ is D50-relative; the work profile matrices map
work RGB <-> that XYZ, so both engine whites are D50 */
static const double d50_xy[2] = { 0.3457, 0.3585 };
for(int i = 0; i < 9; i++)
{
sp.input_rgb_to_xyz[i] = m_in[i];
sp.output_rgb_to_xyz[i] = m_in[i];
sp.output_xyz_to_rgb[i] = m_out[i];
}
sp.input_white_xy[0] = sp.output_white_xy[0] = d50_xy[0];
sp.input_white_xy[1] = sp.output_white_xy[1] = d50_xy[1];
/* Compare what this edit was developed against with what is installed. Only
when the edit actually recorded one -- a 0 means the field predates the
edit, not that anything is wrong. */
/* a substitute pack need not carry the table the edit chose; render on its
default rather than not at all, and say so below */
if(sp.spectral_lut_hash && sf_pack_table_by_hash(pack, sp.spectral_lut_hash) < 0)
{
snprintf(d->sim_warning, sizeof d->sim_warning,
_("developed with a spectral table this data pack does not carry\n"
"recorded: %08x rendering with: %s\n"
"the matching pack can be fetched with the button below"),
sp.spectral_lut_hash, sf_pack_lut_id(pack));
sp.spectral_lut_hash = 0u;
}
else if(edit_pack == SF_EDIT_PACK_LEGACY_GONE)
g_strlcpy(d->sim_warning,
_("made before data packs carried an identity, and the pack it\n"
"was made with is not installed: rendering with a later one\n"
"the matching pack can be fetched with the button below"),
sizeof d->sim_warning);
else if(edit_pack == SF_EDIT_PACK_SUBSTITUTE && p->pack_hash)
snprintf(d->sim_warning, sizeof d->sim_warning,
_("developed with data pack %08x, which is not installed\n"
"rendering with %s\n"
"the matching pack can be fetched with the button below"),
p->pack_hash, sf_pack_version(pack));
const uint32_t pack_lut = sf_pack_lut_hash(pack);
if(!d->sim_warning[0] && p->lut_hash && pack_lut && p->lut_hash != pack_lut)
/* Print the hash as well as the name. The name carries the spektrafilm
version string, and that is not a reliable identifier: an editable dev
install reports whatever pyproject.toml says, so two materially
different checkouts can both call themselves the same thing. Without
the hash the message reads as nonsense when they do. */
snprintf(d->sim_warning, sizeof d->sim_warning,
_("developed with a different spectral table\n"
"recorded: %08x installed: %s (%08x)\n"
"the matching pack can be fetched with the button below"),
p->lut_hash, sf_pack_lut_id(pack), pack_lut);
d->sim = sf_sim_build(pack, film, paper, &sp, &err);
if(!d->sim && err)
{
g_strlcpy(d->sim_error, err, sizeof d->sim_error);
dt_print(DT_DEBUG_DEV, "[spektrafilm] %s\n", err);
}
else if(d->sim)
{
/* float tables for the GPU path */
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_pack_free(pack); /* the reference taken above; the sim keeps no pointer into it */
sf_sim_t *s = d->sim;
/* Hand the outcome to the GUI while it is fresh. The banner is set from
gui_update(), which has no piece to read this out of, and the strings are
only ever written here -- so copying them out at the one point they settle
is both the cheapest and the only reliable moment. Guarded on the module
having a GUI at all, since export pipes run with none. */
_publish_status(d);
dt_pthread_mutex_unlock(&d->lock);
return s;
}
/* ---------------------------------------------------------------------- */
/* ROI / tiling: expand the input by the spatial-effect halo */
/* ---------------------------------------------------------------------- */
/* Widest Gaussian sigma (micrometres on film) one diffusion-filter stage will
dispatch, or 0 when the stage is off / a no-op. Built from the same
sf_diffusion_build_plan() the CPU and GPU paths run, so the ROI padding can
never drift from the bank that is actually convolved. */
static float _diffusion_pad_sigma_um(const gboolean on,
const int family,
const float strength,
const float warmth,
const float scale)
{
if(!on) return 0.0f;
sf_diffusion_plan_t plan;
if(!sf_diffusion_build_plan(family, strength, warmth, &plan) || plan.p_s <= 0.0f) return 0.0f;
float smax = 0.0f;
for(int j = 0; j < plan.n; j++) smax = fmaxf(smax, plan.sigma_um[j]);
return smax * fmaxf(scale, 1e-3f);
}
static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p,
float pixel_um)
{
const float inv_um = 1.0f / fmaxf(pixel_um, 1e-3f);
/* halation stage: first-bounce radius, scaled by halation_scale. The scale
has to be in the padding or a value above 1.0 under-pads. */
const float hal_scale = fmaxf(p->halation_scale, 1e-3f);
const float hal = (p->halation_on && p->halation_amount > 0.0f)
? SF_HALATION_FIRST_SIGMA_UM * SF_HALATION_PSF_SIGMAS * hal_scale * inv_um
: 0.0f;
/* scatter stage: widest core+tail component, scaled by its own
scatter_scale (independent from halation_scale since the scatter_amount/
scatter_scale split). */
const float scat_scale = fmaxf(p->scatter_scale, 1e-3f);
const float scat = (p->halation_on && p->scatter_amount > 0.0f)
/* SF_SCATTER_TAIL_MAX_UM is already the widest tail
component's sigma, so it needs no further factor.
SF_HALATION_PSF_SIGMAS is sqrt(n_bounces) and
belongs to the halation term above only; applying
it here as well would pad scatter 1.73x wider than
anything it dispatches. */
? SF_SCATTER_TAIL_MAX_UM * scat_scale * inv_um
: 0.0f;
/* The widest of film-stage and print-stage diffusion determines the ROI
padding — both must fit in the expanded tile. Take the widest component of
the actual Gaussian bank each stage will dispatch: the bloom scale differs by 2.6x across the
four families (BPM
380*2.5 um vs cinebloom 1000*2.5 um), so one constant either under-pads the
wide families or over-pads the narrow ones. */
const float diff = fmaxf(_diffusion_pad_sigma_um(p->diffusion_on,
(int)p->diffusion_filter_family,
p->diffusion_strength, p->diffusion_warmth,
p->diffusion_scale),
_diffusion_pad_sigma_um(p->print_diffusion_on,
(int)p->print_diffusion_filter_family,
p->print_diffusion_strength,
p->print_diffusion_warmth,
p->print_diffusion_scale))
* inv_um;
/* grain stage: the clump blur and the acutance recovery are dispatched in
pixels rather than micrometres, so neither goes through inv_um.
The clump blur is a fixed pixel radius by design (see its dispatch in
process()), scaled down by preview_scale, which never exceeds 1, so the
unscaled radius is the bound. It must not be converted through pixel_um:
that would scale the pad by SF_GRAIN_REF_UM/pixel_um, a factor below 1
whenever the full-resolution long edge is under
film_format_mm*1000/SF_GRAIN_REF_UM pixels -- 3600 px at the 36 mm
default. The acutance recovery that follows it is bounded the same way.
The per-sub-layer dye-cloud blur inside the sampler stays uncovered. Its
sigma comes from a sub-layer's per-particle optical density, which only
exists once the simulation is built, and the simulation is not available
at ROI time. Bounding it with a constant would mean either capping a blur
the reference does not cap -- changing rendered grain on every stock -- or
inventing a ceiling with no data behind it, so it goes unpadded. Covering
it would need the sigma plumbed out of the simulation and made available
before the simulation is built. */
const float grain = (p->grain_on && p->grain_amount > 0.0f)
? fmaxf(p->grain_blur_base
* fmaxf(p->grain_blur, SF_GRAIN_BLUR_MIN),
(p->grain_usm_amount > 0.0f) ? p->grain_usm_sigma : 0.0f)
: 0.0f;
/* coupler halo: gaussian core plus the widest exponential-tail component.
Both are persisted absolutes and are handed to the simulation unchanged,
so they are read here rather than assumed: the core reaches 60 um and the
tail 400 um, and any fixed pair under-pads the upper half of both ranges.
The tail term is gated on its weight, the same condition process() applies
before dispatching it. */
const float ctail = (p->couplers_tail_weight > 0.0f)
? (float)SF_EXPTAIL_R2 * p->couplers_tail_um
: 0.0f;
const float coupler = (p->couplers_amount > 0.0f)
? fmaxf(p->couplers_diffusion_um, ctail) * inv_um
: 0.0f;
/* scanner stage: already in pixels, so it does not go through inv_um */
const float scan = fmaxf(p->scan_blur,
(p->scan_usm_amount > 0.0f) ? p->scan_usm_sigma : 0.0f);
return fmaxf(fmaxf(fmaxf(hal, scat), fmaxf(diff, scan)), fmaxf(grain, coupler));
}
void modify_roi_in(dt_iop_module_t *self,
dt_dev_pixelpipe_iop_t *piece,
const dt_iop_roi_t *roi_out,
dt_iop_roi_t *roi_in)
{
*roi_in = *roi_out;
const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data;
if(!d) return;
/* film_format_mm is the format's long-edge dimension; buf_in.width alone
is the SHORT edge for portrait-oriented images (post-orientation), so
using it directly here would under-scale pixel_um (and every halo/grain/
halation size derived from it) by the aspect ratio for portrait shots. */
const float full_long_edge
= fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_out->scale, 1.0f);
const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
const int halo = (int)ceilf(SF_HALO_SIGMAS * _max_halo_sigma(&d->p, pixel_um));
if(halo <= 0) return;
const int img_w = (int)roundf((float)piece->buf_in.width * roi_out->scale);
const int img_h = (int)roundf((float)piece->buf_in.height * roi_out->scale);
int x0 = roi_out->x - halo, y0 = roi_out->y - halo;
int x1 = roi_out->x + roi_out->width + halo, y1 = roi_out->y + roi_out->height + halo;
if(x0 < 0) x0 = 0;
if(y0 < 0) y0 = 0;
if(img_w > 0 && x1 > img_w) x1 = img_w;
if(img_h > 0 && y1 > img_h) y1 = img_h;
roi_in->x = x0;
roi_in->y = y0;
roi_in->width = x1 - x0;
roi_in->height = y1 - y0;
}
void tiling_callback(dt_iop_module_t *self,
dt_dev_pixelpipe_iop_t *piece,
const dt_iop_roi_t *roi_in,
const dt_iop_roi_t *roi_out,
dt_develop_tiling_t *tiling)
{
const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data;
/* Guarded as in modify_roi_in(). With no piece data there are no parameters
to size the halo from, so report a plain two-buffer, no-overlap stage:
the tiler still gets a usable plan, and the module passes pixels through
in that state anyway. */
if(!d)
{
tiling->factor = 2.0f;
tiling->factor_cl = 2.0f;
tiling->maxbuf = 1.0f;
tiling->maxbuf_cl = 1.0f;
tiling->overhead = 0;
tiling->overlap = 0;
tiling->align = 1;
return;
}
/* see modify_roi_in: film_format_mm is the long-edge dimension */
const float full_long_edge
= fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f);
const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
/* Peak simultaneous buffers, in multiples of one RGBA tile.
CPU: input + output (2.0) plus plane at 3 channels and the 1-channel
blur scratch (1.0), a 3.0 baseline. Halation and the diffusion filter
each add two 1-channel and two 3-channel buffers (2.0), for 5.0. From
development on, corr at 3 channels is live too (3.75); the coupler tail
adds 1.5 and grain up to 2.0 at the maximum sub-layer count. The stages
run one after another, so grain sets the peak at 5.75.
OpenCL: input + output plus five float4 buffers (plane, plane2, tmpa, acc,
gtmp4) and two single-channel ones (plane1, gtmp1), a 7.5 baseline; the
grain stage adds an accumulator and one buffer per sub-layer, up to 2.25.
Understating these lets the tiler pick tiles that then fail to allocate,
and the CPU path answers an allocation failure with a passthrough of that
tile -- unprocessed rectangles inside an otherwise processed frame, not a
clean skip -- so the estimate has to be an upper bound. */
tiling->factor = 5.75f;
tiling->factor_cl = 9.75f;
tiling->maxbuf = 1.0f;
tiling->maxbuf_cl = 1.0f;
/* Constant tables uploaded once per run and independent of tile size: the
two PCHIP table sets dominate, and they scale with SF_LUT_STEPS. */
const int steps = SF_LUT_STEPS;
size_t overhead = (size_t)192 * 192 * 3 * sizeof(float); /* spectral upsampling table */
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 set = (4 * n3 + 2 * m3) * sizeof(float);
overhead += d->p.scan_film ? set : 2 * set;
tiling->overhead = overhead;
tiling->overlap = (unsigned)ceilf(SF_HALO_SIGMAS * _max_halo_sigma(&d->p, pixel_um));
tiling->align = 1;
}
/* ---------------------------------------------------------------------- */
/* process */
/* ---------------------------------------------------------------------- */
/* base-10 conversions matching sf_pow10f / sf_log10f in spektrafilm.cl */
static inline float _sf_pow10f(const float x)
{
return grain_exp2f(x * 3.321928094887362f);
}
static inline float _sf_log10f(const float x)
{
return grain_log2f(x) * 0.3010299956639812f;
}
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];
}
}
/* Per-image salt for the grain and glare seeds, derived from the EXIF
capture date and time: duplicates of an image share the same pattern, and
it stays the same across re-imports and machines, while images taken at
different times get different patterns. */
static uint32_t _grain_salt(const dt_dev_pixelpipe_iop_t *piece)
{
const uint64_t t = (uint64_t)piece->pipe->image.exif_datetime_taken;
return grain_hash(grain_hash((uint32_t)t) ^ (uint32_t)(t >> 32));
}
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 = (d && work_profile) ? _ensure_sim(d, work_profile) : NULL;
if(!sim)
{
_passthrough(in, out, w, oh, ow, ox, oy);
return;
}
/* physical micrometres per pixel at this pipe resolution; film_format_mm
is the long-edge dimension, see modify_roi_in */
const float full_long_edge
= fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f);
const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
/* Fixed pixel radii (grain clumps, the two unsharp masks, the glare veil) were
validated at export resolution; darktable's preview pipe renders the same
image smaller, where the same nominal radius covers much more real scene
detail. Shrink them there, but never grow them past 1:1. */
const float preview_scale = fminf(roi_in->scale, 1.0f);
float *plane = dt_alloc_align_float(npix * 3); /* raw / lograw / cmy, in place */
float *scratch = dt_alloc_align_float(npix); /* 1ch blur scratch */
if(!plane || !scratch)
{
dt_free_align(plane);
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);
/* negative exposure is not light: the pre-film blurs below would spread it
as a dark halo over its neighbours, so a single outlier from upstream
becomes a black disc the size of the diffusion or halation kernel.
Development floors exposure at 0 per pixel anyway (sf_sim_lograw), so
pixels no blur reaches are unchanged. Twin of the clamp in
spektrafilm_expose. */
DT_OMP_FOR()
for(size_t k = 0; k < npix * 3; k++) plane[k] = fmaxf(plane[k], 0.0f);
/* 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, roi_in->x, roi_in->y, (double)pixel_um,
(int)d->p.diffusion_filter_family,
d->p.diffusion_strength, d->p.diffusion_scale, d->p.diffusion_warmth);
if(d->p.halation_on && (d->p.scatter_amount > 0.0f || d->p.halation_amount > 0.0f))
{
double hal_strength[3], hal_sigma_um;
sf_sim_halation_params(sim, hal_strength, &hal_sigma_um);
/* modify_roi_in()/tiling_callback() already padded for at most
SF_HALATION_FIRST_SIGMA_UM (see _max_halo_sigma); clamp so a future
pack entry larger than that can't under-pad the halo. */
hal_sigma_um = fmin(hal_sigma_um, (double)SF_HALATION_FIRST_SIGMA_UM);
/* per-film scatter PSF; clamped so a pack cannot outrun the ROI padding */
double sc_core[3], sc_tail[3], sc_w[3];
sf_sim_scatter_params(sim, sc_core, sc_tail, sc_w);
for(int c = 0; c < 3; c++)
{
sc_core[c] = fmin(sc_core[c], (double)SF_SCATTER_CORE_CLAMP_UM);
sc_tail[c] = fmin(sc_tail[c], (double)SF_SCATTER_TAIL_CLAMP_UM);
}
sf_halation(plane, w, h, roi_in->x, roi_in->y, (double)pixel_um, sc_core, sc_tail, sc_w,
d->p.scatter_amount, d->p.scatter_scale, d->p.halation_amount,
d->p.halation_scale, hal_strength, hal_sigma_um);
}
/* DIR coupler correction field, later the grain delta and unsharp-mask
buffer; first needed in development, so it is not live during the
pre-film spatial effects above */
float *corr = dt_alloc_align_float(npix * 3);
if(!corr)
{
dt_free_align(plane);
dt_free_align(scratch);
_passthrough(in, out, w, oh, ow, ox, oy);
return;
}
/* 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(sim, 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_wide(tmp, w, h, roi_in->x, roi_in->y, 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_wide(tmp, w, h, roi_in->x, roi_in->y, ts, scratch);
const float wk = (float)ctail_w * amp[g3];
/* fma-pinned, matching spektrafilm_diffusion_accum */
for(size_t i = 0; i < npix * 3; i++) mix[i] = fmaf(wk, tmp[i], mix[i]);
}
dt_free_align(corr);
corr = mix;
mix = NULL;
}
else if(csigma > 0.1f)
sf_blur_plane3_wide(corr, w, h, roi_in->x, roi_in->y, csigma, scratch); /* alloc failed: core only */
dt_free_align(mix);
dt_free_align(tmp);
}
else if(csigma > 0.1f)
sf_blur_plane3_wide(corr, w, h, roi_in->x, roi_in->y, csigma, scratch);
}
sf_sim_develop(sim, plane, couplers ? corr : NULL, plane, npix, 3, 3);
/* 4) grain on the developed CMY film density: sample a grained density,
take its difference from the clean one, scale by strength, add it
back, then clump-blur the combined field and recover acutance with
the multiplicative unsharp mask (upstream's blur/usm pair) */
if(d->p.grain_on && d->p.grain_amount > 0.0f)
{
float *gbuf = corr; /* corr is free now — reuse as the grain delta buffer */
const int roi_x = roi_in->x, roi_y = roi_in->y;
const uint32_t salt = _grain_salt(piece);
const float amount = d->p.grain_amount;
const int mono = sf_sim_film_bw(sim); /* B&W: achromatic grain */
/* sf_grain_delta_ml's layer_npart is precomputed at sf_sim_build time
against the fixed SF_GRAIN_REF_UM reference scale (it depends on
curve/coupler state baked in at build time, not just resolution);
rescale it live to the real pixel_um here. */
const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM);
/* grain_blur_base/SF_GRAIN_DYE_BLUR_UM/grain_usm_sigma are fixed
pixel radii, validated against upstream at whatever single
resolution each of its own renders happens to use -- upstream has
no notion of "the same image, but at a temporarily reduced preview
resolution for interactive editing speed" the way darktable's
preview pipe does. Without this, the SAME nominal pixel radius
covers a much larger fraction of real scene detail in a
downscaled preview than at full/export resolution (a real image
edge that spans ~30px at full res might span ~4px in a heavily
zoomed-out preview), producing visible over-sharpening ringing on
actual scene content, not just grain texture, that isn't present
at 1:1/export. Capped at 1.0 so zooming in PAST 100% doesn't grow
the radii beyond what was actually validated. Particle density
(npart_scale above) is NOT touched by this -- it's correctly
resolution-dependent via pixel_um already, confirmed against the
reference at multiple different resolutions. */
float grms[3], gunif[3], gdmin[3];
/* gdmin here is what the SAMPLER adds -- the sum of the per-sub-layer
floors, not the film's single density_min. The two coincide for a
single-layer stock and differ for a multi-sub-layer one, where
density_min would leave the delta with a constant positive mean. */
sf_sim_grain_dmin_total(sim, gdmin);
float gdmin_unused[3];
sf_sim_film_grain3(sim, grms, gunif, gdmin_unused); /* per-film catalogue grain
(rms-granularity, uniformity, density
floor), read per stock */
sf_grain_layers_t layers;
sf_sim_grain_layers(sim, &layers); /* n==1 for a single-layer curve fit
is already valid data (see the
function's own comment): every stock
goes through this one mechanism. */
const int nsub = layers.n;
/* Upstream's per-sub-layer dye-cloud blur (layer_particle_model's
blur_particle, grain.py) runs INSIDE the particle sampler, on each
sub-layer's raw draw independently, before the main clump blur
below ever sees it. Its sigma depends only on that sub-layer's own
per-particle optical density (dmax/npart) -- constant across the
whole image for a given (channel, sub-layer), so compute it once here. */
float dye_sigma[3][SF_GRAIN_MAX_SUBLAYERS];
for(int c = 0; c < 3; c++)
for(int sl = 0; sl < nsub; sl++)
{
const float npart_c = (float)layers.layer_npart[sl][c] * npart_scale;
const float od_particle = (float)layers.layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f);
dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * d->p.grain_dye_cloud
* sqrtf(fmaxf(od_particle, 0.0f)) * preview_scale;
}
float *raw[SF_GRAIN_MAX_SUBLAYERS] = { 0 };
gboolean raw_ok = TRUE;
for(int sl = 0; sl < nsub; sl++)
{
raw[sl] = dt_alloc_align_float(npix);
if(!raw[sl]) raw_ok = FALSE;
}
if(!raw_ok)
memset(gbuf, 0, npix * 3 * sizeof(float)); /* allocation failed: skip grain gracefully */
else
{
const int n_out_ch = mono ? 1 : 3;
for(int oc = 0; oc < n_out_ch; oc++)
{
const int channel_idx = mono ? 1 : oc; /* mono uses channel 1's curve/params for the
achromatic draw, matching sf_grain_delta_ml */
const int seed_ch = mono ? 0 : oc; /* mono seeds as sl*10 (no channel term), matching
sf_grain_delta_ml's own convention exactly */
const float unif_ch = gunif[channel_idx];
#ifdef _OPENMP
#pragma omp parallel for default(none) \
shared(plane, raw, layers) firstprivate(w, npix, roi_x, roi_y, mono, channel_idx, seed_ch, \
unif_ch, npart_scale, nsub, salt) schedule(static) num_threads(darktable.num_openmp_threads)
#endif
for(size_t k = 0; k < npix; k++)
{
const int x = (int)(k % (size_t)w), y = (int)(k / (size_t)w);
const float density = mono ? (plane[k * 3 + 0] + plane[k * 3 + 1] + plane[k * 3 + 2])
/ 3.0f
: plane[k * 3 + channel_idx];
float samp[SF_GRAIN_MAX_SUBLAYERS];
sf_grain_raw_samples_ml(&layers, density, channel_idx, seed_ch, (uint32_t)(x + roi_x),
(uint32_t)(y + roi_y), unif_ch, npart_scale, salt, samp);
for(int sl = 0; sl < nsub; sl++) raw[sl][k] = samp[sl];
}
/* dye-cloud blur: each sub-layer independently, no variance
restoration (matching upstream: layer_particle_model doesn't
renormalize after its blur_particle pass either). */
for(int sl = 0; sl < nsub; sl++)
sf_blur_plane1(raw[sl], w, h, dye_sigma[channel_idx][sl], NULL, scratch);
/* combine: sum sub-layers, subtract the density floor and the
original clean density, scale by strength. */
#ifdef _OPENMP
#pragma omp parallel for default(none) shared(plane, gbuf, raw) \
firstprivate(npix, nsub, channel_idx, mono, oc, amount, gdmin) schedule(static) num_threads(darktable.num_openmp_threads)
#endif
for(size_t k = 0; k < npix; k++)
{
float total = 0.0f;
for(int sl = 0; sl < nsub; sl++) total += raw[sl][k];
const float g = total - gdmin[channel_idx];
const float density = mono ? (plane[k * 3 + 0] + plane[k * 3 + 1] + plane[k * 3 + 2])
/ 3.0f
: plane[k * 3 + channel_idx];
const float delta = (g - density) * amount;
if(mono) gbuf[k * 3 + 0] = gbuf[k * 3 + 1] = gbuf[k * 3 + 2] = delta;
else gbuf[k * 3 + oc] = delta;
}
}
}
for(int sl = 0; sl < nsub; sl++)
if(raw[sl]) dt_free_align(raw[sl]);
/* No DC-centring pass, because the delta is zero-mean by construction: the
sampler draws an unbiased Poisson (spektra_core.h) and the combine above
takes back exactly the floors the sampler added -- see
sf_sim_grain_dmin_total(). The subtracted term must be that sum;
grain_density_min in its place leaves a constant +(sum - density_min)
per unit strength. */
/* Add the still-UNBLURRED delta onto the clean density first, so the clump
blur below runs on the grained ABSOLUTE density -- image detail and grain
together -- which is what upstream blurs (_finalize_grain in grain.py
smooths density_cmy_out itself, not a separate grain layer).
Blurring only the delta and adding it to an untouched clean signal leaves
real image detail at full sharpness, and the multiplicative unsharp mask
further down then sharpens it anyway. That mask exists solely to recover
the acutance this blur takes away: the two are a tuned pair
(params_schema.py annotates the blur "optimized to go with the mult usm
below" and the usm "optimized to go with the blur above"). Running the
recovery half without the loss half is over-sharpening by construction,
and reads as crunchy, over-defined edges on fine high-contrast texture.
Softening genuine detail here is intended, not a side effect -- it is
what the emulsion does, and what upstream's own output shows. */
#ifdef _OPENMP
#pragma omp parallel for default(none) shared(plane, gbuf) firstprivate(npix) \
schedule(static) num_threads(darktable.num_openmp_threads)
#endif
for(size_t k = 0; k < npix * 3; k++) plane[k] += gbuf[k];
/* Upstream's grain blur (GrainParams.blur, params_schema.py) is a
literal FIXED pixel sigma (0.8), independent of pixel_um/resolution/
film_format_mm -- confirmed empirically: measuring the real
reference's noise autocorrelation at two different resolutions
(87.5 and 35 um/px) gave near-identical radial profiles. Physical
scaling lives entirely in particle DENSITY, which is pixel_um-driven
above, and not in this smoothing pass. preview_scale is a SEPARATE,
darktable-only correction (see its own comment above): upstream renders
one real resolution, but darktable's preview pipe renders the same
image at a temporarily reduced resolution for interactive speed,
so this fixed radius needs shrinking there or it over-affects real
scene detail relative to what 1:1/export shows. */
const float sigma = d->p.grain_blur_base * fmaxf(d->p.grain_blur, SF_GRAIN_BLUR_MIN)
* preview_scale;
/* No variance-restoration renorm here -- upstream's own grain
finalization (_finalize_grain in grain.py) has none either; it just
blurs and lets the natural contrast reduction stand, matching real
optical clumping. Restoring full pre-blur variance would make grain
higher-contrast, and therefore visually coarser, than upstream at any
matching sigma. */
sf_blur_plane3(plane, w, h, sigma, scratch);
/* Acutance recovery for the blur above, and only meaningful because of it:
these two are tuned together (defaults sigma 0.7 / amount 1.5). */
if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f)
/* The reference's multiplicative USM (_finalize_grain in grain.py)
runs on the ABSOLUTE density -- the grain sampler's floor is still
present and is only removed afterwards (add_micro_structure -> blur
-> USM -> -= density_min). The delta combine above already took the
floor back out, so pass it back in here: without it the D/blur(D)
ratio is ill-conditioned in the deepest shadows and the USM
amplifies shadow noise the reference never does. */
sf_multiplicative_unsharp_mask3(plane, w, h, d->p.grain_usm_sigma * preview_scale,
d->p.grain_usm_amount, gdmin, corr, scratch);
}
/* 5) print exposure + development (skipped in scan-film mode) */
if(!d->p.scan_film)
{
sf_sim_print_expose(sim, plane, plane, npix, 3, 3);
sf_diffusion_plan_t pplan;
if(d->p.print_diffusion_on
&& sf_diffusion_build_plan((int)d->p.print_diffusion_filter_family,
d->p.print_diffusion_strength,
d->p.print_diffusion_warmth, &pplan)
&& pplan.p_s > 0.0f)
{
/* the filter scatters light, so it runs on linear print exposure, as the
reference's printing.expose does, and the result goes back to log10
with the reference's 1e-10 floor. Same conversions as the
spektrafilm_pow10 / spektrafilm_log10 kernels. */
DT_OMP_FOR()
for(size_t k = 0; k < npix * 3; k++) plane[k] = _sf_pow10f(plane[k]);
sf_diffusion_filter(plane, w, h, roi_in->x, roi_in->y, (double)pixel_um,
(int)d->p.print_diffusion_filter_family,
d->p.print_diffusion_strength, d->p.print_diffusion_scale,
d->p.print_diffusion_warmth);
DT_OMP_FOR()
for(size_t k = 0; k < npix * 3; k++) plane[k] = _sf_log10f(fmaxf(plane[k], 0.0f) + 1e-10f);
}
sf_sim_print_develop(sim, plane, plane, npix, 3, 3);
}
/* 6) scanning: viewing light through the print/film -> XYZ, plus the viewing
glare veil in XYZ ([gl] add_glare, skipped when the film itself is
scanned), -> work RGB with OkLCh gamut compression. `scratch` carries
the glare field into the scan and is free again afterwards. */
const gboolean glare = !d->p.scan_film && d->p.glare_percent > 0.0f;
if(glare)
sf_glare_field(scratch, w, h, d->p.glare_percent, SF_GLARE_ROUGHNESS,
SF_GLARE_BLUR_PX * preview_scale, roi_in->x, roi_in->y,
_grain_salt(piece));
sf_sim_scan(sim, plane, glare ? scratch : NULL, plane, npix, 3, 3);
/* 6b) scanner optics on the scanned RGB over the full padded ROI so the crop
below never sees an edge artifact ([sc] ScannerParams lens_blur /
unsharp_mask). */
if(d->p.scan_blur > 0.0f)
sf_blur_plane3(plane, w, h, d->p.scan_blur * preview_scale, scratch);
if(d->p.scan_usm_sigma > 0.0f && d->p.scan_usm_amount > 0.0f)
sf_unsharp_mask3(plane, w, h, d->p.scan_usm_sigma * preview_scale, d->p.scan_usm_amount,
corr, scratch);
#ifdef _OPENMP
#pragma omp parallel for default(none) shared(plane) firstprivate(out, in, w, ow, oh, ox, oy) \
schedule(static) num_threads(darktable.num_openmp_threads)
#endif
for(int y = 0; y < oh; y++)
for(int x = 0; x < ow; x++)
{
const size_t ks = (size_t)(y + oy) * w + (x + ox);
const float *pl = plane + ks * 3;
float *o = out + ((size_t)y * ow + x) * 4;
o[0] = pl[0];
o[1] = pl[1];
o[2] = pl[2];
o[3] = in[ks * 4 + 3];
}
dt_free_align(plane);
dt_free_align(corr);
dt_free_align(scratch);
}
#ifdef HAVE_OPENCL
/* Separable Young-van Vliet pass on the device, the GPU half of
sf_gauss_yvv_coeffs / _sf_gauss_iir_1d. `tmp` is a scratch buffer of the same
shape as `buf`.
Both paths must run this same recursion: a blur of a different shape produces
a different halo width for the same sigma, and every spatial radius in the
module was chosen against this one. See sf_gauss_yvv_coeffs. */
static cl_int _sf_yvv_blur_cl(const int devid,
dt_iop_spektrafilm_global_data_t *gd,
cl_mem buf,
cl_mem tmp,
const int w,
const int h,
const float sigma,
const int ch)
{
float b[4];
float pass_sigma = sigma;
const int passes = sf_gauss_iir_passes(sigma, &pass_sigma);
sf_gauss_yvv_coeffs(pass_sigma, b);
const int krow = (ch == 4) ? gd->kernel_yvv_row_4c : gd->kernel_yvv_row_1c;
const int kcol = (ch == 4) ? gd->kernel_yvv_col_4c : gd->kernel_yvv_col_1c;
/* the row kernels work on tiles of sizex x sizey items, up to 8 x 16, and
keep sizey rows of YVV_TILE_PITCH (65) elements in local memory */
dt_opencl_local_buffer_t locopt
= (dt_opencl_local_buffer_t){ .xoffset = 65, .xfactor = 0,
.yoffset = 0, .yfactor = 1,
.cellsize = ch * sizeof(float), .overhead = 0,
.sizex = 8, .sizey = 16 };
if(dt_opencl_local_buffer_opt(devid, krow, &locopt) != CL_SUCCESS)
return DT_OPENCL_DEFAULT_ERROR;
const size_t row_sizes[2] = { locopt.sizex, ROUNDUP(h, locopt.sizey) };
const size_t row_local[2] = { locopt.sizex, locopt.sizey };
const size_t tile_bytes = (size_t)locopt.sizey * 65 * ch * sizeof(float);
cl_int e = CL_SUCCESS;
for(int pass = 0; pass < passes && e == CL_SUCCESS; pass++)
{
e = dt_opencl_enqueue_kernel_2d_local_args(devid, krow, row_sizes, row_local,
CLARG(buf), CLARG(tmp),
CLARG(w), CLARG(h), CLARG(b[0]), CLARG(b[2]),
CLARG(b[3]), CLLOCAL(tile_bytes));
if(e == CL_SUCCESS)
e = dt_opencl_enqueue_kernel_2d_args(devid, kcol, w, 1, CLARG(tmp), CLARG(buf),
CLARG(w), CLARG(h), CLARG(b[0]), CLARG(b[2]),
CLARG(b[3]));
/* a wide sigma runs as many cascaded passes; waiting on each one keeps
every device submission to a single pass, well inside the driver's
job timeout, instead of letting the whole cascade queue up as one job */
if(e == CL_SUCCESS && passes > 1 && !dt_opencl_finish(devid))
e = DT_OPENCL_DEFAULT_ERROR;
}
return e;
}
/* Device half of sf_blur_plane3_wide(): in place on a float4 w*h buffer.
Sets *done and returns the error when sf_wide_blur_plan() decimates;
leaves *done at 0 and does nothing otherwise, so the caller runs its own
blur. */
static cl_int _sf_blur_wide_cl(const int devid,
dt_iop_spektrafilm_global_data_t *gd,
cl_mem buf,
const int w,
const int h,
const int roi_x,
const int roi_y,
const float sigma,
int *const done)
{
int k, lw, lh, ox, oy;
float sigma_low;
*done = 0;
if(!sf_wide_blur_plan(sigma, w, h, roi_x, roi_y, &k, &sigma_low, &lw, &lh, &ox, &oy))
return CL_SUCCESS;
*done = 1;
float *inv = dt_alloc_align_float((size_t)k + 1);
float kw[2 * SF_GAUSS_MAX_RADIUS + 1];
const int kr = dt_gaussian_kernel_1d(sigma_low, kw, SF_GAUSS_MAX_RADIUS);
cl_mem rows = dt_opencl_alloc_device_buffer(devid, sizeof(float) * 4 * lw * h);
cl_mem low = dt_opencl_alloc_device_buffer(devid, sizeof(float) * 4 * lw * lh);
cl_mem lowtmp = dt_opencl_alloc_device_buffer(devid, sizeof(float) * 4 * lw * lh);
cl_mem inv_cl = dt_opencl_alloc_device_buffer(devid, sizeof(float) * (k + 1));
cl_mem kw_cl = dt_opencl_alloc_device_buffer(devid, sizeof(float) * (2 * kr + 1));
cl_int e = CL_MEM_OBJECT_ALLOCATION_FAILURE;
if(inv && rows && low && lowtmp && inv_cl && kw_cl)
{
sf_wide_blur_inv_table(k, inv);
const float inv2k = 1.0f / (float)(2 * k);
e = dt_opencl_write_buffer_to_device(devid, inv, inv_cl, 0, sizeof(float) * (k + 1), TRUE);
if(e == CL_SUCCESS)
e = dt_opencl_write_buffer_to_device(devid, kw, kw_cl, 0, sizeof(float) * (2 * kr + 1), TRUE);
if(e == CL_SUCCESS)
e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_wide_rows, lw, h, CLARG(buf),
CLARG(rows), CLARG(w), CLARG(h), CLARG(k), CLARG(lw),
CLARG(ox));
if(e == CL_SUCCESS)
e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_wide_down, lw, lh, CLARG(rows),
CLARG(low), CLARG(w), CLARG(h), CLARG(k), CLARG(lw),
CLARG(lh), CLARG(ox), CLARG(oy), CLARG(inv_cl));
/* exact kernel at the reduced sigma: the twin of sf_blur_plane3() on the
CPU, rows then columns */
if(e == CL_SUCCESS)
e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, lw, lh, CLARG(low),
CLARG(lowtmp), CLARG(lw), CLARG(lh), CLARG(kw_cl),
CLARG(kr));
if(e == CL_SUCCESS)
e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, lw, lh, CLARG(lowtmp),
CLARG(low), CLARG(lw), CLARG(lh), CLARG(kw_cl),
CLARG(kr));
if(e == CL_SUCCESS)
e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_wide_up, w, h, CLARG(low),
CLARG(buf), CLARG(w), CLARG(h), CLARG(k), CLARG(lw),
CLARG(lh), CLARG(ox), CLARG(oy), CLARG(inv2k));
}
dt_free_align(inv);
if(rows) dt_opencl_release_mem_object(rows);
if(low) dt_opencl_release_mem_object(low);
if(lowtmp) dt_opencl_release_mem_object(lowtmp);
if(inv_cl) dt_opencl_release_mem_object(inv_cl);
if(kw_cl) dt_opencl_release_mem_object(kw_cl);
return e;
}
/* GPU path: mirrors process(). Per-pixel stages run as kernels on the
validated float tables from sf_sim_gpu_export() (POCL-checked to ~1e-6 vs
the CPU engine); the Gaussian blurs (diffusion bank, halation bounces,
coupler correction diffusion, grain clumps) use this file's own direct
separable convolution (spektrafilm_gauss_row/col_*c, weights built
host-side by dt_gaussian_kernel_1d), 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)
/* no kernels (init_global() could not allocate) or no piece data
(init_pipe() could not): hand the tile to the CPU path, which passes it
through */
if(!gd || !d) return DT_OPENCL_DEFAULT_ERROR;
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; /* no GPU tables -> CPU fallback */
/* film_format_mm is the long-edge dimension, see modify_roi_in */
const float full_long_edge
= fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f);
const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
/* see the matching comment in process(): fixed pixel radii shrink with the
preview pipe's reduced resolution, but never grow past 1:1 */
const float preview_scale = fminf(roi_in->scale, 1.0f);
/* ---- table uploads (read-only buffers) -------------------------------- */
/* packed matrix block: layout must match the SF_M_* offsets in the .cl */
float mats[93]; /* SF_M_* layout in spektrafilm.cl */
memcpy(mats + 0, g->m_in, 9 * sizeof(float));
memcpy(mats + 9, g->m_out, 9 * sizeof(float));
memcpy(mats + 18, g->couplers_M, 9 * sizeof(float));
memcpy(mats + 27, g->out_rgb2xyz, 9 * sizeof(float));
memcpy(mats + 36, g->out_xyz2rgb, 9 * sizeof(float));
memcpy(mats + 45, g->oklab_m1, 9 * sizeof(float));
memcpy(mats + 54, g->oklab_m2, 9 * sizeof(float));
memcpy(mats + 63, g->oklab_m1inv, 9 * sizeof(float));
memcpy(mats + 72, g->oklab_m2inv, 9 * sizeof(float));
memcpy(mats + 81, g->couplers_donor_K, 3 * sizeof(float));
memcpy(mats + 84, g->couplers_donor_Dref, 3 * sizeof(float));
memcpy(mats + 87, g->couplers_recv_Kr, 3 * sizeof(float));
memcpy(mats + 90, g->couplers_recv_cref, 3 * sizeof(float));
const int steps = g->steps;
const size_t n3 = (size_t)steps * steps * steps * 3;
const size_t m3 = (size_t)(steps - 1) * (steps - 1) * (steps - 1) * 3;
const size_t f = sizeof(float);
cl_mem mats_cl = dt_opencl_copy_host_to_device_constant(devid, 93 * f, mats);
cl_mem tc_cl = dt_opencl_copy_host_to_device_constant(
devid, (size_t)g->tc_n * g->tc_n * 3 * f, g->tc_lut);
cl_mem cn_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f, g->curves_norm);
cl_mem cb_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f,
g->couplers_active ? g->curves_before
: g->curves_norm);
cl_mem el_cl = NULL, ex_cl = NULL, ey_cl = NULL, ez_cl = NULL, en_cl = NULL, em_cl = NULL;
cl_mem pc_cl = NULL;
if(g->has_print)
{
el_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_lut);
ex_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sx);
ey_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sy);
ez_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sz);
en_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->enl_cmin);
em_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->enl_cmax);
pc_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f, g->print_curves);
}
cl_mem sl_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_lut);
cl_mem sx_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sx);
cl_mem sy_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sy);
cl_mem sz_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sz);
cl_mem sn_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->scan_cmin);
cl_mem sm_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->scan_cmax);
/* cmax_table is only used in oklch mode but the kernel arg must be valid */
cl_mem cm_cl = dt_opencl_copy_host_to_device_constant(
devid, (g->cmax_table ? (size_t)g->cmax_nl * g->cmax_nh : 1) * f,
g->cmax_table ? (void *)g->cmax_table : (void *)mats);
cl_mem plane = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
cl_mem plane2 = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
cl_mem tmpa = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
cl_mem acc = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
/* single-channel scratch for the scatter stage's genuinely per-channel
blurs (spektrafilm_channel_extract + a 1ch Gaussian): 1/4 the size and
1/4 the per-blur cost of running the equivalent work on a float4
buffer, see the scatter stage below. */
cl_mem plane1 = dt_opencl_alloc_device_buffer(devid, npix * f);
/* row-pass intermediates for the direct (exact) separable convolution
below: dedicated buffers, distinct from every buffer a blur might be
called on in place, so the row pass never aliases its own input. */
cl_mem gtmp4 = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
cl_mem gtmp1 = dt_opencl_alloc_device_buffer(devid, npix * f);
/* kernel weights (2*SF_GAUSS_MAX_RADIUS+1 taps, built host-side by
dt_gaussian_kernel_1d and rewritten before each blur dispatch below) */
cl_mem gauss_w = dt_opencl_alloc_device_buffer(devid, sizeof(float) * (2 * SF_GAUSS_MAX_RADIUS + 1));
if(!mats_cl || !tc_cl || !cn_cl || !cb_cl || !sl_cl || !sx_cl || !sy_cl || !sz_cl || !sn_cl
|| !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !acc || !plane1 || !gtmp4 || !gtmp1
|| !gauss_w
|| (g->has_print && (!el_cl || !ex_cl || !ey_cl || !ez_cl || !en_cl || !em_cl || !pc_cl)))
{
err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
goto cleanup;
}
/* Direct (exact) separable Gaussian blur: builds the exact kernel
host-side (the same dt_gaussian_kernel_1d() the CPU path convolves with),
uploads it, then dispatches a row pass into a dedicated scratch buffer
followed by a col pass into `dst` -- safe even when dst==buf (in-place),
since the row pass fully consumes buf into scratch before the col pass
writes buf. No sigma-correction factor: unlike a recursive/IIR
approximation, a direct truncated kernel has no sigma-dependent error to
correct for in the first place. */
#define SF_GAUSS_BLUR4(buf, _sg, label) do { \
if(err == CL_SUCCESS) \
{ \
float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
sizeof(float) * (2 * _kr + 1), TRUE); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \
CLARG(buf), CLARG(gtmp4), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \
CLARG(gtmp4), CLARG(buf), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
} \
SF_CL_STEP(label); \
} while(0)
/* Same as SF_GAUSS_BLUR4, but above SF_GAUSS_EXACT_MAX_SIGMA falls back to
the shared Young-van Vliet recursion (_sf_yvv_blur_cl above)
instead of the exact row/col kernels: for callers with no downstream
dependency on the exact kernel's shape (unlike grain's SF_GAUSS_BLUR4
above, which stays on the exact path unconditionally -- the recursive
approximation's ~18% effective-width error would make grain the wrong
size relative to upstream's own exact-shape Gaussian), this recovers most
of the O(radius) cost the exact kernel pays at large sigma. */
#define SF_GAUSS_BLUR4_FAST(buf, _sg, label) do { \
if(err == CL_SUCCESS) \
{ \
if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \
err = _sf_yvv_blur_cl(devid, gd, (buf), gtmp4, w, h, (_sg), 4); \
else \
{ \
float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
sizeof(float) * (2 * _kr + 1), TRUE); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \
CLARG(buf), CLARG(gtmp4), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \
CLARG(gtmp4), CLARG(buf), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
} \
} \
SF_CL_STEP(label); \
} while(0)
/* loop-safe variant: sets err, caller checks err/breaks; src/dst may differ
(e.g. accumulating several blurred copies of the same source). Falls back
to the fast recursive blur above SF_GAUSS_EXACT_MAX_SIGMA, same rationale
as SF_GAUSS_BLUR4_FAST -- none of this macro's callers (halation bounce,
coupler tail, both diffusion filters) renormalize against the exact
kernel's shape. */
#define SF_GAUSS_BLUR4_OP_L(src, dst, _sg) do { \
if(err == CL_SUCCESS) \
{ \
if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \
{ \
err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \
if(err == CL_SUCCESS) \
err = _sf_yvv_blur_cl(devid, gd, (dst), gtmp4, w, h, (_sg), 4); \
} \
else \
{ \
float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
sizeof(float) * (2 * _kr + 1), TRUE); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \
CLARG(src), CLARG(gtmp4), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \
CLARG(gtmp4), CLARG(dst), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
} \
} \
} while(0)
/* SF_GAUSS_BLUR4_OP_L, decimated from SF_WIDE_BLUR_EFFECT_SIGMA up: the
device twin of sf_diffusion_filter() and sf_halation()'s bounces, which call
sf_blur_plane3_wide() from the same threshold. */
#define SF_BLUR4_WIDE_OP_L(src, dst, _sg) do { \
const float _ws = (_sg); \
if(err == CL_SUCCESS && _ws >= SF_WIDE_BLUR_EFFECT_SIGMA) \
{ \
int _wide = 0; \
err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \
if(err == CL_SUCCESS) \
err = _sf_blur_wide_cl(devid, gd, (dst), w, h, roi_in->x, roi_in->y, _ws, &_wide); \
} \
else \
SF_GAUSS_BLUR4_OP_L(src, dst, _ws); \
} while(0)
/* single-channel in-place blur (scatter stage only, on plane1). Same fast
fallback above SF_GAUSS_EXACT_MAX_SIGMA -- scatter's core/tail sigmas are
normally small (sub-few-px), but the fallback is here for whatever a user's
scatter_scale slider can push them to. */
/* Exact-kernel-only single-channel blur, the GPU twin of sf_blur_plane1():
that function passes exact_only=1 unconditionally, so its call sites must
NOT fall back to the recursive approximation at large sigma the way
SF_GAUSS_BLUR1_L below does. Used by the grain dye-cloud blur. */
#define SF_GAUSS_BLUR1_EXACT(buf, _sg) do { \
if(err == CL_SUCCESS) \
{ \
float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
sizeof(float) * (2 * _kr + 1), TRUE); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_1c, w, h, \
CLARG(buf), CLARG(gtmp1), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_1c, w, h, \
CLARG(gtmp1), CLARG(buf), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
} \
} while(0)
#define SF_GAUSS_BLUR1_L(buf, _sg) do { \
if(err == CL_SUCCESS) \
{ \
if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \
err = _sf_yvv_blur_cl(devid, gd, (buf), gtmp1, w, h, (_sg), 1); \
else \
{ \
float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
sizeof(float) * (2 * _kr + 1), TRUE); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_1c, w, h, \
CLARG(buf), CLARG(gtmp1), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
if(err == CL_SUCCESS) \
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_1c, w, h, \
CLARG(gtmp1), CLARG(buf), CLARG(w), CLARG(h), \
CLARG(gauss_w), CLARG(_kr)); \
} \
} \
} while(0)
/* ---- 1) expose: input image -> linear film raw exposure ---------------- */
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_expose, w, h, CLARG(dev_in),
CLARG(plane), CLARG(w), CLARG(h), CLARG(mats_cl),
CLARG(tc_cl), CLARG(g->tc_n), CLARG(g->ev_scale));
SF_CL_STEP("expose");
/* ---- 2) pre-film spatial effects on linear exposure -------------------- */
{
/* The curve is anchored to the exposure scale, not to a frame maximum, so
the boost agrees between the preview pipe, the export pipe and every
tile. A frame maximum would differ per ROI. */
sf_boost_plan_t bp;
if(sf_boost_build_plan(d->p.boost_ev, d->p.boost_range, d->p.protect_ev, &bp))
{
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_boost, w, h, CLARG(plane),
CLARG(w), CLARG(h), CLARG(bp.raw_x0),
CLARG(bp.xmax), CLARG(bp.inv_max), CLARG(bp.a),
CLARG(bp.a_log2e), CLARG(bp.scale),
CLARG(bp.ext), CLARG(bp.slope));
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)
{
/* double, and no 1e-3f floor: sf_diffusion_filter() computes
(float)(sigma_um * sc / fmax(pixel_um, 1e-3)) with sc and the divide
both in double and no clamp on the result. In float the value differs
in the last places, which is a different kernel out of
dt_gaussian_kernel_1d() and so a different blur on every pixel the
filter touches. */
const double dsc = fmax((double)d->p.diffusion_scale, 1e-6);
for(int j = 0; j < plan.n; j++)
{
const float sigma
= (float)((double)plan.sigma_um[j] * dsc / fmax((double)pixel_um, 1e-3));
SF_BLUR4_WIDE_OP_L(plane, tmpa, sigma);
if(err != CL_SUCCESS) break;
const int reset = (j == 0);
const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j];
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
CLARG(tmpa), CLARG(acc), CLARG(w), CLARG(h),
CLARG(wr), CLARG(wg), CLARG(wb), CLARG(reset));
if(err != CL_SUCCESS) break;
}
if(err == CL_SUCCESS)
{
const float ps = plan.p_s;
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_mix, w, h,
CLARG(plane), CLARG(acc), CLARG(w), CLARG(h),
CLARG(ps));
}
SF_CL_STEP("diffusion");
}
}
/* ---- sigma parity with the CPU ------------------------------------------
Every blur sigma below is derived from the SAME doubles, by the SAME
expression in the SAME association, at the SAME precision, with the SAME
floor as its twin in process()/spektra_core.c.
Both halves of that matter. Reading the float mirrors in sf_sim_gpu_t is
already wrong -- (double)(float)x != x -- so these call the sim accessors
directly, exactly as process() does, and only round at the end.
This is not pedantry. Above SF_GAUSS_EXACT_MAX_SIGMA both paths leave the
truncated kernel for the Young-van Vliet recursion. sf_gauss_yvv_coeffs()
maps sigma continuously onto B/B1/B2/B3, and an IIR response depends
globally on those: the forward pass carries any perturbation to the end of
every line, the backward pass carries it back, and the column pass spreads
it down every column it touched. So a last-place difference in sigma --
from float instead of double, from a*b*c/d instead of a*b/d*c, or from a
1e-3f floor instead of 1e-6f -- stops being a rounding difference and
becomes a different filter over the whole image. Below the threshold the
same mismatch is invisible, which is why the divergence this guards
against is sigma-dependent rather than uniform.
-------------------------------------------------------------------- */
double cl_hal_strength[3], cl_hal_sigma_um = 0.0;
double cl_sc_core[3], cl_sc_tail[3], cl_sc_w[3];
if(d->p.halation_on)
{
sf_sim_halation_params(sim, cl_hal_strength, &cl_hal_sigma_um);
cl_hal_sigma_um = fmin(cl_hal_sigma_um, (double)SF_HALATION_FIRST_SIGMA_UM);
sf_sim_scatter_params(sim, cl_sc_core, cl_sc_tail, cl_sc_w);
for(int c = 0; c < 3; c++)
{
cl_sc_core[c] = fmin(cl_sc_core[c], (double)SF_SCATTER_CORE_CLAMP_UM);
cl_sc_tail[c] = fmin(cl_sc_tail[c], (double)SF_SCATTER_TAIL_CLAMP_UM);
}
}
if(d->p.halation_on && (d->p.scatter_amount > 0.0f || d->p.halation_amount > 0.0f))
{
if(d->p.scatter_amount > 0.0f)
{
const float sscl = fmaxf(d->p.scatter_scale, 1e-3f);
/* per-channel scatter radii (um on film) and tail mixture, identical to
spektra_core.c's sf_halation() sc_core/sc_tail/tail_amp/tail_rat.
Each channel needs its OWN sigma (R/G/B differ): extract that
channel into the single-channel scratch buffer plane1, blur it
alone (1x the work of a same-size float4 blur, not 4x), then
kernel_channel_accum folds it into the target channel of tmpa/acc. */
/* per-film scatter PSF: cl_sc_core/cl_sc_tail above, clamped in double
from the sim's own values -- not fminf() on sf_sim_gpu_t's float
mirrors, which rounds once more than the CPU does. */
const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f };
for(int c = 0; c < 3; c++)
{
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_extract, w, h,
CLARG(plane), CLARG(plane1), CLARG(w), CLARG(h),
CLARG(c));
if(err != CL_SUCCESS) break;
/* CPU: fmaxf((float)(sc_core[c] * scl / pixel_um), 1e-6f) */
SF_GAUSS_BLUR1_L(plane1, fmaxf((float)(cl_sc_core[c] * (double)sscl
/ (double)pixel_um), 1e-6f));
if(err != CL_SUCCESS) break;
const float core_weight = 1.0f;
const int core_reset = (c == 0);
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_accum, w, h,
CLARG(plane1), CLARG(tmpa), CLARG(w), CLARG(h),
CLARG(core_weight), CLARG(c), CLARG(core_reset));
SF_CL_STEP("scatter core blur");
}
for(int g3 = 0; g3 < 3 && err == CL_SUCCESS; g3++)
for(int c = 0; c < 3; c++)
{
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_extract, w, h,
CLARG(plane), CLARG(plane1), CLARG(w), CLARG(h),
CLARG(c));
if(err != CL_SUCCESS) break;
/* CPU associates as rat * (tail * scl / pixel_um), in double */
const float sigma = fmaxf((float)((double)rat[g3]
* (cl_sc_tail[c] * (double)sscl
/ (double)pixel_um)), 1e-6f);
SF_GAUSS_BLUR1_L(plane1, sigma);
if(err != CL_SUCCESS) break;
const int reset = (g3 == 0 && c == 0);
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_accum, w, h,
CLARG(plane1), CLARG(acc), CLARG(w), CLARG(h),
CLARG(amp[g3]), CLARG(c), CLARG(reset));
SF_CL_STEP("scatter tail accum");
}
/* CPU: sf_halation() rounds the sim's double w_s[] to float once and
blends with the same fma nesting as spektrafilm_scatter_combine.
Rounded from the same double here, not read from sf_sim_gpu_t's
float mirror, so the constant the kernel gets is the CPU's. */
const float ws_r = (float)cl_sc_w[0], ws_g = (float)cl_sc_w[1],
ws_b = (float)cl_sc_w[2];
/* (1-s)*raw + s*scattered, matching sf_halation()'s CPU blend; `plane`
doubles as both the pre-scatter `raw` input and the `out` write
target -- safe since this is a purely per-pixel elementwise op. */
/* convex blend weight -- see sf_halation() for why it cannot exceed 1 */
const float s_amount = CLAMP(d->p.scatter_amount, 0.0f, 1.0f);
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scatter_combine, w, h, CLARG(plane),
CLARG(tmpa), CLARG(acc), CLARG(plane), CLARG(w),
CLARG(h), CLARG(s_amount), CLARG(ws_r), CLARG(ws_g),
CLARG(ws_b));
SF_CL_STEP("scatter combine");
}
if(d->p.halation_amount > 0.0f)
{
const float hscl = fmaxf(d->p.halation_scale, 1e-3f);
const int N = 3;
/* per-film first-bounce radius (still ~65um / cine ~50um on real
stocks); clamped to what modify_roi_in()/tiling_callback() padded
for, see the matching comment in process(). */
/* clamped in double above, as process() clamps hal_sigma_um */
const float dec[3] = { 1.0f/1.75f, 0.5f/1.75f, 0.25f/1.75f };
for(int k = 1; k <= N; k++)
{
/* CPU: fmaxf((float)((first_sigma_um * hscl / pixel_um) * sqrt((double)k)), 1e-6f).
Five things have to match on this line, and each is easy to lose:
the sim's double rather than sf_sim_gpu_t's float mirror, double
arithmetic throughout, sqrt() and not sqrtf(), the divide before the
sqrt factor, and the CPU's 1e-6f floor. */
SF_BLUR4_WIDE_OP_L(plane, plane2,
fmaxf((float)((cl_hal_sigma_um * (double)hscl
/ (double)pixel_um) * sqrt((double)k)), 1e-6f));
if(err != CL_SUCCESS) break;
const int reset = (k == 1);
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(plane2),
CLARG(acc), CLARG(w), CLARG(h), CLARG(dec[k - 1]),
CLARG(reset));
SF_CL_STEP("halation bounce accum");
}
/* halation_amount is a direct linear multiplier on strength, matching
upstream's a_tot = halation_strength * halation_amount (no curve). */
const float h_eff = d->p.halation_amount;
/* per-film halation strength (e.g. a strong-AH stock stays near-zero on
blue and much lower on red/green than a no-AH/redscale stock).
CPU: a_tot[c] = halation_strength[c] * halation_amount in double
(sf_halation()), rounded once to float for the per-pixel
fma(a, blur, raw) / (1 + a). Formed the same way here from
cl_hal_strength -- the sim's own double, as the sigma above is. */
const float a_r = (float)(cl_hal_strength[0] * (double)h_eff),
a_g = (float)(cl_hal_strength[1] * (double)h_eff),
a_b = (float)(cl_hal_strength[2] * (double)h_eff);
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_halation_apply, w, h, CLARG(plane),
CLARG(acc), CLARG(w), CLARG(h), CLARG(a_r),
CLARG(a_g), CLARG(a_b));
SF_CL_STEP("halation apply");
}
}
/* ---- 3) film development ------------------------------------------------ */
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_lograw, w, h, CLARG(plane), CLARG(w),
CLARG(h), CLARG(g->raw_scale));
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->le0), CLARG(g->inv_le_step), CLARG(g->film_dmax[0]), CLARG(g->film_dmax[1]),
CLARG(g->film_dmax[2]), CLARG(g->film_positive), CLARG(g->couplers_donor_lm));
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)
{
/* float operands throughout, as process() forms them: amp/rat are float
arrays there, so each product is a float multiply, not a double one
rounded afterwards. tail_px is ctail_um / pixel_um once, then
rat[g3] * tail_px -- R * (tail / pixel), not (R * tail) / pixel. */
const float ea[3] = { SF_EXPTAIL_A0, SF_EXPTAIL_A1, SF_EXPTAIL_A2 };
const float er[3] = { SF_EXPTAIL_R0, SF_EXPTAIL_R1, SF_EXPTAIL_R2 };
const float amp[4] = { 1.0f - g->coupler_tail_w, g->coupler_tail_w * ea[0],
g->coupler_tail_w * ea[1], g->coupler_tail_w * ea[2] };
const float tail_px = g->coupler_tail_um / fmaxf(pixel_um, 1e-3f);
const float sig[4] = { csigma, er[0] * tail_px, er[1] * tail_px, er[2] * tail_px };
for(int g3 = 0; g3 < 4; g3++)
{
/* >= SF_GAUSS_MIN_SIGMA, not > 0.1f: the CPU twin guards with
`if(ts > 0.1f) sf_blur_plane3_wide(...)`, which decimates from
SF_WIDE_BLUR_MIN_SIGMA up and below that hands over to
sf_blur_plane3_fast, which returns without touching the buffer below
SF_GAUSS_MIN_SIGMA -- so the CPU blurs iff the sigma clears 0.3.
Between 0.1 and 0.3 it copies the unblurred correction, which is
what the else branch below does. */
if(sig[g3] >= SF_WIDE_BLUR_MIN_SIGMA)
{
int wide = 0;
err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, acc, plane2, 0, 0, npix * f * 4);
if(err == CL_SUCCESS)
err = _sf_blur_wide_cl(devid, gd, plane2, w, h, roi_in->x, roi_in->y, sig[g3], &wide);
if(err != CL_SUCCESS) break;
}
else if(sig[g3] >= SF_GAUSS_MIN_SIGMA)
{
SF_GAUSS_BLUR4_OP_L(acc, plane2, sig[g3]);
if(err != CL_SUCCESS) break;
}
else
{
err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, acc, plane2, 0, 0, npix * f * 4);
if(err != CL_SUCCESS) break;
}
const int reset = (g3 == 0);
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
CLARG(plane2), CLARG(tmpa), CLARG(w), CLARG(h),
CLARG(amp[g3]), CLARG(amp[g3]), CLARG(amp[g3]),
CLARG(reset));
SF_CL_STEP("coupler tail accum");
}
}
else if(csigma > 0.1f)
{
/* CPU twin is sf_blur_plane3_wide(): decimated from
SF_WIDE_BLUR_MIN_SIGMA up, sf_blur_plane3_fast below, which returns
without touching the buffer below SF_GAUSS_MIN_SIGMA -- match both
cuts here. */
if(csigma >= SF_WIDE_BLUR_MIN_SIGMA)
{
int wide = 0;
err = _sf_blur_wide_cl(devid, gd, acc, w, h, roi_in->x, roi_in->y, csigma, &wide);
SF_CL_STEP("coupler blur");
}
else if(csigma >= SF_GAUSS_MIN_SIGMA)
SF_GAUSS_BLUR4_FAST(acc, csigma, "coupler blur");
}
}
cl_mem corr_buf = (g->coupler_tail_w > 0.0f) ? tmpa : acc;
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_develop, w, h, CLARG(plane),
CLARG(corr_buf), CLARG(use_corr), CLARG(plane2), CLARG(w),
CLARG(h), CLARG(cb_cl), CLARG(mats_cl), CLARG(g->le0),
CLARG(g->inv_le_step), CLARG(g->couplers_recv_lm));
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 uint32_t salt = _grain_salt(piece);
const float amount = d->p.grain_amount;
const int mono = g->film_bw; /* B&W: achromatic grain */
/* see the matching comment on the CPU path (process()) for the full
rationale: darktable's preview pipe renders at a temporarily
reduced resolution, unlike upstream which always renders one real
resolution, so these fixed pixel radii need shrinking there to
avoid over-affecting real scene detail. Capped at 1.0 so zooming
in past 100% doesn't grow radii beyond what was validated. Does
NOT apply to npart_scale below, which is correctly resolution-
dependent via pixel_um already. */
/* Unified through the multi-sublayer table for every stock (nsub can be
1); there is no separate single-layer kernel -- see the matching comment
in process()'s CPU path for why that is valid: the build-time layer
table already carries correct n==1 data for single-layer stocks.
Uploaded once per d->gpu (see d->grain_cl_built_for above) rather than
once per process_cl() call: tiled processing calls this once per tile,
and the tables are identical across every tile of the same image. */
const int nsub = g->grain_n_sublayers, nle = SF_NLE, maxsub = SF_GRAIN_MAX_SUBLAYERS;
if(d->grain_cl_built_for != g || d->grain_cl_devid != devid)
{
if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax);
if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart);
if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin);
if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total);
if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve);
d->grain_cl_dmax = dt_opencl_copy_host_to_device_constant(
devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmax);
d->grain_cl_npart = dt_opencl_copy_host_to_device_constant(
devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_npart);
d->grain_cl_dmin = dt_opencl_copy_host_to_device_constant(
devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmin);
d->grain_cl_total = dt_opencl_copy_host_to_device_constant(
devid, (size_t)nle * 3 * f, (void *)g->grain_layer_curve_total);
d->grain_cl_curve = dt_opencl_copy_host_to_device_constant(
devid, (size_t)nle * maxsub * 3 * f, (void *)g->grain_layer_curve);
/* Claim the cache only once every upload landed. If the key were
recorded before this test, a partial failure would be permanent for
this simulation and device: the guard above would skip the rebuild, the
check below would fail on the same NULL, and the OpenCL path would stay
unavailable until the simulation itself was rebuilt. */
if(d->grain_cl_dmax && d->grain_cl_npart && d->grain_cl_dmin && d->grain_cl_total
&& d->grain_cl_curve)
{
d->grain_cl_built_for = g;
d->grain_cl_devid = devid;
}
}
if(!d->grain_cl_dmax || !d->grain_cl_npart || !d->grain_cl_dmin || !d->grain_cl_total
|| !d->grain_cl_curve)
err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
else
{
/* layer_npart (uploaded above) is precomputed at sf_sim_build time
against the fixed SF_GRAIN_REF_UM reference scale; rescale it live
to the real pixel_um here. */
const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM);
/* Upstream's per-sub-layer dye-cloud blur (layer_particle_model's
blur_particle, grain.py): sigma depends only on that sub-layer's
own per-particle optical density (dmax/npart), which is constant
across the whole image -- computed host-side once here, same as the CPU path, not per-pixel
on the device. */
float dye_sigma[3][SF_GRAIN_MAX_SUBLAYERS];
for(int c = 0; c < 3; c++)
for(int sl = 0; sl < nsub; sl++)
{
const float npart_c = g->grain_layer_npart[sl][c] * npart_scale;
const float od_particle = g->grain_layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f);
dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * d->p.grain_dye_cloud
* sqrtf(fmaxf(od_particle, 0.0f)) * preview_scale;
}
cl_mem raw_buf[SF_GRAIN_MAX_SUBLAYERS] = { NULL };
cl_mem acc_buf = dt_opencl_alloc_device_buffer(devid, npix * f);
gboolean raw_ok = acc_buf != NULL;
for(int sl = 0; sl < nsub; sl++)
{
raw_buf[sl] = dt_opencl_alloc_device_buffer(devid, npix * f);
if(!raw_buf[sl]) raw_ok = FALSE;
}
if(!raw_ok)
err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
else
{
const int n_out_ch = mono ? 1 : 3;
for(int oc = 0; oc < n_out_ch && err == CL_SUCCESS; oc++)
{
const int channel_idx = mono ? 1 : oc;
const int seed_ch = mono ? 0 : oc;
const float unif_ch = g->grain_uniformity[channel_idx];
for(int sl = 0; sl < nsub && err == CL_SUCCESS; sl++)
{
err = dt_opencl_enqueue_kernel_2d_args(
devid, gd->kernel_grain_gen_raw_sl, w, h, CLARG(plane2), CLARG(raw_buf[sl]),
CLARG(w), CLARG(h), CLARG(roi_x), CLARG(roi_y), CLARG(mono), CLARG(channel_idx),
CLARG(seed_ch), CLARG(sl), CLARG(nle), CLARG(maxsub), CLARG(unif_ch),
CLARG(npart_scale), CLARG(salt), CLARG(d->grain_cl_dmax), CLARG(d->grain_cl_npart),
CLARG(d->grain_cl_dmin), CLARG(d->grain_cl_total), CLARG(d->grain_cl_curve));
if(err == CL_SUCCESS && dye_sigma[channel_idx][sl] > 1e-6f)
/* exact-only: the CPU twin is sf_blur_plane1(), which never takes
the recursive path (see SF_GAUSS_BLUR1_EXACT) */
SF_GAUSS_BLUR1_EXACT(raw_buf[sl], dye_sigma[channel_idx][sl]);
}
for(int sl = 0; sl < nsub && err == CL_SUCCESS; sl++)
{
const int reset = sl == 0;
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_accumulate_1c, w, h,
CLARG(acc_buf), CLARG(raw_buf[sl]), CLARG(w),
CLARG(h), CLARG(reset));
}
if(err == CL_SUCCESS)
{
const float dmin_ch = g->grain_dmin[channel_idx];
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_finalize_channel, w, h,
CLARG(tmpa), CLARG(acc_buf), CLARG(plane2),
CLARG(w), CLARG(h), CLARG(mono),
CLARG(channel_idx), CLARG(oc), CLARG(dmin_ch),
CLARG(amount));
}
}
}
for(int sl = 0; sl < nsub; sl++)
if(raw_buf[sl]) dt_opencl_release_mem_object(raw_buf[sl]);
if(acc_buf) dt_opencl_release_mem_object(acc_buf);
}
SF_CL_STEP("grain gen");
/* No DC-centring reduction, matching the CPU path: the Poisson sampler is
unbiased, so there is nothing to centre. Centring would also need a full
device->host readback, stalling the queue once per grain stage. */
/* Add the still-UNBLURRED delta, so the blur below sees the grained
absolute density, not an isolated grain layer -- same ordering as
process(), see the long comment there for why the blur and the unsharp
mask that follows it only make sense as a pair. */
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2),
CLARG(tmpa), CLARG(w), CLARG(h));
SF_CL_STEP("grain add");
/* fixed pixel sigma, matching process()'s CPU-side fix -- see comment
there for the empirical validation. */
const float gsigma = d->p.grain_blur_base * fmaxf(d->p.grain_blur, SF_GRAIN_BLUR_MIN)
* preview_scale;
/* CPU twin is sf_blur_plane3(), which skips below SF_GAUSS_MIN_SIGMA.
This one matters most: the clump blur runs on the grain field, which is
high-frequency by construction, so blurring here where the CPU does not
changes essentially every grained pixel. */
if(gsigma >= SF_GAUSS_MIN_SIGMA)
SF_GAUSS_BLUR4(plane2, gsigma, "grain blur");
if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f)
{
err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane2, acc, 0, 0, npix * f * 4);
if(err != CL_SUCCESS) goto cleanup;
const float usig = d->p.grain_usm_sigma * preview_scale;
/* sf_multiplicative_unsharp_mask3() blurs via sf_blur_plane3(), so this
is exact-only and skipped below SF_GAUSS_MIN_SIGMA -- not _FAST. */
if(usig >= SF_GAUSS_MIN_SIGMA)
SF_GAUSS_BLUR4(plane2, usig, "grain USM blur");
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_usm, w, h, CLARG(plane2),
CLARG(acc), CLARG(w), CLARG(h),
CLARG(d->p.grain_usm_amount),
CLARG(g->grain_dmin[0]), CLARG(g->grain_dmin[1]),
CLARG(g->grain_dmin[2]));
SF_CL_STEP("grain USM");
}
}
/* ---- 5) print ----------------------------------------------------------- */
if(g->has_print)
{
err = dt_opencl_enqueue_kernel_2d_args(
devid, gd->kernel_print_expose, w, h, CLARG(plane2), CLARG(plane), CLARG(w), CLARG(h),
CLARG(el_cl), CLARG(ex_cl), CLARG(ey_cl), CLARG(ez_cl), CLARG(en_cl), CLARG(em_cl),
CLARG(steps), CLARG(g->enl_lo[0]), CLARG(g->enl_lo[1]), CLARG(g->enl_lo[2]),
CLARG(g->enl_inv_range[0]), CLARG(g->enl_inv_range[1]), CLARG(g->enl_inv_range[2]),
CLARG(g->log10_print_exposure));
SF_CL_STEP("print_expose");
/* ---- print diffusion (optional, on linear print exposure) ---- */
if(d->p.print_diffusion_on)
{
sf_diffusion_plan_t pplan;
if(sf_diffusion_build_plan((int)d->p.print_diffusion_filter_family,
d->p.print_diffusion_strength,
d->p.print_diffusion_warmth, &pplan)
&& pplan.p_s > 0.0f)
{
/* linear print exposure, see process() */
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_pow10, w, h, CLARG(plane),
CLARG(w), CLARG(h));
SF_CL_STEP("print_diffusion pow10");
/* see the pre-film diffusion above for why this is in double */
const double pdsc = fmax((double)d->p.print_diffusion_scale, 1e-6);
for(int j = 0; j < pplan.n; j++)
{
const float sigma
= (float)((double)pplan.sigma_um[j] * pdsc / fmax((double)pixel_um, 1e-3));
SF_BLUR4_WIDE_OP_L(plane, tmpa, sigma);
if(err != CL_SUCCESS) break;
const int reset = (j == 0);
const float wr = pplan.wr[j], wg = pplan.wg[j], wb = pplan.wb[j];
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
CLARG(tmpa), CLARG(acc), CLARG(w), CLARG(h),
CLARG(wr), CLARG(wg), CLARG(wb), CLARG(reset));
if(err != CL_SUCCESS) break;
}
if(err == CL_SUCCESS)
{
const float ps = pplan.p_s;
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_mix, w, h,
CLARG(plane), CLARG(acc), CLARG(w), CLARG(h),
CLARG(ps));
}
SF_CL_STEP("print_diffusion");
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_log10, w, h, CLARG(plane),
CLARG(w), CLARG(h));
SF_CL_STEP("print_diffusion log10");
}
}
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->inv_le_step));
SF_CL_STEP("print_develop");
}
/* ---- 6) scan over the full padded ROI into `plane` (free since print) ---- */
/* viewing glare field into tmpa, added in XYZ inside the scan (mirrors
process()); tmpa is free since development */
const int glare_on = !d->p.scan_film && d->p.glare_percent > 0.0f;
if(glare_on)
{
const float gmean = d->p.glare_percent * 0.01f;
const float sigma2 = logf(1.0f + SF_GLARE_ROUGHNESS * SF_GLARE_ROUGHNESS);
const float gs = sqrtf(sigma2), gbias = -0.5f * sigma2;
const int roi_x = roi_in->x, roi_y = roi_in->y;
const uint32_t salt = _grain_salt(piece);
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_glare_gen, w, h, CLARG(tmpa),
CLARG(w), CLARG(h), CLARG(roi_x), CLARG(roi_y),
CLARG(gmean), CLARG(gs), CLARG(gbias), CLARG(salt));
SF_CL_STEP("glare gen");
SF_GAUSS_BLUR4(tmpa, SF_GLARE_BLUR_PX * preview_scale, "glare blur");
}
err = dt_opencl_enqueue_kernel_2d_args(
devid, gd->kernel_scan, w, h, CLARG(plane2), CLARG(plane), CLARG(w), CLARG(h),
CLARG(sl_cl), CLARG(sx_cl), CLARG(sy_cl),
CLARG(sz_cl), CLARG(sn_cl), CLARG(sm_cl), CLARG(steps), CLARG(g->scan_lo[0]),
CLARG(g->scan_lo[1]), CLARG(g->scan_lo[2]), CLARG(g->scan_inv_range[0]),
CLARG(g->scan_inv_range[1]), CLARG(g->scan_inv_range[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->out_scale),
CLARG(g->scan_bw_on), CLARG(g->scan_bw_m),
CLARG(g->scan_bw_q), CLARG(tmpa), CLARG(glare_on), CLARG(g->glare_xyz[0]),
CLARG(g->glare_xyz[1]), CLARG(g->glare_xyz[2]));
SF_CL_STEP("scan");
/* ---- 6b) scanner optics (mirrors process()) ------------------------------ */
if(d->p.scan_blur > 0.0f)
{
/* CPU twin is sf_blur_plane3(); mirror its SF_GAUSS_MIN_SIGMA cut */
if(d->p.scan_blur * preview_scale >= SF_GAUSS_MIN_SIGMA)
SF_GAUSS_BLUR4(plane, d->p.scan_blur * preview_scale, "scanner blur");
}
if(d->p.scan_usm_sigma > 0.0f && d->p.scan_usm_amount > 0.0f)
{
err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, acc, 0, 0, npix * f * 4);
if(err != CL_SUCCESS) goto cleanup;
/* sf_unsharp_mask3() blurs via sf_blur_plane3(): exact-only, and skipped
below SF_GAUSS_MIN_SIGMA -- not _FAST. */
if(d->p.scan_usm_sigma * preview_scale >= SF_GAUSS_MIN_SIGMA)
SF_GAUSS_BLUR4(plane, d->p.scan_usm_sigma * preview_scale, "scanner USM blur");
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scan_usm, w, h, CLARG(plane),
CLARG(acc), CLARG(w), CLARG(h),
CLARG(d->p.scan_usm_amount));
SF_CL_STEP("scanner USM");
}
/* ---- 7) crop the roi_out window into dev_out ----------------------------- */
err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_crop_out, ow, oh, CLARG(plane),
CLARG(dev_in), CLARG(dev_out), CLARG(w), CLARG(ow),
CLARG(oh), CLARG(ox), CLARG(oy));
SF_CL_STEP("crop out");
cleanup:
dt_opencl_release_mem_object(mats_cl);
dt_opencl_release_mem_object(tc_cl);
dt_opencl_release_mem_object(cn_cl);
dt_opencl_release_mem_object(cb_cl);
dt_opencl_release_mem_object(el_cl);
dt_opencl_release_mem_object(ex_cl);
dt_opencl_release_mem_object(ey_cl);
dt_opencl_release_mem_object(ez_cl);
dt_opencl_release_mem_object(en_cl);
dt_opencl_release_mem_object(em_cl);
dt_opencl_release_mem_object(pc_cl);
dt_opencl_release_mem_object(sl_cl);
dt_opencl_release_mem_object(sx_cl);
dt_opencl_release_mem_object(sy_cl);
dt_opencl_release_mem_object(sz_cl);
dt_opencl_release_mem_object(sn_cl);
dt_opencl_release_mem_object(sm_cl);
dt_opencl_release_mem_object(cm_cl);
dt_opencl_release_mem_object(plane);
dt_opencl_release_mem_object(plane2);
dt_opencl_release_mem_object(tmpa);
dt_opencl_release_mem_object(acc);
dt_opencl_release_mem_object(plane1);
dt_opencl_release_mem_object(gtmp4);
dt_opencl_release_mem_object(gtmp1);
dt_opencl_release_mem_object(gauss_w);
return err;
}
#endif /* HAVE_OPENCL */
/* ---------------------------------------------------------------------- */
/* GUI */
/* ---------------------------------------------------------------------- */
static void _rescan(dt_iop_module_t *self)
{
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
g_list_free_full(g->entries, g_free);
g->entries = _scan_profiles(NULL);
/* the tables come from the same pack as the profiles, so they are rescanned
together: a reload that changes one changes the other */
g_list_free_full(g->tables, g_free);
g->tables = _scan_tables();
}
/* Entry at a list position, or NULL. The comboboxes carry the position as their
data, so this is how a user selection resolves back to a profile. */
static const sf_prof_entry_t *_entry_at(const dt_iop_spektrafilm_gui_data_t *g,
const int pos)
{
return (pos >= 0) ? g_list_nth_data(g->entries, pos) : NULL;
}
static void _update_print_sensitivity(dt_iop_module_t *self);
static void _update_development_sensitivity(const dt_iop_spektrafilm_gui_data_t *g,
const dt_iop_spektrafilm_params_t *p);
static float _development_default(const sf_prof_entry_t *e);
static void _rebaseline_print_development(dt_iop_module_t *self);
static void _sync_coupler_diffusion(dt_iop_spektrafilm_gui_data_t *g,
const sf_prof_entry_t *e);
/* forward: needs _entry_by_hash(), which is defined below with the rest of the
stock lookups */
static void _update_paper_auto_entry(dt_iop_module_t *self);
static const sf_prof_entry_t *_current_film_entry(const dt_iop_spektrafilm_gui_data_t *g,
const dt_iop_spektrafilm_params_t *p);
static const sf_prof_entry_t *_effective_paper_entry(const dt_iop_spektrafilm_gui_data_t *g,
const dt_iop_spektrafilm_params_t *p);
/* Stamp the spectral table this edit is being made against, at the point where
a user change has written its param and the history item has not been created
yet, so the value lands in the same edit.
Every path that records a user change has to call this, not just the
params-linked widgets that reach gui_changed(): the film and paper
comboboxes carry their own callbacks and commit history directly, and they
are the changes most likely to be an edit's first and only one. An edit that
never stamps keeps lut_hash 0, which reads as "no table recorded" -- the
mismatch warning in _ensure_sim() and the header row both skip on it, so the
edit silently renders against whatever pack is installed when it is reopened.
Only stamp when there is nothing to lose: no table recorded yet, or the
loaded pack is already the one recorded. Overwriting a DIFFERENT recorded
hash would throw away the only record of which pack renders this edit as it
was made, and it would happen on any incidental slider touch while the
mismatch warning was on screen saying the data was wrong. That record is what
the download button uses to fetch the right pack, so losing it turns a
fixable mismatch into a permanent one. */
static void _stamp_lut_hash(dt_iop_module_t *self)
{
dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
/* Nothing left to record once a table is recorded, which is every edit
after its first stamp. Both writes below are then no-ops: the table is
only ever set when it is 0 or already equal, and the pack only onto an
edit that names neither. Testing pack_hash here as well kept this from
ever returning early on a pack that declares no identity -- every
pack_format 2 pack -- so each slider step resolved the pack again,
several parses of pack.json on the GUI thread. */
if(p->lut_hash) return;
/* _pack is whichever pack a pipe loaded last, which can belong to another
image entirely. Only stamp it when it is the pack this edit resolves to */
char want_dir[SF_PATH_LEN];
if(_edit_pack_dir(p, want_dir, sizeof want_dir, NULL) != SF_EDIT_PACK_OK) return;
dt_pthread_mutex_lock(&_pack_lock);
if(_pack && strcmp(_pack_path, want_dir) == 0)
{
/* only an edit that names neither a table nor a pack is still unbound. One
that names a table but no pack was rendered on a pack without an
identity, and one reaching an identified pack now is on a substitute
whatever its table says: stamping there would make the substitution
permanent, surviving the moment its own pack comes back */
const gboolean unbound = !p->lut_hash && !p->pack_hash;
const uint32_t cur = sf_pack_lut_hash(_pack);
if(!p->lut_hash || p->lut_hash == cur) p->lut_hash = cur;
/* the pack is recorded only onto an unbound edit, and never overwritten:
that would erase the very mismatch the field exists to report */
const uint32_t curp = sf_pack_hash(_pack);
if(curp && unbound) p->pack_hash = curp;
}
dt_pthread_mutex_unlock(&_pack_lock);
}
/* both halves of the hanatos sensitivity adaptation belong to the irradiance
formula; sf_sim_build() does not run them for a reflectance table. Shown
inert there rather than left live, which would offer two switches that
change nothing */
static void _update_adaptation_sensitivity(const dt_iop_spektrafilm_gui_data_t *g,
const sf_table_entry_t *sel)
{
const gboolean reflectance = sel && sel->reflectance;
gtk_widget_set_sensitive(g->adaptation_bandwidth, !reflectance);
gtk_widget_set_sensitive(g->adaptation_surface, !reflectance);
}
static void _upsampling_changed(GtkWidget *w,
dt_iop_module_t *self)
{
if(darktable.gui->reset) return;
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
const sf_table_entry_t *e
= _table_at(g, GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->upsampling)));
if(!e) return;
/* the hash and not the list position: a pack revision can reorder its tables
or drop one, and the edit has to keep naming the table it was developed
against so the mismatch is reported rather than absorbed */
p->upsampling_hash = e->hash;
/* the chosen table may live in another pack than the one this edit currently
resolves to: picking a method a later pack added is the whole point of
listing them all. Release the recorded pack and table so resolution
follows the choice; both are stamped again on the next render, naming
whichever pack actually supplied it */
p->pack_hash = 0u;
p->lut_hash = 0u;
/* here as well as in gui_update(): picking a table is exactly when the pair
becomes inert or live again, and gui_update() does not re-run on a
combobox change */
_update_adaptation_sensitivity(g, e);
dt_dev_add_history_item(darktable.develop, self, TRUE);
}
static void _film_changed(GtkWidget *w,
dt_iop_module_t *self)
{
if(darktable.gui->reset) return;
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
const sf_prof_entry_t *e
= _entry_at(g, GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film)));
if(!e) return;
p->film_hash = e->hash;
/* A development time from the previous stock means nothing here -- the times
differ per stock, and a stale value can sit above the new stock's longest
(Double-X at 12 min, then 2302's family topping out at 9). Land on the new
stock's own default, so the slider always shows a time it actually has. */
p->development_min = _development_default(e);
DT_ENTER_GUI_UPDATE();
dt_bauhaus_slider_set(g->development_min, p->development_min);
DT_LEAVE_GUI_UPDATE();
/* 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 choose freely afterwards -- this only
re-baselines when the film itself changes, like the paper auto-follow.
The paper combobox is what expresses this state, so it moves with the
value: -2 for "none (scan the film)", -1 for "auto". */
if(p->scan_film != e->positive)
{
p->scan_film = e->positive;
/* the black/white point goes with it, as in upstream's scan-for-print
mode: a slide scans washed out without it, and a print is not fitted
unless asked */
p->scan_black_correction = p->scan_white_correction = e->positive;
/* leaving scan mode returns to whatever paper_hash names: "auto" when it
is 0, otherwise the pinned paper, which is still what the pipeline
prints on */
int ppos = SF_COMBO_PAPER_AUTO;
if(p->scan_film)
ppos = SF_COMBO_SCAN_FILM;
else if(p->paper_hash)
{
const sf_prof_entry_t *pe = _effective_paper_entry(g, p);
if(pe) ppos = g_list_index(g->entries, pe);
}
DT_ENTER_GUI_UPDATE();
dt_bauhaus_combobox_set_from_value(g->paper, ppos);
dt_bauhaus_toggle_set(g->scan_black_correction, p->scan_black_correction);
dt_bauhaus_toggle_set(g->scan_white_correction, p->scan_white_correction);
DT_LEAVE_GUI_UPDATE();
}
/* Unconditional: the paper combobox follows the film's own positive flag,
which can change while scan_film does not -- selecting "none" on a
negative and then switching to a positive stock leaves scan_film already
set, and the combobox would stay live on a film that has no print
stage. */
_update_print_sensitivity(self);
/* On "auto" (hash 0) the paper follows the film's target print and the
combobox keeps reading "auto" -- the selection is the link, not the
destination -- with the entry's own text naming the stock it resolves to.
Relabelled on every film change, so the entry is already correct whenever
the paper is set back to auto later. The pipeline resolves it identically
either way (_resolve_stock). */
_update_paper_auto_entry(self);
/* A film switch can move the automatic paper (Double-X resolves to print
film 2302, a color negative to a color paper), and the print time does
not transfer between them any more than the film time does. Only on auto:
an explicitly chosen paper has not changed, so neither should its time */
if(!p->paper_hash) _rebaseline_print_development(self);
/* moves the coupler spread sliders' reset targets onto this stock, without
touching the values the user set */
_sync_coupler_diffusion(g, e);
/* last, once scan_film and the auto-followed paper have settled: both
development sliders are gated on their own stock, and the print one also on
there being a print stage at all */
_update_development_sensitivity(g, p);
_stamp_lut_hash(self);
dt_dev_add_history_item(darktable.develop, self, TRUE);
}
static void _paper_changed(GtkWidget *w,
dt_iop_module_t *self)
{
if(darktable.gui->reset) return;
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
const int ppos = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->paper));
const gboolean scan = (ppos == SF_COMBO_SCAN_FILM);
if(p->scan_film != scan)
{
p->scan_film = scan;
_update_print_sensitivity(self);
}
if(ppos < 0)
{
/* auto, or no paper at all: drop the explicit choice so the film resolves
it again if the print stage comes back */
p->paper_hash = 0;
/* then take the time from whatever that resolves to. Zeroing here instead
would render the same (0 means the stock's own default) but leave the
slider reading 0 on a paper that has a whole family of times */
_rebaseline_print_development(self);
_update_development_sensitivity(g, p);
_stamp_lut_hash(self);
dt_dev_add_history_item(darktable.develop, self, TRUE);
return;
}
const sf_prof_entry_t *pe = _entry_at(g, ppos);
if(!pe) return;
p->paper_hash = pe->hash;
/* same as _film_changed: a time from the previous paper does not transfer */
p->print_development_min = _development_default(pe);
DT_ENTER_GUI_UPDATE();
dt_bauhaus_slider_set(g->print_development_min, p->print_development_min);
DT_LEAVE_GUI_UPDATE();
_update_development_sensitivity(g, p);
_stamp_lut_hash(self);
dt_dev_add_history_item(darktable.develop, self, TRUE);
}
/* Entry for a stock hash, or NULL. `printing` disambiguates, since the same
stock name can exist as both a film and a paper. */
static const sf_prof_entry_t *_entry_by_hash(const dt_iop_spektrafilm_gui_data_t *g,
const uint32_t hash,
const gboolean printing)
{
for(const GList *l = g->entries; l; l = l->next)
{
const sf_prof_entry_t *e = l->data;
if(e->hash == hash && e->printing == printing) return e;
}
return NULL;
}
/* The film the pipeline is rendering with: the stock this edit names, or -- for
a hash of 0, or a stock that has since left the pack -- the same fallback
gui_update() selects the combobox on, so the label below never names a film
the combobox does not show. */
static const sf_prof_entry_t *_current_film_entry(const dt_iop_spektrafilm_gui_data_t *g,
const dt_iop_spektrafilm_params_t *p)
{
const sf_prof_entry_t *hit = _entry_by_hash(g, p->film_hash, FALSE);
if(p->film_hash && hit) return hit;
const sf_prof_entry_t *fallback = NULL;
for(const GList *l = g->entries; l; l = l->next)
{
const sf_prof_entry_t *e = l->data;
if(e->printing) continue;
if(!fallback || !strcmp(e->stock, "kodak_gold_200")) fallback = e;
}
return fallback;
}
/* The paper the pipeline actually prints on while "auto" is selected: the film's
own target print when the pack ships it as a paper, otherwise the first print
stock in the list. The second half mirrors _resolve_stock()'s own last
resort, so the label names the paper in the two cases target_print alone does
not cover: a film that names no target print, and Double-X, whose target 2302
the pack exports as a film and not as a paper. */
static const sf_prof_entry_t *_auto_paper_entry(const dt_iop_spektrafilm_gui_data_t *g,
const sf_prof_entry_t *film)
{
const sf_prof_entry_t *first = NULL;
const sf_prof_entry_t *same_channel = NULL;
const char *target = film && film->target_print[0] ? film->target_print : NULL;
for(const GList *l = g->entries; l; l = l->next)
{
const sf_prof_entry_t *pe = l->data;
if(!pe->printing) continue;
if(!first) first = pe;
if(film && !same_channel && pe->bw == film->bw) same_channel = pe;
if(target && !strcmp(pe->stock, target)) return pe;
}
/* Three tiers, in order: the film's own named target print, then any print
stock with the same channel model, then the first printing entry there is.
The channel-model tier matters because target_print is optional in the pack
and the entry list is sorted by display name, so the first printing entry
bears no relation to the film -- for a black-and-white negative it is a
colour paper. Stocks that name no target and print nonetheless are rare
(kodak_doublex is the one in the current pack; the other untargeted stocks
are positives, which force scan_film and skip the print stage), so the last
tier is only reached when nothing in the pack matches at all. */
return same_channel ? same_channel : first;
}
/* Name the resolved paper in the "auto" entry itself, not only in the
tooltip. The combobox deliberately keeps reading "auto" while it follows a
film (the selection is the link, not the destination), so without this the
paper actually being printed on is visible only on hover. Renaming the entry
shows both at once and keeps the link intact -- the selection does not move,
only its text changes, so paper_hash stays 0.
With scan_film there is no print stage, but the entry keeps its name: on a
negative the combobox stays live while scanning and "auto" is one of the ways
back to printing, so it has to remain readable in the list. Only the tooltip
says that no paper is in use. */
static void _update_paper_auto_entry(dt_iop_module_t *self)
{
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
const dt_iop_spektrafilm_params_t *p = (const dt_iop_spektrafilm_params_t *)self->params;
if(!g || !g->paper) return;
char label[SF_NAME_LEN + 32];
char tip[SF_NAME_LEN + 256];
const sf_prof_entry_t *re = _auto_paper_entry(g, _current_film_entry(g, p));
/* the label names what selecting the entry would print on, scanning or not */
if(re)
snprintf(label, sizeof label, _("auto (%s)"), re->name);
else
g_strlcpy(label, _("auto"), sizeof label);
if(p->scan_film)
{
g_strlcpy(tip, _("print paper. not used: the film is being scanned directly."),
sizeof tip);
}
else if(re)
{
snprintf(tip, sizeof tip,
_("print paper. \"auto\" follows the film stock's own target print,\n"
"currently %s. picking a paper pins it until you select auto again."),
re->name);
}
else
{
/* no print stock in the pack at all -- the list below reads "(none)" */
g_strlcpy(tip, _("print paper. \"auto\" follows the film stock's own target print."),
sizeof tip);
}
/* The auto entry is added before any section header, so its list index is
fixed whatever paper groups the pack turns out to contain -- but it is not
necessarily the first entry, so use the index recorded when the list was
built rather than assuming one. */
dt_bauhaus_combobox_set_entry_label(g->paper, g->paper_auto_pos, label);
/* The widget renders the active entry's label straight out of that array at
draw time, so a relabel needs nothing but a redraw -- and does need one. */
gtk_widget_queue_draw(g->paper);
gtk_widget_set_tooltip_text(g->paper, tip);
}
/* Default development time for a stock, in minutes: the representative middle
member of its family, which is what select_development_time(None) picks. 0 when
the stock is characterised at a single development. */
static float _development_default(const sf_prof_entry_t *e)
{
return (e && e->n_dev > 1) ? (float)e->dev_times[(e->n_dev - 1) / 2] : 0.0f;
}
/* The inhibitor spread this stock ships, so the sliders can show real numbers
instead of the -1 sentinel the params carry for "ask the pack". Falls back to
the reference's own DirCouplersParams defaults when the pack has no entry for
the stock, which is also what sf_sim_build() lands on. */
static void _coupler_diffusion_default(const sf_prof_entry_t *e,
float *size_um,
float *tail_um,
float *tail_w)
{
double d = SF_COUPLER_BLUR_UM, t = 0.0, w = 0.0;
dt_pthread_mutex_lock(&_pack_lock);
if(_pack && e) sf_pack_film_coupler_diffusion(_pack, e->stock, &d, &t, &w);
dt_pthread_mutex_unlock(&_pack_lock);
if(w <= 0.0 || t <= 0.0) { t = 0.0; w = 0.0; }
*size_um = (float)d;
*tail_um = (float)t;
*tail_w = (float)w;
}
/* Point the three diffusion sliders' reset targets at this stock's own numbers,
leaving the values alone. The pack fits the spread per stock, so a reset
should land on what the loaded film ships -- but the values are the user's,
and a film change is not a reason to discard them. The development time above
is overwritten instead because a time from another stock may not exist on
this one; a spread in micrometres is meaningful on any emulsion. */
static void _sync_coupler_diffusion(dt_iop_spektrafilm_gui_data_t *g,
const sf_prof_entry_t *e)
{
float d, t, w;
_coupler_diffusion_default(e, &d, &t, &w);
DT_ENTER_GUI_UPDATE();
dt_bauhaus_slider_set_default(g->couplers_diffusion_um, d);
dt_bauhaus_slider_set_default(g->couplers_tail_um, t);
dt_bauhaus_slider_set_default(g->couplers_tail_weight, w);
DT_LEAVE_GUI_UPDATE();
}
/* Point one development slider at one stock: sensitive only where that stock is
characterised at more than one development time, spanning exactly the times it
offers, and naming them -- they differ per stock and the value snaps to them,
so a bare 0-15 range would be guesswork. */
static void _development_widget_update(GtkWidget *w,
const sf_prof_entry_t *e)
{
if(!w) return;
const gboolean have = (e && e->n_dev > 1);
/* Never hidden. The print slider is the only widget in the print "chemistry"
section, so hiding it leaves a collapsible with an empty body -- a heading
and a disclosure arrow that open onto nothing, which reads as a broken
panel rather than as an option that does not apply here. A stock
characterised at one development still has a development time, and the
slider shows it; it just cannot be moved, which greying it out says
plainly. */
gtk_widget_set_visible(w, TRUE);
gtk_widget_set_sensitive(w, have);
float hi = 15.0f;
if(have)
{
hi = (float)e->dev_times[0];
for(int i = 1; i < e->n_dev; i++) hi = fmaxf(hi, (float)e->dev_times[i]);
}
/* 0 stays reachable at the bottom -- it means "this stock's own default" -- and
the hard 15 min ceiling covers the widest family in the release (Double-X,
12 min) with room to spare. */
dt_bauhaus_slider_set_soft_range(w, 0.0f, hi);
/* Reset gestures (double-click, scroll-reset) go to the widget's own default,
which introspection set to the compiled 0. That renders correctly -- 0 means
"this stock's default" -- but leaves the slider reading 0 instead of the time
it resolved to, on the one discontinuity in the range. Point it at the real
number for the stock in hand, so a reset shows 6.5 min on Double-X and 5 min on 2302, not 0. */
dt_bauhaus_slider_set_default(w, _development_default(e));
if(have)
{
char times[128] = { 0 };
for(int i = 0; i < e->n_dev; i++)
{
char one[24];
snprintf(one, sizeof one, "%s%.3g", i ? ", " : "", e->dev_times[i]);
g_strlcat(times, one, sizeof times);
}
char tip[320];
snprintf(tip, sizeof tip,
_("development time, in minutes. snaps to the nearest time %s is\n"
"characterized at: %s min. 0 uses the stock's own default (%.3g min)."),
e->name, times, (double)_development_default(e));
gtk_widget_set_tooltip_text(w, tip);
}
else
gtk_widget_set_tooltip_text(w,
_("development time. this stock is characterized at a "
"single\n"
"development, so there is nothing to choose.\n"
"\n"
"the stocks that carry a family of times are "
"Double-X and print film 2302:\n"
"Double-X at 4/5/6.5/9/12 min, print film 2302 at "
"2/3.5/5/7/9 min."));
}
/* Film and print are separate chemistries developed for separate times, so they
get a slider each, pointed at their own stock. Lives in its own function
because every film / paper / scan_film change routes through here; driving
the sliders from gui_update() alone would leave them stale from the moment a
stock is switched until the module is next rebuilt. */
/* the paper actually being printed on, or NULL when there is no print stage.
"auto" prints on a real paper while leaving paper_hash at 0: the link is
the selection, the destination is resolved from the film's target print. A
hash lookup alone finds no paper on that selection, which would leave the
print slider dead at 0 min even when the paper it resolves to carries a whole
development family. Resolved the same way the combobox label and the pipeline
resolve it, so all three name one paper */
static const sf_prof_entry_t *_effective_paper_entry(const dt_iop_spektrafilm_gui_data_t *g,
const dt_iop_spektrafilm_params_t *p)
{
if(p->scan_film) return NULL;
const sf_prof_entry_t *pinned = p->paper_hash ? _entry_by_hash(g, p->paper_hash, TRUE) : NULL;
/* a pinned paper missing from the pack falls back the way _resolve_stock()
does, to the film's automatic paper */
return pinned ? pinned : _auto_paper_entry(g, _current_film_entry(g, p));
}
/* put the print development slider on the paper in force, as _film_changed()
does for the film: a time from the previous paper means nothing on this one,
and 0 ("this stock's own default") renders correctly but reads as though
nothing is set, on the one discontinuity in the range */
static void _rebaseline_print_development(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 sf_prof_entry_t *paper = _effective_paper_entry(g, p);
p->print_development_min = paper ? _development_default(paper) : 0.0f;
DT_ENTER_GUI_UPDATE();
dt_bauhaus_slider_set(g->print_development_min, p->print_development_min);
DT_LEAVE_GUI_UPDATE();
}
static void _update_development_sensitivity(const dt_iop_spektrafilm_gui_data_t *g,
const dt_iop_spektrafilm_params_t *p)
{
_development_widget_update(g->development_min, _entry_by_hash(g, p->film_hash, FALSE));
_development_widget_update(g->print_development_min, _effective_paper_entry(g, p));
}
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;
/* The paper combobox is gated on the film, not on `printing`. A positive or
reversal stock has no print stage at all, so a paper is not a choice there
and picking one would put the image through a print it never has. For a
negative the combobox stays live even while scanning, because it is the
control that turns scanning off again -- gating it on `printing` would
leave no way back. */
{
const sf_prof_entry_t *fe = _current_film_entry(g, p);
gtk_widget_set_sensitive(g->paper, !(fe && fe->positive));
}
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->print_gamma_r, printing);
gtk_widget_set_sensitive(g->print_gamma_g, printing);
gtk_widget_set_sensitive(g->print_gamma_b, printing);
/* the endpoint fit runs where the endpoints come from something other than
the medium being rendered: a scan of positive film, whose slide has base
density and never reaches D-max, and a print on negative-type paper, whose
range is set by the film behind it. A scan of a negative is not placed this
way, and positive paper carries its own black and white */
const sf_prof_entry_t *film_now = _current_film_entry(g, p);
const sf_prof_entry_t *paper_now = _effective_paper_entry(g, p);
const gboolean bw_point = printing ? (paper_now && !paper_now->positive)
: (film_now && film_now->positive);
gtk_widget_set_sensitive(g->scan_black_correction, bw_point);
gtk_widget_set_sensitive(g->scan_white_correction, bw_point);
/* a level does nothing while its correction is off */
gtk_widget_set_sensitive(g->scan_black_level, bw_point && p->scan_black_correction);
gtk_widget_set_sensitive(g->scan_white_level, bw_point && p->scan_white_correction);
gtk_widget_set_sensitive(g->filter_m, printing);
gtk_widget_set_sensitive(g->filter_y, printing);
gtk_widget_set_sensitive(g->print_diffusion_on, printing);
gtk_widget_set_sensitive(g->print_diffusion_filter_family, printing && p->print_diffusion_on);
gtk_widget_set_sensitive(g->print_diffusion_strength, printing && p->print_diffusion_on);
gtk_widget_set_sensitive(g->print_diffusion_scale, printing && p->print_diffusion_on);
gtk_widget_set_sensitive(g->print_diffusion_warmth, printing && p->print_diffusion_on);
gtk_widget_set_sensitive(g->preflash_exposure, printing);
gtk_widget_set_sensitive(g->preflash_m_shift, printing);
gtk_widget_set_sensitive(g->preflash_y_shift, printing);
/* viewing glare simulates light reflecting off the print surface, so it has
no effect once scan_film skips the print stage entirely. process() and
process_cl() already skip it (see the `!d->p.scan_film` guards), this
just keeps the slider from looking live when it does nothing. */
gtk_widget_set_sensitive(g->glare_percent, printing);
/* toggle_from_params checkboxes keep showing their tick even when made
insensitive -- GTK just dims the whole widget, so a checked-but-grayed
box can read as "this is still on" when it has no effect at all (no
print stage on positive/reversal film). Blank the tick while
insensitive and restore the real value once re-enabled. Wrapped in
DT_ENTER/LEAVE_GUI_UPDATE -- the same guard dt_iop_gui_update's own
programmatic widget syncs rely on -- so this is purely visual and
never writes back into the param. */
DT_ENTER_GUI_UPDATE();
dt_bauhaus_toggle_set(g->print_auto_exposure,
printing && p->print_auto_exposure);
dt_bauhaus_toggle_set(g->print_diffusion_on,
printing && p->print_diffusion_on);
DT_LEAVE_GUI_UPDATE();
/* The auto entry names the paper in use, and with no print stage there is
none: relabel from here, the one place every scan_film change passes. */
_update_paper_auto_entry(self);
/* Also from here: gui_changed() routes the scan_film toggle to this function
and not to _toggle_sensitivity(), so this is the call that greys the print
development slider out for a scan-the-film workflow, which has no print
stage at all. */
_update_development_sensitivity(g, p);
}
/* Grays out each effect's sub-controls when its master "enable" toggle is
off. A clickable slider under an unchecked toggle reads as if it still does
something. Covers halation, grain and both diffusion filters;
_update_print_sensitivity above does the same for the print controls.
Every gate here must match what process()/process_cl() actually skip: a
control the pipeline still reads has to stay live, whichever tab it sits
on. */
static void _toggle_sensitivity(dt_iop_spektrafilm_gui_data_t *g,
dt_iop_spektrafilm_params_t *p)
{
const gboolean hal = p->halation_on;
gtk_widget_set_sensitive(g->scatter_amount, hal);
gtk_widget_set_sensitive(g->scatter_scale, hal);
gtk_widget_set_sensitive(g->halation_amount, hal);
gtk_widget_set_sensitive(g->halation_scale, hal);
/* The highlight boost sits on the halation tab but is not halation's, and is
not the two spatial stages' either: it runs ahead of both (see process()'s
step 2) and process()/process_cl() reach it on boost_ev alone, with no
toggle consulted on the way. boost_ev is therefore the only gate that
matches the pipeline, and it gates only the two sliders that shape what it
does -- boost_ev itself stays live, or there would be no way to switch the
stage back on. */
const gboolean bst = p->boost_ev > 0.0f;
gtk_widget_set_sensitive(g->boost_range, bst);
gtk_widget_set_sensitive(g->protect_ev, bst);
const gboolean grn = p->grain_on;
gtk_widget_set_sensitive(g->grain_amount, grn);
gtk_widget_set_sensitive(g->grain_granularity, grn);
gtk_widget_set_sensitive(g->grain_uniformity, grn);
gtk_widget_set_sensitive(g->grain_sublayer_scale, grn);
gtk_widget_set_sensitive(g->grain_density_min, grn);
gtk_widget_set_sensitive(g->grain_blur, grn);
gtk_widget_set_sensitive(g->grain_dye_cloud, grn);
gtk_widget_set_sensitive(g->grain_usm_sigma, grn);
gtk_widget_set_sensitive(g->grain_usm_amount, grn);
const gboolean dif = p->diffusion_on;
gtk_widget_set_sensitive(g->diffusion_filter_family, dif);
gtk_widget_set_sensitive(g->diffusion_strength, dif);
gtk_widget_set_sensitive(g->diffusion_scale, dif);
gtk_widget_set_sensitive(g->diffusion_warmth, dif);
/* scan_film as well as the toggle, because _update_print_sensitivity() gates
these same four on `printing && print_diffusion_on` and this function is
reached without it: gui_changed() pairs the two only for the
print_diffusion_on widget, so a halation, grain, diffusion or boost_ev
change ran this alone and re-enabled all four with the print gate dropped.
print_diffusion_on stays TRUE while scanning -- _update_print_sensitivity()
blanks its tick but deliberately leaves the param -- so the sliders came
back live on a workflow that has no print stage at all. Same hazard the
development sliders are protected from at the end of this function. */
const gboolean pdif = p->print_diffusion_on && !p->scan_film;
gtk_widget_set_sensitive(g->print_diffusion_filter_family, pdif);
gtk_widget_set_sensitive(g->print_diffusion_strength, pdif);
gtk_widget_set_sensitive(g->print_diffusion_scale, pdif);
gtk_widget_set_sensitive(g->print_diffusion_warmth, pdif);
/* last: the development sliders are gated on their own stock's family, and
must not be re-enabled by any of the plain `printing` toggles above */
_update_development_sensitivity(g, p);
}
void gui_reset(dt_iop_module_t *self)
{
dt_iop_color_picker_reset(self, TRUE);
}
/* called by the core whenever a params-linked widget changed */
void gui_changed(dt_iop_module_t *self,
GtkWidget *w,
void *previous)
{
_stamp_lut_hash(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;
/* `w` is always a real widget. dt_iop_gui_changed() is reached only from
bauhaus's value-changed handlers, each passing the widget and a pointer to
the field's previous value, and nothing in the core calls it to refresh a
module's dependent widgets -- gui_update() calls the sensitivity helpers
directly for that.
boost_ev is in this list for the same reason the master toggles are: it
gates the two sliders under it. */
if(w == g->scan_black_correction || w == g->scan_white_correction)
_update_print_sensitivity(self);
if(w == g->halation_on || w == g->grain_on || w == g->diffusion_on
|| w == g->print_diffusion_on || w == g->boost_ev)
{
_toggle_sensitivity(g, p);
if(w == g->print_diffusion_on) _update_print_sensitivity(self);
}
if(w == g->print_auto_exposure && !*(gboolean *)previous && p->print_auto_exposure)
{
/* print_exposure_ev (manual) and print_auto_exposure (automatic) are
independent, always-additive factors -- matching the reference app's
own architecture (raw *= exposure_factor; raw *= enlarger.print_exposure,
two separate multiplications), not a mutually-exclusive pair.
Left alone, re-enabling auto stacks on top of whatever manual EV was
dialed in while it was off, which reads as "auto exposure is now
offset by the old manual value". Reset the manual slider on OFF->ON
so re-enabling auto gives a clean auto result to fine-tune from. */
p->print_exposure_ev = 0.0f;
dt_bauhaus_slider_set(g->print_exposure_ev, 0.0f);
}
}
/* ---------------------------------------------------------------------- */
/* data pack status row */
/* ---------------------------------------------------------------------- */
static gboolean _data_poll_cb(gpointer user_data);
/* Reflect pack state into the header row. Called whenever the module's trouble
state is refreshed and once per second while a download runs. */
static void _update_data_row(dt_iop_module_t *self)
{
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
const dt_iop_spektrafilm_params_t *p =
(const dt_iop_spektrafilm_params_t *)self->params;
if(!g || !g->data_box) return;
char msg[256] = { 0 };
double progress = 0.0;
const sf_fetch_state_t state = sf_fetch_status(msg, sizeof msg, &progress);
/* Is any pack usable at all, and is it the one this edit was made with?
Local-only, so this is cheap enough to answer on every refresh. */
char dir[SF_PATH_LEN];
uint32_t want_table = 0;
const sf_edit_pack_t st = _edit_pack_dir(p, dir, sizeof dir, &want_table);
/* the controls stay whenever something renders. A substitute renders too;
it is what the row below offers a way out of, not a reason to hide the
edit's own settings */
const gboolean have_any = st != SF_EDIT_PACK_NONE;
const gboolean exact = st == SF_EDIT_PACK_OK;
/* With no pack there is nothing any of the controls could act on: a film
list with no films, sliders driving a simulation that cannot be built.
Collapse the module to the one control that changes that, and bring the
rest back only once a pack is in place. */
if(g->main_box) gtk_widget_set_visible(g->main_box, have_any);
if(state == SF_FETCH_RUNNING)
{
/* started from the update row, which reports its own progress. Recorded
before returning all the same: the running-to-done transition below is
what rebuilds the GUI once a pack lands, and a fetch that never looked
like it was running never looks like it finished either: the module
would keep the table list it read before the download */
if(g->update_fetching)
{
g->data_last_state = state;
gtk_widget_set_visible(g->data_box, FALSE);
if(!g->data_poll) g->data_poll = g_timeout_add(500, _data_poll_cb, self);
return;
}
g->data_last_state = state;
/* Percentage first. The message that follows it is a filename and a file
count, which grows and shrinks as the download moves between files, so a
trailing percentage sits at a different place on every refresh and has to
be hunted for. Leading, it is always in the same column. Progress is -1
while a step has no measurable fraction, in which case there is nothing
to put in front and the message stands alone. */
char line[320];
if(progress >= 0.0)
snprintf(line, sizeof line, "%3d%% %s", (int)(progress * 100.0 + 0.5), msg);
else
g_strlcpy(line, msg, sizeof line);
gtk_label_set_text(GTK_LABEL(g->data_status), line);
gtk_button_set_label(GTK_BUTTON(g->data_button), _("cancel"));
gtk_widget_set_sensitive(g->data_button, TRUE);
gtk_widget_set_visible(g->data_box, TRUE);
if(!g->data_poll) g->data_poll = g_timeout_add(500, _data_poll_cb, self);
return;
}
if(g->data_poll)
{
g_source_remove(g->data_poll);
g->data_poll = 0;
}
/* A fetch just finished. Reprocessing alone is not enough to make the new
pack usable: the film and paper comboboxes were filled by _rescan() at a
time when no profiles existed, and nothing refills them on a pipe
reprocess. Without this the module renders but every stock list stays
empty, so no selection can be made and changing a slider appears to do
nothing. dt_iop_gui_update() re-runs gui_update(), which rescans. */
if(g->data_last_state == SF_FETCH_RUNNING && state == SF_FETCH_DONE
&& sf_fetch_generation() != g->data_gen)
{
g->data_last_state = state;
g->data_gen = sf_fetch_generation();
dt_iop_gui_update(self);
/* Drop the cached pipeline output from this module onwards before asking
for a reprocess.
The pixelpipe caches by a hash over module parameters, and installing a
pack changes none of them -- so the cacheline computed while the module
had no data (and therefore passed pixels through untouched) still
matches and gets reused. The symptom is a module that stays inert after
a successful download, starts working the moment any slider moves, and
goes inert again the instant that slider returns to its original value,
because that value hashes back onto the stale line. */
dt_develop_t *dev = darktable.develop;
if(dev)
{
dt_dev_pixelpipe_t *pipes[]
= { dev->full.pipe, dev->preview_pipe, dev->preview2.pipe };
for(size_t i = 0; i < sizeof(pipes) / sizeof(pipes[0]); i++)
if(pipes[i])
dt_dev_pixelpipe_cache_invalidate_later(pipes[i], self->iop_order,
"spektrafilm data pack installed");
dt_dev_reprocess_all(dev);
}
return; /* gui_update() calls back into here with the settled state */
}
g->data_last_state = state;
/* Nothing installed at all, or installed but not the pack this edit wants.
Those are the only two states worth offering a download for; anything else
leaves the row hidden. */
if(exact)
{
gtk_widget_set_visible(g->data_box, FALSE);
return;
}
/* the table rather than 0 when the edit names one: the manifest is ordered
oldest first, so asking for the table lands on the pack the edit was made
with, where asking for nothing lands on whatever is default */
g->data_wanted = want_table;
g->data_wanted_pack = p->pack_hash;
gtk_label_set_text(
GTK_LABEL(g->data_status),
have_any
? _("the data pack this edit was developed with is not installed")
: _("no data pack installed -- the module's controls appear once one is"));
/* a failed download says why in a toast that is gone in seconds; keep the
reason here, beside the button that retries it, until the next attempt */
{
char why[256] = { 0 };
if(sf_fetch_status(why, sizeof why, NULL) == SF_FETCH_FAILED && why[0])
{
gchar *both = g_strdup_printf("%s\n%s",
gtk_label_get_text(GTK_LABEL(g->data_status)), why);
gtk_label_set_text(GTK_LABEL(g->data_status), both);
g_free(both);
}
}
gtk_button_set_label(GTK_BUTTON(g->data_button), _("download data pack"));
gtk_widget_set_tooltip_text(g->data_button,
_("fetch the matching spectral data pack over the network"));
gtk_widget_set_visible(g->data_box, TRUE);
}
/* the update row, which asks a question the module cannot answer on its own:
whether the repository has moved on. Separate from the data row above, which
reports a pack missing right now: that belongs at the top of the module
because nothing renders without it, while this is housekeeping.
Offered rather than run on its own: it is network traffic nobody asked for,
and a pack fetched here is never adopted behind an existing edit. It
installs beside the one in use, and only a fresh edit or a deliberate table
choice renders on it */
static void _update_update_row(dt_iop_module_t *self)
{
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
if(!g || !g->update_box) return;
char msg[256] = { 0 };
double progress = -1.0;
const sf_fetch_state_t state = sf_fetch_status(msg, sizeof msg, &progress);
if(g->update_fetching && state == SF_FETCH_RUNNING)
{
/* reported here rather than in the row at the top of the module: that one
is about a pack missing right now, and a check or an update fetch is
neither missing nor urgent */
gtk_label_set_text(GTK_LABEL(g->update_status), msg);
gtk_button_set_label(GTK_BUTTON(g->update_button), _("cancel"));
if(progress >= 0.0)
gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(g->update_progress), progress);
else
gtk_progress_bar_pulse(GTK_PROGRESS_BAR(g->update_progress));
gtk_widget_set_visible(g->update_progress, TRUE);
return;
}
gtk_widget_set_visible(g->update_progress, FALSE);
if(g->update_fetching && state != SF_FETCH_RUNNING)
{
g->update_fetching = FALSE;
if(state == SF_FETCH_FAILED)
g_strlcpy(g->update_error, msg[0] ? msg : _("the data repository could not be reached"),
sizeof g->update_error);
}
const uint32_t avail = sf_fetch_available_pack();
char ver[32];
sf_fetch_available_version(ver, sizeof ver);
/* the pack the check found may since have been fetched, and nothing clears
avail_pack on install: ask the disk rather than keep offering it */
char installed_at[SF_PATH_LEN] = { 0 };
const gboolean now_installed =
avail && sf_fetch_pack_dir_for_pack_hash(avail, installed_at,
sizeof installed_at);
char label[96], status[96];
if(avail && ver[0])
{
g_snprintf(label, sizeof label, _("download data pack %s"), ver);
g_snprintf(status, sizeof status, _("data pack %s is available"), ver);
}
else
{
g_strlcpy(label, _("download data pack"), sizeof label);
g_strlcpy(status, _("a newer data pack is available"), sizeof status);
}
char done_msg[96];
if(now_installed && ver[0])
g_snprintf(done_msg, sizeof done_msg, _("data pack %s installed"), ver);
else
g_strlcpy(done_msg, _("data pack installed"), sizeof done_msg);
gtk_label_set_text(GTK_LABEL(g->update_status),
g->update_error[0] ? g->update_error
: now_installed ? done_msg
: avail ? status
: g->data_checked
? _("the installed data packs are up to date")
: "");
gtk_button_set_label(GTK_BUTTON(g->update_button),
(avail && !now_installed) ? label
: _("check for data pack updates"));
gtk_widget_set_tooltip_text(
g->update_button,
(avail && !now_installed)
? _("fetch the newer pack. it installs beside the one you have;\n"
"existing edits keep rendering on the pack they were made with.")
: _("ask the data repository whether a newer pack is published"));
}
static void _update_button_clicked(GtkButton *button,
dt_iop_module_t *self)
{
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
if(!g) return;
g->update_error[0] = 0;
/* the pack the last check found stays recorded after it is installed, and
the row then offers a check again; downloading it once more would answer
the button with the wrong action */
const uint32_t avail = sf_fetch_available_pack();
char installed_at[SF_PATH_LEN];
const gboolean avail_missing
= avail && !sf_fetch_pack_dir_for_pack_hash(avail, installed_at, sizeof installed_at);
if(sf_fetch_status(NULL, 0, NULL) == SF_FETCH_RUNNING)
sf_fetch_cancel();
else if(avail_missing)
{
g->update_fetching = TRUE;
sf_fetch_start(0u, avail);
}
else
{
/* set whether or not the check finds anything: it is what lets the row say
"up to date" rather than stay blank, which before a check has run would
claim knowledge it does not have */
g->data_checked = TRUE;
g->update_fetching = TRUE;
sf_fetch_check_start();
}
/* the worker answers on its own thread, so the row this call paints is the
one from before it started. Poll until it finishes, exactly as a download
does: _update_data_row() clears the timer once the fetch stops running,
and _data_poll_cb repaints both rows on every tick. Without this the row
keeps the answer it had when the button was pressed */
if(!g->data_poll) g->data_poll = g_timeout_add(500, _data_poll_cb, self);
_update_update_row(self);
}
static gboolean _data_poll_cb(gpointer user_data)
{
dt_iop_module_t *self = (dt_iop_module_t *)user_data;
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
if(!g || !g->data_box) return G_SOURCE_REMOVE;
_update_data_row(self);
_update_update_row(self);
/* _update_data_row clears data_poll once the fetch stops running, which is
also the signal to stop this timeout. */
return g->data_poll ? G_SOURCE_CONTINUE : G_SOURCE_REMOVE;
}
static void _data_button_clicked(GtkButton *button,
dt_iop_module_t *self)
{
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
if(!g) return;
if(sf_fetch_status(NULL, 0, NULL) == SF_FETCH_RUNNING)
sf_fetch_cancel();
else
sf_fetch_start(g->data_wanted, g->data_wanted_pack);
_update_data_row(self);
}
/* sim_error and sim_warning reach the user through the module's trouble
banner, darktable's own mechanism for this: a missing data pack, an
unreadable profile or a spectral-table mismatch would otherwise show only as
a blank or silently wrong render.
Refresh the banner from the main loop, posted by _ensure_sim() when the
status changed. Rechecks gui_data, since the module can be collapsed or the
darkroom left between the pipe run and this firing. */
static gboolean _trouble_idle_cb(gpointer user_data)
{
dt_iop_module_t *self = (dt_iop_module_t *)user_data;
dt_iop_spektrafilm_gui_data_t *g = self ? self->gui_data : NULL;
if(!g) return G_SOURCE_REMOVE;
/* Clear the id first: gui_cleanup() removes the source by it, and once this
callback is running there is nothing left to remove. */
dt_iop_gui_enter_critical_section(self);
g->trouble_idle = 0;
dt_iop_gui_leave_critical_section(self);
_update_trouble_message(self);
return G_SOURCE_REMOVE;
}
static void _update_trouble_message(dt_iop_module_t *self)
{
const dt_iop_spektrafilm_gui_data_t *g =
(const dt_iop_spektrafilm_gui_data_t *)self->gui_data;
_update_data_row(self);
if(!g) return;
/* Snapshot under the same section _publish_status() writes in: the writer is
a pixelpipe thread and this runs on GTK, so reading the arrays directly
races the copy. */
char err[sizeof g->status_error], warn[sizeof g->status_warning];
dt_iop_gui_enter_critical_section(self);
g_strlcpy(err, g->status_error, sizeof err);
g_strlcpy(warn, g->status_warning, sizeof warn);
dt_iop_gui_leave_critical_section(self);
if(err[0])
dt_iop_set_module_trouble_message(self, _("cannot render"), err, NULL);
else if(warn[0])
dt_iop_set_module_trouble_message(self, _("data mismatch"), warn, NULL);
else
dt_iop_set_module_trouble_message(self, NULL, NULL, NULL);
}
/* --- generated presets: begin --- */
/* The starting point every shipped preset is built from: a zeroed struct with
each non-zero introspection default written back, so a preset below only has
to name the parameters its own look moves.
Must list every field whose $DEFAULT is not zero: a field added to
dt_iop_spektrafilm_params_t and forgotten here ships in all 17 presets at
zero, which for a boolean defaulting to TRUE means the preset silently turns
the feature off.
The values are the presets' baseline, not a mirror of the annotations. They
track $DEFAULT wherever a preset has no opinion, but a preset was tuned
against the values in force when it was authored, so a later change to a
$DEFAULT does not propagate here -- see grain_blur_base below. Never
"resynchronise" this function against the struct without rendering the
presets that leave the field unset. */
static void _preset_defaults(dt_iop_spektrafilm_params_t *p)
{
memset(p, 0, sizeof(*p));
p->print_contrast = 1.0f;
p->print_gamma_r = p->print_gamma_g = p->print_gamma_b = 1.0f;
/* deliberately 0.8, not the 0.89 $DEFAULT: the presets were authored against
it, and none of them names grain_blur_base to say otherwise */
p->grain_blur_base = 0.8f;
p->upsampling_hash = 0u; /* the pack's default table */
p->pack_hash = 0u; /* resolve by table, as a preset must */
/* off, as the printing presets were authored; the scan presets switch
them on with scan_film, as choosing a positive stock does */
p->scan_black_correction = p->scan_white_correction = FALSE;
p->scan_black_level = 0.01f;
p->scan_white_level = 0.98f;
p->couplers_amount = 1.0f;
p->couplers_diffusion_um = 20.0f;
p->couplers_tail_um = 200.0f;
p->couplers_tail_weight = 0.03f;
p->couplers_inhibition_same = 1.0f;
p->couplers_inhibition_inter = 1.0f;
p->halation_on = TRUE;
p->scatter_amount = 1.0f;
p->scatter_scale = 1.0f;
p->halation_amount = 1.0f;
p->halation_scale = 1.0f;
p->boost_range = 0.3f;
p->protect_ev = 4.0f;
p->diffusion_strength = 0.5f;
p->diffusion_scale = 1.0f;
p->print_diffusion_strength = 0.5f;
p->print_diffusion_scale = 1.0f;
p->grain_on = TRUE;
p->grain_amount = 1.0f;
p->grain_blur = 1.0f;
p->grain_granularity = 1.0f;
p->grain_uniformity = 1.0f;
p->grain_sublayer_scale = 1.0f;
p->grain_density_min = 1.0f;
p->grain_dye_cloud = 1.0f;
p->film_format_mm = 36.0f;
p->output_luminance_boost = 1.0f;
p->output_scale = 1.0f;
p->grain_usm_sigma = 0.7f;
p->grain_usm_amount = 1.5f;
p->film_gamma_factor = 1.0f;
p->film_gamma_factor_fast = 1.0f;
p->film_gamma_factor_slow = 1.0f;
p->scan_usm_sigma = 0.7f;
p->scan_usm_amount = 0.7f;
p->adaptation_bandwidth = TRUE;
p->gamut_compress = TRUE;
}
void init_presets(dt_iop_module_so_t *self)
{
/* The scene-referred preset below exists only under one workflow, so the
built-in presets have to be rebuilt when that setting changes. Without this
flag _iop_preferences_changed() skips the module and the preset is created
only if the workflow already happened to be spektrafilm when the module was
first loaded -- selecting it later then does nothing until the library is
rebuilt. filmicrgb, sigmoid and agx all set it for the same reason. */
self->pref_based_presets = TRUE;
dt_iop_spektrafilm_params_t p;
/* The module is a view transform, so it takes the place of filmic, sigmoid or
agx and not a place beside them. Selecting "scene-referred (spektrafilm)"
auto-applies this preset and leaves those three unapplied, each of them
already testing for its own workflow by name.
Everything else the scene-referred workflow does still happens:
dt_is_scene_referred() counts this value, so exposure and colour
calibration are applied as usual. The film needs a correctly placed middle
grey to land on its curve, so removing exposure would work against the
module as much as against the workflow.
Defaults, with no stock named: film_hash 0 resolves to the pack's first
filming stock, which keeps the preset working whatever pack is installed.
FOR_RAW | FOR_MATRIX matches the other view transforms -- a scene-referred
starting point needs a camera the input profile can place. */
const char *workflow = dt_conf_get_string_const("plugins/darkroom/workflow");
if(!g_strcmp0(workflow, "scene-referred (spektrafilm)"))
{
_preset_defaults(&p);
/* The one preset that is a startup value rather than a look: it is
auto-applied to every new raw under this workflow, so it -- not the
introspection default -- is what a fresh image opens with. Fields whose
$DEFAULT is meant to reach new images are therefore named here as well,
since _preset_defaults() holds the baseline the shipped looks were
authored against and does not track later changes to the annotations. */
p.grain_blur_base = 0.89f;
dt_gui_presets_add_generic(_("scene-referred default"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
dt_gui_presets_update_format(BUILTIN_PRESET("scene-referred default"),
self->op, self->version(),
FOR_RAW | FOR_MATRIX);
dt_gui_presets_update_autoapply(BUILTIN_PRESET("scene-referred default"),
self->op, self->version(), TRUE);
}
/* look|crisp black and white -- by Bazdesh, shared as "BW_Crispy TriX"
Tri-X pushed a stop and a third onto a hard slow layer, scanner
sharpening well up, acutance recovery off so the grain stays as drawn. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_trix");
p.exposure_ev = 1.05f;
p.couplers_diffusion_um = 5.0f;
p.couplers_tail_um = 30.0f;
p.scan_film = TRUE;
p.scan_black_correction = p.scan_white_correction = TRUE;
p.halation_amount = 2.0f;
p.halation_scale = 4.0f;
p.diffusion_on = TRUE;
p.diffusion_strength = 0.375f;
p.grain_amount = 0.7f;
p.grain_blur = 0.75f;
p.grain_granularity = 0.7f;
p.grain_uniformity = 0.9f;
p.film_format_mm = 24.0f;
p.output_luminance_boost = 1.3f;
p.output_scale = 1.3f;
p.grain_usm_sigma = 0.0f;
p.grain_usm_amount = 0.0f;
p.film_gamma_factor = 0.9f;
p.film_gamma_factor_fast = 1.3f;
p.film_gamma_factor_slow = 2.0f;
p.push_pull_stops = 1.3f;
p.scan_usm_sigma = 1.7f;
p.scan_usm_amount = 1.0f;
p.adaptation_bandwidth = FALSE;
dt_gui_presets_add_generic(_("look|crisp black and white"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* film|black and white -- by Bazdesh, shared as "BW_Low-cal TriX"
The same stock pulled instead: flat fast and slow layers under a raised
overall gamma, lifted on the output scale rather than in development. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_trix");
p.exposure_ev = -0.7f;
p.couplers_diffusion_um = 5.0f;
p.couplers_tail_um = 30.0f;
p.scan_film = TRUE;
p.scan_black_correction = p.scan_white_correction = TRUE;
p.grain_blur = 0.75f;
p.grain_granularity = 0.4f;
p.output_scale = 1.65f;
p.grain_usm_sigma = 0.0f;
p.grain_usm_amount = 0.0f;
p.film_gamma_factor = 1.7f;
p.film_gamma_factor_fast = 0.4f;
p.film_gamma_factor_slow = 0.6f;
p.push_pull_stops = -0.7f;
p.scan_usm_sigma = 1.7f;
p.scan_usm_amount = 1.0f;
p.adaptation_bandwidth = FALSE;
dt_gui_presets_add_generic(_("film|black and white"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* look|split-toned -- by Bazdesh, shared as "BW_Split-toned"
Double-X printed onto colour paper, which is where the split tone comes
from; the M/Y filtration sets which way each end of the scale leans. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_doublex");
p.paper_hash = _name_hash("fujifilm_crystal_archive_typeii");
p.exposure_ev = -0.7f;
p.filter_m = 5.0f;
p.filter_y = -3.0f;
p.couplers_diffusion_um = 5.0f;
p.couplers_tail_um = 30.0f;
p.preflash_exposure = 0.03f;
p.diffusion_on = TRUE;
p.diffusion_strength = 0.25f;
p.grain_blur = 0.75f;
p.grain_granularity = 0.5f;
p.output_scale = 1.2f;
p.grain_usm_sigma = 0.0f;
p.grain_usm_amount = 0.0f;
p.film_gamma_factor = 1.7f;
p.film_gamma_factor_fast = 0.5f;
p.film_gamma_factor_slow = 0.7f;
p.push_pull_stops = -1.0f;
p.scan_usm_sigma = 1.7f;
p.scan_usm_amount = 1.0f;
p.glare_percent = 0.03f;
p.development_min = 6.5f;
p.adaptation_bandwidth = FALSE;
dt_gui_presets_add_generic(_("look|split-toned"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* look|bleach bypass -- by Boris Hajdukovic, shared as "bleach bypass
ryan"
Silver retention emulated by driving same-layer inhibition to maximum
and interlayer to zero: black-and-white contrast under a muted palette. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_vision3_200t");
p.paper_hash = _name_hash("kodak_2393");
p.exposure_ev = 1.39f;
p.filter_m = 4.43f;
p.filter_y = -0.15f;
p.couplers_amount = 0.99f;
p.couplers_inhibition_same = 3.0f;
p.couplers_inhibition_inter = 0.0f;
p.preflash_m_shift = -1.07f;
p.preflash_y_shift = -0.76f;
p.diffusion_on = TRUE;
p.diffusion_filter_family = DT_SPEKTRAFILM_DIFF_CINEBLOOM;
p.diffusion_strength = 0.183f;
p.diffusion_scale = 4.0f;
p.diffusion_warmth = 0.01f;
p.film_format_mm = 24.89f;
p.film_gamma_factor = 0.72f;
p.film_gamma_factor_fast = 0.52f;
p.film_gamma_factor_slow = 1.69f;
p.push_pull_stops = 1.25f;
p.scan_usm_sigma = 2.73f;
dt_gui_presets_add_generic(_("look|bleach bypass"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* film|cinema -- by Cristian, shared as "Kodak Vision3 250D"
The same stock left on its own print target and steered cool-magenta
through filtration and a strongly shifted preflash. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_vision3_250d");
p.exposure_ev = 0.26f;
p.filter_m = -3.75f;
p.filter_y = -7.5f;
p.couplers_inhibition_same = 1.93f;
p.couplers_inhibition_inter = 0.5f;
p.preflash_exposure = 0.02f;
p.preflash_m_shift = -30.22f;
p.preflash_y_shift = 35.74f;
p.output_luminance_boost = 1.19f;
dt_gui_presets_add_generic(_("film|cinema"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* look|kino 250D -- by Bazdesh, shared as "Kino250D"
Vision3 250D on 2383 print stock, pulled and lifted hard on the pre-
compression boost, with wide halation and a cool diffusion halo. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_vision3_250d");
p.paper_hash = _name_hash("kodak_2383");
p.exposure_ev = -0.7f;
p.print_contrast = 1.2f;
p.filter_m = -3.0f;
p.filter_y = -4.5f;
p.couplers_diffusion_um = 5.0f;
p.couplers_tail_um = 30.0f;
p.couplers_inhibition_same = 0.7f;
p.couplers_inhibition_inter = 0.6f;
p.preflash_exposure = 0.03f;
p.preflash_m_shift = 5.0f;
p.preflash_y_shift = 10.0f;
p.halation_amount = 2.0f;
p.halation_scale = 4.0f;
p.diffusion_on = TRUE;
p.diffusion_strength = 0.125f;
p.diffusion_scale = 0.5f;
p.diffusion_warmth = -1.5f;
p.grain_amount = 0.5f;
p.grain_granularity = 0.9f;
p.output_luminance_boost = 1.9f;
p.grain_usm_sigma = 0.0f;
p.grain_usm_amount = 0.0f;
p.film_gamma_factor = 0.9f;
p.film_gamma_factor_fast = 0.7f;
p.film_gamma_factor_slow = 1.1f;
p.push_pull_stops = -0.85f;
p.scan_usm_sigma = 1.4f;
p.scan_usm_amount = 0.8f;
dt_gui_presets_add_generic(_("look|kino 250D"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* film|classic -- by Cristian, shared as "Kodachrome 64"
The gentler Kodachrome: no push, the separation carried by strong same-
layer and weak interlayer inhibition instead of by contrast. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_kodachrome_64");
p.couplers_diffusion_um = 23.6f;
p.couplers_inhibition_same = 1.54f;
p.couplers_inhibition_inter = 0.3f;
p.scan_film = TRUE;
p.scan_black_correction = p.scan_white_correction = TRUE;
p.film_gamma_factor_slow = 1.32f;
dt_gui_presets_add_generic(_("film|classic"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* film|contrast -- by Roberto Abella, shared as "C-200 [Contrast]
[Best for: Color Calibrated - ETTL]"
C200 on Endura Premier at a stop over, half-strength couplers and
doubled grain under strong viewing glare. Expects a calibrated, right-
biased exposure. */
_preset_defaults(&p);
p.film_hash = _name_hash("fujifilm_c200");
p.paper_hash = _name_hash("kodak_endura_premier");
p.exposure_ev = 1.05f;
p.couplers_amount = 0.5f;
p.grain_amount = 2.0f;
p.film_format_mm = 24.0f;
p.film_gamma_factor = 0.9f;
p.film_gamma_factor_fast = 0.8f;
p.glare_percent = 0.75f;
p.adaptation_surface = TRUE;
dt_gui_presets_add_generic(_("film|contrast"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* film|neutral -- by Roberto Abella, shared as "X-Tra 400 [00] [Best
for: Color Calibrated - well exposed]"
X-Tra 400 on its own print target, half-strength couplers, lifted on
both sides of the gamut compressor. Expects a calibrated, well exposed
frame. */
_preset_defaults(&p);
p.film_hash = _name_hash("fujifilm_xtra_400");
p.couplers_amount = 0.5f;
p.output_luminance_boost = 1.5f;
p.output_scale = 1.2056f;
p.glare_percent = 0.25f;
p.adaptation_surface = TRUE;
dt_gui_presets_add_generic(_("film|neutral"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* film|soft pastel -- by Bazdesh, shared as "Pastel C200"
C200 preflashed hard toward green, printed through a tight print-side
diffusion filter, grain coarsened to sit on top of the flattened colour. */
_preset_defaults(&p);
p.film_hash = _name_hash("fujifilm_c200");
p.paper_hash = _name_hash("fujifilm_crystal_archive_typeii");
p.exposure_ev = 1.0f;
p.print_exposure_ev = -0.2f;
p.filter_m = 5.0f;
p.filter_y = -1.0f;
p.couplers_amount = 0.75f;
p.couplers_diffusion_um = 5.0f;
p.couplers_tail_um = 15.0f;
p.couplers_inhibition_inter = 0.9f;
p.preflash_exposure = 0.05f;
p.preflash_m_shift = -23.5f;
p.preflash_y_shift = -30.0f;
p.halation_scale = 3.0f;
p.print_diffusion_on = TRUE;
p.print_diffusion_strength = 0.75f;
p.print_diffusion_scale = 0.5f;
p.grain_amount = 1.3f;
p.grain_granularity = 1.7f;
p.output_luminance_boost = 1.25f;
p.output_scale = 1.05f;
p.grain_usm_sigma = 0.0f;
p.grain_usm_amount = 0.0f;
p.film_gamma_factor = 0.9f;
p.film_gamma_factor_fast = 0.9f;
p.film_gamma_factor_slow = 1.7f;
p.push_pull_stops = 0.8f;
p.scan_usm_sigma = 1.2f;
p.scan_usm_amount = 1.0f;
dt_gui_presets_add_generic(_("film|soft pastel"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* film|warm -- by Cristian, shared as "Kodak Portra 800"
Portra 800 pulled slightly, warmed through magenta filtration and an
offsetting preflash, then lifted on the pre-compression boost. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_portra_800");
p.exposure_ev = 0.53f;
p.print_exposure_ev = 0.13f;
p.filter_m = 10.37f;
p.filter_y = -2.87f;
p.couplers_inhibition_same = 1.51f;
p.couplers_inhibition_inter = 0.81f;
p.preflash_exposure = 0.04f;
p.preflash_m_shift = -28.46f;
p.preflash_y_shift = -33.97f;
p.output_luminance_boost = 1.64f;
p.film_gamma_factor_slow = 1.32f;
p.push_pull_stops = -0.32f;
dt_gui_presets_add_generic(_("film|warm"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* film|portrait -- by Roberto Abella, shared as "Kodak Portra 400
[Bright - Endura Premier - No Glare] [Best for: slightly ETTL]"
Portra 400 on Endura Premier, exposed well up with soft fast and slow
layers and half the developer exhausted. Expects a slightly right-biased
exposure. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_portra_400");
p.paper_hash = _name_hash("kodak_endura_premier");
p.exposure_ev = 1.4f;
p.couplers_amount = 0.3f;
p.output_luminance_boost = 1.3f;
p.output_scale = 1.3f;
p.film_gamma_factor_fast = 0.64f;
p.film_gamma_factor_slow = 0.44f;
p.film_developer_exhaustion = 0.5f;
p.adaptation_surface = TRUE;
dt_gui_presets_add_generic(_("film|portrait"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* film|portrait contrast -- by Roberto Abella, shared as "Kodak Portra 400
[Contrast - Endura Premier - Glare] [Best for: Color Calibrated - well
exposed]"
The same pairing left at box exposure, its contrast coming from
exhausted development and a heavy viewing-glare simulation. Expects a
calibrated, well exposed frame. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_portra_400");
p.paper_hash = _name_hash("kodak_endura_premier");
p.couplers_amount = 0.25f;
p.film_developer_exhaustion = 0.45f;
p.glare_percent = 0.82f;
dt_gui_presets_add_generic(_("film|portrait contrast"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* look|vivid -- by Bazdesh, shared as "Velveetia"
Velvia 100 scanned direct, with the interlayer inhibition pulled back so
the saturation the stock is known for survives the push. */
_preset_defaults(&p);
p.film_hash = _name_hash("fujifilm_velvia_100");
p.exposure_ev = 0.9f;
p.couplers_diffusion_um = 5.0f;
p.couplers_tail_um = 15.0f;
p.couplers_inhibition_same = 1.2f;
p.couplers_inhibition_inter = 0.6f;
p.scan_film = TRUE;
p.scan_black_correction = p.scan_white_correction = TRUE;
p.halation_scale = 4.0f;
p.output_scale = 1.3f;
p.grain_usm_sigma = 0.0f;
p.grain_usm_amount = 0.0f;
p.film_gamma_factor = 0.8f;
p.film_gamma_factor_fast = 1.1f;
p.film_gamma_factor_slow = 2.0f;
p.push_pull_stops = 0.7f;
p.scan_usm_sigma = 1.5f;
p.scan_usm_amount = 1.0f;
p.adaptation_bandwidth = FALSE;
dt_gui_presets_add_generic(_("look|vivid"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* look|skodachrome -- by Bazdesh, shared as "Skodachrome"
Kodachrome 64 scanned rather than printed, pushed and steepened in the
slow layer: the punchiest of the three reversal looks. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_kodachrome_64");
p.exposure_ev = 1.1f;
p.couplers_amount = 0.6f;
p.couplers_diffusion_um = 5.0f;
p.couplers_tail_um = 15.0f;
p.couplers_inhibition_inter = 0.6f;
p.scan_film = TRUE;
p.scan_black_correction = p.scan_white_correction = TRUE;
p.halation_scale = 4.0f;
p.output_scale = 1.3f;
p.grain_usm_sigma = 0.0f;
p.grain_usm_amount = 0.0f;
p.film_gamma_factor = 0.8f;
p.film_gamma_factor_fast = 1.1f;
p.film_gamma_factor_slow = 2.0f;
p.push_pull_stops = 0.8f;
p.scan_usm_sigma = 1.5f;
p.scan_usm_amount = 1.0f;
p.adaptation_bandwidth = FALSE;
dt_gui_presets_add_generic(_("look|skodachrome"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* look|foliage -- by Bazdesh, shared as "Gold200_foliage"
Gold 200 on Endura Premier with the print contrast up and heavy yellow
filtration, aimed at green subjects. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_gold_200");
p.paper_hash = _name_hash("kodak_endura_premier");
p.exposure_ev = -0.2f;
p.print_contrast = 1.3f;
p.filter_m = 4.5f;
p.filter_y = 9.5f;
p.couplers_amount = 0.85f;
p.couplers_diffusion_um = 5.0f;
p.couplers_tail_um = 20.0f;
p.preflash_exposure = 0.03f;
p.diffusion_strength = 0.125f;
p.diffusion_scale = 0.5f;
p.diffusion_warmth = -1.5f;
p.grain_granularity = 1.1f;
p.grain_usm_sigma = 0.0f;
p.grain_usm_amount = 0.0f;
p.film_gamma_factor = 0.9f;
p.film_gamma_factor_fast = 0.9f;
p.film_gamma_factor_slow = 1.1f;
p.push_pull_stops = 0.45f;
p.scan_usm_sigma = 1.2f;
p.adaptation_bandwidth = FALSE;
dt_gui_presets_add_generic(_("look|foliage"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
/* look|glow -- by Bazdesh, shared as "KinoGolden"
Gold 200 on Ultra Endura, pushed half a stop into warm filtration, with
the wide halation and cool diffusion halo of the cinema pair. */
_preset_defaults(&p);
p.film_hash = _name_hash("kodak_gold_200");
p.paper_hash = _name_hash("kodak_ultra_endura");
p.exposure_ev = 0.45f;
p.filter_m = 4.0f;
p.filter_y = -5.0f;
p.couplers_diffusion_um = 5.0f;
p.couplers_tail_um = 30.0f;
p.couplers_inhibition_same = 0.7f;
p.couplers_inhibition_inter = 0.6f;
p.preflash_exposure = 0.03f;
p.preflash_m_shift = 0.5f;
p.halation_amount = 2.0f;
p.halation_scale = 4.0f;
p.diffusion_on = TRUE;
p.diffusion_strength = 0.125f;
p.diffusion_scale = 0.5f;
p.diffusion_warmth = -1.5f;
p.grain_granularity = 1.1f;
p.output_luminance_boost = 1.2f;
p.output_scale = 1.3f;
p.grain_usm_sigma = 0.0f;
p.grain_usm_amount = 0.0f;
p.film_gamma_factor = 0.9f;
p.film_gamma_factor_fast = 0.9f;
p.film_gamma_factor_slow = 1.1f;
p.push_pull_stops = 0.5f;
p.scan_usm_sigma = 1.2f;
dt_gui_presets_add_generic(_("look|glow"), self->op,
self->version(), &p, sizeof(p), TRUE,
DEVELOP_BLEND_CS_RGB_SCENE);
}
/* --- generated presets: end --- */
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);
_update_update_row(self);
/* Films and papers share one list; e->printing separates them, and each
combobox entry carries its position in that list as its data. */
static const struct { int pos; int bw; const char *label; } groups[] = {
{ 0, 0, N_("negative color") },
{ 0, 1, N_("negative monochrome") },
{ 1, 0, N_("positive color") },
{ 1, 1, N_("positive monochrome") },
};
static const struct { int bw; const char *label; } pgroups[] = {
{ 0, N_("color") },
{ 1, N_("monochrome") },
};
dt_bauhaus_combobox_clear(g->film);
gboolean any_film = FALSE;
for(int gi = 0; gi < 4; gi++)
{
gboolean first = TRUE;
int pos = 0;
for(const GList *l = g->entries; l; l = l->next, pos++)
{
const sf_prof_entry_t *e = l->data;
if(e->printing || e->positive != groups[gi].pos || e->bw != groups[gi].bw) continue;
if(first) { dt_bauhaus_combobox_add_section(g->film, _(groups[gi].label)); first = FALSE; }
dt_bauhaus_combobox_add_full(g->film, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
GINT_TO_POINTER(pos), NULL, TRUE);
any_film = TRUE;
}
}
/* Insensitive as well as carrying a sentinel: a placeholder describes the
pack, it is not a choice, and _film_changed() has no stock to resolve. */
if(!any_film)
dt_bauhaus_combobox_add_full(g->film, _("(no profiles found)"),
DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
GINT_TO_POINTER(SF_COMBO_NO_PROFILES), NULL, FALSE);
dt_bauhaus_combobox_clear(g->paper);
/* Two entries ahead of the stocks, both expressing a state rather than a
paper, so the combobox alone can reach every configuration and no separate
reset button is needed.
"auto" (data -1) is paper_hash 0: follow the film's target print, the
state a fresh edit starts in and the one _film_changed() keeps updating.
Naming it in the list is what makes it selectable again after a specific
paper has been picked.
"none (scan the film)" (data -2) is scan_film: the pipeline treats "no
print stage" and "scan the film" as one state, so it belongs in the same
control. Both data values stay clear of the list positions used below. */
dt_bauhaus_combobox_add_full(g->paper, _("none (scan the film)"),
DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
GINT_TO_POINTER(SF_COMBO_SCAN_FILM), NULL, TRUE);
/* ask the list where auto is about to land rather than assuming a position */
g->paper_auto_pos = dt_bauhaus_combobox_length(g->paper);
dt_bauhaus_combobox_add_full(g->paper, _("auto (follow film stock)"),
DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
GINT_TO_POINTER(SF_COMBO_PAPER_AUTO), NULL, TRUE);
gboolean any_paper = FALSE;
for(int gi = 0; gi < 2; gi++)
{
gboolean first = TRUE;
int pos = 0;
for(const GList *l = g->entries; l; l = l->next, pos++)
{
const sf_prof_entry_t *e = l->data;
if(!e->printing || e->bw != pgroups[gi].bw) continue;
if(first) { dt_bauhaus_combobox_add_section(g->paper, _(pgroups[gi].label)); first = FALSE; }
dt_bauhaus_combobox_add_full(g->paper, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
GINT_TO_POINTER(pos), NULL, TRUE);
any_paper = TRUE;
}
}
if(!any_paper)
dt_bauhaus_combobox_add_full(g->paper, _("(none)"),
DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
GINT_TO_POINTER(SF_COMBO_NO_PROFILES), NULL, FALSE);
/* rebuild the spectral upsampling list from every installed pack */
dt_bauhaus_combobox_clear(g->upsampling);
{
const int ntab = g_list_length(g->tables);
/* shown even when the pack offers one table, so the control is where the
user expects it and names what is in use: hiding it makes a pack that
carries fewer tables than expected look like a build without the
feature. Insensitive rather than absent when there is nothing to pick */
gtk_widget_set_visible(g->upsampling, TRUE);
gtk_widget_set_sensitive(g->upsampling, ntab > 1);
int tpos = -1, ti = 0;
for(const GList *l = g->tables; l; l = l->next, ti++)
{
const sf_table_entry_t *e = l->data;
dt_bauhaus_combobox_add_full(g->upsampling, e->label, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
GINT_TO_POINTER(ti), NULL, TRUE);
if(p->upsampling_hash && e->hash == p->upsampling_hash) tpos = ti;
}
/* with no table chosen the edit renders on its pack's default, which is
what lut_hash records once stamped. Position 0 is only the first pack's
default, so it is the answer only when nothing is recorded. A chosen
table that is not installed renders on the substitute's default and is
reported by the banner */
for(const GList *l = g->tables; tpos < 0 && p->lut_hash && l; l = l->next)
if(((const sf_table_entry_t *)l->data)->hash == p->lut_hash)
tpos = g_list_position(g->tables, (GList *)l);
if(tpos < 0) tpos = 0;
if(ntab) dt_bauhaus_combobox_set_from_value(g->upsampling, tpos);
const sf_table_entry_t *sel = _table_at(g, tpos);
_update_adaptation_sensitivity(g, sel);
}
/* Select the saved film. On no hash match -- a fresh param with film_hash 0,
or a stock that vanished from the pack -- mirror _resolve_stock's fallback
so the combobox agrees with what the pipeline actually renders, instead of
landing on whatever sorts first while the pipe renders the real default. */
int fpos = -1, fallback = -1, pos = 0;
const sf_prof_entry_t *fe = NULL;
for(const GList *l = g->entries; l; l = l->next, pos++)
{
const sf_prof_entry_t *e = l->data;
if(e->printing) continue;
if(fallback < 0 || !strcmp(e->stock, "kodak_gold_200")) fallback = pos;
if(p->film_hash && e->hash == p->film_hash) { fpos = pos; fe = e; }
}
if(fpos < 0) fpos = fallback;
if(fpos >= 0)
{
if(!fe) fe = _entry_at(g, fpos);
dt_bauhaus_combobox_set_from_value(g->film, fpos);
}
/* p->scan_film is deliberately not re-derived from the film here: the loaded
value may be an intentional override of the stock's natural mode and has to
survive the load. Only _film_changed() re-baselines it, and only when the
film itself changes. */
const char *target = fe ? fe->target_print : NULL;
int ppos = SF_COMBO_PAPER_AUTO, pfirst = -1;
pos = 0;
for(const GList *l = g->entries; l; l = l->next, pos++)
{
const sf_prof_entry_t *e = l->data;
if(!e->printing) continue;
if(pfirst < 0) pfirst = pos;
if(p->paper_hash ? (e->hash == p->paper_hash) : (target && !strcmp(e->stock, target)))
ppos = pos;
}
/* an edit that never picked a paper shows "auto", not the stock it happens to
resolve to -- otherwise the link looks broken the moment it is displayed.
A picked paper the pack no longer carries shows the paper the pipeline
falls back to, which _resolve_stock() takes in the same order as
_auto_paper_entry(). */
if(!p->paper_hash) ppos = SF_COMBO_PAPER_AUTO;
else if(ppos < 0)
{
const sf_prof_entry_t *fb = _auto_paper_entry(g, fe);
ppos = fb ? g_list_index(g->entries, fb) : pfirst;
}
/* scanning outranks any stored paper: it is the state the pipeline is in */
if(p->scan_film) ppos = SF_COMBO_SCAN_FILM;
dt_bauhaus_combobox_set_from_value(g->paper, ppos);
/* after the repopulation above, which reset the auto entry to its plain
label: the entry names the paper this film resolves to, so a module that
opens on auto shows the paper it is really printing on */
_update_paper_auto_entry(self);
{
const int fpreset = _format_mm_to_preset(p->film_format_mm);
dt_bauhaus_combobox_set_from_value(g->film_format_combo, fpreset);
gtk_widget_set_visible(g->film_format_mm_slider,
fpreset < 0 || fpreset >= FORMAT_PRESETS_N);
}
/* toggle_from_params check buttons are NOT auto-synced by
dt_bauhaus_update_from_field (it only handles sliders/combos), so set
them here or they drift from the params: a stale box makes the first
click a no-op (field already has that value -> no history item) and
module reset never updates them. */
dt_bauhaus_toggle_set(g->adaptation_bandwidth, p->adaptation_bandwidth);
dt_bauhaus_toggle_set(g->adaptation_surface, p->adaptation_surface);
dt_bauhaus_toggle_set(g->print_auto_exposure, p->print_auto_exposure);
dt_bauhaus_toggle_set(g->halation_on, p->halation_on);
dt_bauhaus_toggle_set(g->diffusion_on, p->diffusion_on);
dt_bauhaus_toggle_set(g->print_diffusion_on, p->print_diffusion_on);
dt_bauhaus_toggle_set(g->grain_on, p->grain_on);
_toggle_sensitivity(g, p);
_update_print_sensitivity(self);
_update_trouble_message(self);
}
/* Boost that puts the probe lightness of `rgb` at `target_L`.
*
* Solved numerically, because L does not scale as boost^(1/3). That identity
* would hold only if the scan stage were a pure scale on XYZ; sf_sim_scan
* applies the scanner black/white-point correction after the boost, and that
* correction is affine and clipped -- the delivered luminance is
* clamp(m * boost * Y + q, 0, 1), so L goes as boost^a with a < 1/3. A
* closed-form update from one probe converges on the answer over repeated
* picks without reaching it. (scan_bw_on is set for a scan of positive film
* and for a print on negative paper with the print fit on.)
*
* boost -> L is monotone non-decreasing and one probe is a single pixel through
* the sim, so geometric bisection over the slider's range is exact and cheap:
* 20 steps pin the answer to ~2e-6 of the range. The result does not depend on
* the current slider value, so picking twice gives the same number. */
static float _solve_boost_for_lightness(const sf_sim_t *sim,
const float rgb[3],
const float target_L)
{
float lo = 0.5f, hi = 4.0f; /* the slider's own $MIN / $MAX */
if(sf_sim_probe_lightness(sim, rgb, lo) >= target_L) return lo;
if(sf_sim_probe_lightness(sim, rgb, hi) <= target_L) return hi;
for(int i = 0; i < 20; i++)
{
const float mid = sqrtf(lo * hi);
if(sf_sim_probe_lightness(sim, rgb, mid) < target_L) lo = mid;
else hi = mid;
}
return sqrtf(lo * hi);
}
/* The post-compression output scale, solved in its own slot.
*
* out_scale multiplies the finished RGB uniformly and RGB->XYZ is linear, so
* L(scale) = scale^(1/3) * L(1) exactly -- one probe at 1.0 gives the answer in
* closed form. Probing at 1.0 rather than at the current value is what makes
* the result independent of where the slider already sits, so picking the same
* area twice returns the same number.
*
* The compressor stays enabled and the boost keeps its real value here, unlike
* the boost probe: this factor is applied after both, so a measurement taken
* without them describes a different transfer. */
static float _solve_scale_for_lightness(const sf_sim_t *sim,
const float rgb[3],
const float target_L)
{
const float L1 = sf_sim_probe_lightness_scale(sim, rgb, 1.0f);
if(!(L1 > 1e-6f)) return 4.0f; /* black sample: nothing to scale toward the target */
const float ratio = target_L / L1;
/* fminf/fmaxf rather than a clamp macro: a non-finite ratio settles on the
minimum here instead of passing through. */
return fminf(fmaxf(ratio * ratio * ratio, 0.5f), 4.0f); /* the slider's own $MIN / $MAX */
}
void color_picker_apply(dt_iop_module_t *self,
GtkWidget *picker,
dt_dev_pixelpipe_t *pipe)
{
dt_iop_spektrafilm_gui_data_t *g = self->gui_data;
const gboolean is_scale = (picker == g->output_scale);
if(picker != g->output_boost && !is_scale) return;
/* picked_color_min/max start at sentinel values (+FLT_MAX / -FLT_MAX)
until a real area pick has actually landed; if this callback fires
before that (e.g. some other programmatic trigger of the picker
path), max stays below min and using it directly would feed garbage
(-FLT_MAX for every channel) into the simulation -- that propagates
into a wildly out-of-range LUT lookup and segfaults. Standard
darktable idiom for this check, matching e.g. exposure.c's own
color_picker_apply. */
if(self->picked_color_max[0] < self->picked_color_min[0]) return;
const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_work_profile_info(pipe);
if(!work_profile) return;
/* Build a standalone sim from the module's current live params: the
picker runs independently of any specific piece's cached data, so this
is a one-off build for this measurement, not the pipe's own d->sim
(which _ensure_sim also caches on -- see there for what gets set). */
dt_iop_spektrafilm_data_t d_tmp;
memset(&d_tmp, 0, sizeof(d_tmp));
d_tmp.p = *(dt_iop_spektrafilm_params_t *)self->params;
dt_pthread_mutex_init(&d_tmp.lock);
sf_sim_t *sim = _ensure_sim(&d_tmp, work_profile);
if(!sim)
{
dt_pthread_mutex_destroy(&d_tmp.lock);
return;
}
/* the brightest tone in the picked area is what determines whether the
compressor's knee engages usefully; picked_color_max is already an
area-mode min/max/mean pick (see dt_color_picker_new(..., DT_COLOR_PICKER_AREA, ...)
above in gui_init). */
const float rgb_max[3] = { self->picked_color_max[0], self->picked_color_max[1],
self->picked_color_max[2] };
/* Where the picked highlight lands, in the probe's lightness. Above the
compressor's knee threshold (SF_OUT_LIGHT_T = 0.7) and short of its
asymptotic limit of 1.0: the knee only starts doing useful work well above
its threshold, so aiming just past it leaves visible unused headroom and
reads dark; 0.95 still does. 0.97 is close enough to the limit to use
nearly all of the range while still landing on the curve rather than at
its asymptote, where the solver's bisection would have little gradient to
work with. Nothing hard-clips at any target -- the knee is asymptotic by
construction. */
/* The scale aims higher than the boost. The boost hands its result to the
compressor, whose knee bends anything approaching 1.0 back down, so a
target near the limit still lands short of it. The scale multiplies the
finished colour with nothing behind it, so what it aims for is what it
gets, and a highlight can sit closer to white before anything is lost. */
const float target_L = is_scale ? 0.995f : 0.97f;
const float solved = is_scale ? _solve_scale_for_lightness(sim, rgb_max, target_L)
: _solve_boost_for_lightness(sim, rgb_max, target_L);
if(d_tmp.gpu) sf_sim_gpu_free(d_tmp.gpu);
if(d_tmp.sim) sf_sim_free(d_tmp.sim);
dt_pthread_mutex_destroy(&d_tmp.lock);
dt_iop_spektrafilm_params_t *p = self->params;
GtkWidget *w = is_scale ? g->output_scale : g->output_boost;
if(is_scale)
p->output_scale = solved;
else
p->output_luminance_boost = solved;
DT_ENTER_GUI_UPDATE();
dt_bauhaus_slider_set(w, solved);
DT_LEAVE_GUI_UPDATE();
dt_dev_add_history_item(darktable.develop, self, TRUE);
}
/* Section heading with its own reset button.
Each tab holds several unrelated groups and darktable resets whole modules or
single widgets, nothing in between -- so trying one idea in "chemistry" means
either undoing every slider by hand or throwing away the rest of the tab. The
button resets exactly the widgets its own section holds.
Each section owns a container and every widget in it belongs to that section,
so the button carries the container and resets its children. */
static void _section_reset_clicked(GtkButton *button,
dt_iop_module_t *self)
{
if(darktable.gui->reset) return;
GtkWidget *box = g_object_get_data(G_OBJECT(button), "sf_section_container");
if(!box) return;
/* Deliberately NOT wrapped in darktable.gui->reset: each widget's own
value-changed handler is what writes the param, so suppressing it would
move the sliders without changing the render. The cost is one history entry
per widget rather than one per click, which is chattier than ideal but
correct. */
GList *kids = gtk_container_get_children(GTK_CONTAINER(box));
/* Undo, however, needs the whole click to be one step, and left to itself it
is not even a complete one. Every history entry the loop below produces
carries the widget that caused it as its undo target, and
_dev_undo_start_record_target() in develop/develop.c drops a change
entirely -- no undo record at all -- when its target matches the previous
one and the two fall inside darkroom/undo/merge_same_secs. That rule exists
to keep a slider drag from filling the undo stack, and it cannot tell a
drag from this button writing to the same slider: resetting a widget the
user has just moved by hand reads as a continuation of that move, so the
value it held is gone and no amount of undo brings it back.
dt_dev_undo_start_record() clears the stored target, which closes that hole
for the first widget in the loop, and the level counter behind the history
signals (record_history_level in libs/history.c) folds the records raised
inside the loop into this outer one, so the section reverts on a single
undo. */
dt_dev_undo_start_record(darktable.develop);
/* Toggles go last, as dt_ui_notebook_page()'s own double-click page reset
does (_reset_all_bauhaus() in gui/gtk.c): a module may switch one of its
checkboxes in reaction to one of its sliders, so a checkbox reset while
sliders are still to come could be undone by a slider reset after it. This
button is the page reset narrowed to a range of widgets, so it should not
behave differently within that range. */
for(int toggles_pass = 0; toggles_pass < 2; toggles_pass++)
{
for(const GList *l = kids; l; l = l->next)
{
GtkWidget *w = l->data;
if(g_object_get_data(G_OBJECT(w), "sf_section_exempt")) continue;
if(!DT_IS_BAUHAUS_WIDGET(w)) continue;
if((dt_bauhaus_widget_get_type(w) == DT_BAUHAUS_TOGGLE) != (toggles_pass == 1)) continue;
dt_bauhaus_widget_reset(w);
}
}
dt_dev_undo_end_record(darktable.develop);
g_list_free(kids);
}
/* Mark a widget as belonging to no section, so no section reset button touches
it however the page is later reordered. For the master switches: an effect's
on/off state is the tab's, not a subsection's, and sweeping it up would mean
a click meant to restore one group's defaults silently switched the whole
effect off -- most visibly on the diffusion stages, which default to off.
Every bauhaus widget still resets on its own double-click, so nothing is
lost. Returns its argument, to wrap the constructor at the point of use. */
static GtkWidget *_section_exempt(GtkWidget *w)
{
g_object_set_data(G_OBJECT(w), "sf_section_exempt", GINT_TO_POINTER(1));
return w;
}
/* Open a collapsible section on the current page and point self->widget at its
container, so every widget built afterwards lands inside it until the next
call. The reset button sits in the section header and carries the container,
so it resets exactly the widgets the section holds.
`confname` persists the expanded state and must be unique across the module:
several tabs use the same section label. Passing NULL builds a fixed section
instead -- same container and same reset button, but always open and with no
disclosure arrow -- for a section that should not be hidden behind a click.
Both forms put their widgets in a container of their own, so the reset button
works identically for either. Returns the section's root widget: the
expander, or the wrapper holding the fixed form's heading and container. */
static GtkWidget *_section_add(dt_iop_module_t *self,
const char *label,
const char *confname)
{
dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
/* self->widget is the page only on the first section of a page; afterwards it
is the previous section's container. */
if(self->widget != g->section_container) g->section_page = self->widget;
GtkWidget *page = g->section_page;
if(!confname)
{
/* header and container live in a wrapper, so the section is a single widget
from the outside and can be hidden in one call */
GtkWidget *hdr = dt_gui_hbox(dt_gui_expand(dt_ui_section_label_new(label)));
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
GtkWidget *rst = dtgtk_button_new(dtgtk_cairo_paint_reset, 0, NULL);
gtk_widget_set_tooltip_text(rst, _("reset only this section"));
g_object_set_data(G_OBJECT(rst), "sf_section_container", box);
/* No expand on the button: the label beside it already takes the spare
width, so a plain add leaves the button at the right-hand end. Expanding
it as well makes the box split the spare width between the two, which
moves the button in from the edge. */
dt_gui_box_add(hdr, rst);
g_signal_connect(G_OBJECT(rst), "clicked", G_CALLBACK(_section_reset_clicked), self);
GtkWidget *wrap = dt_gui_vbox(hdr, box);
dt_gui_box_add(page, wrap);
g->section_container = box;
self->widget = box;
return wrap;
}
dt_gui_collapsible_section_t *cs = g_malloc0(sizeof(*cs));
dt_gui_new_collapsible_section(cs, confname, label, GTK_BOX(page), DT_ACTION(self));
/* dt_gui_new_collapsible_section appends at the end of the box, which suits a
module carrying a single trailing section but reverses the order of
several. Move it to the front so sections appear in the order they are
declared. */
g_object_ref(cs->expander);
gtk_container_remove(GTK_CONTAINER(page), cs->expander);
dt_gui_box_add(page, cs->expander);
g_object_unref(cs->expander);
GtkWidget *hdr = dtgtk_expander_get_header(DTGTK_EXPANDER(cs->expander));
GtkWidget *btn = dtgtk_button_new(dtgtk_cairo_paint_reset, 0, NULL);
gtk_widget_set_tooltip_text(btn, _("reset only this section"));
g_object_set_data(G_OBJECT(btn), "sf_section_container", cs->container);
/* Same as above, and here the arrow is the thing that moves:
dt_gui_new_collapsible_section() packs its label to expand and its toggle
not to, so an expanding button added after them takes half the spare width
and pushes the arrow off the edge. */
dt_gui_box_add(hdr, btn);
g_signal_connect(G_OBJECT(btn), "clicked", G_CALLBACK(_section_reset_clicked), self);
g->sections = g_list_prepend(g->sections, cs);
g->section_container = GTK_WIDGET(cs->container);
self->widget = GTK_WIDGET(cs->container);
return cs->expander;
}
void gui_init(dt_iop_module_t *self)
{
dt_iop_spektrafilm_gui_data_t *g = IOP_GUI_ALLOC(spektrafilm);
self->widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget *sf_main_box = self->widget;
/* ---- data pack row (packed first, so it reads as a precondition) ----
Deliberately outside main_box: it is the one thing that must stay on
screen when there is no pack, since it is what gets you one.
Built with no_show_all set, because dt_iop_gui_init() runs a single
gtk_widget_show_all() over the whole module at creation time. Without the
flag that one call would reveal the row before _update_data_row() has had
any chance to decide whether it should be there. */
/* Children of these vertical boxes are added plain: a fixed-height bauhaus
widget has no use for vertical growth, and across the column it already
fills. dt_gui_expand() would ask for horizontal growth, which is not what
a column wants. */
g->data_gen = sf_fetch_generation();
g->data_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_PIXEL_APPLY_DPI(2));
gtk_widget_set_no_show_all(g->data_box, TRUE);
g->data_status = gtk_label_new("");
gtk_label_set_line_wrap(GTK_LABEL(g->data_status), TRUE);
gtk_label_set_xalign(GTK_LABEL(g->data_status), 0.0);
gtk_widget_set_name(g->data_status, "spektrafilm-data-status");
dt_gui_box_add(g->data_box, g->data_status);
g->data_button = gtk_button_new_with_label(_("download data pack"));
g_signal_connect(G_OBJECT(g->data_button), "clicked",
G_CALLBACK(_data_button_clicked), self);
dt_gui_box_add(g->data_box, g->data_button);
gtk_widget_show(g->data_status);
gtk_widget_show(g->data_button);
dt_gui_box_add(sf_main_box, g->data_box);
/* Everything else lives under main_box so a single set_visible() hides the
lot. No no_show_all here: the creation-time show_all is what establishes
the correct visibility of every child, including the ones with their own
rules (the format slider is only shown for a custom format), and redoing
that by hand on reveal would quietly override them. Since that show_all
runs once at creation and gui_update() runs after it, hiding here sticks,
and revealing later restores exactly the state show_all left behind. */
g->main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
dt_gui_box_add(sf_main_box, g->main_box);
/* ---- header ---- */
GtkWidget *header_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
dt_gui_box_add(g->main_box, header_box);
/* Inline labels, like every other control in the module. _shorten_name()
strips the filler words the paper names share, so they diverge at the
seventh character ("Kodak Endura Premier" / "Kodak Portra Endura" /
"Kodak Supra Endura" / "Kodak Ultra Endura") and fit beside a label. */
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);
dt_gui_box_add(header_box, g->film);
g->paper = dt_bauhaus_combobox_new(self);
gtk_widget_set_tooltip_text(
g->paper, _("print paper. \"none\" views the developed film directly, with no\n"
"print stage, which is the natural mode for positive and reversal\n"
"stocks; \"auto\" uses the paper named in the film stock's profile."));
dt_bauhaus_widget_set_label(g->paper, NULL, N_("print paper"));
g_signal_connect(G_OBJECT(g->paper), "value-changed", G_CALLBACK(_paper_changed), self);
dt_gui_box_add(header_box, g->paper);
/* redirect self->widget so from_params widgets pack into header_box */
self->widget = header_box;
g->film_format_combo = dt_bauhaus_combobox_new(self);
dt_bauhaus_widget_set_label(g->film_format_combo, NULL, N_("format"));
gtk_widget_set_tooltip_text(g->film_format_combo,
_("common film/sensor gate presets; picking one sets the "
"frame long\n"
"edge slider below (pick \"custom\" to dial in an exact "
"value). the\n"
"preset names a film gauge (35mm) while the slider is the "
"frame's\n"
"long edge (36mm). both describe the same format."));
_populate_format_combo(self);
g_signal_connect(G_OBJECT(g->film_format_combo), "value-changed",
G_CALLBACK(_format_changed), self);
dt_gui_box_add(header_box, g->film_format_combo);
g->film_format_mm_slider = dt_bauhaus_slider_from_params(self, "film_format_mm");
dt_bauhaus_slider_set_format(g->film_format_mm_slider, _(" mm"));
/* the hard range spans every format preset, Super 8 to 8x10; dragging
covers the common still and cine gauges */
dt_bauhaus_slider_set_soft_range(g->film_format_mm_slider, 8.0f, 130.0f);
gtk_widget_set_tooltip_text(g->film_format_mm_slider,
_("physical frame size, long edge. sets the scale that "
"grain, scatter,\n"
"halation, diffusion and the coupler spread are all "
"computed at, so\n"
"a smaller format shows every one of them proportionally "
"larger for\n"
"the same print size."));
/* restore main widget for the notebook */
self->widget = sf_main_box;
/* ---- notebook / tabs ---- */
static struct dt_action_def_t notebook_def = { };
g->notebook = dt_ui_notebook_new(¬ebook_def);
dt_action_define_iop(self, NULL, N_("page"), GTK_WIDGET(g->notebook), ¬ebook_def);
dt_gui_box_add(g->main_box, GTK_WIDGET(g->notebook));
/* ---- tab 1: film (exposure + development) ---- */
self->widget = dt_ui_notebook_page(g->notebook, N_("film"), NULL);
_section_add(self, C_("section", "exposure"), NULL);
g->exposure_ev = dt_bauhaus_slider_from_params(self, "exposure_ev");
/* Dragging covers +-4 EV, the range this is used over. The hard range reaches
+-12 for a typed value: an evening frame whose highlights were held by a
large positive exposure upstream needs a comparable negative here to put
the scene back on the film's curve, and +-4 cannot undo +8. */
dt_bauhaus_slider_set_soft_range(g->exposure_ev, -4.0f, 4.0f);
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\n"
"exposure follows automatically so this has no net brightness "
"effect\n"
"(except when the film is scanned directly, with no print "
"stage)."));
g->push_pull_stops = dt_bauhaus_slider_from_params(self, "push_pull_stops");
dt_bauhaus_slider_set_format(g->push_pull_stops, _(" stops"));
gtk_widget_set_tooltip_text(
g->push_pull_stops,
_("push (positive) or pull (negative) processing: shoot at an effective\n"
"ISO different from box speed, then under- or over-develop to\n"
"compensate. combines an exposure shift with a derived contrast\n"
"increase/decrease (approximate: the exact relationship depends on\n"
"the specific film/developer combination, which isn't modeled here).\n"
"stacks with the granular gamma controls below for\n"
"further fine-tuning."));
_section_add(self, C_("section", "chemistry"), "plugins/darkroom/spektrafilm/expand_film_chemistry");
g->development_min = dt_bauhaus_slider_from_params(self, "development_min");
dt_bauhaus_slider_set_format(g->development_min, _(" min"));
/* gui_update() replaces this with the selected stock's own times, and greys
the slider out for stocks characterised at a single development */
gtk_widget_set_tooltip_text(g->development_min,
_("development time. snaps to the nearest time the stock was\n"
"characterized at; 0 uses the stock's own default."));
g->film_gamma_factor = dt_bauhaus_slider_from_params(self, "film_gamma_factor");
dt_bauhaus_slider_set_soft_range(g->film_gamma_factor, 0.25f, 2.0f);
gtk_widget_set_tooltip_text(
g->film_gamma_factor,
_("overall development contrast (morphs the film's density curves).\n"
"extended or reduced development time, as in push/pull processing;\n"
"1.0 = normal development."));
g->film_gamma_factor_fast = dt_bauhaus_slider_from_params(self, "film_gamma_factor_fast");
dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_fast, 0.25f, 2.0f);
gtk_widget_set_tooltip_text(
g->film_gamma_factor_fast,
_("contrast of the fastest (most light-sensitive) emulsion sub-layer\n"
"only, independent of the slow layer, since push/pull processing\n"
"doesn't always affect every sub-layer equally."));
g->film_gamma_factor_slow = dt_bauhaus_slider_from_params(self, "film_gamma_factor_slow");
dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_slow, 0.25f, 2.0f);
gtk_widget_set_tooltip_text(
g->film_gamma_factor_slow,
_("contrast of the mid and slow emulsion sub-layers"));
g->film_developer_exhaustion = dt_bauhaus_slider_from_params(self, "film_developer_exhaustion");
gtk_widget_set_tooltip_text(
g->film_developer_exhaustion,
_("developer depletion in dense (highly-exposed) areas, modelled as a\n"
"change to the density curve rather than a spatial effect: blends\n"
"the highlight shoulder toward a self-limiting rolloff without\n"
"shifting midgray (0 = off)."));
/* One mechanism, six controls, and the two spread values read as sharpening
rather than as colour -- enough of a group, and enough of a surprise, to
want a heading naming it. */
_section_add(self, C_("section", "DIR couplers"), "plugins/darkroom/spektrafilm/expand_film_dir_couplers");
g->couplers_amount = dt_bauhaus_slider_from_params(self, "couplers_amount");
gtk_widget_set_tooltip_text(g->couplers_amount,
_("overall coupler strength: inter-layer inhibition drives "
"saturation\n"
"and edge effects (1.0 = film-accurate, 0 = off)."));
g->couplers_inhibition_same = dt_bauhaus_slider_from_params(self, "couplers_inhibition_same");
gtk_widget_set_tooltip_text(g->couplers_inhibition_same,
_("how strongly a layer inhibits its own development. "
"raising it\n"
"flattens contrast within each channel."));
g->couplers_inhibition_inter = dt_bauhaus_slider_from_params(self, "couplers_inhibition_inter");
gtk_widget_set_tooltip_text(g->couplers_inhibition_inter,
_("how strongly each layer inhibits the other two. this is "
"the part\n"
"that produces the saturation couplers are known for; at 0 "
"the\n"
"channels develop independently."));
g->couplers_diffusion_um = dt_bauhaus_slider_from_params(self, "couplers_diffusion_um");
dt_bauhaus_slider_set_format(g->couplers_diffusion_um, _(" \302\265m"));
gtk_widget_set_tooltip_text(
g->couplers_diffusion_um,
_("how far the released inhibitor spreads through the emulsion.\n"
"\n"
"this is what turns the couplers into an edge effect: a wide spread\n"
"lets a bright area hold back development well beyond its own edge,\n"
"which reads as added clarity or sharpening. shorten it for a softer,\n"
"more purely tonal coupler response.\n"
"\n"
"resetting returns it to the loaded film stock's own value."));
g->couplers_tail_um = dt_bauhaus_slider_from_params(self, "couplers_tail_um");
dt_bauhaus_slider_set_format(g->couplers_tail_um, _(" \302\265m"));
gtk_widget_set_tooltip_text(
g->couplers_tail_um,
_("length of the long, faint tail on the spread above, which carries\n"
"the inhibitor much further at low weight.\n"
"\n"
"it is what gives large bright areas a broad, gentle falloff. set the\n"
"weight to 0 to drop it entirely."));
g->couplers_tail_weight = dt_bauhaus_slider_from_params(self, "couplers_tail_weight");
gtk_widget_set_tooltip_text(g->couplers_tail_weight,
_("the share of the inhibitor that travels in the long tail. 0 "
"removes the tail."));
/* The diffusion filter sits in front of the taking lens, so it belongs with
the rest of the film stage rather than on a tab of its own. */
_section_add(self, C_("section", "diffusion"),
"plugins/darkroom/spektrafilm/expand_film_diffusion");
/* Inside the section, not ahead of it: widgets built before _section_add()
go into whichever container is still open, so on a page of collapsible
sections a master toggle placed first lands in the previous section.
Exempt, so resetting this section leaves the switch alone. */
g->diffusion_on = _section_exempt(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,\n"
"deep blacks) / glimmerglass (tight, subtle, sharp-preserving) /\n"
"pro-mist (broader, pastel, atmospheric) / cinebloom (frame-wide,\n"
"slow-decaying veil)."));
g->diffusion_strength = dt_bauhaus_slider_from_params(self, "diffusion_strength");
/* Diffusion filters are sold in stops, and the deflected-light table this
drives is calibrated at those stops -- 1/8, 1/4, 1/2, 1 and 2 -- with log2
interpolation between them. The ste