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