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,300 @@
|
||||
//! Lens profile lookup — the Lensfun database, reduced to coefficients.
|
||||
//!
|
||||
//! # What this crate is for
|
||||
//!
|
||||
//! `dr-pipeline` knows the *maths* of lens correction: the `ptlens`
|
||||
//! polynomial, the `poly3` per-channel scale, the `pa` vignetting curve. It
|
||||
//! does not know which coefficients belong to which lens, and deliberately
|
||||
//! has no dependencies with which to find out.
|
||||
//!
|
||||
//! This crate closes that gap. Given what EXIF reports — a lens name, a focal
|
||||
//! length, an aperture — it returns the coefficients for that shot, and
|
||||
//! nothing else. The pipeline consumes plain `f32`s and never links the
|
||||
//! database.
|
||||
//!
|
||||
//! # Why a separate crate rather than a module
|
||||
//!
|
||||
//! Two reasons, both about keeping a dependency contained:
|
||||
//!
|
||||
//! - **`dr-pipeline` has no dependencies on purpose.** Its codegen is testable
|
||||
//! without a GPU, and adding an XML parser plus 5.5 MB of profile data to it
|
||||
//! would cost exactly the property it is organised around (ARCH §6.5a).
|
||||
//! - **The `lensfun` crate is a third-party port**, not upstream Lensfun.
|
||||
//! Confining it behind [`LensProfile`] means replacing it — with the C
|
||||
//! library, with our own parser, with vendor-supplied profiles — touches
|
||||
//! this crate and nothing downstream.
|
||||
//!
|
||||
//! # Matching is best-effort, and says so
|
||||
//!
|
||||
//! EXIF lens names are not clean identifiers. Different bodies report the same
|
||||
//! lens differently, third-party lenses often report nothing, and adapted
|
||||
//! manual lenses report nothing at all. So every lookup returns an `Option`,
|
||||
//! and the UI is expected to say plainly whether a profile was found — an
|
||||
//! automatic correction that silently did nothing is worse than one the user
|
||||
//! can see is unavailable (ARCH §9.4 applies the same honesty rule to sync).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The `ptlens` distortion coefficients, as Lensfun stores them.
|
||||
///
|
||||
/// Mirrors `dr_pipeline::ops::distortion::PtLens`. Duplicated rather than
|
||||
/// shared because the dependency would have to run the wrong way: the
|
||||
/// pipeline must not depend on the profile database.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Distortion {
|
||||
pub a: f32,
|
||||
pub b: f32,
|
||||
pub c: f32,
|
||||
}
|
||||
|
||||
/// Lateral chromatic aberration: a per-channel radial scale.
|
||||
///
|
||||
/// Red and blue are scaled about the optical axis; green is the reference and
|
||||
/// is never moved, so a correction that is wrong still leaves the image
|
||||
/// sharp in one channel rather than softening all three.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Tca {
|
||||
pub red_scale: f32,
|
||||
pub blue_scale: f32,
|
||||
}
|
||||
|
||||
/// The `pa` vignetting polynomial: `1 + k1·r² + k2·r⁴ + k3·r⁶`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Vignetting {
|
||||
pub k1: f32,
|
||||
pub k2: f32,
|
||||
pub k3: f32,
|
||||
}
|
||||
|
||||
/// Everything known about one lens at one set of shooting parameters.
|
||||
///
|
||||
/// Each field is independently optional: the Lensfun database frequently
|
||||
/// carries distortion for a lens but no vignetting, or covers only part of a
|
||||
/// zoom's range. A partial profile is useful and must not be discarded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct LensProfile {
|
||||
pub distortion: Option<Distortion>,
|
||||
pub tca: Option<Tca>,
|
||||
pub vignetting: Option<Vignetting>,
|
||||
}
|
||||
|
||||
impl LensProfile {
|
||||
/// Whether this profile carries anything at all.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.distortion.is_none() && self.tca.is_none() && self.vignetting.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// What EXIF reports about a shot, as far as lens correction cares.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShotInfo<'a> {
|
||||
/// The lens model string. Frequently absent or unhelpful.
|
||||
pub lens: &'a str,
|
||||
/// Focal length in mm. Selects between a zoom's calibration points.
|
||||
pub focal_length: f32,
|
||||
/// Aperture as an f-number. Vignetting depends on it strongly — a lens
|
||||
/// wide open can be two stops down in the corners and clean by f/8.
|
||||
pub aperture: f32,
|
||||
/// Focus distance in metres, when known. Vignetting varies with it, but
|
||||
/// EXIF rarely reports it, so the default stands in for "far away".
|
||||
pub distance: f32,
|
||||
}
|
||||
|
||||
impl<'a> ShotInfo<'a> {
|
||||
/// The distance Lensfun's calibrations use for "not a close-up".
|
||||
///
|
||||
/// Most database entries are measured at 1000 m — effectively infinity —
|
||||
/// and EXIF almost never carries focus distance, so this is the value
|
||||
/// nearly every lookup uses.
|
||||
pub const FAR: f32 = 1000.0;
|
||||
|
||||
pub fn new(lens: &'a str, focal_length: f32, aperture: f32) -> Self {
|
||||
Self {
|
||||
lens,
|
||||
focal_length,
|
||||
aperture,
|
||||
distance: Self::FAR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundled Lensfun database, loaded once on first use.
|
||||
///
|
||||
/// Decompressing 56 XML files costs enough to be worth doing once, and little
|
||||
/// enough not to be worth doing in the background. `OnceLock` rather than a
|
||||
/// constructor the callers must thread through: this is a read-only reference
|
||||
/// table, and making every call site own a handle to it would buy nothing.
|
||||
fn database() -> Option<&'static lensfun::Database> {
|
||||
static DB: OnceLock<Option<lensfun::Database>> = OnceLock::new();
|
||||
DB.get_or_init(|| match lensfun::Database::load_bundled() {
|
||||
Ok(db) => Some(db),
|
||||
Err(e) => {
|
||||
// Not fatal. Manual correction still works, so the develop panel
|
||||
// stays usable with the automatic profile absent.
|
||||
log::warn!("lens profile database unavailable: {e}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Look up correction coefficients for a shot.
|
||||
///
|
||||
/// Returns `None` when the lens is unknown — which is common and not an
|
||||
/// error. A blank lens name short-circuits, because matching on one returns
|
||||
/// arbitrary entries rather than no entries.
|
||||
pub fn lookup(shot: &ShotInfo<'_>) -> Option<LensProfile> {
|
||||
if shot.lens.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let db = database()?;
|
||||
let matches = db.find_lenses(None, shot.lens);
|
||||
// The database returns candidates ranked by match quality; anything
|
||||
// beyond the best is a different lens that merely reads similarly.
|
||||
let lens = matches.first()?;
|
||||
|
||||
let profile = LensProfile {
|
||||
distortion: lens
|
||||
.interpolate_distortion(shot.focal_length)
|
||||
.and_then(|d| match d.model {
|
||||
lensfun::DistortionModel::Ptlens { a, b, c } => Some(Distortion { a, b, c }),
|
||||
// Other models exist in the database (`poly3`, `fov1`). Rather
|
||||
// than approximate one with another — which would correct by
|
||||
// the wrong curve and look like a bad profile — report nothing
|
||||
// and let the manual control take over.
|
||||
_ => None,
|
||||
}),
|
||||
tca: lens
|
||||
.interpolate_tca(shot.focal_length)
|
||||
.and_then(|t| match t.model {
|
||||
lensfun::TcaModel::Poly3 { red, blue } => Some(Tca {
|
||||
// Index 0 is the linear radial scale `v`, which carries
|
||||
// essentially all of the correction; the higher terms are
|
||||
// zero throughout the database in practice.
|
||||
red_scale: red[0],
|
||||
blue_scale: blue[0],
|
||||
}),
|
||||
_ => None,
|
||||
}),
|
||||
vignetting: lens
|
||||
.interpolate_vignetting(shot.focal_length, shot.aperture, shot.distance)
|
||||
.and_then(|v| match v.model {
|
||||
lensfun::VignettingModel::Pa { k1, k2, k3 } => Some(Vignetting { k1, k2, k3 }),
|
||||
_ => None,
|
||||
}),
|
||||
};
|
||||
|
||||
// A match that yielded no usable coefficients is the same as no match, and
|
||||
// reporting it as a hit would tell the user a correction is active when
|
||||
// nothing is being corrected.
|
||||
(!profile.is_empty()).then_some(profile)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A lens with dense calibration across a zoom range, so interpolation is
|
||||
/// genuinely exercised.
|
||||
const KNOWN: &str = "Canon EF 16-35mm f/2.8L USM";
|
||||
|
||||
#[test]
|
||||
fn the_bundled_database_loads() {
|
||||
// The property that makes this crate work on Android: no system
|
||||
// library, no data directory, no filesystem path (ARCH §6.9).
|
||||
assert!(database().is_some(), "the bundled database must load");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_known_lens_resolves() {
|
||||
let profile = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).expect("a stocked lens");
|
||||
assert!(profile.distortion.is_some(), "distortion expected");
|
||||
assert!(!profile.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_lens_is_none_rather_than_a_panic() {
|
||||
// Adapted and third-party lenses report names absent from the
|
||||
// database. That is the normal case, not an error.
|
||||
assert!(lookup(&ShotInfo::new("Nonexistent 999mm f/0.5", 50.0, 2.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_lens_name_does_not_match_arbitrarily() {
|
||||
// EXIF often carries no lens at all. Matching on an empty string
|
||||
// returns unrelated entries, which would silently apply another
|
||||
// lens's correction — worse than applying none.
|
||||
for name in ["", " "] {
|
||||
assert!(
|
||||
lookup(&ShotInfo::new(name, 50.0, 2.0)).is_none(),
|
||||
"{name:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coefficients_interpolate_between_calibration_points() {
|
||||
// 20mm and 22mm are calibrated; 21mm is not. If interpolation were
|
||||
// absent, a zoom would snap between corrections mid-range.
|
||||
let at20 = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.distortion);
|
||||
let at21 = lookup(&ShotInfo::new(KNOWN, 21.0, 2.8)).and_then(|p| p.distortion);
|
||||
let at22 = lookup(&ShotInfo::new(KNOWN, 22.0, 2.8)).and_then(|p| p.distortion);
|
||||
|
||||
let (a, b, c) = (at20.unwrap(), at21.unwrap(), at22.unwrap());
|
||||
assert_ne!(a, b, "21mm must not reuse the 20mm coefficients verbatim");
|
||||
let between = (a.a.min(c.a)..=a.a.max(c.a)).contains(&b.a);
|
||||
assert!(
|
||||
between,
|
||||
"21mm coefficient {} is outside [{}, {}]",
|
||||
b.a, a.a, c.a
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vignetting_responds_to_aperture() {
|
||||
// The reason aperture is in `ShotInfo` at all: a lens wide open
|
||||
// vignettes heavily and is clean stopped down, so a correction that
|
||||
// ignored aperture would be wrong at both ends.
|
||||
let wide = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.vignetting);
|
||||
let stopped = lookup(&ShotInfo::new(KNOWN, 20.0, 8.0)).and_then(|p| p.vignetting);
|
||||
|
||||
if let (Some(w), Some(s)) = (wide, stopped) {
|
||||
assert_ne!(w, s, "aperture must change the vignetting curve");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tca_leaves_green_as_the_reference() {
|
||||
// Green is never scaled, so red and blue are corrected towards it.
|
||||
// Both scales sit within a fraction of a percent of 1.0; anything
|
||||
// far from that would be a unit error rather than a real correction.
|
||||
if let Some(tca) = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.tca) {
|
||||
for s in [tca.red_scale, tca.blue_scale] {
|
||||
assert!(
|
||||
(0.99..=1.01).contains(&s),
|
||||
"{s} is not a plausible per-channel scale"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_far_focus_distance_is_the_default() {
|
||||
// EXIF rarely carries focus distance, so nearly every real lookup
|
||||
// relies on this standing in.
|
||||
assert_eq!(ShotInfo::new(KNOWN, 20.0, 2.8).distance, ShotInfo::FAR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_lookups_reuse_one_database() {
|
||||
// The database is decompressed on first use; a second lookup must not
|
||||
// pay for it again.
|
||||
assert!(lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).is_some());
|
||||
assert!(lookup(&ShotInfo::new(KNOWN, 24.0, 4.0)).is_some());
|
||||
assert!(std::ptr::eq(
|
||||
database().expect("loaded"),
|
||||
database().expect("loaded")
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user