Add secure credential storage, sessions, and a launch screen

Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
This commit is contained in:
2026-08-09 15:20:39 +02:00
parent c8bb08e661
commit 09e3043f4c
33 changed files with 3506 additions and 155 deletions
+72 -19
View File
@@ -8,7 +8,7 @@
//! been answered. And a crop changes the output's dimensions and aspect
//! ratio, which no colour fragment can express.
//!
//! [`crate::warp::Warp`] is closer: it also rewrites coordinates before the
//! [`crate::lens::Warp`] is closer: it also rewrites coordinates before the
//! fetch. But a warp is a *correction to the optics* — distortion and CA are
//! properties of the lens, defined about the optical axis, over the whole
//! frame the lens projected. Framing is a decision about *composition*, made
@@ -144,13 +144,16 @@ impl CropRect {
pub fn normalised(self) -> Self {
let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
let width = finite(self.width, 1.0).clamp(Self::MIN_EXTENT, 1.0 - x);
let height = finite(self.height, 1.0).clamp(Self::MIN_EXTENT, 1.0 - y);
Self {
x,
y,
width,
height,
// `max` before `min`, not `f32::clamp`. With the origin at its
// limit, `1.0 - x` rounds to fractionally *below* `MIN_EXTENT` —
// an inverted range, which `clamp` panics on rather than
// resolving. Ordering it this way lets the lower bound win, which
// is also the answer that keeps the rect non-degenerate.
width: finite(self.width, 1.0).min(1.0 - x).max(Self::MIN_EXTENT),
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
}
}
}
@@ -168,6 +171,7 @@ fn finite(v: f32, fallback: f32) -> f32 {
}
}
/// TRACES: FR-DEV-3 | FR-DEV-3d
/// Crop, straighten, rotation and flips for one image.
///
/// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost
@@ -424,12 +428,25 @@ impl Framing {
/// position, ready for the warp chain.
///
/// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner —
/// exactly the space [`crate::warp`] documents, so lens correction
/// exactly the space [`crate::lens`] documents, so lens correction
/// composes on top of this without either stage naming the other.
///
/// `aspect` is left in scope alongside it, since the warp chain and the
/// sampler both need it to return to texture coordinates.
pub fn wgsl_prologue(&self) -> String {
// Neutral framing still has to produce `p`, since the warp chain and
// the sampler read it either way. It emits no `---- ` marker: those
// count active stages, and a neutral graph must generate none.
if !self.is_active() {
return " // Source position, normalised and centred: the whole frame, unrotated.
let src_dims = textureDimensions(source);
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
var p = (uv - vec2<f32>(0.5)) * aspect;
"
.into();
}
let mut s = String::new();
s.push_str(
@@ -444,19 +461,6 @@ impl Framing {
",
);
if !self.is_active() {
// Neutral framing still has to produce `p`, since the warp chain
// and the sampler read it either way. It is only the crop,
// rotation and flip steps that vanish.
s.push_str(
"
// Framing is neutral: the whole frame, unrotated.
var p = (uv - vec2<f32>(0.5)) * aspect;
",
);
return s;
}
s.push_str(
"
// Into the crop rect.
@@ -559,6 +563,18 @@ mod tests {
assert!(!src.contains("framing_angle"));
}
#[test]
fn neutral_framing_emits_no_stage_marker() {
// `---- ` markers count *active* stages, and a neutral graph must
// generate none — the assertion behind "opening an image shows the
// image" is written against that count.
assert!(!Framing::new().wgsl_prologue().contains("---- "));
let mut f = Framing::new();
f.set_param(ANGLE, 2.0);
assert!(f.wgsl_prologue().contains("---- framing ----"));
}
#[test]
fn an_active_framing_reads_the_crop_rect() {
let mut f = Framing::new();
@@ -664,6 +680,43 @@ mod tests {
assert!(f.crop().width.is_finite() && f.crop().x.is_finite());
}
#[test]
fn an_origin_at_its_limit_does_not_panic() {
// Found by the codegen test that drives every parameter to its
// maximum. With the origin at `1 - MIN_EXTENT`, `1.0 - x` rounds to
// just under `MIN_EXTENT`, and `f32::clamp` panics on an inverted
// range rather than resolving it — a crash reachable by dragging a
// crop handle to the edge.
for origin in [1.0 - CropRect::MIN_EXTENT, 0.99, 0.999_999, 1.0, f32::MAX] {
let c = CropRect {
x: origin,
y: origin,
width: 1.0,
height: 1.0,
}
.normalised();
assert!(
c.width >= CropRect::MIN_EXTENT && c.height >= CropRect::MIN_EXTENT,
"origin {origin} produced a degenerate rect: {c:?}"
);
}
}
#[test]
fn every_parameter_at_its_extremes_is_survivable() {
// The whole descriptor driven to both ends, which is what a codegen
// test does and what a corrupt sidecar can do.
for p in DESCRIPTOR.params {
for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] {
let mut f = Framing::new();
f.set_param(p.id, p.clamp(value));
let (w, h) = f.output_size(6000, 4000);
assert!(w >= 1 && h >= 1, "{} at {value} gave {w}x{h}", p.id);
assert!(f.uniforms().iter().all(|v| v.is_finite()));
}
}
}
#[test]
fn output_size_rounds_rather_than_truncating() {
// Truncation biases every crop smaller; half of 101 should be 51.
+23 -4
View File
@@ -398,7 +398,14 @@ mod tests {
// something the UI would have to hardcode.
let g = EditGraph::default_chain();
let caps = g.capabilities();
assert_eq!(caps.len(), g.descriptors().len());
// Every operation, plus framing — which is not an operation and so
// is absent from `descriptors`, but must still reach the panel.
assert_eq!(caps.len(), g.descriptors().len() + 1);
assert!(
caps.iter().any(|c| c.id == crate::framing::ID),
"framing must appear in the capability list, or the UI cannot \
build a crop control without naming it"
);
for cap in &caps {
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
@@ -458,13 +465,25 @@ mod tests {
fn capabilities_survive_a_round_trip_through_set_param() {
// The UI reads a capability, writes the value back, and must get the
// same thing out — no hidden scaling between the two.
//
// Written at the parameter's declared precision, because that is what
// the UI can actually produce: a control declaring 0 decimals emits
// whole numbers, and a stage free to quantise to them is behaving
// correctly rather than losing the value.
let mut g = EditGraph::default_chain();
for cap in g.capabilities() {
for p in &cap.params {
if let ParamKind::Scalar { max, .. } = p.kind {
let target = max * 0.5;
if let ParamKind::Scalar { max, precision, .. } = p.kind {
let step = 10f32.powi(i32::from(precision));
let target = (max * 0.5 * step).round() / step;
g.set_param(cap.id, p.id, target);
assert_eq!(g.param(cap.id, p.id), Some(target));
assert_eq!(
g.param(cap.id, p.id),
Some(target),
"{}.{} did not round-trip",
cap.id,
p.id
);
}
}
}
+3
View File
@@ -34,6 +34,7 @@
pub mod descriptor;
pub mod framing;
pub mod graph;
pub mod lens;
pub mod operation;
pub mod ops;
@@ -42,8 +43,10 @@ pub use descriptor::{
};
pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability};
pub use lens::{compose_warps, ComposedWarp, Warp};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
RESERVED_UNIFORM_FIELDS,
};
#[cfg(test)]
+18 -4
View File
@@ -128,6 +128,14 @@ pub struct ComposedShader {
/// are needed by every generated shader in any case.
const BASE_UNIFORM_FIELDS: usize = 16;
/// Where an operation's own uniforms begin in the generated block.
///
/// The base fields, then framing's. Exported because `dr-gpu` writes the
/// camera matrix into the leading slots by index and would otherwise carry
/// its own copy of this arithmetic — a duplicate that silently corrupts every
/// operation's uniforms the moment either block changes size.
pub const RESERVED_UNIFORM_FIELDS: usize = BASE_UNIFORM_FIELDS + FRAMING_UNIFORM_FIELDS;
/// Compose enabled operations into a single compute shader.
///
/// Inactive operations are skipped entirely — they contribute no code, no
@@ -417,7 +425,7 @@ fn hash_structure(active: &[&dyn Operation]) -> u64 {
/// Whole-word matching matters: an operation with uniforms `amount` and
/// `amount_hi` must not have the first rewrite corrupt the second.
///
/// Shared with [`crate::warp`], which prefixes its uniforms by the same rule
/// Shared with [`crate::lens`], which prefixes its uniforms by the same rule
/// and must not diverge from it.
/// Comments are skipped. A fragment explaining what `factor` does should not
/// have its prose rewritten to `u.saturation_factor` — the generated source
@@ -545,11 +553,17 @@ mod tests {
);
assert_eq!(
shader.uniforms.len(),
BASE_UNIFORM_FIELDS,
PREAMBLE_FIELDS,
"it must contribute no uniforms either"
);
}
/// Uniform slots reserved before any operation's own: the camera matrix
/// and as-shot white balance, plus framing. The same constant `dr-gpu`
/// writes against, so these offsets cannot agree with each other while
/// disagreeing with the shader.
const PREAMBLE_FIELDS: usize = RESERVED_UNIFORM_FIELDS;
#[test]
fn an_active_operation_appears_once() {
let ops = vec![fake(&DESC_A, 2.0, false)];
@@ -575,8 +589,8 @@ mod tests {
fn uniform_values_follow_declaration_order() {
let ops = vec![fake(&DESC_A, 1.5, false), fake(&DESC_B, 2.5, false)];
let shader = compose(&ops);
assert_eq!(shader.uniforms[BASE_UNIFORM_FIELDS], 1.5);
assert_eq!(shader.uniforms[BASE_UNIFORM_FIELDS + 1], 2.5);
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
}
#[test]
+305
View File
@@ -0,0 +1,305 @@
//! Lateral chromatic aberration correction.
//!
//! The purple-and-green fringing on high-contrast edges toward the frame
//! corners. A lens focuses short wavelengths and long wavelengths at slightly
//! different magnifications, so the red, green and blue images it projects are
//! very slightly different sizes. Rescaling two of them about the optical axis
//! puts them back on top of each other.
//!
//! # Why this cannot be an `Operation`
//!
//! This is the correction that forced [`crate::lens::Warp`] to exist. An
//! [`crate::operation::Operation`] receives `c` — a colour already sampled,
//! with all three channels fetched from *one* coordinate. Lateral CA needs
//! three *different* coordinates, and by the time an operation runs, the
//! information needed to pick them is gone. So it declares
//! [`Warp::splits_channels`] and the composer emits the three-sample path.
//!
//! # The model
//!
//! Lensfun's `poly3`, reduced to its linear term: a per-channel radial scale
//! with green as the fixed reference.
//!
//! ```text
//! r_red = r · v_red
//! r_blue = r · v_blue
//! ```
//!
//! Green is never moved, and that is a deliberate asymmetry rather than an
//! arbitrary choice of reference. Green carries most of the luminance a Bayer
//! sensor records — twice the photosites of red or blue — so leaving it
//! untouched means a mis-set correction shifts the channels that contribute
//! least to perceived sharpness. Scaling all three about a virtual reference
//! would soften the image even when the correction is right.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
use crate::lens::Warp;
use crate::operation::{Helper, Uniform};
pub const ID: OpId = OpId("aberration");
pub const RED: ParamId = ParamId("red");
pub const BLUE: ParamId = ParamId("blue");
/// The radial scale at full slider travel, as a fraction.
///
/// Lateral CA is a tiny effect — the database's own coefficients sit within
/// ±0.1% — so a slider spanning ±0.5% covers every real lens with enough
/// resolution left to tune by eye at 100%.
const MAX_SCALE: f32 = 0.005;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.aberration"),
params: &[
// Two independent controls rather than one: the red and blue
// displacements are caused by different ends of the spectrum and are
// not symmetric, so a single "fringing" slider could not remove both.
ParamDescriptor::scalar(
"red",
"param.aberration.red",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::scalar(
"blue",
"param.aberration.blue",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
],
};
#[derive(Debug, Default, Clone)]
pub struct Aberration {
red: f32,
blue: f32,
/// Per-channel scales from a lens profile, when one is loaded.
profile: Option<(f32, f32)>,
}
impl Aberration {
pub fn new() -> Self {
Self::default()
}
/// Apply a profile's red and blue radial scales.
///
/// As with distortion, the sliders then trim rather than replace: CA
/// varies between copies of a lens and with focus distance, so a profile
/// gets close and the user finishes the job.
pub fn set_profile(&mut self, scales: Option<(f32, f32)>) {
self.profile = scales;
}
/// The effective per-channel scales, profile plus manual trim.
fn scales(&self) -> (f32, f32) {
let (base_r, base_b) = self.profile.unwrap_or((1.0, 1.0));
(
base_r + self.red / 100.0 * MAX_SCALE,
base_b + self.blue / 100.0 * MAX_SCALE,
)
}
}
impl Warp for Aberration {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
RED => self.red = value,
BLUE => self.blue = value,
_ => log::warn!("aberration: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
RED => self.red,
BLUE => self.blue,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
let (r, b) = self.scales();
r != 1.0 || b != 1.0
}
fn wgsl_body(&self) -> String {
// `p_r` and `p_b` enter equal to `p` and are carried out of the block.
// Green is deliberately absent: it is the reference and never moves.
"\
p_r = p * ca_red;
p_b = p * ca_blue;"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
let (red, blue) = self.scales();
vec![
Uniform {
name: "ca_red",
value: red,
},
Uniform {
name: "ca_blue",
value: blue,
},
]
}
fn splits_channels(&self) -> bool {
true
}
fn helpers(&self) -> &'static [Helper] {
&[]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neutral_does_nothing() {
let a = Aberration::new();
assert!(!a.is_active());
assert_eq!(a.scales(), (1.0, 1.0));
}
#[test]
fn a_neutral_correction_leaves_every_channel_coincident() {
// If the channels diverge at neutral, an image with no CA correction
// is resampled into colour fringing that was not there.
let (r, b) = Aberration::new().scales();
assert_eq!(r, 1.0);
assert_eq!(b, 1.0);
}
#[test]
fn the_channels_are_controlled_independently() {
// Red and blue displacement have different causes and are not
// symmetric; one slider could not remove both.
let mut a = Aberration::new();
a.set_param(RED, 100.0);
let (r, b) = a.scales();
assert!(r > 1.0, "red should be scaled");
assert_eq!(b, 1.0, "blue must be untouched by the red control");
}
#[test]
fn green_is_never_scaled() {
// The reference channel. Asserted through the shader body, since that
// is where a stray green term would actually do damage.
let mut a = Aberration::new();
a.set_param(RED, 50.0);
a.set_param(BLUE, -50.0);
let body = a.wgsl_body();
assert!(body.contains("p_r"), "red must be displaced");
assert!(body.contains("p_b"), "blue must be displaced");
assert!(
!body.contains("p_g"),
"green is the reference and must never be displaced"
);
}
#[test]
fn the_centre_never_moves() {
// A radial scale about the optical axis leaves r = 0 fixed whatever
// the coefficients, which is why CA correction cannot shift a frame.
let mut a = Aberration::new();
a.set_param(RED, 100.0);
a.set_param(BLUE, -100.0);
let (r, b) = a.scales();
for scale in [r, b] {
assert_eq!(0.0 * scale, 0.0);
}
}
#[test]
fn the_correction_stays_subpixel_at_the_extremes() {
// Lateral CA is a fraction of a percent. If full travel displaced a
// corner by more than a pixel or two on a 6000px frame, the slider
// would be a smear control rather than a correction.
let mut a = Aberration::new();
a.set_param(RED, 100.0);
a.set_param(BLUE, -100.0);
let (r, b) = a.scales();
// Half-diagonal of a 6000x4000 frame, the worst case.
let half_diag = ((6000.0f32 / 2.0).powi(2) + (4000.0f32 / 2.0).powi(2)).sqrt();
for scale in [r, b] {
let px = (scale - 1.0).abs() * half_diag;
assert!(px < 25.0, "full travel displaces the corner by {px} px");
}
}
#[test]
fn it_always_requests_the_per_channel_path() {
// The declaration that makes the composer emit three samples. Without
// it the fragment would write `p_r`/`p_b` that nothing reads.
assert!(Aberration::new().splits_channels());
}
#[test]
fn a_profile_corrects_with_both_sliders_at_zero() {
let mut a = Aberration::new();
assert!(!a.is_active());
a.set_profile(Some((1.0003211, 1.0000667)));
assert!(a.is_active());
assert_eq!(a.param(RED), 0.0);
assert_eq!(a.param(BLUE), 0.0);
}
#[test]
fn the_sliders_trim_a_loaded_profile() {
// CA varies between copies of a lens and with focus distance, so a
// profile must remain tunable rather than being all-or-nothing.
let profile = (1.0003, 1.0001);
let mut a = Aberration::new();
a.set_profile(Some(profile));
a.set_param(RED, 100.0);
let (r, b) = a.scales();
assert!((r - (profile.0 + MAX_SCALE)).abs() < 1e-9);
assert_eq!(b, profile.1, "the red trim must not disturb blue");
}
#[test]
fn a_profile_can_be_cleared() {
let mut a = Aberration::new();
a.set_profile(Some((1.0003, 1.0001)));
assert!(a.is_active());
a.set_profile(None);
assert!(!a.is_active());
}
#[test]
fn the_wgsl_body_reads_its_declared_uniforms() {
let mut a = Aberration::new();
a.set_param(RED, 50.0);
let body = a.wgsl_body();
for u in a.uniforms() {
assert!(body.contains(u.name), "{} is declared but unused", u.name);
}
}
#[test]
fn every_default_is_neutral() {
let mut a = Aberration::new();
for p in DESCRIPTOR.params {
a.set_param(p.id, p.default);
}
assert!(!a.is_active(), "descriptor defaults must be neutral");
}
}
+3 -5
View File
@@ -1,7 +1,7 @@
//! Geometric distortion correction.
//!
//! Straightens the lines a lens bends: barrel distortion on wide angles,
//! pincushion on telephotos. A [`crate::warp::Warp`] rather than an
//! pincushion on telephotos. A [`crate::lens::Warp`] rather than an
//! [`crate::operation::Operation`], because it changes *where* a pixel is read
//! from rather than what its value becomes.
//!
@@ -26,11 +26,9 @@
//! profile is a "make the horizon straight" task, which one term does well.
//! The full triple is reachable by loading a profile.
use crate::descriptor::{
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
use crate::lens::Warp;
use crate::operation::{Helper, Uniform};
use crate::warp::Warp;
pub const ID: OpId = OpId("distortion");
pub const AMOUNT: ParamId = ParamId("amount");
+15 -3
View File
@@ -1,21 +1,33 @@
//! The develop operations.
//!
//! Each operation is a self-contained file implementing
//! [`crate::operation::Operation`]. Adding one means writing that file and
//! adding it to [`crate::graph::EditGraph::default_chain`] — no central
//! Each operation is a self-contained file. Adding one means writing that file
//! and adding it to [`crate::graph::EditGraph::default_chain`] — no central
//! shader to edit, no UI change (FR-DEV-3c).
//!
//! Most implement [`crate::operation::Operation`], a function from colour to
//! colour. The optical corrections ([`distortion`]) implement
//! [`crate::lens::Warp`] instead, because they rewrite *coordinates* before
//! the source is sampled rather than transforming a colour after it. Both
//! publish the same [`crate::descriptor::OpDescriptor`], so the UI builds
//! controls for them identically and never learns the difference.
pub mod aberration;
pub mod colour;
pub mod colour_mixer;
pub mod contrast;
pub mod distortion;
pub mod exposure;
pub mod helpers;
pub mod tone;
pub mod vignetting;
pub mod white_balance;
pub use aberration::Aberration;
pub use colour::{Brilliance, Saturation, Vibrance};
pub use colour_mixer::ColourMixer;
pub use contrast::Contrast;
pub use distortion::Distortion;
pub use exposure::Exposure;
pub use tone::{BlacksWhites, HighlightsShadows};
pub use vignetting::Vignetting;
pub use white_balance::WhiteBalance;
+375
View File
@@ -0,0 +1,375 @@
//! Vignetting correction — the corner falloff a lens imposes.
//!
//! Every lens delivers less light to the corners than to the centre, by up to
//! two stops wide open. This restores it.
//!
//! # Why this one *is* an `Operation`
//!
//! Distortion and CA are [`crate::lens::Warp`]s because they change which
//! pixel is read. Vignetting does not: it applies a gain to the pixel already
//! there. That the gain happens to depend on the pixel's *position* does not
//! make it a coordinate transform — the sampling is unchanged, so it composes
//! as an ordinary colour fragment and costs no extra texture read.
//!
//! It needs the pixel's normalised radius, which a colour fragment is not
//! otherwise given. The composer publishes `radius` in the shader prologue for
//! exactly this reason: it is derived from coordinates the prologue has
//! already computed, so making it available costs nothing.
//!
//! # The model
//!
//! Lensfun's `pa` polynomial, in even powers of the radius:
//!
//! ```text
//! attenuation = 1 + k1·r² + k2·r⁴ + k3·r⁶
//! ```
//!
//! Only even powers, because vignetting is symmetric about the optical axis —
//! an odd term would describe a lens brighter on one side than the other,
//! which is a mount fault rather than a lens characteristic.
//!
//! The value is what the lens *did*, so correcting means dividing by it. That
//! division is the whole reason this operation must run before the tonal
//! stages: a corner recovered by two stops has to be recovered while the
//! highlight headroom to hold it still exists (ARCH §5.2).
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
pub const ID: OpId = OpId("vignetting");
pub const AMOUNT: ParamId = ParamId("amount");
/// The `k1` coefficient at full manual travel.
///
/// Chosen so +100 lifts the extreme corner by roughly a stop, which covers a
/// fast prime wide open — the case that actually needs correcting.
const MAX_K1: f32 = -0.5;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.vignetting"),
// Bidirectional deliberately. Negative values *add* falloff, which is a
// legitimate creative choice as well as a correction, and a control that
// only removed vignetting would need a second one beside it to put any
// back.
params: &[ParamDescriptor::amount("amount", "param.vignetting.amount")],
};
/// The `pa` polynomial coefficients, as Lensfun stores them.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Pa {
pub k1: f32,
pub k2: f32,
pub k3: f32,
}
#[derive(Debug, Default, Clone)]
pub struct Vignetting {
amount: f32,
profile: Option<Pa>,
}
impl Vignetting {
pub fn new() -> Self {
Self::default()
}
/// Apply a lens profile's falloff coefficients.
pub fn set_profile(&mut self, profile: Option<Pa>) {
self.profile = profile;
}
/// The effective coefficients: profile plus manual trim on `k1`.
///
/// The trim lands on `k1` alone. It is the dominant term, and adjusting
/// the higher orders by eye would change the *shape* of the falloff rather
/// than its depth — which is not what a photographer reaching for this
/// slider wants.
fn coefficients(&self) -> Pa {
let trim = self.amount / 100.0 * MAX_K1;
match self.profile {
Some(p) => Pa {
k1: p.k1 + trim,
k2: p.k2,
k3: p.k3,
},
None => Pa {
k1: trim,
k2: 0.0,
k3: 0.0,
},
}
}
}
impl Operation for Vignetting {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
AMOUNT => self.amount = value,
_ => log::warn!("vignetting: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
AMOUNT => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
let c = self.coefficients();
c.k1 != 0.0 || c.k2 != 0.0 || c.k3 != 0.0
}
fn wgsl_body(&self) -> String {
// `radius` comes from the prologue: the pixel's distance from the
// optical axis, normalised so the corner is 1.
"\
c = c / vignette_attenuation(radius, vig_k1, vig_k2, vig_k3);"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
let c = self.coefficients();
vec![
Uniform {
name: "vig_k1",
value: c.k1,
},
Uniform {
name: "vig_k2",
value: c.k2,
},
Uniform {
name: "vig_k3",
value: c.k3,
},
]
}
fn helpers(&self) -> &'static [Helper] {
VIGNETTE
}
}
static VIGNETTE: &[Helper] = &[Helper {
name: "vignette_attenuation",
source: "\
// Lensfun's `pa` vignetting polynomial: 1 + k1*r^2 + k2*r^4 + k3*r^6.
//
// Returns what the lens *did* to this pixel, so correcting divides by it.
// Even powers only: vignetting is symmetric about the optical axis, and an
// odd term would describe a lens brighter on one side than the other.
//
// The result is floored well above zero. A profile evaluated slightly outside
// its calibrated range can produce a near-zero or negative attenuation, and
// dividing by that turns the extreme corners into blown or inverted pixels —
// a far more visible fault than the under-correction the floor causes.
fn vignette_attenuation(r: f32, k1: f32, k2: f32, k3: f32) -> f32 {
let r2 = r * r;
let a = 1.0 + r2 * (k1 + r2 * (k2 + r2 * k3));
return max(a, 0.05);
}",
}];
#[cfg(test)]
mod tests {
use super::*;
/// The attenuation the shader would compute, mirrored on the CPU so the
/// maths is testable without a device (ARCH §6.5a).
fn attenuation(c: Pa, r: f32) -> f32 {
let r2 = r * r;
(1.0 + r2 * (c.k1 + r2 * (c.k2 + r2 * c.k3))).max(0.05)
}
#[test]
fn neutral_does_nothing() {
let v = Vignetting::new();
assert!(!v.is_active());
let c = v.coefficients();
assert_eq!((c.k1, c.k2, c.k3), (0.0, 0.0, 0.0));
}
#[test]
fn a_neutral_polynomial_is_unity_everywhere() {
// Otherwise opening an image would rescale its brightness for nothing.
let c = Vignetting::new().coefficients();
for r in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert!((attenuation(c, r) - 1.0).abs() < 1e-6, "r={r} was scaled");
}
}
#[test]
fn the_centre_is_never_altered() {
// r = 0 kills every term, so the optical axis keeps its exposure
// whatever the coefficients. If this failed, the correction would be
// an exposure control with a gradient attached.
for amount in [-100.0, -50.0, 50.0, 100.0] {
let mut v = Vignetting::new();
v.set_param(AMOUNT, amount);
assert!(
(attenuation(v.coefficients(), 0.0) - 1.0).abs() < 1e-6,
"amount {amount} changed the centre"
);
}
}
#[test]
fn a_positive_amount_brightens_the_corners() {
// The correcting direction: the lens darkened the corners, so the
// attenuation there must be below 1 and the division lifts them.
let mut v = Vignetting::new();
v.set_param(AMOUNT, 100.0);
let a = attenuation(v.coefficients(), 1.0);
assert!(a < 1.0, "corner attenuation was {a}, expected < 1");
}
#[test]
fn a_negative_amount_darkens_them_instead() {
// The creative direction. A control that only removed vignetting
// would need a second one beside it to add any back.
let mut v = Vignetting::new();
v.set_param(AMOUNT, -100.0);
assert!(attenuation(v.coefficients(), 1.0) > 1.0);
}
#[test]
fn falloff_increases_monotonically_with_radius() {
// Vignetting is a smooth darkening toward the corners. A polynomial
// that reversed partway would produce a visible bright ring, which
// reads as a rendering fault rather than as a wrong setting.
let mut v = Vignetting::new();
v.set_param(AMOUNT, 100.0);
let c = v.coefficients();
let mut prev = f32::MAX;
for i in 0..=100 {
let a = attenuation(c, i as f32 / 100.0);
assert!(
a <= prev + 1e-6,
"attenuation rose at r={}",
i as f32 / 100.0
);
prev = a;
}
}
#[test]
fn full_correction_is_worth_about_a_stop() {
// The calibration behind MAX_K1. If this drifts, the slider either
// cannot fix a fast prime or overshoots wildly at half travel.
let mut v = Vignetting::new();
v.set_param(AMOUNT, 100.0);
let gain = 1.0 / attenuation(v.coefficients(), 1.0);
assert!(
(1.5..=2.5).contains(&gain),
"corner gain was {gain}x, expected roughly a stop"
);
}
#[test]
fn the_attenuation_never_reaches_zero() {
// Division by a near-zero attenuation blows the corners to white or
// inverts them. The floor must hold even for coefficients well past
// anything the sliders can reach.
let extreme = Pa {
k1: -5.0,
k2: -5.0,
k3: -5.0,
};
for i in 0..=100 {
let a = attenuation(extreme, i as f32 / 100.0);
assert!(a >= 0.05, "attenuation fell to {a}");
assert!(a.is_finite() && a > 0.0);
}
}
#[test]
fn a_profile_corrects_with_the_slider_at_zero() {
let mut v = Vignetting::new();
assert!(!v.is_active());
v.set_profile(Some(Pa {
k1: -0.3499,
k2: -0.914,
k3: 0.6689,
}));
assert!(v.is_active());
assert_eq!(v.param(AMOUNT), 0.0);
}
#[test]
fn a_real_profile_darkens_the_corners() {
// Coefficients taken from the Lensfun entry for the Canon EF 16-35mm
// f/2.8L at 20mm, f/2.8 — a real lens wide open, which is the case
// this correction exists for.
let profile = Pa {
k1: -0.3499,
k2: -0.914,
k3: 0.6689,
};
let mut v = Vignetting::new();
v.set_profile(Some(profile));
let c = v.coefficients();
assert!(attenuation(c, 1.0) < attenuation(c, 0.0));
assert!((attenuation(c, 0.0) - 1.0).abs() < 1e-6);
}
#[test]
fn the_slider_trims_a_loaded_profile_rather_than_replacing_it() {
let profile = Pa {
k1: -0.35,
k2: -0.91,
k3: 0.67,
};
let mut v = Vignetting::new();
v.set_profile(Some(profile));
v.set_param(AMOUNT, 100.0);
let c = v.coefficients();
assert_eq!(c.k2, profile.k2, "the higher orders must survive a trim");
assert_eq!(c.k3, profile.k3);
assert!((c.k1 - (profile.k1 + MAX_K1)).abs() < 1e-6);
}
#[test]
fn a_profile_can_be_cleared() {
let mut v = Vignetting::new();
v.set_profile(Some(Pa {
k1: -0.3,
k2: 0.0,
k3: 0.0,
}));
assert!(v.is_active());
v.set_profile(None);
assert!(!v.is_active());
}
#[test]
fn the_wgsl_body_reads_its_declared_uniforms_and_the_radius() {
let mut v = Vignetting::new();
v.set_param(AMOUNT, 50.0);
let body = v.wgsl_body();
for u in v.uniforms() {
assert!(body.contains(u.name), "{} is declared but unused", u.name);
}
assert!(
body.contains("radius"),
"vignetting is radial and must read the prologue's radius"
);
}
#[test]
fn every_default_is_neutral() {
let mut v = Vignetting::new();
for p in DESCRIPTOR.params {
v.set_param(p.id, p.default);
}
assert!(!v.is_active());
}
}