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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user