Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.
dr-plat SecretStore trait plus a Secret Service backend.
Verified against the live GNOME Keyring: store,
retrieve, delete, confirm-gone all round-trip.
Session/SessionStore splits credentials from settings — the app
password goes to the keyring (FR-NC-2), while
server, login, chosen root and format selection are
ordinary config. A test asserts the credential never
appears in the config file.
LaunchModel the launch-screen state machine, testable without a
display server: sign in, approve in browser, choose
folder, tick formats, sign out.
launch.slint the screen itself, in its own file.
Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.
Two bugs caught by tests rather than by running it:
- fail() after busy() signed the user out, because busy() had already
discarded the session. A failed *scan* would have logged you out.
Busy now carries the session.
- normalise_server upgrades http:// to https:// rather than accepting
it. NFR-SEC-3 requires TLS, and silently sending a credential in the
clear is not a decision to make on the user's behalf.
launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.
419 tests passing across ten crates.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
//! Lateral chromatic aberration correction.
|
||||
//!
|
||||
//! The purple-and-green fringing on high-contrast edges toward the frame
|
||||
//! corners. A lens focuses short wavelengths and long wavelengths at slightly
|
||||
//! different magnifications, so the red, green and blue images it projects are
|
||||
//! very slightly different sizes. Rescaling two of them about the optical axis
|
||||
//! puts them back on top of each other.
|
||||
//!
|
||||
//! # Why this cannot be an `Operation`
|
||||
//!
|
||||
//! This is the correction that forced [`crate::lens::Warp`] to exist. An
|
||||
//! [`crate::operation::Operation`] receives `c` — a colour already sampled,
|
||||
//! with all three channels fetched from *one* coordinate. Lateral CA needs
|
||||
//! three *different* coordinates, and by the time an operation runs, the
|
||||
//! information needed to pick them is gone. So it declares
|
||||
//! [`Warp::splits_channels`] and the composer emits the three-sample path.
|
||||
//!
|
||||
//! # The model
|
||||
//!
|
||||
//! Lensfun's `poly3`, reduced to its linear term: a per-channel radial scale
|
||||
//! with green as the fixed reference.
|
||||
//!
|
||||
//! ```text
|
||||
//! r_red = r · v_red
|
||||
//! r_blue = r · v_blue
|
||||
//! ```
|
||||
//!
|
||||
//! Green is never moved, and that is a deliberate asymmetry rather than an
|
||||
//! arbitrary choice of reference. Green carries most of the luminance a Bayer
|
||||
//! sensor records — twice the photosites of red or blue — so leaving it
|
||||
//! untouched means a mis-set correction shifts the channels that contribute
|
||||
//! least to perceived sharpness. Scaling all three about a virtual reference
|
||||
//! would soften the image even when the correction is right.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
|
||||
use crate::lens::Warp;
|
||||
use crate::operation::{Helper, Uniform};
|
||||
|
||||
pub const ID: OpId = OpId("aberration");
|
||||
pub const RED: ParamId = ParamId("red");
|
||||
pub const BLUE: ParamId = ParamId("blue");
|
||||
|
||||
/// The radial scale at full slider travel, as a fraction.
|
||||
///
|
||||
/// Lateral CA is a tiny effect — the database's own coefficients sit within
|
||||
/// ±0.1% — so a slider spanning ±0.5% covers every real lens with enough
|
||||
/// resolution left to tune by eye at 100%.
|
||||
const MAX_SCALE: f32 = 0.005;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.aberration"),
|
||||
params: &[
|
||||
// Two independent controls rather than one: the red and blue
|
||||
// displacements are caused by different ends of the spectrum and are
|
||||
// not symmetric, so a single "fringing" slider could not remove both.
|
||||
ParamDescriptor::scalar(
|
||||
"red",
|
||||
"param.aberration.red",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"blue",
|
||||
"param.aberration.blue",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Aberration {
|
||||
red: f32,
|
||||
blue: f32,
|
||||
/// Per-channel scales from a lens profile, when one is loaded.
|
||||
profile: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl Aberration {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Apply a profile's red and blue radial scales.
|
||||
///
|
||||
/// As with distortion, the sliders then trim rather than replace: CA
|
||||
/// varies between copies of a lens and with focus distance, so a profile
|
||||
/// gets close and the user finishes the job.
|
||||
pub fn set_profile(&mut self, scales: Option<(f32, f32)>) {
|
||||
self.profile = scales;
|
||||
}
|
||||
|
||||
/// The effective per-channel scales, profile plus manual trim.
|
||||
fn scales(&self) -> (f32, f32) {
|
||||
let (base_r, base_b) = self.profile.unwrap_or((1.0, 1.0));
|
||||
(
|
||||
base_r + self.red / 100.0 * MAX_SCALE,
|
||||
base_b + self.blue / 100.0 * MAX_SCALE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Warp for Aberration {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
RED => self.red = value,
|
||||
BLUE => self.blue = value,
|
||||
_ => log::warn!("aberration: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
RED => self.red,
|
||||
BLUE => self.blue,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
let (r, b) = self.scales();
|
||||
r != 1.0 || b != 1.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
// `p_r` and `p_b` enter equal to `p` and are carried out of the block.
|
||||
// Green is deliberately absent: it is the reference and never moves.
|
||||
"\
|
||||
p_r = p * ca_red;
|
||||
p_b = p * ca_blue;"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
let (red, blue) = self.scales();
|
||||
vec![
|
||||
Uniform {
|
||||
name: "ca_red",
|
||||
value: red,
|
||||
},
|
||||
Uniform {
|
||||
name: "ca_blue",
|
||||
value: blue,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn splits_channels(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn neutral_does_nothing() {
|
||||
let a = Aberration::new();
|
||||
assert!(!a.is_active());
|
||||
assert_eq!(a.scales(), (1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_correction_leaves_every_channel_coincident() {
|
||||
// If the channels diverge at neutral, an image with no CA correction
|
||||
// is resampled into colour fringing that was not there.
|
||||
let (r, b) = Aberration::new().scales();
|
||||
assert_eq!(r, 1.0);
|
||||
assert_eq!(b, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_channels_are_controlled_independently() {
|
||||
// Red and blue displacement have different causes and are not
|
||||
// symmetric; one slider could not remove both.
|
||||
let mut a = Aberration::new();
|
||||
a.set_param(RED, 100.0);
|
||||
let (r, b) = a.scales();
|
||||
assert!(r > 1.0, "red should be scaled");
|
||||
assert_eq!(b, 1.0, "blue must be untouched by the red control");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn green_is_never_scaled() {
|
||||
// The reference channel. Asserted through the shader body, since that
|
||||
// is where a stray green term would actually do damage.
|
||||
let mut a = Aberration::new();
|
||||
a.set_param(RED, 50.0);
|
||||
a.set_param(BLUE, -50.0);
|
||||
let body = a.wgsl_body();
|
||||
assert!(body.contains("p_r"), "red must be displaced");
|
||||
assert!(body.contains("p_b"), "blue must be displaced");
|
||||
assert!(
|
||||
!body.contains("p_g"),
|
||||
"green is the reference and must never be displaced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_centre_never_moves() {
|
||||
// A radial scale about the optical axis leaves r = 0 fixed whatever
|
||||
// the coefficients, which is why CA correction cannot shift a frame.
|
||||
let mut a = Aberration::new();
|
||||
a.set_param(RED, 100.0);
|
||||
a.set_param(BLUE, -100.0);
|
||||
let (r, b) = a.scales();
|
||||
for scale in [r, b] {
|
||||
assert_eq!(0.0 * scale, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_correction_stays_subpixel_at_the_extremes() {
|
||||
// Lateral CA is a fraction of a percent. If full travel displaced a
|
||||
// corner by more than a pixel or two on a 6000px frame, the slider
|
||||
// would be a smear control rather than a correction.
|
||||
let mut a = Aberration::new();
|
||||
a.set_param(RED, 100.0);
|
||||
a.set_param(BLUE, -100.0);
|
||||
let (r, b) = a.scales();
|
||||
// Half-diagonal of a 6000x4000 frame, the worst case.
|
||||
let half_diag = ((6000.0f32 / 2.0).powi(2) + (4000.0f32 / 2.0).powi(2)).sqrt();
|
||||
for scale in [r, b] {
|
||||
let px = (scale - 1.0).abs() * half_diag;
|
||||
assert!(px < 25.0, "full travel displaces the corner by {px} px");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_always_requests_the_per_channel_path() {
|
||||
// The declaration that makes the composer emit three samples. Without
|
||||
// it the fragment would write `p_r`/`p_b` that nothing reads.
|
||||
assert!(Aberration::new().splits_channels());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_corrects_with_both_sliders_at_zero() {
|
||||
let mut a = Aberration::new();
|
||||
assert!(!a.is_active());
|
||||
a.set_profile(Some((1.0003211, 1.0000667)));
|
||||
assert!(a.is_active());
|
||||
assert_eq!(a.param(RED), 0.0);
|
||||
assert_eq!(a.param(BLUE), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sliders_trim_a_loaded_profile() {
|
||||
// CA varies between copies of a lens and with focus distance, so a
|
||||
// profile must remain tunable rather than being all-or-nothing.
|
||||
let profile = (1.0003, 1.0001);
|
||||
let mut a = Aberration::new();
|
||||
a.set_profile(Some(profile));
|
||||
a.set_param(RED, 100.0);
|
||||
|
||||
let (r, b) = a.scales();
|
||||
assert!((r - (profile.0 + MAX_SCALE)).abs() < 1e-9);
|
||||
assert_eq!(b, profile.1, "the red trim must not disturb blue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_can_be_cleared() {
|
||||
let mut a = Aberration::new();
|
||||
a.set_profile(Some((1.0003, 1.0001)));
|
||||
assert!(a.is_active());
|
||||
a.set_profile(None);
|
||||
assert!(!a.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wgsl_body_reads_its_declared_uniforms() {
|
||||
let mut a = Aberration::new();
|
||||
a.set_param(RED, 50.0);
|
||||
let body = a.wgsl_body();
|
||||
for u in a.uniforms() {
|
||||
assert!(body.contains(u.name), "{} is declared but unused", u.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_is_neutral() {
|
||||
let mut a = Aberration::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
a.set_param(p.id, p.default);
|
||||
}
|
||||
assert!(!a.is_active(), "descriptor defaults must be neutral");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user