Look the lens up and say plainly whether one was found

`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting
coefficients from a lens name, a focal length and an aperture — with no
dependents anywhere in the workspace. The three corrections it feeds now
exist in the graph, so this connects the two and finishes the chain.

The coefficient structs stay duplicated. `dr-pipeline` is organised around
having no dependencies so its codegen is testable without a device or a
database (ARCH §6.5a), and `dr-lens` carries an XML parser and 5.5 MB of
profile data. Neither crate can convert to the other, so the conversion goes
above both, in `develop.rs`, which is the only place that sees them together.

Both traits grow the same defaulted door. The optical corrections do not sit
on the same side of the fetch — distortion and CA rewrite coordinates and are
`Warp`s, vignetting applies a gain to the pixel already there and is an
ordinary node — and fanning a profile out by which trait each happens to
implement would make the caller reason about that distinction. Each correction
takes its own share of the whole profile instead, and `set_lens_profile` walks
both lists identically.

The lookup happens in `set_source_metadata` rather than in its caller, because
that is the one place a session is told which file it came from. Doing it
there makes it unforgettable, in the shape `FilmRebake` already uses for the
other derived thing — and, more to the point, makes *clearing* unforgettable:
a session that opened a second photograph while still holding the first one's
profile would correct it for the wrong optics, invisibly, in a way that looks
exactly like the lens.

It needs the whole shot and not just a name. Distortion is interpolated across
a zoom's focal range and vignetting depends strongly on aperture — a fast
prime can be two stops down in the corners wide open and clean by f/8 — so a
lookup missing either returns coefficients measured for a shot nobody took.
Missing any of the three refuses rather than guesses.

A profile is derived, not persisted: it comes from the file's EXIF and a
database, so it is not a parameter, not in the sidecar and not undoable. What
is an edit is the manual trim beside it, which each correction composes with
the measurement — so a photographer can lean on it, override it, or work
without one.

`InfoPanel` gains a lens line, and it distinguishes three cases rather than
two. `dr-lens` states the rule it exists for: an automatic correction that
silently did nothing is worse than one the user can see is unavailable. A
session with no header draws nothing, a header naming no lens reads "Lens not
recorded", and a lens the database has never heard of reads "· no profile".
Collapsing the last two would send somebody hunting for a profile that was
never missing — which, for third-party and adapted glass, is the ordinary case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 15:11:32 +02:00
co-authored by Claude Opus 5
parent a1165ef182
commit c4ddcbe0f7
14 changed files with 525 additions and 42 deletions
Generated
+1
View File
@@ -1658,6 +1658,7 @@ dependencies = [
"dr-film", "dr-film",
"dr-gpu", "dr-gpu",
"dr-ingest", "dr-ingest",
"dr-lens",
"dr-pipeline", "dr-pipeline",
"dr-plat", "dr-plat",
"dr-preset-xmp", "dr-preset-xmp",
+114
View File
@@ -14,6 +14,7 @@ use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation, Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
}; };
use crate::framing::{CropRect, Framing}; use crate::framing::{CropRect, Framing};
use crate::lens::LensProfile;
use crate::mask::MaskStack; use crate::mask::MaskStack;
use crate::operation::{compose_full, ComposedShader, Operation}; use crate::operation::{compose_full, ComposedShader, Operation};
use crate::ops; use crate::ops;
@@ -134,6 +135,19 @@ pub struct EditGraph {
/// result — but held separately because framing also changes the output's /// result — but held separately because framing also changes the output's
/// dimensions, which a warp never does. /// dimensions, which a warp never does.
warps: Vec<Box<dyn crate::lens::Warp>>, warps: Vec<Box<dyn crate::lens::Warp>>,
/// The lens profile the corrections above were given, if any.
///
/// Kept as well as fanned out, because the corrections hold it in a form
/// nothing can read back: each has folded its own share of the profile
/// into private coefficients. Something has to be able to answer "is this
/// photograph corrected from a measurement, or by hand?" — the interface
/// is required to say so plainly rather than let an automatic correction
/// silently do nothing — and this is the only place that can.
///
/// Not in [`EditState`], not in the sidecar, and not undoable: it is
/// derived from the file's EXIF and a database, exactly as the film's
/// baked tables are derived from a stock's id.
lens_profile: Option<LensProfile>,
} }
/// TRACES: FR-DEV-3f /// TRACES: FR-DEV-3f
@@ -188,6 +202,7 @@ impl EditGraph {
Box::new(crate::ops::Distortion::new()), Box::new(crate::ops::Distortion::new()),
Box::new(crate::ops::Aberration::new()), Box::new(crate::ops::Aberration::new()),
], ],
lens_profile: None,
} }
} }
@@ -272,6 +287,41 @@ impl EditGraph {
self.ops.iter().map(|o| o.descriptor()).collect() self.ops.iter().map(|o| o.descriptor()).collect()
} }
/// TRACES: FR-DEV-3
/// Apply a lens profile's measured coefficients to every correction that
/// wants some, or clear them all with `None`.
///
/// **Derived state, not an edit.** A profile comes from the file's EXIF
/// plus a database this crate does not link, so it is not a parameter, is
/// not in the sidecar, and is not undoable. What *is* an edit is the manual
/// trim beside it: each correction composes the profile with its own
/// slider, so a photographer can lean on the measurement, override it, or
/// work without one.
///
/// **Clearing matters as much as setting.** Opening a photograph from an
/// unrecognised lens must pass `None` rather than simply not calling this:
/// a graph reused across images would otherwise correct this frame for the
/// optics of the last one, which is both wrong and invisible.
///
/// Fans out over the warps and the operations alike. Which trait a
/// correction implements is a fact about where it sits relative to the
/// fetch, and no business of the caller's — see
/// [`crate::Operation::set_lens_profile`].
pub fn set_lens_profile(&mut self, profile: Option<LensProfile>) {
self.lens_profile = profile;
for warp in &mut self.warps {
warp.set_profile(profile.as_ref());
}
for op in &mut self.ops {
op.set_lens_profile(profile.as_ref());
}
}
/// The profile currently applied, if any.
pub fn lens_profile(&self) -> Option<&LensProfile> {
self.lens_profile.as_ref()
}
/// Descriptors for the coordinate-domain lens corrections, in order. /// Descriptors for the coordinate-domain lens corrections, in order.
/// ///
/// The counterpart to [`Self::descriptors`] and split from it for the same /// The counterpart to [`Self::descriptors`] and split from it for the same
@@ -436,6 +486,10 @@ impl EditGraph {
// the sidecar, the clipboard and the undo stack carry them with // the sidecar, the clipboard and the undo stack carry them with
// nothing registered anywhere (FR-DEV-3c). // nothing registered anywhere (FR-DEV-3c).
warps: _, warps: _,
// Derived from the file and a database, so it is rebuilt on open
// rather than restored — the same reason the film's tables travel
// as an id and not as numbers.
lens_profile: _,
masks, masks,
film, film,
spots, spots,
@@ -973,6 +1027,66 @@ mod tests {
assert_eq!(restored.param(aberration::ID, aberration::RED), Some(25.0)); assert_eq!(restored.param(aberration::ID, aberration::RED), Some(25.0));
} }
/// A profile has to reach all three corrections, across both traits.
///
/// The failure this guards is the quiet one: a profile that reached the
/// warps and not the vignetting node would correct the geometry and leave
/// the corners dark, which looks like an under-corrected lens rather than
/// like a wiring fault.
#[test]
fn a_lens_profile_reaches_every_correction_that_wants_one() {
use crate::lens::{LensProfile, Tca};
use crate::ops::{aberration, distortion, vignetting};
let mut g = EditGraph::default_chain();
for id in [distortion::ID, aberration::ID, vignetting::ID] {
assert_eq!(
g.param(id, ParamId("amount")).unwrap_or(0.0),
0.0,
"{id} should start neutral"
);
}
g.set_lens_profile(Some(LensProfile {
distortion: Some(distortion::PtLens {
a: 0.0,
b: -0.012,
c: 0.0,
}),
tca: Some(Tca {
red_scale: 1.000_32,
blue_scale: 0.999_93,
}),
vignetting: Some(vignetting::Pa {
k1: -0.42,
k2: 0.05,
k3: 0.0,
}),
}));
// Every correction is now doing something, with every slider still at
// its default — which is the whole point of a profile.
let source = g.compose().source;
for marker in [
"---- warp: distortion ----",
"---- warp: aberration ----",
"---- vignetting ----",
] {
assert!(
source.contains(marker),
"a profile did not reach {marker}: {source}"
);
}
// And clearing it puts the photograph back, which is what opening an
// image from an unrecognised lens has to do.
g.set_lens_profile(None);
let cleared = g.compose().source;
assert!(!cleared.contains("---- warp: "));
assert!(!cleared.contains("---- vignetting ----"));
assert!(g.lens_profile().is_none());
}
/// A warp is an edit, so `reset` has to reach it. It did not until the /// A warp is an edit, so `reset` has to reach it. It did not until the
/// loop was added: a reset that left the lens corrections standing would /// loop was added: a reset that left the lens corrections standing would
/// mean "back to the file as it is" quietly did not mean that. /// mean "back to the file as it is" quietly did not mean that.
+40
View File
@@ -64,6 +64,32 @@ use std::sync::Arc;
use crate::descriptor::{OpDescriptor, ParamId}; use crate::descriptor::{OpDescriptor, ParamId};
use crate::operation::{Helper, Uniform}; use crate::operation::{Helper, Uniform};
/// Lateral chromatic aberration, as a per-channel radial scale.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tca {
pub red_scale: f32,
pub blue_scale: f32,
}
/// What a lens profile says about one shot, in the pipeline's own types.
///
/// **Deliberately a mirror of `dr_lens::LensProfile` rather than that type
/// itself.** The dependency would have to run the wrong way: `dr-lens` carries
/// an XML parser and 5.5 MB of Lensfun data, and this crate is organised around
/// having no dependencies so that its codegen is testable without a GPU or a
/// database (ARCH §6.5a). Whatever sits above both does the conversion; it is
/// nine fields and a `match`.
///
/// Every field is independently optional because the database is: it commonly
/// carries distortion for a lens and no vignetting, or covers only part of a
/// zoom. A partial profile is useful and must not be discarded wholesale.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct LensProfile {
pub distortion: Option<crate::ops::distortion::PtLens>,
pub tca: Option<Tca>,
pub vignetting: Option<crate::ops::vignetting::Pa>,
}
/// A coordinate-domain operation, applied before the source is sampled. /// A coordinate-domain operation, applied before the source is sampled.
/// ///
/// Object-safe for the same reason [`crate::operation::Operation`] is: the /// Object-safe for the same reason [`crate::operation::Operation`] is: the
@@ -118,6 +144,20 @@ pub trait Warp: Send + Sync {
fn helpers(&self) -> &'static [Helper] { fn helpers(&self) -> &'static [Helper] {
&[] &[]
} }
/// Take whatever this warp needs from a lens profile.
///
/// Handed the *whole* profile rather than its own slice of it, so that the
/// graph fanning one out does not have to know which correction wants
/// which coefficients — the same reason an operation is handed a
/// [`ParamId`] rather than a field. `None` clears any profile in place,
/// which is what opening a photograph from an unrecognised lens must do:
/// leaving the previous one standing would correct this frame for the
/// optics of the last one.
///
/// Defaulted, because a warp need not be profile-driven. Nothing about the
/// coordinate stage requires a database behind it.
fn set_profile(&mut self, _profile: Option<&LensProfile>) {}
} }
/// The composed geometry stage: WGSL, uniforms, and what it needs from the /// The composed geometry stage: WGSL, uniforms, and what it needs from the
+1 -1
View File
@@ -61,7 +61,7 @@ pub use detail::{
pub use framing::{CropRect, Framing}; pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability}; pub use graph::{EditGraph, OpCapability, ParamCapability};
pub use history::{Edit, Entry as HistoryEntry, History, Step}; pub use history::{Edit, Entry as HistoryEntry, History, Step};
pub use lens::{compose_warps, ComposedWarp, Warp}; pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{ pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation, compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS, OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
+18 -1
View File
@@ -377,6 +377,20 @@ pub trait Operation: Send + Sync {
fn presentation(&self) -> Option<Presentation> { fn presentation(&self) -> Option<Presentation> {
None None
} }
/// Take whatever this operation needs from a lens profile.
///
/// The counterpart of [`crate::lens::Warp::set_profile`], and it exists on
/// this trait as well because the optical corrections do not all live on
/// the same side of the fetch. Distortion and CA rewrite coordinates and
/// are warps; vignetting applies a gain to the pixel already there and is
/// an ordinary node. Splitting the fan-out by which trait a correction
/// happens to implement would make the caller reason about that, so both
/// traits carry the same door and `EditGraph::set_lens_profile` walks
/// both lists the same way.
///
/// Defaulted: fourteen of the fifteen operations have nothing to take.
fn set_lens_profile(&mut self, _profile: Option<&crate::lens::LensProfile>) {}
} }
/// A named WGSL helper function, deduplicated across operations. /// A named WGSL helper function, deduplicated across operations.
@@ -1598,7 +1612,10 @@ mod tests {
ca.set_param(crate::ops::aberration::RED, 25.0); ca.set_param(crate::ops::aberration::RED, 25.0);
let with_ca = compose_with(vec![Box::new(ca)]); let with_ca = compose_with(vec![Box::new(ca)]);
assert!(with_ca.contains("var p_r"), "CA needs per-channel positions"); assert!(
with_ca.contains("var p_r"),
"CA needs per-channel positions"
);
assert_eq!( assert_eq!(
with_ca.matches("sample_bilinear(").count(), with_ca.matches("sample_bilinear(").count(),
// Three fetches in the body, plus the helper's own definition. // Three fetches in the body, plus the helper's own definition.
+11
View File
@@ -141,6 +141,17 @@ impl Warp for Aberration {
r != 1.0 || b != 1.0 r != 1.0 || b != 1.0
} }
fn set_profile(&mut self, profile: Option<&crate::lens::LensProfile>) {
// The inherent `set_profile` taking just this correction's own
// coefficients, not this trait method: an inherent method wins over a
// trait one of the same name, so this is a narrowing and not a loop.
self.set_profile(
profile
.and_then(|p| p.tca)
.map(|t| (t.red_scale, t.blue_scale)),
);
}
fn wgsl_body(&self) -> String { fn wgsl_body(&self) -> String {
// `p_r` and `p_b` enter equal to `p` and are carried out of the block. // `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. // Green is deliberately absent: it is the reference and never moves.
+7
View File
@@ -136,6 +136,13 @@ impl Warp for Distortion {
c.a != 0.0 || c.b != 0.0 || c.c != 0.0 c.a != 0.0 || c.b != 0.0 || c.c != 0.0
} }
fn set_profile(&mut self, profile: Option<&crate::lens::LensProfile>) {
// The inherent `set_profile` taking just this correction's own
// coefficients, not this trait method: an inherent method wins over a
// trait one of the same name, so this is a narrowing and not a loop.
self.set_profile(profile.and_then(|p| p.distortion));
}
fn wgsl_body(&self) -> String { fn wgsl_body(&self) -> String {
// Written against `p`, which is already normalised and centred. // Written against `p`, which is already normalised and centred.
"\ "\
+7
View File
@@ -133,6 +133,13 @@ impl Operation for Vignetting {
c.k1 != 0.0 || c.k2 != 0.0 || c.k3 != 0.0 c.k1 != 0.0 || c.k2 != 0.0 || c.k3 != 0.0
} }
fn set_lens_profile(&mut self, profile: Option<&crate::lens::LensProfile>) {
// The inherent `set_profile` taking just this correction's own
// coefficients, not this trait method: an inherent method wins over a
// trait one of the same name, so this is a narrowing and not a loop.
self.set_profile(profile.and_then(|p| p.vignetting));
}
fn wgsl_body(&self) -> String { fn wgsl_body(&self) -> String {
// `radius` comes from the prologue: the pixel's distance from the // `radius` comes from the prologue: the pixel's distance from the
// optical axis, normalised so the corner is 1. // optical axis, normalised so the corner is 1.
+40 -40
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -34,6 +34,12 @@ dr-sync-nextcloud.workspace = true
dr-export.workspace = true dr-export.workspace = true
dr-ingest.workspace = true dr-ingest.workspace = true
dr-film.workspace = true dr-film.workspace = true
# The lens profile database, here for the same reason dr-film is: dr-pipeline
# knows the maths of lens correction and deliberately has no dependency with
# which to find out which coefficients belong to which lens. The conversion
# between the two crates' mirrored coefficient types happens in `develop.rs`,
# because it is the only place that can see both.
dr-lens.workspace = true
dr-pipeline.workspace = true dr-pipeline.workspace = true
dr-catalog.workspace = true dr-catalog.workspace = true
# The face pipeline, with the ONNX runtime: this is the layer that actually # The face pipeline, with the ONNX runtime: this is the layer that actually
+238
View File
@@ -728,6 +728,14 @@ pub struct DevelopSession {
/// the memory of where the pixels came from; the allowlist that turns it /// the memory of where the pixels came from; the allowlist that turns it
/// into something writable stays the one function in `export.rs`. /// into something writable stays the one function in `export.rs`.
source_meta: Option<dr_decode::Metadata>, source_meta: Option<dr_decode::Metadata>,
/// Whether the lens in the header matched a profile in the database.
///
/// A separate flag rather than `graph.lens_profile().is_some()`, because
/// the two answer different questions once the photographer starts work:
/// the graph says what is *applied*, which a manual correction also
/// satisfies, and this says whether a *measurement* was found. Only the
/// second can honestly caption "no profile".
lens_profile_found: bool,
/// Kept so the session can build GPU resources after construction. /// Kept so the session can build GPU resources after construction.
/// ///
/// The distance fields behind a subject mask are made when a layer is /// The distance fields behind a subject mask are made when a layer is
@@ -955,6 +963,9 @@ impl DevelopSession {
// built straight from pixels — a test, `masks_ui`'s fixture — // built straight from pixels — a test, `masks_ui`'s fixture —
// honestly has no header, and says so. // honestly has no header, and says so.
source_meta: None, source_meta: None,
// Nothing has been looked up, which is not the same as "looked up
// and not found" — `lens_summary` distinguishes them.
lens_profile_found: false,
ctx: ctx.clone(), ctx: ctx.clone(),
graph, graph,
history, history,
@@ -3479,9 +3490,106 @@ impl DevelopSession {
/// its header together — every other way of making a session starts from /// its header together — every other way of making a session starts from
/// pixels that never had a file behind them. /// pixels that never had a file behind them.
pub fn set_source_metadata(&mut self, meta: dr_decode::Metadata) { pub fn set_source_metadata(&mut self, meta: dr_decode::Metadata) {
// The lens profile is applied *here* rather than by the caller, and
// that is the point of putting it in this method. This is the one
// place a session is told which file it came from, so it is the one
// place the lookup can be made unforgettable — the same shape
// `FilmRebake` uses to stop a derived thing being quietly skipped.
self.apply_lens_profile(&meta);
self.source_meta = Some(meta); self.source_meta = Some(meta);
} }
/// TRACES: FR-DEV-3
/// Look this shot's lens up and hand the coefficients to the corrections.
///
/// Called with **every** header, including ones naming no lens: the
/// clearing case matters as much as the setting one, because a session
/// reused for a second photograph would otherwise correct it for the
/// optics of the first.
fn apply_lens_profile(&mut self, meta: &dr_decode::Metadata) {
let found = Self::profile_for(meta);
self.lens_profile_found = found.is_some();
self.graph.set_lens_profile(found);
}
/// The profile for one shot, converted into the pipeline's own types.
///
/// **The conversion lives here because nowhere else can see both sides.**
/// `dr-lens` carries the Lensfun database and `dr-pipeline` carries the
/// maths, and the coefficient structs are deliberately duplicated so that
/// the dependency between them does not exist (ARCH §6.5a). This function
/// is the seam, and it is a `match` on three optionals.
///
/// Every field is taken independently. The database routinely knows a
/// lens's distortion and not its vignetting, or covers only part of a
/// zoom's range, and a partial profile is worth applying — discarding it
/// because one field is missing would turn a good correction into none.
fn profile_for(meta: &dr_decode::Metadata) -> Option<dr_pipeline::LensProfile> {
// All three are needed to ask the question at all. A lens name alone
// does not identify a correction: distortion is interpolated across a
// zoom's focal range and vignetting depends strongly on aperture — a
// fast prime can be two stops down in the corners wide open and clean
// by f/8 — so a lookup missing either would return a profile measured
// for a shot nobody took.
let (lens, focal, aperture) = (meta.lens.as_deref()?, meta.focal_length?, meta.aperture?);
let shot = dr_lens::ShotInfo::new(lens, focal, aperture);
let found = dr_lens::lookup(&shot)?;
if found.is_empty() {
return None;
}
Some(dr_pipeline::LensProfile {
distortion: found
.distortion
.map(|d| dr_pipeline::ops::distortion::PtLens {
a: d.a,
b: d.b,
c: d.c,
}),
tca: found.tca.map(|t| dr_pipeline::Tca {
red_scale: t.red_scale,
blue_scale: t.blue_scale,
}),
vignetting: found.vignetting.map(|v| dr_pipeline::ops::vignetting::Pa {
k1: v.k1,
k2: v.k2,
k3: v.k3,
}),
})
}
/// TRACES: FR-DEV-3
/// What to tell the photographer about the automatic lens correction.
///
/// `dr-lens` states the rule this exists to satisfy: an automatic
/// correction that silently did nothing is worse than one the user can see
/// is unavailable. Most lenses in most photographs will not be in the
/// database — third-party glass often reports nothing, adapted manual
/// lenses report nothing at all — so "no profile" is the ordinary case and
/// has to read as a fact rather than as a failure.
pub fn lens_summary(&self) -> String {
let Some(meta) = self.source_meta.as_ref() else {
return String::new();
};
let Some(lens) = meta
.lens
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty())
else {
// Not "no profile found": nothing was looked up, because the file
// does not say what it was taken with. Naming the wrong reason
// would send someone hunting for a profile that was never missing.
return "Lens not recorded".into();
};
if self.lens_profile_found {
format!("{lens} · corrected")
} else {
format!("{lens} · no profile")
}
}
/// TRACES: FR-EXP-8 /// TRACES: FR-EXP-8
/// The header this session was opened from, where there was one. /// The header this session was opened from, where there was one.
/// ///
@@ -4814,6 +4922,136 @@ mod tests {
.clone() .clone()
} }
/// A lookup needs all three of lens, focal length and aperture.
///
/// Not pedantry about missing fields: distortion is interpolated across a
/// zoom's focal range and vignetting depends strongly on aperture, so a
/// lookup done without them would return coefficients measured for a shot
/// nobody took and apply them with full confidence. Refusing is the honest
/// answer, and the panel says so.
#[test]
fn a_lookup_needs_the_whole_shot_and_not_just_the_lens() {
let complete = dr_decode::Metadata {
lens: Some("Nikon AF-S 50mm f/1.8G".into()),
focal_length: Some(50.0),
aperture: Some(1.8),
..Default::default()
};
for (name, meta) in [
(
"no lens",
dr_decode::Metadata {
lens: None,
..complete.clone()
},
),
(
"no focal length",
dr_decode::Metadata {
focal_length: None,
..complete.clone()
},
),
(
"no aperture",
dr_decode::Metadata {
aperture: None,
..complete.clone()
},
),
] {
assert!(
DevelopSession::profile_for(&meta).is_none(),
"{name}: a partial header must not produce a confident profile"
);
}
}
/// "Not recorded" and "no profile" are different facts.
///
/// Collapsing them would send someone hunting for a missing profile when
/// the file simply never said what took the photograph — and `dr-lens`'s
/// own rule is that the interface must be plain about which it is, because
/// a correction that silently did nothing is worse than one visibly
/// unavailable.
#[test]
fn the_lens_line_says_which_kind_of_nothing_it_found() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let session = |meta: dr_decode::Metadata| {
let mut s = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
s.set_source_metadata(meta);
s
};
// A session that was never given a header at all.
let bare = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
assert_eq!(bare.lens_summary(), "");
let unrecorded = session(dr_decode::Metadata::default());
assert_eq!(unrecorded.lens_summary(), "Lens not recorded");
// A name no database will match. Deliberately absurd rather than a real
// obscure lens, so the test cannot start passing for the wrong reason
// if the bundled database grows.
let unmatched = session(dr_decode::Metadata {
lens: Some("Nonexistent 999mm f/0.5".into()),
focal_length: Some(999.0),
aperture: Some(0.5),
..Default::default()
});
assert_eq!(
unmatched.lens_summary(),
"Nonexistent 999mm f/0.5 · no profile"
);
assert!(
unmatched.graph.lens_profile().is_none(),
"an unmatched lens must leave the corrections alone"
);
}
/// Opening a second photograph must not correct it for the first one's lens.
///
/// The clearing case, and the reason `apply_lens_profile` runs on every
/// header rather than only on the ones that match something. A stale
/// profile is invisible: the picture is simply wrong in a way that looks
/// like the lens.
#[test]
fn a_second_photograph_does_not_inherit_the_first_lens_profile() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
// Stand in for a matched lens by applying a profile directly, so the
// test does not depend on what the bundled database happens to hold.
session
.graph
.set_lens_profile(Some(dr_pipeline::LensProfile {
distortion: Some(dr_pipeline::ops::distortion::PtLens {
a: 0.0,
b: -0.02,
c: 0.0,
}),
tca: None,
vignetting: None,
}));
assert!(session.graph.lens_profile().is_some());
session.set_source_metadata(dr_decode::Metadata::default());
assert!(
session.graph.lens_profile().is_none(),
"a header naming no lens must clear the previous photograph's \
correction, not leave it standing"
);
}
/// TRACES: FR-DEV-3 /// TRACES: FR-DEV-3
/// A gradient needs no segmentation, and until now it silently got no mask. /// A gradient needs no segmentation, and until now it silently got no mask.
/// ///
+14
View File
@@ -1995,6 +1995,19 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
window.set_exposure(describe_exposure(&l.meta).into()); window.set_exposure(describe_exposure(&l.meta).into());
window.set_dimensions(format!("{} × {}", l.width, l.height).into()); window.set_dimensions(format!("{} × {}", l.width, l.height).into());
// Composed by the session rather than here, because only it
// knows whether the lookup found anything — the header can
// name a lens the database has never heard of, which is the
// ordinary case for third-party and adapted glass and must
// read as a fact rather than as a failure.
window.set_lens(
l.session
.as_ref()
.map(|s| s.lens_summary())
.unwrap_or_default()
.into(),
);
// The panel is built from what the pipeline reports, so // The panel is built from what the pipeline reports, so
// this code names no operation (FR-DEV-3a). // this code names no operation (FR-DEV-3a).
match l.session { match l.session {
@@ -2041,6 +2054,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
window.set_camera("".into()); window.set_camera("".into());
window.set_exposure("".into()); window.set_exposure("".into());
window.set_dimensions("".into()); window.set_dimensions("".into());
window.set_lens("".into());
} }
} }
}) })
+7
View File
@@ -65,6 +65,12 @@ export component AppWindow inherits Window {
// Current image, for the status strip and empty state. // Current image, for the status strip and empty state.
in property <string> filename: ""; in property <string> filename: "";
in property <string> camera: ""; in property <string> camera: "";
/// TRACES: FR-DEV-3
/// The lens the file names, and whether a correction profile was found for
/// it. Composed in Rust because the sentence depends on a database lookup
/// this file cannot see, and because "not recorded" and "no profile" are
/// different facts that must not be collapsed into one.
in property <string> lens: "";
in property <string> exposure: ""; in property <string> exposure: "";
in property <string> dimensions: ""; in property <string> dimensions: "";
in property <int> index: 0; in property <int> index: 0;
@@ -2416,6 +2422,7 @@ in property <bool> panel-visible: true;
column := VerticalLayout { column := VerticalLayout {
if !root.local-mode && !root.repairing: InfoPanel { if !root.local-mode && !root.repairing: InfoPanel {
camera: root.camera; camera: root.camera;
lens: root.lens;
exposure: root.exposure; exposure: root.exposure;
dimensions: root.dimensions; dimensions: root.dimensions;
} }
+21
View File
@@ -217,6 +217,17 @@ export component StatusBar inherits Rectangle {
// wanted, and the per-group lids only stood between the user and the controls. // wanted, and the per-group lids only stood between the user and the controls.
export component InfoPanel inherits Rectangle { export component InfoPanel inherits Rectangle {
in property <string> camera; in property <string> camera;
/// TRACES: FR-DEV-3
/// The lens, and whether it matched a correction profile.
///
/// Here rather than beside the optical sliders, and the reason is what the
/// line is *for*. `dr-lens` states the rule — an automatic correction that
/// silently did nothing is worse than one the user can see is unavailable
/// — and the fact it reports is a fact about the file: which lens took
/// this photograph, and whether the database has heard of it. That is the
/// same kind of thing as the body and the exposure, and it wants reading
/// once on opening rather than hunting for under a group filter.
in property <string> lens;
in property <string> exposure; in property <string> exposure;
in property <string> dimensions; in property <string> dimensions;
@@ -239,6 +250,16 @@ export component InfoPanel inherits Rectangle {
Label { text: root.exposure; } Label { text: root.exposure; }
// Dim, like the dimensions below it: this is something the file says,
// not something the photographer chose. An empty string draws nothing
// — a session built from pixels with no header has no lens to report,
// and an empty row is honest where "Unknown" would be noise.
Caption {
text: root.lens;
visible: root.lens != "";
wrap: word-wrap;
}
Caption { text: root.dimensions; } Caption { text: root.dimensions; }
} }
} }