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:
@@ -12,8 +12,8 @@
|
||||
//! it. This is a diagnostic, not the export path (FR-EXP-*).
|
||||
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::ops::{colour, exposure, tone, white_balance};
|
||||
use dr_pipeline::EditGraph;
|
||||
use dr_pipeline::ops::{colour, colour_mixer, contrast, exposure, tone, white_balance};
|
||||
use dr_pipeline::{EditGraph, ParamId};
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
@@ -65,6 +65,27 @@ fn main() {
|
||||
graph.set_param(colour::BRILLIANCE_ID, colour::BRILLIANCE, 40.0);
|
||||
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
|
||||
}
|
||||
// Contrast alone, so its effect can be judged without anything else
|
||||
// moving.
|
||||
"contrast" => {
|
||||
graph.set_param(contrast::ID, contrast::CONTRAST, 60.0);
|
||||
}
|
||||
"flat" => {
|
||||
graph.set_param(contrast::ID, contrast::CONTRAST, -60.0);
|
||||
}
|
||||
// The mixer, pushed hard on the two things this scene actually has:
|
||||
// green vegetation and grey-blue rock.
|
||||
"mixer" => {
|
||||
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 80.0);
|
||||
graph.set_param(colour_mixer::ID, ParamId("green_hue"), -40.0);
|
||||
graph.set_param(colour_mixer::ID, ParamId("chartreuse_sat"), 60.0);
|
||||
graph.set_param(colour_mixer::ID, ParamId("azure_lum"), -50.0);
|
||||
}
|
||||
// One band only, to check the weighting really is selective rather
|
||||
// than affecting the whole image.
|
||||
"mixer_one" => {
|
||||
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 100.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
+226
-12
@@ -21,10 +21,15 @@ use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||
|
||||
/// Number of leading floats in the generated uniform block that the composer
|
||||
/// reserves for base parameters — three padded matrix rows and the as-shot
|
||||
/// white balance. Must match `BASE_UNIFORM_FIELDS` in dr-pipeline.
|
||||
const BASE_FIELDS: usize = 16;
|
||||
/// Leading floats the composer reserves before any operation's own uniforms:
|
||||
/// three padded matrix rows, the as-shot white balance, and framing's block.
|
||||
///
|
||||
/// Imported rather than restated. It was a local literal, which was a latent
|
||||
/// bug of exactly the kind that is invisible until it is severe: growing the
|
||||
/// reserved block on the pipeline side would leave this short, and every
|
||||
/// operation's uniforms would silently shift out from under the shader that
|
||||
/// reads them.
|
||||
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
||||
|
||||
/// Runs composed operation chains against demosaiced images.
|
||||
pub struct AdjustPass {
|
||||
@@ -206,10 +211,11 @@ impl AdjustPass {
|
||||
|
||||
// Base uniforms: the camera matrix and as-shot white balance, which
|
||||
// every generated shader reads regardless of which operations are
|
||||
// active.
|
||||
// active. Framing's slots follow them and are filled by the composer,
|
||||
// which is why only the first sixteen are written here.
|
||||
let mut uniforms = shader.uniforms.clone();
|
||||
if uniforms.len() < BASE_FIELDS {
|
||||
uniforms.resize(BASE_FIELDS, 0.0);
|
||||
if uniforms.len() < RESERVED_FIELDS {
|
||||
uniforms.resize(RESERVED_FIELDS, 0.0);
|
||||
}
|
||||
let m = source.color_matrix();
|
||||
let wb = source.as_shot_wb();
|
||||
@@ -373,7 +379,7 @@ fn numbered(src: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_pipeline::ops::{colour, exposure, tone, white_balance};
|
||||
use dr_pipeline::ops::{colour, exposure};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
use crate::Demosaicer;
|
||||
@@ -420,6 +426,14 @@ mod tests {
|
||||
}
|
||||
|
||||
fn read_centre(ctx: &GpuContext, tex: &wgpu::Texture) -> [u8; 4] {
|
||||
let (w, h) = (tex.width(), tex.height());
|
||||
read_pixel(ctx, tex, w / 2, h / 2)
|
||||
}
|
||||
|
||||
/// One pixel, by coordinate. What the geometry tests need: proving a
|
||||
/// rotation moved content requires looking somewhere other than the
|
||||
/// centre, which every rotation leaves fixed.
|
||||
fn read_pixel(ctx: &GpuContext, tex: &wgpu::Texture, x: u32, y: u32) -> [u8; 4] {
|
||||
let w = tex.width();
|
||||
let h = tex.height();
|
||||
let unpadded = w * 4;
|
||||
@@ -466,7 +480,7 @@ mod tests {
|
||||
rx.recv().expect("map").expect("map ok");
|
||||
|
||||
let data = slice.get_mapped_range();
|
||||
let off = ((h / 2) * padded + (w / 2) * 4) as usize;
|
||||
let off = (y.min(h - 1) * padded + x.min(w - 1) * 4) as usize;
|
||||
let px = [data[off], data[off + 1], data[off + 2], data[off + 3]];
|
||||
drop(data);
|
||||
buf.unmap();
|
||||
@@ -521,6 +535,195 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An image bright on one side and dark on the other, so a transform that
|
||||
/// moves content is visible. A flat grey cannot show a rotation at all.
|
||||
///
|
||||
/// `vertical` puts the bright band at the top; otherwise at the left.
|
||||
fn split_image(ctx: &GpuContext, vertical: bool) -> DemosaicedImage {
|
||||
let size = 32u32;
|
||||
let mut data = vec![0u16; (size * size) as usize];
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let near_start = if vertical { y } else { x } < size / 2;
|
||||
data[(y * size + x) as usize] = if near_start { 12000 } else { 500 };
|
||||
}
|
||||
}
|
||||
let raw = RawImage {
|
||||
width: size,
|
||||
height: size,
|
||||
data,
|
||||
cfa_pattern: CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: 16383,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
};
|
||||
Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
.expect("demosaic")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_moves_a_vertical_edge_to_a_horizontal_one() {
|
||||
// The end-to-end check that the coordinate permutation is wired the
|
||||
// right way round. A left-bright image turned 90° clockwise must come
|
||||
// out top-bright; getting the sign wrong yields bottom-bright, which
|
||||
// compiles perfectly and is simply the wrong image.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = split_image(&ctx, false);
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.rotate_quarters(1);
|
||||
let (w, h) = g.output_size(32, 32);
|
||||
let shader = g.compose();
|
||||
let tex = pass.render(&img, &shader, w, h).expect("render");
|
||||
|
||||
let top = read_pixel(&ctx, tex, w / 2, h / 8)[0];
|
||||
let bottom = read_pixel(&ctx, tex, w / 2, h * 7 / 8)[0];
|
||||
assert!(
|
||||
top > bottom + 40,
|
||||
"a left-bright image turned 90° clockwise should be top-bright, \
|
||||
got top={top} bottom={bottom}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizontal_flip_swaps_the_sides() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = split_image(&ctx, false);
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, 1.0);
|
||||
let shader = g.compose();
|
||||
let tex = pass.render(&img, &shader, 32, 32).expect("render");
|
||||
|
||||
let left = read_pixel(&ctx, tex, 4, 16)[0];
|
||||
let right = read_pixel(&ctx, tex, 28, 16)[0];
|
||||
assert!(
|
||||
right > left + 40,
|
||||
"flipping a left-bright image should make it right-bright, \
|
||||
got left={left} right={right}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cropping_to_one_half_shows_only_that_half() {
|
||||
// The property a crop exists for, checked against content rather than
|
||||
// against the output dimensions alone: a crop of the dark side must
|
||||
// be dark everywhere, edge to edge.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = split_image(&ctx, false);
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_crop(dr_pipeline::CropRect {
|
||||
x: 0.5,
|
||||
y: 0.0,
|
||||
width: 0.5,
|
||||
height: 1.0,
|
||||
});
|
||||
let (w, h) = g.output_size(32, 32);
|
||||
assert_eq!((w, h), (16, 32), "half a 32px frame is 16px wide");
|
||||
|
||||
let shader = g.compose();
|
||||
let tex = pass.render(&img, &shader, w, h).expect("render");
|
||||
assert_eq!((tex.width(), tex.height()), (16, 32));
|
||||
|
||||
for x in [1, w / 2, w - 2] {
|
||||
let v = read_pixel(&ctx, tex, x, h / 2)[0];
|
||||
assert!(v < 90, "cropped to the dark half, x={x} came out {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straightening_darkens_the_exposed_corners() {
|
||||
// Rotating a frame inside its own bounds leaves no source pixel at the
|
||||
// corners. They must read black rather than a smeared edge pixel — the
|
||||
// difference between "the frame is rotated" and "the image is smudged".
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = split_image(&ctx, false);
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 30.0);
|
||||
let shader = g.compose();
|
||||
let tex = pass.render(&img, &shader, 32, 32).expect("render");
|
||||
|
||||
// The top-left corner of a 30° rotation is off the source.
|
||||
let corner = read_pixel(&ctx, tex, 0, 0);
|
||||
assert_eq!(
|
||||
corner,
|
||||
[0, 0, 0, 255],
|
||||
"an exposed corner must be black and opaque"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_crop_does_not_recompile() {
|
||||
// The cache contract for framing, which is what makes an interactive
|
||||
// crop drag viable: the rect changes every frame, and each frame must
|
||||
// reuse the compiled pipeline.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = grey_image(&ctx, 4000);
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
for i in 1..=10 {
|
||||
let inset = i as f32 * 0.02;
|
||||
g.set_crop(dr_pipeline::CropRect {
|
||||
x: inset,
|
||||
y: inset,
|
||||
width: 1.0 - 2.0 * inset,
|
||||
height: 1.0 - 2.0 * inset,
|
||||
});
|
||||
let (w, h) = g.output_size(64, 64);
|
||||
pass.render(&img, &g.compose(), w, h).expect("render");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
pass.cached_pipelines(),
|
||||
1,
|
||||
"ten crop rectangles must share one compiled pipeline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straightening_compiles_its_own_pipeline_but_reuses_it() {
|
||||
// Straightening changes the sampling path from an integer load to a
|
||||
// bilinear fetch, so it *must* compile a second pipeline — and then
|
||||
// must stop at two however far the slider travels.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = grey_image(&ctx, 4000);
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
pass.render(&img, &g.compose(), 32, 32).expect("render");
|
||||
assert_eq!(pass.cached_pipelines(), 1);
|
||||
|
||||
for i in 1..=8 {
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::ANGLE,
|
||||
i as f32 * 0.5,
|
||||
);
|
||||
pass.render(&img, &g.compose(), 32, 32).expect("render");
|
||||
}
|
||||
assert_eq!(
|
||||
pass.cached_pipelines(),
|
||||
2,
|
||||
"straightening compiles one more pipeline, not one per angle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_whole_chain_at_once_compiles() {
|
||||
// Individually-valid fragments can still collide when combined —
|
||||
@@ -543,10 +746,21 @@ mod tests {
|
||||
let shader = g.compose();
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
g.descriptors().len(),
|
||||
"every operation should be active"
|
||||
// Every operation, plus framing — which emits a stage of its own
|
||||
// rather than an operation block, and is not in `descriptors`.
|
||||
g.descriptors().len() + 1,
|
||||
"every operation and the framing should be active"
|
||||
);
|
||||
pass.render(&img, &shader, 32, 32)
|
||||
assert!(
|
||||
shader.source.contains("---- framing ----"),
|
||||
"framing must reach the shader alongside the colour operations"
|
||||
);
|
||||
|
||||
// Cropped, so the render is against an output size that is not the
|
||||
// source size — the case where a wrong dispatch or a wrong texture
|
||||
// allocation would show up.
|
||||
let (w, h) = g.output_size(32, 32);
|
||||
pass.render(&img, &shader, w, h)
|
||||
.expect("the full chain must compile");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "dr-lens"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# Isolated from dr-pipeline deliberately. That crate has no dependencies at
|
||||
# all so its codegen stays testable without a device (ARCH §6.5a); pulling an
|
||||
# XML parser and 5.5 MB of profile data into it would cost exactly the
|
||||
# property it is organised around. The pipeline consumes the coefficient
|
||||
# structs this crate produces, and never links the database.
|
||||
[dependencies]
|
||||
lensfun.workspace = true
|
||||
log.workspace = true
|
||||
@@ -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")
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
//! been answered. And a crop changes the output's dimensions and aspect
|
||||
//! ratio, which no colour fragment can express.
|
||||
//!
|
||||
//! [`crate::warp::Warp`] is closer: it also rewrites coordinates before the
|
||||
//! [`crate::lens::Warp`] is closer: it also rewrites coordinates before the
|
||||
//! fetch. But a warp is a *correction to the optics* — distortion and CA are
|
||||
//! properties of the lens, defined about the optical axis, over the whole
|
||||
//! frame the lens projected. Framing is a decision about *composition*, made
|
||||
@@ -144,13 +144,16 @@ impl CropRect {
|
||||
pub fn normalised(self) -> Self {
|
||||
let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||||
let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||||
let width = finite(self.width, 1.0).clamp(Self::MIN_EXTENT, 1.0 - x);
|
||||
let height = finite(self.height, 1.0).clamp(Self::MIN_EXTENT, 1.0 - y);
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
// `max` before `min`, not `f32::clamp`. With the origin at its
|
||||
// limit, `1.0 - x` rounds to fractionally *below* `MIN_EXTENT` —
|
||||
// an inverted range, which `clamp` panics on rather than
|
||||
// resolving. Ordering it this way lets the lower bound win, which
|
||||
// is also the answer that keeps the rect non-degenerate.
|
||||
width: finite(self.width, 1.0).min(1.0 - x).max(Self::MIN_EXTENT),
|
||||
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,6 +171,7 @@ fn finite(v: f32, fallback: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
||||
/// Crop, straighten, rotation and flips for one image.
|
||||
///
|
||||
/// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost
|
||||
@@ -424,12 +428,25 @@ impl Framing {
|
||||
/// position, ready for the warp chain.
|
||||
///
|
||||
/// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner —
|
||||
/// exactly the space [`crate::warp`] documents, so lens correction
|
||||
/// exactly the space [`crate::lens`] documents, so lens correction
|
||||
/// composes on top of this without either stage naming the other.
|
||||
///
|
||||
/// `aspect` is left in scope alongside it, since the warp chain and the
|
||||
/// sampler both need it to return to texture coordinates.
|
||||
pub fn wgsl_prologue(&self) -> String {
|
||||
// Neutral framing still has to produce `p`, since the warp chain and
|
||||
// the sampler read it either way. It emits no `---- ` marker: those
|
||||
// count active stages, and a neutral graph must generate none.
|
||||
if !self.is_active() {
|
||||
return " // Source position, normalised and centred: the whole frame, unrotated.
|
||||
let src_dims = textureDimensions(source);
|
||||
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||||
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||
"
|
||||
.into();
|
||||
}
|
||||
|
||||
let mut s = String::new();
|
||||
|
||||
s.push_str(
|
||||
@@ -444,19 +461,6 @@ impl Framing {
|
||||
",
|
||||
);
|
||||
|
||||
if !self.is_active() {
|
||||
// Neutral framing still has to produce `p`, since the warp chain
|
||||
// and the sampler read it either way. It is only the crop,
|
||||
// rotation and flip steps that vanish.
|
||||
s.push_str(
|
||||
"
|
||||
// Framing is neutral: the whole frame, unrotated.
|
||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||
",
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
s.push_str(
|
||||
"
|
||||
// Into the crop rect.
|
||||
@@ -559,6 +563,18 @@ mod tests {
|
||||
assert!(!src.contains("framing_angle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_framing_emits_no_stage_marker() {
|
||||
// `---- ` markers count *active* stages, and a neutral graph must
|
||||
// generate none — the assertion behind "opening an image shows the
|
||||
// image" is written against that count.
|
||||
assert!(!Framing::new().wgsl_prologue().contains("---- "));
|
||||
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 2.0);
|
||||
assert!(f.wgsl_prologue().contains("---- framing ----"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_active_framing_reads_the_crop_rect() {
|
||||
let mut f = Framing::new();
|
||||
@@ -664,6 +680,43 @@ mod tests {
|
||||
assert!(f.crop().width.is_finite() && f.crop().x.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_origin_at_its_limit_does_not_panic() {
|
||||
// Found by the codegen test that drives every parameter to its
|
||||
// maximum. With the origin at `1 - MIN_EXTENT`, `1.0 - x` rounds to
|
||||
// just under `MIN_EXTENT`, and `f32::clamp` panics on an inverted
|
||||
// range rather than resolving it — a crash reachable by dragging a
|
||||
// crop handle to the edge.
|
||||
for origin in [1.0 - CropRect::MIN_EXTENT, 0.99, 0.999_999, 1.0, f32::MAX] {
|
||||
let c = CropRect {
|
||||
x: origin,
|
||||
y: origin,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
}
|
||||
.normalised();
|
||||
assert!(
|
||||
c.width >= CropRect::MIN_EXTENT && c.height >= CropRect::MIN_EXTENT,
|
||||
"origin {origin} produced a degenerate rect: {c:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_parameter_at_its_extremes_is_survivable() {
|
||||
// The whole descriptor driven to both ends, which is what a codegen
|
||||
// test does and what a corrupt sidecar can do.
|
||||
for p in DESCRIPTOR.params {
|
||||
for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(p.id, p.clamp(value));
|
||||
let (w, h) = f.output_size(6000, 4000);
|
||||
assert!(w >= 1 && h >= 1, "{} at {value} gave {w}x{h}", p.id);
|
||||
assert!(f.uniforms().iter().all(|v| v.is_finite()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_size_rounds_rather_than_truncating() {
|
||||
// Truncation biases every crop smaller; half of 101 should be 51.
|
||||
|
||||
@@ -398,7 +398,14 @@ mod tests {
|
||||
// something the UI would have to hardcode.
|
||||
let g = EditGraph::default_chain();
|
||||
let caps = g.capabilities();
|
||||
assert_eq!(caps.len(), g.descriptors().len());
|
||||
// Every operation, plus framing — which is not an operation and so
|
||||
// is absent from `descriptors`, but must still reach the panel.
|
||||
assert_eq!(caps.len(), g.descriptors().len() + 1);
|
||||
assert!(
|
||||
caps.iter().any(|c| c.id == crate::framing::ID),
|
||||
"framing must appear in the capability list, or the UI cannot \
|
||||
build a crop control without naming it"
|
||||
);
|
||||
|
||||
for cap in &caps {
|
||||
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
|
||||
@@ -458,13 +465,25 @@ mod tests {
|
||||
fn capabilities_survive_a_round_trip_through_set_param() {
|
||||
// The UI reads a capability, writes the value back, and must get the
|
||||
// same thing out — no hidden scaling between the two.
|
||||
//
|
||||
// Written at the parameter's declared precision, because that is what
|
||||
// the UI can actually produce: a control declaring 0 decimals emits
|
||||
// whole numbers, and a stage free to quantise to them is behaving
|
||||
// correctly rather than losing the value.
|
||||
let mut g = EditGraph::default_chain();
|
||||
for cap in g.capabilities() {
|
||||
for p in &cap.params {
|
||||
if let ParamKind::Scalar { max, .. } = p.kind {
|
||||
let target = max * 0.5;
|
||||
if let ParamKind::Scalar { max, precision, .. } = p.kind {
|
||||
let step = 10f32.powi(i32::from(precision));
|
||||
let target = (max * 0.5 * step).round() / step;
|
||||
g.set_param(cap.id, p.id, target);
|
||||
assert_eq!(g.param(cap.id, p.id), Some(target));
|
||||
assert_eq!(
|
||||
g.param(cap.id, p.id),
|
||||
Some(target),
|
||||
"{}.{} did not round-trip",
|
||||
cap.id,
|
||||
p.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
pub mod descriptor;
|
||||
pub mod framing;
|
||||
pub mod graph;
|
||||
pub mod lens;
|
||||
pub mod operation;
|
||||
pub mod ops;
|
||||
|
||||
@@ -42,8 +43,10 @@ pub use descriptor::{
|
||||
};
|
||||
pub use framing::{CropRect, Framing};
|
||||
pub use graph::{EditGraph, OpCapability, ParamCapability};
|
||||
pub use lens::{compose_warps, ComposedWarp, Warp};
|
||||
pub use operation::{
|
||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
|
||||
RESERVED_UNIFORM_FIELDS,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -128,6 +128,14 @@ pub struct ComposedShader {
|
||||
/// are needed by every generated shader in any case.
|
||||
const BASE_UNIFORM_FIELDS: usize = 16;
|
||||
|
||||
/// Where an operation's own uniforms begin in the generated block.
|
||||
///
|
||||
/// The base fields, then framing's. Exported because `dr-gpu` writes the
|
||||
/// camera matrix into the leading slots by index and would otherwise carry
|
||||
/// its own copy of this arithmetic — a duplicate that silently corrupts every
|
||||
/// operation's uniforms the moment either block changes size.
|
||||
pub const RESERVED_UNIFORM_FIELDS: usize = BASE_UNIFORM_FIELDS + FRAMING_UNIFORM_FIELDS;
|
||||
|
||||
/// Compose enabled operations into a single compute shader.
|
||||
///
|
||||
/// Inactive operations are skipped entirely — they contribute no code, no
|
||||
@@ -417,7 +425,7 @@ fn hash_structure(active: &[&dyn Operation]) -> u64 {
|
||||
/// Whole-word matching matters: an operation with uniforms `amount` and
|
||||
/// `amount_hi` must not have the first rewrite corrupt the second.
|
||||
///
|
||||
/// Shared with [`crate::warp`], which prefixes its uniforms by the same rule
|
||||
/// Shared with [`crate::lens`], which prefixes its uniforms by the same rule
|
||||
/// and must not diverge from it.
|
||||
/// Comments are skipped. A fragment explaining what `factor` does should not
|
||||
/// have its prose rewritten to `u.saturation_factor` — the generated source
|
||||
@@ -545,11 +553,17 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
shader.uniforms.len(),
|
||||
BASE_UNIFORM_FIELDS,
|
||||
PREAMBLE_FIELDS,
|
||||
"it must contribute no uniforms either"
|
||||
);
|
||||
}
|
||||
|
||||
/// Uniform slots reserved before any operation's own: the camera matrix
|
||||
/// and as-shot white balance, plus framing. The same constant `dr-gpu`
|
||||
/// writes against, so these offsets cannot agree with each other while
|
||||
/// disagreeing with the shader.
|
||||
const PREAMBLE_FIELDS: usize = RESERVED_UNIFORM_FIELDS;
|
||||
|
||||
#[test]
|
||||
fn an_active_operation_appears_once() {
|
||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
||||
@@ -575,8 +589,8 @@ mod tests {
|
||||
fn uniform_values_follow_declaration_order() {
|
||||
let ops = vec![fake(&DESC_A, 1.5, false), fake(&DESC_B, 2.5, false)];
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(shader.uniforms[BASE_UNIFORM_FIELDS], 1.5);
|
||||
assert_eq!(shader.uniforms[BASE_UNIFORM_FIELDS + 1], 2.5);
|
||||
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
|
||||
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Geometric distortion correction.
|
||||
//!
|
||||
//! Straightens the lines a lens bends: barrel distortion on wide angles,
|
||||
//! pincushion on telephotos. A [`crate::warp::Warp`] rather than an
|
||||
//! pincushion on telephotos. A [`crate::lens::Warp`] rather than an
|
||||
//! [`crate::operation::Operation`], because it changes *where* a pixel is read
|
||||
//! from rather than what its value becomes.
|
||||
//!
|
||||
@@ -26,11 +26,9 @@
|
||||
//! 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::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
|
||||
use crate::lens::Warp;
|
||||
use crate::operation::{Helper, Uniform};
|
||||
use crate::warp::Warp;
|
||||
|
||||
pub const ID: OpId = OpId("distortion");
|
||||
pub const AMOUNT: ParamId = ParamId("amount");
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
//! The develop operations.
|
||||
//!
|
||||
//! Each operation is a self-contained file implementing
|
||||
//! [`crate::operation::Operation`]. Adding one means writing that file and
|
||||
//! adding it to [`crate::graph::EditGraph::default_chain`] — no central
|
||||
//! Each operation is a self-contained file. Adding one means writing that file
|
||||
//! and adding it to [`crate::graph::EditGraph::default_chain`] — no central
|
||||
//! shader to edit, no UI change (FR-DEV-3c).
|
||||
//!
|
||||
//! Most implement [`crate::operation::Operation`], a function from colour to
|
||||
//! colour. The optical corrections ([`distortion`]) implement
|
||||
//! [`crate::lens::Warp`] instead, because they rewrite *coordinates* before
|
||||
//! the source is sampled rather than transforming a colour after it. Both
|
||||
//! publish the same [`crate::descriptor::OpDescriptor`], so the UI builds
|
||||
//! controls for them identically and never learns the difference.
|
||||
|
||||
pub mod aberration;
|
||||
pub mod colour;
|
||||
pub mod colour_mixer;
|
||||
pub mod contrast;
|
||||
pub mod distortion;
|
||||
pub mod exposure;
|
||||
pub mod helpers;
|
||||
pub mod tone;
|
||||
pub mod vignetting;
|
||||
pub mod white_balance;
|
||||
|
||||
pub use aberration::Aberration;
|
||||
pub use colour::{Brilliance, Saturation, Vibrance};
|
||||
pub use colour_mixer::ColourMixer;
|
||||
pub use contrast::Contrast;
|
||||
pub use distortion::Distortion;
|
||||
pub use exposure::Exposure;
|
||||
pub use tone::{BlacksWhites, HighlightsShadows};
|
||||
pub use vignetting::Vignetting;
|
||||
pub use white_balance::WhiteBalance;
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Vignetting correction — the corner falloff a lens imposes.
|
||||
//!
|
||||
//! Every lens delivers less light to the corners than to the centre, by up to
|
||||
//! two stops wide open. This restores it.
|
||||
//!
|
||||
//! # Why this one *is* an `Operation`
|
||||
//!
|
||||
//! Distortion and CA are [`crate::lens::Warp`]s because they change which
|
||||
//! pixel is read. Vignetting does not: it applies a gain to the pixel already
|
||||
//! there. That the gain happens to depend on the pixel's *position* does not
|
||||
//! make it a coordinate transform — the sampling is unchanged, so it composes
|
||||
//! as an ordinary colour fragment and costs no extra texture read.
|
||||
//!
|
||||
//! It needs the pixel's normalised radius, which a colour fragment is not
|
||||
//! otherwise given. The composer publishes `radius` in the shader prologue for
|
||||
//! exactly this reason: it is derived from coordinates the prologue has
|
||||
//! already computed, so making it available costs nothing.
|
||||
//!
|
||||
//! # The model
|
||||
//!
|
||||
//! Lensfun's `pa` polynomial, in even powers of the radius:
|
||||
//!
|
||||
//! ```text
|
||||
//! attenuation = 1 + k1·r² + k2·r⁴ + k3·r⁶
|
||||
//! ```
|
||||
//!
|
||||
//! Only even powers, because vignetting is symmetric about the optical axis —
|
||||
//! an odd term would describe a lens brighter on one side than the other,
|
||||
//! which is a mount fault rather than a lens characteristic.
|
||||
//!
|
||||
//! The value is what the lens *did*, so correcting means dividing by it. That
|
||||
//! division is the whole reason this operation must run before the tonal
|
||||
//! stages: a corner recovered by two stops has to be recovered while the
|
||||
//! highlight headroom to hold it still exists (ARCH §5.2).
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
|
||||
pub const ID: OpId = OpId("vignetting");
|
||||
pub const AMOUNT: ParamId = ParamId("amount");
|
||||
|
||||
/// The `k1` coefficient at full manual travel.
|
||||
///
|
||||
/// Chosen so +100 lifts the extreme corner by roughly a stop, which covers a
|
||||
/// fast prime wide open — the case that actually needs correcting.
|
||||
const MAX_K1: f32 = -0.5;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.vignetting"),
|
||||
// Bidirectional deliberately. Negative values *add* falloff, which is a
|
||||
// legitimate creative choice as well as a correction, and a control that
|
||||
// only removed vignetting would need a second one beside it to put any
|
||||
// back.
|
||||
params: &[ParamDescriptor::amount("amount", "param.vignetting.amount")],
|
||||
};
|
||||
|
||||
/// The `pa` polynomial coefficients, as Lensfun stores them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Pa {
|
||||
pub k1: f32,
|
||||
pub k2: f32,
|
||||
pub k3: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Vignetting {
|
||||
amount: f32,
|
||||
profile: Option<Pa>,
|
||||
}
|
||||
|
||||
impl Vignetting {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Apply a lens profile's falloff coefficients.
|
||||
pub fn set_profile(&mut self, profile: Option<Pa>) {
|
||||
self.profile = profile;
|
||||
}
|
||||
|
||||
/// The effective coefficients: profile plus manual trim on `k1`.
|
||||
///
|
||||
/// The trim lands on `k1` alone. It is the dominant term, and adjusting
|
||||
/// the higher orders by eye would change the *shape* of the falloff rather
|
||||
/// than its depth — which is not what a photographer reaching for this
|
||||
/// slider wants.
|
||||
fn coefficients(&self) -> Pa {
|
||||
let trim = self.amount / 100.0 * MAX_K1;
|
||||
match self.profile {
|
||||
Some(p) => Pa {
|
||||
k1: p.k1 + trim,
|
||||
k2: p.k2,
|
||||
k3: p.k3,
|
||||
},
|
||||
None => Pa {
|
||||
k1: trim,
|
||||
k2: 0.0,
|
||||
k3: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for Vignetting {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
AMOUNT => self.amount = value,
|
||||
_ => log::warn!("vignetting: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
AMOUNT => self.amount,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
let c = self.coefficients();
|
||||
c.k1 != 0.0 || c.k2 != 0.0 || c.k3 != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
// `radius` comes from the prologue: the pixel's distance from the
|
||||
// optical axis, normalised so the corner is 1.
|
||||
"\
|
||||
c = c / vignette_attenuation(radius, vig_k1, vig_k2, vig_k3);"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
let c = self.coefficients();
|
||||
vec![
|
||||
Uniform {
|
||||
name: "vig_k1",
|
||||
value: c.k1,
|
||||
},
|
||||
Uniform {
|
||||
name: "vig_k2",
|
||||
value: c.k2,
|
||||
},
|
||||
Uniform {
|
||||
name: "vig_k3",
|
||||
value: c.k3,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
VIGNETTE
|
||||
}
|
||||
}
|
||||
|
||||
static VIGNETTE: &[Helper] = &[Helper {
|
||||
name: "vignette_attenuation",
|
||||
source: "\
|
||||
// Lensfun's `pa` vignetting polynomial: 1 + k1*r^2 + k2*r^4 + k3*r^6.
|
||||
//
|
||||
// Returns what the lens *did* to this pixel, so correcting divides by it.
|
||||
// Even powers only: vignetting is symmetric about the optical axis, and an
|
||||
// odd term would describe a lens brighter on one side than the other.
|
||||
//
|
||||
// The result is floored well above zero. A profile evaluated slightly outside
|
||||
// its calibrated range can produce a near-zero or negative attenuation, and
|
||||
// dividing by that turns the extreme corners into blown or inverted pixels —
|
||||
// a far more visible fault than the under-correction the floor causes.
|
||||
fn vignette_attenuation(r: f32, k1: f32, k2: f32, k3: f32) -> f32 {
|
||||
let r2 = r * r;
|
||||
let a = 1.0 + r2 * (k1 + r2 * (k2 + r2 * k3));
|
||||
return max(a, 0.05);
|
||||
}",
|
||||
}];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The attenuation the shader would compute, mirrored on the CPU so the
|
||||
/// maths is testable without a device (ARCH §6.5a).
|
||||
fn attenuation(c: Pa, r: f32) -> f32 {
|
||||
let r2 = r * r;
|
||||
(1.0 + r2 * (c.k1 + r2 * (c.k2 + r2 * c.k3))).max(0.05)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_does_nothing() {
|
||||
let v = Vignetting::new();
|
||||
assert!(!v.is_active());
|
||||
let c = v.coefficients();
|
||||
assert_eq!((c.k1, c.k2, c.k3), (0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_polynomial_is_unity_everywhere() {
|
||||
// Otherwise opening an image would rescale its brightness for nothing.
|
||||
let c = Vignetting::new().coefficients();
|
||||
for r in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
assert!((attenuation(c, r) - 1.0).abs() < 1e-6, "r={r} was scaled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_centre_is_never_altered() {
|
||||
// r = 0 kills every term, so the optical axis keeps its exposure
|
||||
// whatever the coefficients. If this failed, the correction would be
|
||||
// an exposure control with a gradient attached.
|
||||
for amount in [-100.0, -50.0, 50.0, 100.0] {
|
||||
let mut v = Vignetting::new();
|
||||
v.set_param(AMOUNT, amount);
|
||||
assert!(
|
||||
(attenuation(v.coefficients(), 0.0) - 1.0).abs() < 1e-6,
|
||||
"amount {amount} changed the centre"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_positive_amount_brightens_the_corners() {
|
||||
// The correcting direction: the lens darkened the corners, so the
|
||||
// attenuation there must be below 1 and the division lifts them.
|
||||
let mut v = Vignetting::new();
|
||||
v.set_param(AMOUNT, 100.0);
|
||||
let a = attenuation(v.coefficients(), 1.0);
|
||||
assert!(a < 1.0, "corner attenuation was {a}, expected < 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_amount_darkens_them_instead() {
|
||||
// The creative direction. A control that only removed vignetting
|
||||
// would need a second one beside it to add any back.
|
||||
let mut v = Vignetting::new();
|
||||
v.set_param(AMOUNT, -100.0);
|
||||
assert!(attenuation(v.coefficients(), 1.0) > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falloff_increases_monotonically_with_radius() {
|
||||
// Vignetting is a smooth darkening toward the corners. A polynomial
|
||||
// that reversed partway would produce a visible bright ring, which
|
||||
// reads as a rendering fault rather than as a wrong setting.
|
||||
let mut v = Vignetting::new();
|
||||
v.set_param(AMOUNT, 100.0);
|
||||
let c = v.coefficients();
|
||||
let mut prev = f32::MAX;
|
||||
for i in 0..=100 {
|
||||
let a = attenuation(c, i as f32 / 100.0);
|
||||
assert!(
|
||||
a <= prev + 1e-6,
|
||||
"attenuation rose at r={}",
|
||||
i as f32 / 100.0
|
||||
);
|
||||
prev = a;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_correction_is_worth_about_a_stop() {
|
||||
// The calibration behind MAX_K1. If this drifts, the slider either
|
||||
// cannot fix a fast prime or overshoots wildly at half travel.
|
||||
let mut v = Vignetting::new();
|
||||
v.set_param(AMOUNT, 100.0);
|
||||
let gain = 1.0 / attenuation(v.coefficients(), 1.0);
|
||||
assert!(
|
||||
(1.5..=2.5).contains(&gain),
|
||||
"corner gain was {gain}x, expected roughly a stop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_attenuation_never_reaches_zero() {
|
||||
// Division by a near-zero attenuation blows the corners to white or
|
||||
// inverts them. The floor must hold even for coefficients well past
|
||||
// anything the sliders can reach.
|
||||
let extreme = Pa {
|
||||
k1: -5.0,
|
||||
k2: -5.0,
|
||||
k3: -5.0,
|
||||
};
|
||||
for i in 0..=100 {
|
||||
let a = attenuation(extreme, i as f32 / 100.0);
|
||||
assert!(a >= 0.05, "attenuation fell to {a}");
|
||||
assert!(a.is_finite() && a > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_corrects_with_the_slider_at_zero() {
|
||||
let mut v = Vignetting::new();
|
||||
assert!(!v.is_active());
|
||||
v.set_profile(Some(Pa {
|
||||
k1: -0.3499,
|
||||
k2: -0.914,
|
||||
k3: 0.6689,
|
||||
}));
|
||||
assert!(v.is_active());
|
||||
assert_eq!(v.param(AMOUNT), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_profile_darkens_the_corners() {
|
||||
// Coefficients taken from the Lensfun entry for the Canon EF 16-35mm
|
||||
// f/2.8L at 20mm, f/2.8 — a real lens wide open, which is the case
|
||||
// this correction exists for.
|
||||
let profile = Pa {
|
||||
k1: -0.3499,
|
||||
k2: -0.914,
|
||||
k3: 0.6689,
|
||||
};
|
||||
let mut v = Vignetting::new();
|
||||
v.set_profile(Some(profile));
|
||||
|
||||
let c = v.coefficients();
|
||||
assert!(attenuation(c, 1.0) < attenuation(c, 0.0));
|
||||
assert!((attenuation(c, 0.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slider_trims_a_loaded_profile_rather_than_replacing_it() {
|
||||
let profile = Pa {
|
||||
k1: -0.35,
|
||||
k2: -0.91,
|
||||
k3: 0.67,
|
||||
};
|
||||
let mut v = Vignetting::new();
|
||||
v.set_profile(Some(profile));
|
||||
v.set_param(AMOUNT, 100.0);
|
||||
|
||||
let c = v.coefficients();
|
||||
assert_eq!(c.k2, profile.k2, "the higher orders must survive a trim");
|
||||
assert_eq!(c.k3, profile.k3);
|
||||
assert!((c.k1 - (profile.k1 + MAX_K1)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_can_be_cleared() {
|
||||
let mut v = Vignetting::new();
|
||||
v.set_profile(Some(Pa {
|
||||
k1: -0.3,
|
||||
k2: 0.0,
|
||||
k3: 0.0,
|
||||
}));
|
||||
assert!(v.is_active());
|
||||
v.set_profile(None);
|
||||
assert!(!v.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wgsl_body_reads_its_declared_uniforms_and_the_radius() {
|
||||
let mut v = Vignetting::new();
|
||||
v.set_param(AMOUNT, 50.0);
|
||||
let body = v.wgsl_body();
|
||||
for u in v.uniforms() {
|
||||
assert!(body.contains(u.name), "{} is declared but unused", u.name);
|
||||
}
|
||||
assert!(
|
||||
body.contains("radius"),
|
||||
"vignetting is radial and must read the prologue's radius"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_is_neutral() {
|
||||
let mut v = Vignetting::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
v.set_param(p.id, p.default);
|
||||
}
|
||||
assert!(!v.is_active());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
dr-sync.workspace = true
|
||||
dr-plat.workspace = true
|
||||
reqwest.workspace = true
|
||||
rustls.workspace = true
|
||||
quick-xml.workspace = true
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
//! FR-NC-2 requires the real app to use platform secure storage.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_plat::PlatformSecretStore;
|
||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy};
|
||||
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend};
|
||||
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, Session, SessionStore};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -55,26 +55,33 @@ async fn main() {
|
||||
dr_types::FormatFilter::all()
|
||||
};
|
||||
|
||||
let creds = match load_cached(&server) {
|
||||
Some(c) => {
|
||||
println!("using cached credentials for {}", c.login_name);
|
||||
c
|
||||
}
|
||||
None => match authenticate(&server).await {
|
||||
Ok(c) => c,
|
||||
// Sessions persist across runs: credentials in the platform keyring
|
||||
// (FR-NC-2), everything else as ordinary config.
|
||||
let sessions = SessionStore::open(Box::new(PlatformSecretStore::new()));
|
||||
if !sessions.can_remember() {
|
||||
println!("note: no secrets daemon — sign-in will not persist this session");
|
||||
}
|
||||
|
||||
let existing = sessions
|
||||
.current()
|
||||
.filter(|s| s.server == server.trim_end_matches('/'));
|
||||
|
||||
let (session, creds) = match existing {
|
||||
Some(s) => match sessions.credentials(&s) {
|
||||
Ok(c) => {
|
||||
println!("signed in: {}", s.describe());
|
||||
(s, c)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("authentication failed: {e}");
|
||||
std::process::exit(1);
|
||||
// Revoked server-side, or the keyring was cleared.
|
||||
println!("stored credential unusable ({e}); signing in again");
|
||||
sign_in(&server, &sessions).await
|
||||
}
|
||||
},
|
||||
None => sign_in(&server, &sessions).await,
|
||||
};
|
||||
|
||||
// The DAV base needs the *user id*, which may differ from the login name
|
||||
// (a login can be an email address). OCS reports the real one.
|
||||
let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| {
|
||||
eprintln!("could not resolve user id ({e}); falling back to login name");
|
||||
creds.login_name.clone()
|
||||
});
|
||||
let user_id = session.user_id.clone();
|
||||
println!("user id: {user_id}");
|
||||
|
||||
let backend = NextcloudBackend::new(&creds, &user_id).expect("build backend");
|
||||
@@ -205,7 +212,17 @@ async fn main() {
|
||||
println!("\n[range] skipped — no RAW over 300KB found");
|
||||
}
|
||||
|
||||
println!("\nscan complete");
|
||||
// Remember what was scanned, so the next launch resumes here.
|
||||
let mut updated = session.clone();
|
||||
updated.root = start_path.clone();
|
||||
updated.set_format_filter(&filter);
|
||||
if let Err(e) = sessions.update(&updated) {
|
||||
eprintln!("could not update session: {e}");
|
||||
} else {
|
||||
println!("\nremembered: {}", updated.describe());
|
||||
}
|
||||
|
||||
println!("scan complete");
|
||||
}
|
||||
|
||||
fn describe_filter(f: &dr_types::FormatFilter) -> String {
|
||||
@@ -217,24 +234,48 @@ fn describe_filter(f: &dr_types::FormatFilter) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate(server: &str) -> Result<AppCredentials, String> {
|
||||
let client =
|
||||
dr_sync_nextcloud::http_client("DarkRoom (connect example)").map_err(|e| e.to_string())?;
|
||||
/// Run Login Flow v2 and persist the result.
|
||||
async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredentials) {
|
||||
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("could not build http client: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let flow = auth::begin(&client, server, "DarkRoom (connect example)")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let flow = match auth::begin(&client, server, "DarkRoom (connect example)").await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("could not start login: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
println!("\n Open this in a browser and approve:\n");
|
||||
println!(" {}\n", flow.login_url);
|
||||
println!(" waiting (20 minute limit)…");
|
||||
|
||||
let creds = auth::poll(&client, &flow)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let creds = match auth::poll(&client, &flow).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("login failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
println!(" authenticated as {}", creds.login_name);
|
||||
save_cached(server, &creds);
|
||||
Ok(creds)
|
||||
|
||||
let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| {
|
||||
eprintln!(" could not resolve user id ({e}); using login name");
|
||||
creds.login_name.clone()
|
||||
});
|
||||
|
||||
let session = Session::new(&creds, user_id);
|
||||
match sessions.save(&session, &creds) {
|
||||
Ok(()) => println!(" session saved to {}", sessions.config_path().display()),
|
||||
Err(e) => eprintln!(" could not persist session: {e}"),
|
||||
}
|
||||
(session, creds)
|
||||
}
|
||||
|
||||
/// Resolve the real user id, which the DAV path needs.
|
||||
@@ -262,38 +303,6 @@ async fn fetch_user_id(creds: &AppCredentials) -> Result<String, String> {
|
||||
.ok_or_else(|| "no id in OCS response".to_string())
|
||||
}
|
||||
|
||||
fn cache_path(server: &str) -> PathBuf {
|
||||
let dir = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".cache"))
|
||||
.join("darkroom");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let key: String = server
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '_' })
|
||||
.collect();
|
||||
dir.join(format!("{key}.json"))
|
||||
}
|
||||
|
||||
fn load_cached(server: &str) -> Option<AppCredentials> {
|
||||
let text = std::fs::read_to_string(cache_path(server)).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
fn save_cached(server: &str, creds: &AppCredentials) {
|
||||
let path = cache_path(server);
|
||||
if let Ok(json) = serde_json::to_string(creds) {
|
||||
let _ = std::fs::write(&path, json);
|
||||
// Testing convenience only — FR-NC-2 requires platform secure storage.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
println!(" cached credentials at {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn human(bytes: u64) -> String {
|
||||
match bytes {
|
||||
b if b >= 1_000_000_000 => format!("{:.1}GB", b as f64 / 1e9),
|
||||
|
||||
@@ -16,9 +16,11 @@ use dr_sync::{
|
||||
pub mod auth;
|
||||
pub mod desktop_client;
|
||||
mod propfind;
|
||||
pub mod session;
|
||||
|
||||
pub use auth::{AppCredentials, LoginFlow};
|
||||
pub use desktop_client::DesktopClient;
|
||||
pub use session::{Session, SessionError, SessionStore};
|
||||
|
||||
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
|
||||
const CHUNKS: ChunkConstraints = ChunkConstraints {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Account sessions — logging in once and staying logged in.
|
||||
//!
|
||||
//! Splits deliberately in two:
|
||||
//!
|
||||
//! - **Credentials** go to platform secure storage (FR-NC-2). Never the
|
||||
//! catalog, never a file, never a log line.
|
||||
//! - **Everything else** — server, login, chosen root, format filter — is
|
||||
//! ordinary configuration, safe to write as plain JSON.
|
||||
//!
|
||||
//! That split is what lets the app show "signed in as duncan, watching
|
||||
//! /PhotosRaw" before it has touched the keyring, and re-authenticate cleanly
|
||||
//! if the credential has been revoked server-side.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dr_plat::{SecretError, SecretRef, SecretStore};
|
||||
use dr_sync::RemoteError;
|
||||
use dr_types::{Format, FormatFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AppCredentials;
|
||||
|
||||
/// A configured account, minus its credential.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub server: String,
|
||||
pub login: String,
|
||||
/// The DAV path segment, which may differ from `login` — a login can be
|
||||
/// an email address while the user id is something else.
|
||||
pub user_id: String,
|
||||
/// The folder chosen as the library root. Empty means the account root.
|
||||
#[serde(default)]
|
||||
pub root: String,
|
||||
/// Which formats the scan looks for (the tick-boxes).
|
||||
#[serde(default)]
|
||||
pub formats: Vec<String>,
|
||||
/// Unix seconds of the last completed scan, for display.
|
||||
#[serde(default)]
|
||||
pub last_scan: Option<i64>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(creds: &AppCredentials, user_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
server: creds.server.trim_end_matches('/').to_string(),
|
||||
login: creds.login_name.clone(),
|
||||
user_id: user_id.into(),
|
||||
root: String::new(),
|
||||
formats: Vec::new(),
|
||||
last_scan: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored format selection, defaulting to every supported format.
|
||||
///
|
||||
/// An unconfigured session must find everything rather than nothing.
|
||||
pub fn format_filter(&self) -> FormatFilter {
|
||||
if self.formats.is_empty() {
|
||||
FormatFilter::all()
|
||||
} else {
|
||||
FormatFilter::from_formats(
|
||||
self.formats
|
||||
.iter()
|
||||
.filter_map(|s| Format::from_extension(&s.to_ascii_lowercase())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_format_filter(&mut self, filter: &FormatFilter) {
|
||||
self.formats = filter
|
||||
.iter()
|
||||
.map(|f| format!("{f:?}").to_lowercase())
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Where this session's credential lives.
|
||||
pub fn secret_ref(&self) -> SecretRef {
|
||||
SecretRef::app_password(&self.server, &self.login)
|
||||
}
|
||||
|
||||
/// A short description for the UI.
|
||||
pub fn describe(&self) -> String {
|
||||
let host = self
|
||||
.server
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
if self.root.is_empty() {
|
||||
format!("{} on {host}", self.login)
|
||||
} else {
|
||||
format!("{} on {host}/{}", self.login, self.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-1 | FR-NC-2 | M-1 | M-2
|
||||
/// Loads and saves sessions, keeping credentials in secure storage.
|
||||
pub struct SessionStore {
|
||||
config_path: PathBuf,
|
||||
secrets: Box<dyn SecretStore>,
|
||||
}
|
||||
|
||||
/// What is written to disk. Versioned so a format change is a migration
|
||||
/// rather than a parse failure.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct ConfigFile {
|
||||
#[serde(default = "one")]
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// Open the store at the platform config location.
|
||||
///
|
||||
/// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to
|
||||
/// `~/.config` (FR-PLAT-LIN-1).
|
||||
pub fn open(secrets: Box<dyn SecretStore>) -> Self {
|
||||
let dir = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
|
||||
})
|
||||
.join("darkroom");
|
||||
Self::open_at(dir.join("sessions.json"), secrets)
|
||||
}
|
||||
|
||||
/// Open at an explicit path — used by tests, and by anything wanting a
|
||||
/// non-default config location.
|
||||
pub fn open_at(config_path: PathBuf, secrets: Box<dyn SecretStore>) -> Self {
|
||||
Self {
|
||||
config_path,
|
||||
secrets,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_path(&self) -> &Path {
|
||||
&self.config_path
|
||||
}
|
||||
|
||||
/// Whether credentials can be remembered at all.
|
||||
///
|
||||
/// Where false the UI should say sign-in will not persist, rather than
|
||||
/// letting the user discover it next launch.
|
||||
pub fn can_remember(&self) -> bool {
|
||||
self.secrets.is_available()
|
||||
}
|
||||
|
||||
/// Every configured session. Missing or unreadable config yields an empty
|
||||
/// list rather than an error — a first run is not a failure.
|
||||
pub fn list(&self) -> Vec<Session> {
|
||||
self.read_config().sessions
|
||||
}
|
||||
|
||||
/// The most recently configured session, if any.
|
||||
pub fn current(&self) -> Option<Session> {
|
||||
self.read_config().sessions.into_iter().next_back()
|
||||
}
|
||||
|
||||
/// Persist a session and its credential.
|
||||
///
|
||||
/// The credential goes to secure storage first: if that fails there is no
|
||||
/// point recording a session that cannot authenticate.
|
||||
pub fn save(&self, session: &Session, creds: &AppCredentials) -> Result<(), SessionError> {
|
||||
self.secrets
|
||||
.store(&session.secret_ref(), &creds.app_password)?;
|
||||
|
||||
let mut config = self.read_config();
|
||||
config
|
||||
.sessions
|
||||
.retain(|s| !(s.server == session.server && s.login == session.login));
|
||||
config.sessions.push(session.clone());
|
||||
self.write_config(&config)
|
||||
}
|
||||
|
||||
/// Update a session's settings, leaving its credential untouched.
|
||||
pub fn update(&self, session: &Session) -> Result<(), SessionError> {
|
||||
let mut config = self.read_config();
|
||||
match config
|
||||
.sessions
|
||||
.iter_mut()
|
||||
.find(|s| s.server == session.server && s.login == session.login)
|
||||
{
|
||||
Some(existing) => *existing = session.clone(),
|
||||
None => config.sessions.push(session.clone()),
|
||||
}
|
||||
self.write_config(&config)
|
||||
}
|
||||
|
||||
/// Rebuild credentials for a session from secure storage.
|
||||
///
|
||||
/// [`SecretError::NotFound`] means the credential was revoked or the
|
||||
/// keyring was cleared — the caller re-runs the login flow.
|
||||
pub fn credentials(&self, session: &Session) -> Result<AppCredentials, SessionError> {
|
||||
let password = self.secrets.retrieve(&session.secret_ref())?;
|
||||
Ok(AppCredentials {
|
||||
server: session.server.clone(),
|
||||
login_name: session.login.clone(),
|
||||
app_password: password,
|
||||
})
|
||||
}
|
||||
|
||||
/// Forget a session and delete its credential.
|
||||
///
|
||||
/// The credential is removed even if the config write fails, so a logout
|
||||
/// never leaves a usable secret behind.
|
||||
pub fn forget(&self, session: &Session) -> Result<(), SessionError> {
|
||||
let deleted = self.secrets.delete(&session.secret_ref());
|
||||
|
||||
let mut config = self.read_config();
|
||||
config
|
||||
.sessions
|
||||
.retain(|s| !(s.server == session.server && s.login == session.login));
|
||||
let written = self.write_config(&config);
|
||||
|
||||
deleted?;
|
||||
written
|
||||
}
|
||||
|
||||
fn read_config(&self) -> ConfigFile {
|
||||
std::fs::read_to_string(&self.config_path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_config(&self, config: &ConfigFile) -> Result<(), SessionError> {
|
||||
if let Some(parent) = self.config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(config)?;
|
||||
|
||||
// Write and rename, so an interrupted save cannot truncate an
|
||||
// existing config.
|
||||
let tmp = self.config_path.with_extension("tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &self.config_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SessionError {
|
||||
#[error("secure storage: {0}")]
|
||||
Secret(#[from] SecretError),
|
||||
|
||||
#[error("config io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("config format: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Remote(#[from] RemoteError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_plat::EphemeralSecretStore;
|
||||
|
||||
fn creds() -> AppCredentials {
|
||||
AppCredentials {
|
||||
server: "https://cloud.example/".into(),
|
||||
login_name: "duncan".into(),
|
||||
app_password: "secret-token".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_in(dir: &Path) -> SessionStore {
|
||||
SessionStore::open_at(
|
||||
dir.join("sessions.json"),
|
||||
Box::new(EphemeralSecretStore::new()),
|
||||
)
|
||||
}
|
||||
|
||||
fn tmpdir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("darkroom-test-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_session_survives_reopening() {
|
||||
let dir = tmpdir("survives");
|
||||
let secrets = Box::new(EphemeralSecretStore::new());
|
||||
|
||||
// Same secret store instance, as a real process would have.
|
||||
let store = SessionStore::open_at(dir.join("sessions.json"), secrets);
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
s.root = "PhotosRaw".into();
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
let reloaded = store.current().expect("session persisted");
|
||||
assert_eq!(reloaded.login, "duncan");
|
||||
assert_eq!(reloaded.root, "PhotosRaw");
|
||||
// Trailing slash normalised, so URLs built from it are consistent.
|
||||
assert_eq!(reloaded.server, "https://cloud.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_credential_never_reaches_the_config_file() {
|
||||
// NFR-SEC-2: the whole point of the split.
|
||||
let dir = tmpdir("nocreds");
|
||||
let store = store_in(&dir);
|
||||
let s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
let text = std::fs::read_to_string(dir.join("sessions.json")).unwrap();
|
||||
assert!(!text.contains("secret-token"), "credential leaked to disk");
|
||||
assert!(text.contains("duncan"), "session metadata should be there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_round_trip_through_secure_storage() {
|
||||
let dir = tmpdir("roundtrip");
|
||||
let store = store_in(&dir);
|
||||
let s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
let got = store.credentials(&s).unwrap();
|
||||
assert_eq!(got.app_password, "secret-token");
|
||||
assert_eq!(got.login_name, "duncan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_removes_both_halves() {
|
||||
let dir = tmpdir("forget");
|
||||
let store = store_in(&dir);
|
||||
let s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
store.forget(&s).unwrap();
|
||||
assert!(store.current().is_none());
|
||||
assert!(matches!(
|
||||
store.credentials(&s),
|
||||
Err(SessionError::Secret(SecretError::NotFound))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_the_same_account_twice_does_not_duplicate_it() {
|
||||
let dir = tmpdir("dedupe");
|
||||
let store = store_in(&dir);
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
s.root = "Photos".into();
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
assert_eq!(store.list().len(), 1);
|
||||
assert_eq!(store.current().unwrap().root, "Photos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_config_is_a_first_run_not_an_error() {
|
||||
let dir = tmpdir("firstrun");
|
||||
let store = store_in(&dir);
|
||||
assert!(store.list().is_empty());
|
||||
assert!(store.current().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_config_does_not_prevent_starting() {
|
||||
// Better to present a first-run state than to refuse to launch.
|
||||
let dir = tmpdir("corrupt");
|
||||
std::fs::write(dir.join("sessions.json"), "{ not json").unwrap();
|
||||
let store = store_in(&dir);
|
||||
assert!(store.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_selection_round_trips() {
|
||||
let dir = tmpdir("formats");
|
||||
let store = store_in(&dir);
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2, Format::Dng]));
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
let f = store.current().unwrap().format_filter();
|
||||
assert!(f.allows(Format::Cr2));
|
||||
assert!(f.allows(Format::Dng));
|
||||
assert!(!f.allows(Format::Nef));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unset_filter_means_every_format() {
|
||||
// Never "no formats", which would silently find nothing.
|
||||
let s = Session::new(&creds(), "duncan");
|
||||
let f = s.format_filter();
|
||||
assert!(f.allows(Format::Cr2));
|
||||
assert!(f.allows(Format::Jpeg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_is_readable_and_hides_the_scheme() {
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
assert_eq!(s.describe(), "duncan on cloud.example");
|
||||
s.root = "PhotosRaw".into();
|
||||
assert_eq!(s.describe(), "duncan on cloud.example/PhotosRaw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updating_settings_leaves_the_credential_alone() {
|
||||
let dir = tmpdir("update");
|
||||
let store = store_in(&dir);
|
||||
let mut s = Session::new(&creds(), "duncan");
|
||||
store.save(&s, &creds()).unwrap();
|
||||
|
||||
s.root = "Elsewhere".into();
|
||||
store.update(&s).unwrap();
|
||||
|
||||
assert_eq!(store.current().unwrap().root, "Elsewhere");
|
||||
assert_eq!(store.credentials(&s).unwrap().app_password, "secret-token");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user