Add folder scan with format selection; validate A3 on a real library

Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.

  dr-types::FormatFilter  the tick-box selection, seeing through VFS
                          placeholder suffixes so a dehydrated CR2 still
                          matches as a CR2
  dr-sync::scan           recursive walk, Depth:1 per directory, pruning
                          unchanged subtrees where the backend propagates
                          directory ETags

Verified against nextcloud.tourolle.paris (34.0.2) on a real library:

  browse root      32 entries, 98ms
  scan PhotosRaw   17,185 RAW files in 334 directories, 34.1s
                   (7,836 CR2 + 9,349 DNG)
  range read       262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
                   and enough to read "Canon EOS 6D | ISO 100"

That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.

Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.

Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
This commit is contained in:
2026-08-09 12:22:31 +02:00
parent fbadf9afc8
commit c8bb08e661
29 changed files with 7193 additions and 234 deletions
+611
View File
@@ -0,0 +1,611 @@
//! The colour mixer — twelve hue bands, each with hue, saturation and
//! luminance.
//!
//! The control photographers mean by "per-colour adjustment": pick a colour
//! range, then shift its hue, deepen or mute it, or lighten it, without
//! touching the rest of the image. Thirty-six parameters in one operation.
//!
//! # Why bands overlap
//!
//! Each band has a centre hue and influences colours near it with a weight
//! that falls smoothly to zero at its neighbours' centres. A hard assignment
//! — "this pixel is orange, that one is yellow" — puts a visible seam through
//! any gradient crossing a boundary, and skies and skin are exactly where
//! that shows. Overlapping weights mean adjacent bands blend, and a colour
//! halfway between two centres receives half of each.
//!
//! # Why the weights are normalised
//!
//! With overlap, a pixel's weights sum to more than one, so applying each
//! band's gain independently would compound them. The shader normalises, so
//! setting every band's saturation to +100 gives the same result as setting
//! the global saturation to +100 rather than something far stronger.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("colour_mixer");
/// The twelve bands, in hue order starting at red.
///
/// Twelve rather than Lightroom's eight: the extra bands fall between the
/// primaries and secondaries, which is where skin (orange-to-red) and
/// foliage (yellow-to-green) actually sit, and where eight bands force a
/// compromise.
pub struct Band {
/// Stable id fragment, used to build parameter ids.
pub key: &'static str,
/// Centre hue in degrees.
pub hue: f32,
}
pub static BANDS: [Band; 12] = [
Band {
key: "red",
hue: 0.0,
},
Band {
key: "orange",
hue: 30.0,
},
Band {
key: "yellow",
hue: 60.0,
},
Band {
key: "chartreuse",
hue: 90.0,
},
Band {
key: "green",
hue: 120.0,
},
Band {
key: "spring",
hue: 150.0,
},
Band {
key: "cyan",
hue: 180.0,
},
Band {
key: "azure",
hue: 210.0,
},
Band {
key: "blue",
hue: 240.0,
},
Band {
key: "violet",
hue: 270.0,
},
Band {
key: "magenta",
hue: 300.0,
},
Band {
key: "rose",
hue: 330.0,
},
];
/// The three adjustments each band carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
Hue,
Saturation,
Luminance,
}
impl Channel {
pub const ALL: [Channel; 3] = [Channel::Hue, Channel::Saturation, Channel::Luminance];
pub const fn suffix(self) -> &'static str {
match self {
Channel::Hue => "hue",
Channel::Saturation => "sat",
Channel::Luminance => "lum",
}
}
}
// Parameter descriptors, one per band per channel. Written out rather than
// generated because `ParamDescriptor` must be `const` to live in a `static`,
// and a const loop cannot build a slice. The macro keeps it honest.
macro_rules! band_params {
($($key:literal),* $(,)?) => {
&[
$(
ParamDescriptor::amount(
concat!($key, "_hue"),
concat!("param.mixer.", $key, ".hue"),
),
ParamDescriptor::amount(
concat!($key, "_sat"),
concat!("param.mixer.", $key, ".sat"),
),
ParamDescriptor::amount(
concat!($key, "_lum"),
concat!("param.mixer.", $key, ".lum"),
),
)*
]
};
}
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.colour_mixer"),
params: band_params![
"red",
"orange",
"yellow",
"chartreuse",
"green",
"spring",
"cyan",
"azure",
"blue",
"violet",
"magenta",
"rose",
],
};
static MIXER_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
Helper {
name: "rgb_to_hcl",
source: "\
// Hue (degrees), chroma, and the max channel, in one pass.
//
// Not a full HSL conversion: the mixer needs hue to weight the bands and
// chroma to know how much colour there is to adjust, and computing lightness
// separately from Rec. 709 luminance gives a better-behaved result than
// HSL's (max+min)/2.
fn rgb_to_hcl(c: vec3<f32>) -> vec3<f32> {
let hi = max(c.r, max(c.g, c.b));
let lo = min(c.r, min(c.g, c.b));
let chroma = hi - lo;
var hue = 0.0;
if (chroma > 0.00001) {
if (hi == c.r) {
// fract handles the wrap from -60 to 300 without a branch.
hue = 60.0 * fract(((c.g - c.b) / chroma) / 6.0 + 1.0) * 6.0 / 6.0;
hue = 60.0 * (((c.g - c.b) / chroma) % 6.0);
if (hue < 0.0) { hue = hue + 360.0; }
} else if (hi == c.g) {
hue = 60.0 * (((c.b - c.r) / chroma) + 2.0);
} else {
hue = 60.0 * (((c.r - c.g) / chroma) + 4.0);
}
}
return vec3<f32>(hue, chroma, hi);
}",
},
Helper {
name: "band_weight",
source: "\
// How strongly a hue belongs to a band centred at `centre`.
//
// Cosine falloff over +/-60 degrees, so a band reaches zero exactly at its
// neighbours' centres and adjacent weights sum to one across the gap. A
// narrower window would leave hues between bands unreachable; a wider one
// would make every adjustment affect the whole wheel.
fn band_weight(hue: f32, centre: f32) -> f32 {
// Shortest angular distance, accounting for the wrap at 360.
var d = abs(hue - centre);
if (d > 180.0) { d = 360.0 - d; }
if (d >= 60.0) { return 0.0; }
// cos ramp: 1 at the centre, 0 at 60 degrees.
return 0.5 + 0.5 * cos(d * 3.14159265 / 60.0);
}",
},
Helper {
name: "hue_to_rgb_scale",
source: "\
// Rebuild a colour after shifting its hue, preserving chroma and level.
//
// Reconstructing from HSV rather than rotating in RGB: an RGB rotation
// matrix desaturates as it turns, which is visible as colours going pale
// mid-shift.
fn hue_to_rgb_scale(hue: f32, chroma: f32, hi: f32) -> vec3<f32> {
let h = fract(hue / 360.0) * 6.0;
let x = chroma * (1.0 - abs((h % 2.0) - 1.0));
var rgb = vec3<f32>(0.0);
if (h < 1.0) { rgb = vec3<f32>(chroma, x, 0.0); }
else if (h < 2.0) { rgb = vec3<f32>(x, chroma, 0.0); }
else if (h < 3.0) { rgb = vec3<f32>(0.0, chroma, x); }
else if (h < 4.0) { rgb = vec3<f32>(0.0, x, chroma); }
else if (h < 5.0) { rgb = vec3<f32>(x, 0.0, chroma); }
else { rgb = vec3<f32>(chroma, 0.0, x); }
return rgb + vec3<f32>(hi - chroma);
}",
},
];
/// Twelve hue bands, each with hue, saturation and luminance.
#[derive(Debug, Clone)]
pub struct ColourMixer {
/// `[band][channel]`, matching [`BANDS`] and [`Channel::ALL`].
values: [[f32; 3]; 12],
}
impl Default for ColourMixer {
fn default() -> Self {
Self {
values: [[0.0; 3]; 12],
}
}
}
impl ColourMixer {
pub fn new() -> Self {
Self::default()
}
/// The parameter id for one band and channel.
///
/// Ids are `"<band>_<channel>"`, matching the descriptors above.
fn index_of(id: ParamId) -> Option<(usize, usize)> {
let (band, channel) = id.0.rsplit_once('_')?;
let b = BANDS.iter().position(|x| x.key == band)?;
let c = Channel::ALL.iter().position(|x| x.suffix() == channel)?;
Some((b, c))
}
/// Whether any band has a non-zero setting.
fn any_set(&self) -> bool {
self.values.iter().flatten().any(|v| *v != 0.0)
}
}
impl Operation for ColourMixer {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match Self::index_of(id) {
Some((b, c)) => self.values[b][c] = value,
None => log::warn!("colour_mixer: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
Self::index_of(id).map_or(0.0, |(b, c)| self.values[b][c])
}
fn is_active(&self) -> bool {
self.any_set()
}
fn wgsl_body(&self) -> String {
// Only the bands the user actually touched contribute code. A single
// adjusted band therefore costs one weight evaluation rather than
// twelve — the composition property applied within an operation.
let mut lines = String::from(
"\
let hcl = rgb_to_hcl(c);
let hue = hcl.x;
let chroma = hcl.y;
let hi = hcl.z;
// Achromatic pixels have no hue to match, and adjusting them would tint
// neutrals — the most visible way a mixer can go wrong.
if (chroma > 0.0001) {
var w_total = 0.0;
var d_hue = 0.0;
var d_sat = 0.0;
var d_lum = 0.0;
",
);
for (b, band) in BANDS.iter().enumerate() {
let v = self.values[b];
if v.iter().all(|x| *x == 0.0) {
continue;
}
let key = band.key;
lines.push_str(&format!(
"\n // {key}\n {{\n let w = band_weight(hue, {:.1});\n w_total = w_total + w;\n",
band.hue
));
if v[0] != 0.0 {
lines.push_str(&format!(" d_hue = d_hue + w * {key}_hue;\n"));
}
if v[1] != 0.0 {
lines.push_str(&format!(" d_sat = d_sat + w * {key}_sat;\n"));
}
if v[2] != 0.0 {
lines.push_str(&format!(" d_lum = d_lum + w * {key}_lum;\n"));
}
lines.push_str(" }\n");
}
lines.push_str(
"
// Normalise by the total weight, so overlapping bands blend rather than
// compound. Without this, a hue sitting between two adjusted bands would
// receive roughly twice the intended adjustment.
if (w_total > 0.0001) {
d_hue = d_hue / w_total;
d_sat = d_sat / w_total;
d_lum = d_lum / w_total;
// Hue: up to 30 degrees at full travel. Enough to move foliage from
// yellow-green to green, not enough to turn it blue by accident.
let new_hue = hue + d_hue * 30.0;
// Saturation scales chroma; luminance scales the whole colour.
let new_chroma = clamp(chroma * (1.0 + d_sat), 0.0, hi);
c = hue_to_rgb_scale(new_hue, new_chroma, hi);
c = c * exp2(d_lum);
}
}
c = max(c, vec3<f32>(0.0));",
);
lines
}
fn uniforms(&self) -> Vec<Uniform> {
// Only the bands that contributed code declare uniforms, and in the
// same order the fragment references them.
let mut out = Vec::new();
for b in 0..BANDS.len() {
let v = self.values[b];
if v.iter().all(|x| *x == 0.0) {
continue;
}
// Names must match those the fragment emitted.
if v[0] != 0.0 {
out.push(Uniform {
name: HUE_NAMES[b],
value: v[0] / 100.0,
});
}
if v[1] != 0.0 {
out.push(Uniform {
name: SAT_NAMES[b],
value: v[1] / 100.0,
});
}
if v[2] != 0.0 {
out.push(Uniform {
name: LUM_NAMES[b],
// Up to half a stop per band.
value: v[2] / 100.0 * 0.5,
});
}
}
out
}
fn helpers(&self) -> &'static [Helper] {
MIXER_HELPERS
}
}
// Uniform names must be `&'static str`, and they are built from the band
// keys. Declared as tables rather than formatted at runtime, so the fragment
// and the uniform list cannot disagree.
static HUE_NAMES: [&str; 12] = [
"red_hue",
"orange_hue",
"yellow_hue",
"chartreuse_hue",
"green_hue",
"spring_hue",
"cyan_hue",
"azure_hue",
"blue_hue",
"violet_hue",
"magenta_hue",
"rose_hue",
];
static SAT_NAMES: [&str; 12] = [
"red_sat",
"orange_sat",
"yellow_sat",
"chartreuse_sat",
"green_sat",
"spring_sat",
"cyan_sat",
"azure_sat",
"blue_sat",
"violet_sat",
"magenta_sat",
"rose_sat",
];
static LUM_NAMES: [&str; 12] = [
"red_lum",
"orange_lum",
"yellow_lum",
"chartreuse_lum",
"green_lum",
"spring_lum",
"cyan_lum",
"azure_lum",
"blue_lum",
"violet_lum",
"magenta_lum",
"rose_lum",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn there_are_twelve_bands_with_thirty_six_parameters() {
assert_eq!(BANDS.len(), 12);
assert_eq!(DESCRIPTOR.params.len(), 36);
}
#[test]
fn bands_are_evenly_spaced_around_the_wheel() {
// Uneven spacing would leave some hues weakly covered, since the
// weight window is a fixed 60 degrees.
for (i, band) in BANDS.iter().enumerate() {
assert!(
(band.hue - i as f32 * 30.0).abs() < 1e-6,
"{} is at {}, expected {}",
band.key,
band.hue,
i as f32 * 30.0
);
}
}
#[test]
fn every_descriptor_id_resolves_to_a_band_and_channel() {
// The link between the descriptor list and the value array. A
// mismatch would make a slider silently adjust nothing.
for p in DESCRIPTOR.params {
assert!(
ColourMixer::index_of(p.id).is_some(),
"{} does not map to a band",
p.id
);
}
}
#[test]
fn every_band_and_channel_has_a_descriptor() {
// The reverse direction: a band with no descriptor is unreachable
// from the UI.
for band in BANDS.iter() {
for ch in Channel::ALL {
let id = format!("{}_{}", band.key, ch.suffix());
assert!(
DESCRIPTOR.params.iter().any(|p| p.id.0 == id),
"{id} has no descriptor"
);
}
}
}
#[test]
fn the_uniform_name_tables_match_the_band_keys() {
// Three parallel tables and a band list; if they drift, the fragment
// references a uniform that was never declared and the shader fails
// to compile.
for (i, band) in BANDS.iter().enumerate() {
assert_eq!(HUE_NAMES[i], format!("{}_hue", band.key));
assert_eq!(SAT_NAMES[i], format!("{}_sat", band.key));
assert_eq!(LUM_NAMES[i], format!("{}_lum", band.key));
}
}
#[test]
fn a_fresh_mixer_is_inactive() {
assert!(!ColourMixer::new().is_active());
}
#[test]
fn setting_any_band_activates_it() {
let mut m = ColourMixer::new();
m.set_param(ParamId("blue_sat"), 40.0);
assert!(m.is_active());
assert_eq!(m.param(ParamId("blue_sat")), 40.0);
}
#[test]
fn only_adjusted_bands_reach_the_shader() {
// The composition property applied within an operation: adjusting
// one band must not cost twelve weight evaluations.
let mut m = ColourMixer::new();
m.set_param(ParamId("blue_sat"), 40.0);
let body = m.wgsl_body();
assert!(body.contains("blue_sat"), "the adjusted band must appear");
assert!(!body.contains("red_sat"), "untouched bands must not");
assert_eq!(
body.matches("band_weight(").count(),
1,
"one adjusted band means one weight evaluation"
);
}
#[test]
fn only_adjusted_channels_within_a_band_reach_the_shader() {
let mut m = ColourMixer::new();
m.set_param(ParamId("green_lum"), -25.0);
let body = m.wgsl_body();
assert!(body.contains("green_lum"));
assert!(!body.contains("green_hue"));
assert!(!body.contains("green_sat"));
}
#[test]
fn the_fragment_and_uniforms_agree_on_names() {
// The failure this prevents is a compile error in generated code,
// which is far harder to read than a failed assertion here.
let mut m = ColourMixer::new();
m.set_param(ParamId("orange_hue"), 20.0);
m.set_param(ParamId("orange_sat"), -30.0);
m.set_param(ParamId("azure_lum"), 15.0);
let body = m.wgsl_body();
for u in m.uniforms() {
assert!(
body.contains(u.name),
"uniform {} is declared but never used",
u.name
);
}
// And nothing referenced without being declared.
let declared: Vec<&str> = m.uniforms().iter().map(|u| u.name).collect();
for band in BANDS.iter() {
for ch in Channel::ALL {
let name = format!("{}_{}", band.key, ch.suffix());
if body.contains(&name) {
assert!(
declared.contains(&name.as_str()),
"{name} is used but not declared"
);
}
}
}
}
#[test]
fn achromatic_pixels_are_excluded() {
// Adjusting a hue-less pixel would tint neutrals, which is the most
// visible way a mixer misbehaves.
let mut m = ColourMixer::new();
m.set_param(ParamId("red_sat"), 50.0);
assert!(m.wgsl_body().contains("chroma > 0.0001"));
}
#[test]
fn overlapping_weights_are_normalised() {
// Without normalising, a hue between two adjusted bands gets roughly
// double the intended adjustment.
let mut m = ColourMixer::new();
m.set_param(ParamId("red_sat"), 50.0);
m.set_param(ParamId("orange_sat"), 50.0);
let body = m.wgsl_body();
assert!(body.contains("d_sat / w_total"));
}
#[test]
fn unknown_parameters_are_ignored() {
let mut m = ColourMixer::new();
m.set_param(ParamId("puce_sat"), 50.0);
m.set_param(ParamId("malformed"), 50.0);
assert!(!m.is_active());
}
#[test]
fn luminance_travel_is_bounded_to_half_a_stop() {
let mut m = ColourMixer::new();
m.set_param(ParamId("blue_lum"), 100.0);
let v = m.uniforms()[0].value;
assert!((v - 0.5).abs() < 1e-6, "got {v}");
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Contrast — an S-curve about a fixed mid-point.
//!
//! Pushes tones away from middle grey (positive) or toward it (negative),
//! pivoting where the eye reads "neither light nor dark". In linear light
//! that point is 0.18, not 0.5: a scene-referred value of 0.5 is roughly a
//! stop and a half above middle grey, and pivoting there would darken almost
//! every photograph.
//!
//! The curve is applied in a perceptual domain rather than directly to linear
//! values. Applied linearly, an S-curve crushes shadows far harder than it
//! lifts highlights, because linear light devotes most of its range to the
//! brightest stop.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("contrast");
pub const CONTRAST: ParamId = ParamId("contrast");
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.contrast"),
params: &[ParamDescriptor::amount("contrast", "param.contrast")],
};
/// The helpers this operation needs, including its own S-curve.
static CONTRAST_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
helpers::APPLY_TONE_GAIN,
Helper {
name: "contrast_curve",
source: "\
// A symmetric S-curve on a 0..1 perceptual position.
//
// `amount` above zero steepens, below zero flattens. The smoothstep form is
// used for the steepening direction because it has zero gradient at both
// ends, so the curve cannot invert however hard it is pushed — the failure
// that makes naive gain-about-a-pivot unusable past moderate settings.
fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0);
if (amount >= 0.0) {
// Blend toward a smoothstep, which is the S.
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
// Flattening: pull toward the mid-point. At amount = -1 every tone
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
return mix(clamped, 0.5, -amount);
}",
},
];
#[derive(Debug, Default, Clone)]
pub struct Contrast {
amount: f32,
}
impl Contrast {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for Contrast {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
CONTRAST => self.amount = value,
_ => log::warn!("contrast: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
CONTRAST => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
fn wgsl_body(&self) -> String {
"\
let luma = luminance(c);
if (luma > 0.0001) {
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises.
//
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The
// curve operates on luma/(2*0.18) so that middle grey lands at the
// curve's own 0.5 pivot.
let pos = clamp(luma / 0.36, 0.0, 1.0);
let curved = contrast_curve(pos, amount);
// Not `target`: that is a WGSL reserved keyword, and using it produces a
// parse error in generated code rather than anywhere a reader would look.
let curved_luma = curved * 0.36;
c = apply_tone_gain(c, curved_luma / luma);
}
c = max(c, vec3<f32>(0.0));"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "amount",
value: self.amount / 100.0,
}]
}
fn helpers(&self) -> &'static [Helper] {
CONTRAST_HELPERS
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operation::compose;
#[test]
fn neutral_does_nothing() {
let c = Contrast::new();
assert!(!c.is_active());
assert_eq!(c.uniforms()[0].value, 0.0);
}
#[test]
fn the_amount_is_normalised_to_unit_range() {
// The shader's curve expects -1..1; the descriptor speaks -100..100.
let mut c = Contrast::new();
c.set_param(CONTRAST, 100.0);
assert!((c.uniforms()[0].value - 1.0).abs() < 1e-6);
c.set_param(CONTRAST, -100.0);
assert!((c.uniforms()[0].value + 1.0).abs() < 1e-6);
}
#[test]
fn contrast_works_on_luminance_not_per_channel() {
// Curving each channel separately shifts hue; the ratio form is what
// keeps a blue sky blue as contrast rises.
let mut c = Contrast::new();
c.set_param(CONTRAST, 50.0);
let body = c.wgsl_body();
assert!(body.contains("luminance(c)"));
assert!(
body.contains("apply_tone_gain"),
"the colour must be scaled by a ratio, not curved per channel"
);
}
#[test]
fn the_pivot_is_middle_grey_not_half() {
// Pivoting at 0.5 in linear light would darken nearly every image:
// scene-referred 0.5 is well above what the eye calls mid-tone.
let c = Contrast::new();
assert!(
c.wgsl_body().contains("0.36"),
"the curve must pivot about middle grey (0.18, doubled to place \
it at the curve's own midpoint)"
);
}
#[test]
fn the_curve_cannot_invert() {
// A gain-about-a-pivot form produces a non-monotonic curve past
// moderate settings, which inverts tones. smoothstep cannot.
let helper = CONTRAST_HELPERS
.iter()
.find(|h| h.name == "contrast_curve")
.expect("declares its curve");
assert!(helper.source.contains("3.0 - 2.0 * clamped"));
}
#[test]
fn it_composes_with_the_other_tonal_operations() {
// Contrast, highlights/shadows and brilliance all want `luminance`;
// the composer must emit it once.
let ops: Vec<Box<dyn Operation>> = vec![
Box::new({
let mut o = Contrast::new();
o.set_param(CONTRAST, 40.0);
o
}),
Box::new({
let mut o = crate::ops::HighlightsShadows::new();
o.set_param(crate::ops::tone::HIGHLIGHTS, -30.0);
o
}),
];
let shader = compose(&ops);
assert_eq!(shader.source.matches("fn luminance(").count(), 1);
assert_eq!(shader.source.matches("fn apply_tone_gain(").count(), 1);
assert_eq!(shader.source.matches("fn contrast_curve(").count(), 1);
}
#[test]
fn a_division_by_luminance_is_guarded() {
// A black pixel has zero luminance; dividing by it would produce NaN
// and propagate through everything downstream.
assert!(
Contrast::new().wgsl_body().contains("luma > 0.0001"),
"the ratio must be guarded against black pixels"
);
}
}
+336
View File
@@ -0,0 +1,336 @@
//! Geometric distortion correction.
//!
//! Straightens the lines a lens bends: barrel distortion on wide angles,
//! pincushion on telephotos. A [`crate::warp::Warp`] rather than an
//! [`crate::operation::Operation`], because it changes *where* a pixel is read
//! from rather than what its value becomes.
//!
//! # The model
//!
//! Lensfun's `ptlens` model, matched deliberately so a lens profile from the
//! Lensfun database applies with no conversion:
//!
//! ```text
//! r_d = r_u · (a·r_u³ + b·r_u² + c·r_u + 1 − a − b − c)
//! ```
//!
//! The `1 − a − b − c` term is not decoration: it forces the polynomial to
//! equal 1 at `r_u = 1`, pinning the image corner in place. Without it every
//! coefficient change would also rescale the frame, so the distortion slider
//! would double as a zoom and no setting would leave the framing alone.
//!
//! `a` and `b` are the higher-order terms that describe a lens's real,
//! slightly wavy profile; `c` alone gives the simple barrel/pincushion shape.
//! The manual control drives `c` only — a single slider cannot meaningfully
//! set three correlated coefficients, and hand-correcting a lens with no
//! 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::operation::{Helper, Uniform};
use crate::warp::Warp;
pub const ID: OpId = OpId("distortion");
pub const AMOUNT: ParamId = ParamId("amount");
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.distortion"),
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
// fisheye at one end and strong pincushion at the other; beyond it the
// inverse mapping stops being single-valued near the corners and the
// correction folds the image over itself.
params: &[ParamDescriptor::scalar(
"amount",
"param.distortion.amount",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
)],
};
/// The cubic coefficient at full slider travel.
const MAX_COEFF: f32 = 0.25;
#[derive(Debug, Default, Clone)]
pub struct Distortion {
amount: f32,
/// Profile coefficients, when a lens profile is loaded. `None` means the
/// manual slider drives `c` alone.
profile: Option<PtLens>,
}
/// The three `ptlens` coefficients, as Lensfun stores them.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PtLens {
pub a: f32,
pub b: f32,
pub c: f32,
}
impl Distortion {
pub fn new() -> Self {
Self::default()
}
/// Apply a lens profile's coefficients.
///
/// The manual slider then acts as a *trim* on top: photographers routinely
/// find a profile slightly over- or under-corrects on their copy of a
/// lens, and having to choose between "profile" and "manual" would make
/// that untunable.
pub fn set_profile(&mut self, profile: Option<PtLens>) {
self.profile = profile;
}
/// The effective coefficients: profile plus manual trim.
fn coefficients(&self) -> PtLens {
let trim = self.amount / 100.0 * MAX_COEFF;
match self.profile {
Some(p) => PtLens {
a: p.a,
b: p.b,
c: p.c + trim,
},
None => PtLens {
a: 0.0,
b: 0.0,
c: trim,
},
}
}
}
impl Warp for Distortion {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
AMOUNT => self.amount = value,
_ => log::warn!("distortion: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
AMOUNT => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
// A loaded profile corrects even with the slider at zero — that is
// the whole point of a profile.
let c = self.coefficients();
c.a != 0.0 || c.b != 0.0 || c.c != 0.0
}
fn wgsl_body(&self) -> String {
// Written against `p`, which is already normalised and centred.
"\
let r = length(p);
p = p * ptlens_scale(r, dist_a, dist_b, dist_c);"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
let c = self.coefficients();
vec![
Uniform {
name: "dist_a",
value: c.a,
},
Uniform {
name: "dist_b",
value: c.b,
},
Uniform {
name: "dist_c",
value: c.c,
},
]
}
fn helpers(&self) -> &'static [Helper] {
PTLENS
}
}
static PTLENS: &[Helper] = &[Helper {
name: "ptlens_scale",
source: "\
// The `ptlens` radial polynomial (Lensfun's model).
//
// Returns the factor mapping an undistorted radius to the distorted radius
// it should be sampled from. The trailing `1 - a - b - c` normalises the
// polynomial to 1 at r = 1, which pins the corner and stops a coefficient
// change from also rescaling the frame.
fn ptlens_scale(r: f32, a: f32, b: f32, c: f32) -> f32 {
let d = 1.0 - a - b - c;
return ((a * r + b) * r + c) * r + d;
}",
}];
#[cfg(test)]
mod tests {
use super::*;
/// The scale factor the shader would compute, mirrored on the CPU so the
/// maths is testable without a device (ARCH §6.5a).
fn scale(c: PtLens, r: f32) -> f32 {
let d = 1.0 - c.a - c.b - c.c;
((c.a * r + c.b) * r + c.c) * r + d
}
#[test]
fn neutral_does_nothing() {
let d = Distortion::new();
assert!(!d.is_active());
let c = d.coefficients();
assert_eq!((c.a, c.b, c.c), (0.0, 0.0, 0.0));
}
#[test]
fn a_neutral_polynomial_is_the_identity() {
// Every radius must map to itself when no correction is set,
// otherwise opening an image would resample it for nothing.
let c = Distortion::new().coefficients();
for r in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert!((scale(c, r) - 1.0).abs() < 1e-6, "r={r} was rescaled");
}
}
#[test]
fn the_corner_is_pinned_whatever_the_coefficients() {
// The property the `1 - a - b - c` term exists for: correction must
// not silently zoom the frame. If this fails, the distortion slider
// doubles as a crop and no setting leaves framing untouched.
for amount in [-100.0, -50.0, -1.0, 1.0, 50.0, 100.0] {
let mut d = Distortion::new();
d.set_param(AMOUNT, amount);
let s = scale(d.coefficients(), 1.0);
assert!(
(s - 1.0).abs() < 1e-5,
"amount {amount} moved the corner by {}",
s - 1.0
);
}
}
#[test]
fn the_centre_never_moves() {
// r = 0 is the optical axis; a radial model must leave it fixed, and
// `p * scale` does so for any finite scale.
let mut d = Distortion::new();
d.set_param(AMOUNT, 100.0);
assert!(scale(d.coefficients(), 0.0).is_finite());
}
#[test]
fn positive_amounts_correct_barrel_distortion() {
// Barrel distortion pushes detail outward, so correcting it must
// sample from further out at mid radii — an inverse map (see the
// `warp` module docs), which is why "correct barrel" magnifies.
let mut d = Distortion::new();
d.set_param(AMOUNT, 100.0);
let s = scale(d.coefficients(), 0.5);
assert!(s < 1.0, "mid-radius scale was {s}, expected < 1");
}
#[test]
fn negative_amounts_go_the_other_way() {
let mut pin = Distortion::new();
pin.set_param(AMOUNT, -100.0);
let mut bar = Distortion::new();
bar.set_param(AMOUNT, 100.0);
assert!(scale(pin.coefficients(), 0.5) > scale(bar.coefficients(), 0.5));
}
#[test]
fn the_mapping_stays_monotonic_across_the_whole_range() {
// If radius stops increasing with radius, the correction folds the
// image over itself and produces a mirrored ring. This is what bounds
// the slider at ±100, so it is worth asserting rather than trusting.
for amount in [-100.0, -50.0, 0.0, 50.0, 100.0] {
let mut d = Distortion::new();
d.set_param(AMOUNT, amount);
let c = d.coefficients();
let mut prev = 0.0;
for i in 1..=100 {
let r = i as f32 / 100.0;
let mapped = r * scale(c, r);
assert!(
mapped > prev,
"amount {amount}: mapping folded at r={r} ({mapped} <= {prev})"
);
prev = mapped;
}
}
}
#[test]
fn a_profile_corrects_with_the_slider_at_zero() {
// Loading a lens profile must do something on its own; requiring the
// user to also move a slider would make profiles pointless.
let mut d = Distortion::new();
assert!(!d.is_active());
d.set_profile(Some(PtLens {
a: 0.0168,
b: -0.0320,
c: -0.0287,
}));
assert!(d.is_active());
assert_eq!(d.param(AMOUNT), 0.0);
}
#[test]
fn the_slider_trims_a_loaded_profile_rather_than_replacing_it() {
// A profile that over-corrects on this copy of the lens must stay
// tunable, so the manual control adds to `c` and leaves a and b.
let profile = PtLens {
a: 0.01,
b: -0.02,
c: 0.03,
};
let mut d = Distortion::new();
d.set_profile(Some(profile));
d.set_param(AMOUNT, 100.0);
let c = d.coefficients();
assert_eq!(c.a, profile.a, "the profile's a must survive a trim");
assert_eq!(c.b, profile.b);
assert!((c.c - (profile.c + MAX_COEFF)).abs() < 1e-6);
}
#[test]
fn a_profile_can_be_cleared() {
let mut d = Distortion::new();
d.set_profile(Some(PtLens {
a: 0.01,
b: 0.0,
c: 0.0,
}));
assert!(d.is_active());
d.set_profile(None);
assert!(!d.is_active(), "clearing a profile must return to neutral");
}
#[test]
fn the_wgsl_body_reads_its_declared_uniforms() {
// The composer rewrites bare names; a body naming something it did
// not declare would compile to a reference to a nonexistent field.
let mut d = Distortion::new();
d.set_param(AMOUNT, 50.0);
let body = d.wgsl_body();
for u in d.uniforms() {
assert!(body.contains(u.name), "{} is declared but unused", u.name);
}
}
}
+4
View File
@@ -6,12 +6,16 @@
//! shader to edit, no UI change (FR-DEV-3c).
pub mod colour;
pub mod colour_mixer;
pub mod contrast;
pub mod exposure;
pub mod helpers;
pub mod tone;
pub mod white_balance;
pub use colour::{Brilliance, Saturation, Vibrance};
pub use colour_mixer::ColourMixer;
pub use contrast::Contrast;
pub use exposure::Exposure;
pub use tone::{BlacksWhites, HighlightsShadows};
pub use white_balance::WhiteBalance;