A preset could not choose a film stock. The stock is a choice of material rather than a parameter, so `Preset` — a map of `op.param = value` — had nowhere to hold it, and "Portra 400, printed" could not be saved, copied or shipped as a look. Worse, the film node's own sliders *were* parameters: a paste moved one stock's exposure and push onto whatever stock the target was on, and left the target's tables baked from the values it had just replaced. A preset now carries a `FilmRef` beside its parameters. It travels under whichever scope carries the film node, so the stock and its sliders are never split, and by the replacement rule every other parameter follows: applied at that scope, a preset without a film develops the target without one. `Preset::apply` returns the `FilmRebake` it owes, as `EditGraph::set_state` already did, because this crate cannot bake a stock; the develop session pays it before recording the step, and the batch paste writes the stock into each sidecar through `film_for`. The library file spells it `film =` / `film_print =`, as a sidecar does, and an older build keeps those lines as ones it does not understand. `EditState` keeps the film in its own field only: the parameters it captures leave it out, so one edit has one place to say which stock it is on. Second, a preset now has a reach. Replacement is right for a copy of a whole edit — "make these match" — and wrong for a look: a stock-only "Portra 400" applied that way would put the photograph's exposure, white balance and noise reduction back to default. `Reach::Named` replaces only the operations a preset names (whole operations, so a look that sets the blacks resets the whites beside them) and the film only if it names one. Saved edits and the clipboard keep `Reach::Whole`; the line `reach = named` is written only for the other, so existing libraries write the same bytes.
794 lines
37 KiB
Rust
794 lines
37 KiB
Rust
//! The develop session's identity, lifecycle and file metadata.
|
|
//!
|
|
//! `DevelopSession` itself, opening a photograph, and the small set of
|
|
//! whole-session queries (settings, metadata) that do not belong to any of
|
|
//! the more specific areas split out alongside this one.
|
|
use dr_decode::RawImage;
|
|
use dr_gpu::{
|
|
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext,
|
|
HistogramPass, MaskPass, RawHistogram, RawHistogramPass,
|
|
};
|
|
use dr_pipeline::{CropRect, Edit, EditGraph, History, Preset, Scope};
|
|
use std::sync::Arc;
|
|
|
|
use crate::labels;
|
|
use crate::segmentation::Segmentation;
|
|
|
|
use super::masks::MaskView;
|
|
use super::segmentation::SessionId;
|
|
|
|
pub struct DevelopSession {
|
|
/// This session's name, for work that outlives the frame it started on.
|
|
pub(super) id: SessionId,
|
|
/// TRACES: FR-CULL-10
|
|
/// Confirmed faces in this photograph, normalised to the long edge.
|
|
///
|
|
/// Empty until [`DevelopSession::set_face_names`] is called, and empty for
|
|
/// ever on a library with no face indexing — in which case segmentation
|
|
/// behaves exactly as it did before, which is the point.
|
|
pub(super) face_names: Vec<crate::identity::NormalisedNamedBox>,
|
|
/// How this photograph is stored relative to how it is shown.
|
|
///
|
|
/// Kept because the segmentation proxy is rendered through a *neutral*
|
|
/// graph and is therefore in sensor order, while faces were found on the
|
|
/// upright thumbnail. On anything shot in portrait the two differ by a
|
|
/// quarter turn, and matching them without undoing it finds nothing.
|
|
pub(super) orientation: dr_types::Orientation,
|
|
/// TRACES: FR-EXP-8
|
|
/// What the file these pixels came from said about itself.
|
|
///
|
|
/// A session is one photograph, and this is that photograph's header: the
|
|
/// body, the lens, the moment the shutter fired, the rights statement.
|
|
/// Nothing in develop reads it. It is remembered so that an export made
|
|
/// from the open image can disclose the same things an export of the same
|
|
/// file from the grid does, and so `{date}` can mean the capture date on
|
|
/// both paths rather than nothing on one of them.
|
|
///
|
|
/// **Why here rather than beside the frame on `export::Source::Rendered`.**
|
|
/// Hanging it on the export request would work and would touch less of
|
|
/// this file, but the header would then have to be held somewhere in the
|
|
/// interface *alongside* the session and paired with it at export time —
|
|
/// two cells to keep in step across the six places a photograph is opened,
|
|
/// replaced or fails to open. The failure mode of getting that pairing
|
|
/// wrong is not a missing tag: it is one photograph exported under
|
|
/// another's byline and coordinates, silently. Kept here, the header
|
|
/// arrives with the pixels it belongs to or not at all, and there is no
|
|
/// pairing left to break.
|
|
///
|
|
/// It is the *decoded header* and not a `dr_export::SourceMetadata`, which
|
|
/// matters: what an export may disclose is a decision taken per export
|
|
/// from the settings, inside `dr-export` — see that crate's note on why
|
|
/// source metadata is a parameter and not a field on `Frame`. This is only
|
|
/// the memory of where the pixels came from; the allowlist that turns it
|
|
/// into something writable stays the one function in `export.rs`.
|
|
pub(super) 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".
|
|
pub(super) lens_profile_found: bool,
|
|
/// Kept so the session can build GPU resources after construction.
|
|
///
|
|
/// The distance fields behind a subject mask are made when a layer is
|
|
/// *shaped*, not when the image opens, and cloning a `GpuContext` is two
|
|
/// `Arc` bumps.
|
|
pub(super) ctx: GpuContext,
|
|
pub(super) graph: EditGraph,
|
|
/// TRACES: FR-DEV-5
|
|
/// Undo, kept beside the graph rather than in the window.
|
|
///
|
|
/// Every mutator below records into it, so a caller cannot change the edit
|
|
/// and forget to. That is the whole reason it lives here: the callbacks in
|
|
/// `lib.rs` are generic by construction and there are a dozen of them, and
|
|
/// a history the *call sites* had to remember would be one press of undo
|
|
/// away from wrong every time a control is added.
|
|
pub(super) history: History,
|
|
/// TRACES: FR-DEV-5
|
|
/// The named snapshots of this edit, as the sidecar had them plus what
|
|
/// this sitting took, minus what it deleted.
|
|
///
|
|
/// Beside the history rather than inside it, because they answer a
|
|
/// different question. The history is what was done in this sitting and
|
|
/// is deliberately forgotten with it; a snapshot is a state the
|
|
/// photographer *named*, which is the act of saying it should outlive
|
|
/// the sitting. It is persisted as a version of the sidecar pointing at
|
|
/// this one (`Version::snapshot_of`), which is what makes it survive a
|
|
/// restart and reach the other device.
|
|
pub(super) snapshots: Vec<dr_pipeline::Version>,
|
|
/// The ids of snapshots deleted this sitting, so the save can remove
|
|
/// them from the file without removing what another device added since
|
|
/// — see `Sidecar::replace_snapshots`.
|
|
pub(super) removed_snapshots: Vec<String>,
|
|
/// TRACES: FR-DEV-7
|
|
/// The snapshot the canvas is showing instead of the edit, while a
|
|
/// comparison is held. Viewing state: nothing about the edit changes,
|
|
/// and it goes down with the session.
|
|
pub(super) compared_snapshot: Option<String>,
|
|
/// TRACES: FR-DEV-17
|
|
/// The layers the last committed crop took out of the frame, while the
|
|
/// history still stands on that crop. See [`super::framing::CropNotice`].
|
|
pub(super) crop_notice: Option<super::framing::CropNotice>,
|
|
pub(super) demosaiced: Arc<DemosaicedImage>,
|
|
pub(super) adjust: AdjustPass,
|
|
/// TRACES: FR-DSP-7
|
|
/// Optional, because a session that cannot count its frames is still a
|
|
/// session that can develop them. If the reduction fails to build — an
|
|
/// old driver, a device without the storage-buffer atomics it needs — the
|
|
/// photographer loses the histogram and keeps the photograph.
|
|
pub(super) histogram: Option<HistogramPass>,
|
|
/// TRACES: FR-CULL-3
|
|
/// The raw-domain reduction, on the same terms as the display one above:
|
|
/// optional, because a session that cannot count the sensor data is still
|
|
/// a session that can develop it.
|
|
pub(super) raw_histogram: Option<RawHistogramPass>,
|
|
/// The raw reading, once taken.
|
|
///
|
|
/// **Cached, where the display histogram is recomputed every settled
|
|
/// frame, and the difference is not an optimisation.** This measures the
|
|
/// demosaiced source, which nothing downstream of the demosaic can change:
|
|
/// no slider, no crop, no zoom, no output space moves a single count in
|
|
/// it. Recomputing it per frame would be a dispatch and a device sync
|
|
/// point spent to arrive back at the number already held — and on the
|
|
/// culling pass FR-CULL-3 is written for, that is a cost paid three
|
|
/// thousand times over.
|
|
///
|
|
/// `None` until first asked for, and it stays `None` on a file with no
|
|
/// sensor data behind it. The session is one photograph and the demosaiced
|
|
/// source is fixed for its life, so there is no invalidation to get wrong.
|
|
pub(super) raw_counts: Option<RawHistogram>,
|
|
/// TRACES: FR-CULL-3
|
|
/// The focus-peaking overlay, on the same terms as the histogram above:
|
|
/// optional, because a device that cannot compile the pass is still a
|
|
/// device that can develop the photograph. What is lost is an instrument,
|
|
/// not the picture.
|
|
pub(super) peak: Option<FocusPeakPass>,
|
|
/// TRACES: FR-CULL-3
|
|
/// What the photographer asked the overlay to look like, or `None` for
|
|
/// off.
|
|
///
|
|
/// **Interface state, not part of the edit** — the same category as
|
|
/// `show_overlay` beside it. It changes no pixel of the photograph, it is
|
|
/// not in the sidecar, and it is not on the undo stack: pressing undo
|
|
/// after switching peaking on should take back the last *edit*, not the
|
|
/// last thing looked at.
|
|
///
|
|
/// An `Option` rather than a bool plus a settings field, so that "off" and
|
|
/// "on, in some configuration" cannot disagree with each other.
|
|
pub(super) peaking: Option<FocusPeaking>,
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// The region map local masks select from, once it has been computed.
|
|
///
|
|
/// `None` until the photographer asks for it. Segmentation costs about
|
|
/// half a second and most edits never need one, so running it on open
|
|
/// would tax every photograph for a feature used on some of them.
|
|
pub(super) segmentation: Option<Segmentation>,
|
|
/// Rasterises the mask layers. Built lazily for the same reason.
|
|
pub(super) masks: Option<MaskPass>,
|
|
/// One signed distance field per active subject layer, on the GPU.
|
|
pub(super) subjects: Option<dr_gpu::SubjectMasks>,
|
|
/// What `subjects` was built from.
|
|
///
|
|
/// The fields are expensive — an exact distance transform over the proxy
|
|
/// for each layer — and almost nothing changes them. Feather, falloff and
|
|
/// simple growing are arithmetic the shader does on the field it already
|
|
/// has, so this deliberately does *not* include them: dragging those
|
|
/// sliders must not rebuild anything.
|
|
pub(super) subject_key: u64,
|
|
/// Which layers the develop panel is editing, if any.
|
|
///
|
|
/// This is what lets one panel serve both scopes: with layers selected,
|
|
/// the sliders read and write *their* chains, and the photographer is
|
|
/// adjusting one or more regions rather than the frame.
|
|
///
|
|
/// A plain click replaces this outright; a modifier-click toggles one id
|
|
/// in or out, so several layers can be shaped by the same slider drag —
|
|
/// "make these three subjects a stop darker" is one gesture rather than
|
|
/// three. Order is insertion order and nothing reads it, only membership.
|
|
pub(super) active_masks: Vec<String>,
|
|
/// TRACES: FR-DEV-19a
|
|
/// Which part of the selected layer the tools and the edge controls point
|
|
/// at. Zero — the base — whenever a layer is selected afresh.
|
|
///
|
|
/// An index rather than an id, because it addresses a row the panel is
|
|
/// already showing by position, and because a gesture that outlived the
|
|
/// part it was aimed at would be a stroke landing somewhere nobody asked
|
|
/// for. Clamped on the way in and re-checked on the way out.
|
|
pub(super) active_part: usize,
|
|
/// TRACES: FR-DEV-19b
|
|
/// The layer and part a stroke in progress is going into.
|
|
///
|
|
/// Captured on press and held for the gesture: the panel's selection can
|
|
/// change under a finger — a stray tap, a sync arriving — and a stroke
|
|
/// that changed target half way through would leave half a mark in each.
|
|
pub(super) painting: Option<(String, usize)>,
|
|
/// TRACES: FR-DEV-19b
|
|
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
|
|
/// and flow.
|
|
///
|
|
/// On the session rather than on a layer, because it belongs to the
|
|
/// *tool*: somebody who sets a small eraser expects it to still be small
|
|
/// the next time they erase, whichever mask they are working on.
|
|
pub(super) brush: (f32, f32, f32),
|
|
/// TRACES: FR-DEV-8
|
|
/// Which repair the panel is describing, if any.
|
|
///
|
|
/// Interface state and not part of the edit, exactly as `active_masks` is:
|
|
/// it changes no pixel, it is not in the sidecar, and it is not on the undo
|
|
/// stack. One at a time rather than a set — a repair is eight numbers and
|
|
/// there is no gesture that usefully moves several at once, where three
|
|
/// masked layers really can share a slider drag.
|
|
pub(super) selected_spot: Option<String>,
|
|
/// Whether to draw the false-coloured region overlay.
|
|
pub(super) show_overlay: bool,
|
|
/// TRACES: FR-DEV-19c
|
|
/// How shown masks are drawn — one style for all of them.
|
|
///
|
|
/// Interface state, like `show_overlay` beside it and `active_masks` above
|
|
/// — it changes no pixel of the photograph, it is not in the sidecar and
|
|
/// it is not on the undo stack. It reaches the pipeline as an argument to
|
|
/// the one composition that draws the canvas, which is what makes an
|
|
/// export structurally unable to carry it (`EditGraph::compose_revealing`).
|
|
pub(super) reveal_style: dr_pipeline::mask::RevealStyle,
|
|
/// TRACES: FR-DEV-19c
|
|
/// Per layer: whether its mask is shown, and in what colour.
|
|
///
|
|
/// Per layer rather than "the selected one", because the question a
|
|
/// photographer asks of two masks is how they meet — where the sky's edge
|
|
/// sits against the building's — and that needs both on screen at once,
|
|
/// in colours that can be told apart. Keyed by id, and an id that is no
|
|
/// longer in the stack is simply never asked for; `reveal` walks the
|
|
/// stack, not this map.
|
|
///
|
|
/// Viewing state and not edit state, for the reason the style is: it does
|
|
/// not travel in a sidecar, so a photograph reopened has every eye closed.
|
|
pub(super) mask_views: std::collections::HashMap<String, MaskView>,
|
|
/// Which attribute the panel is filtered to, or all of them.
|
|
///
|
|
/// `None` is "show everything" and is what a frontend that ignores
|
|
/// attributes leaves it at — the tabs are the interface's idea, not the
|
|
/// core's, and nothing breaks without them (ARCH §4.3a).
|
|
pub(super) active_tab: Option<dr_pipeline::Attribute>,
|
|
/// TRACES: FR-DEV-3
|
|
/// Which of the curve widget's subjects the panel is plotting.
|
|
///
|
|
/// The tone curve is four curves — one over tone and one per colour
|
|
/// channel — and one square plot draws one of them at a time. The index
|
|
/// is into the subjects the operation's parameters are faceted on, in the
|
|
/// order it declares them, so nothing here knows that "red" exists.
|
|
///
|
|
/// **Interface state, not part of the edit.** It changes no pixel, so it
|
|
/// is not a parameter, it is not in the graph, it is not in the sidecar
|
|
/// and it is not on the undo stack — the same standing as which tab is
|
|
/// open. One value rather than one per operation, for the same reason
|
|
/// `curve_samples` is one polyline: the panel draws one curve.
|
|
pub(super) curve_channel: usize,
|
|
/// TRACES: FR-DSP-8
|
|
/// The space the canvas is encoded into, for the display now showing it.
|
|
///
|
|
/// **Not part of the edit, and not interface state either.** It is a fact
|
|
/// about the glass in front of the photographer: the same graph on the
|
|
/// same file composes differently on a wide-gamut second monitor, and
|
|
/// neither the sidecar nor the undo stack has any business knowing about
|
|
/// it. That is also why it lives here rather than on the `EditGraph` —
|
|
/// `compose_for` deliberately takes the space per call because "the same
|
|
/// edit goes to the screen in the display's space and to a file in
|
|
/// whatever the export asks for, and neither is more authoritative".
|
|
///
|
|
/// sRGB until the application says otherwise, which is the same answer
|
|
/// `dr_plat::display`'s fallback gives and means a session constructed in
|
|
/// a test behaves exactly as it did before this existed.
|
|
/// TRACES: FR-DEV-3
|
|
/// What the straightening auto-crop last wrote, and what it was derived
|
|
/// from: `(applied, intended)`.
|
|
///
|
|
/// **The graph holds the corrected rectangle; this remembers the intent
|
|
/// behind it.** `auto_crop_to_angle` pulls the crop inside the area an
|
|
/// angle leaves defined, and that operation can only ever shrink. Applied
|
|
/// to its own output it ratchets — straighten to 20 degrees, come back to
|
|
/// 3, and the crop stays at the size 20 degrees demanded, which is not
|
|
/// what turning the slider back means. So the correction is never
|
|
/// accumulated: it is recomputed from the intent every time, and as the
|
|
/// angle falls the crop grows back and stops exactly where the user put
|
|
/// it. At zero degrees the safe area is the whole frame and the two are
|
|
/// equal again.
|
|
///
|
|
/// **A pair rather than a single remembered rectangle, so it repairs
|
|
/// itself.** Every other route to the crop — a handle dragged, a ratio
|
|
/// chosen, a sidecar loaded, a paste, an undo — leaves the graph holding
|
|
/// something other than `applied`, and that mismatch is exactly the signal
|
|
/// that the remembered intent is stale. [`Self::intended_crop`] checks it
|
|
/// rather than requiring each of those paths to remember to write here,
|
|
/// which is the kind of bookkeeping that is correct until someone adds a
|
|
/// seventh path.
|
|
///
|
|
/// Session-scoped. A sidecar records the crop that was *applied*, because
|
|
/// that is the one that describes the photograph, so reopening starts from
|
|
/// that rectangle as its own intent.
|
|
pub(super) auto_crop: Option<(CropRect, CropRect)>,
|
|
pub(super) display_space: dr_types::ColourSpace,
|
|
}
|
|
|
|
impl DevelopSession {
|
|
/// Demosaic an image and prepare its edit graph.
|
|
///
|
|
/// `orientation` is the file's EXIF orientation, not an edit: a sensor is
|
|
/// scanned the same way whichever way the body was held, so this is what
|
|
/// makes a portrait frame open upright. It is fixed for the life of the
|
|
/// session and survives a reset.
|
|
pub fn open(
|
|
ctx: &GpuContext,
|
|
raw: &RawImage,
|
|
orientation: dr_types::Orientation,
|
|
) -> Result<Self, String> {
|
|
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
|
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
|
Ok(Self::with_source(ctx, demosaiced, orientation))
|
|
}
|
|
|
|
/// Prepare an edit graph over an already-processed RGB image.
|
|
///
|
|
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
|
|
/// stage to run — but everything after it is identical, which is why this
|
|
/// shares [`Self::with_source`] rather than duplicating the session.
|
|
///
|
|
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
|
|
/// clipped highlights and quantised shadows that no edit brings back, so
|
|
/// exposure has far less latitude here than on sensor data. The controls
|
|
/// are the same controls; the file simply carries less to work with.
|
|
pub fn open_rgb(
|
|
ctx: &GpuContext,
|
|
rgba: &[u8],
|
|
width: u32,
|
|
height: u32,
|
|
orientation: dr_types::Orientation,
|
|
) -> Result<Self, String> {
|
|
let source =
|
|
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
|
|
Ok(Self::with_source(ctx, source, orientation))
|
|
}
|
|
|
|
pub(super) fn with_source(
|
|
ctx: &GpuContext,
|
|
demosaiced: DemosaicedImage,
|
|
orientation: dr_types::Orientation,
|
|
) -> Self {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_orientation(orientation);
|
|
let history = History::new(&graph);
|
|
Self {
|
|
id: SessionId::next(),
|
|
face_names: Vec::new(),
|
|
orientation,
|
|
// Filled by `crate::open_session`, which is the only place that
|
|
// has both the bytes and the header read from them. A session
|
|
// built straight from pixels — a test, `masks_ui`'s fixture —
|
|
// honestly has no header, and says so.
|
|
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(),
|
|
graph,
|
|
history,
|
|
snapshots: Vec::new(),
|
|
removed_snapshots: Vec::new(),
|
|
compared_snapshot: None,
|
|
crop_notice: None,
|
|
demosaiced: Arc::new(demosaiced),
|
|
adjust: AdjustPass::new(ctx),
|
|
histogram: HistogramPass::new(ctx)
|
|
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
|
.ok(),
|
|
raw_histogram: RawHistogramPass::new(ctx)
|
|
.inspect_err(|e| log::warn!("no raw histogram on this device: {e}"))
|
|
.ok(),
|
|
raw_counts: None,
|
|
peak: FocusPeakPass::new(ctx)
|
|
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
|
|
.ok(),
|
|
peaking: None,
|
|
segmentation: None,
|
|
masks: None,
|
|
subjects: None,
|
|
subject_key: 0,
|
|
active_masks: Vec::new(),
|
|
active_part: 0,
|
|
painting: None,
|
|
brush: (
|
|
dr_pipeline::mask::DEFAULT_BRUSH_RADIUS,
|
|
dr_pipeline::mask::DEFAULT_BRUSH_HARDNESS,
|
|
dr_pipeline::mask::DEFAULT_BRUSH_FLOW,
|
|
),
|
|
selected_spot: None,
|
|
show_overlay: false,
|
|
reveal_style: dr_pipeline::mask::RevealStyle::Tint,
|
|
mask_views: std::collections::HashMap::new(),
|
|
active_tab: None,
|
|
curve_channel: 0,
|
|
display_space: dr_types::ColourSpace::Srgb,
|
|
auto_crop: None,
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-EXP-8
|
|
/// Remember what the file this session was opened from said about itself.
|
|
///
|
|
/// Called by [`crate::open_session`] rather than by the constructors,
|
|
/// because that is the one function that reads a photograph's bytes and
|
|
/// its header together — every other way of making a session starts from
|
|
/// pixels that never had a file behind them.
|
|
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);
|
|
}
|
|
|
|
/// 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.
|
|
pub(super) 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.
|
|
pub(crate) 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();
|
|
};
|
|
match (self.lens_profile_found, self.graph.lens_profile_applied()) {
|
|
(true, true) => format!("{lens} · corrected"),
|
|
// A profile exists and is switched off, which is neither of the
|
|
// other two answers: the photographer turned it off, and a line
|
|
// reading "no profile" would send them looking for one that is
|
|
// sitting right there in the panel with its box unticked.
|
|
(true, false) => format!("{lens} · profile off"),
|
|
(false, _) => format!("{lens} · no profile"),
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-EXP-8
|
|
/// The header this session was opened from, where there was one.
|
|
///
|
|
/// `None` is a real answer and not a failure: a JPEG with no EXIF block, a
|
|
/// file opened from bytes whose header would not parse, a session built in
|
|
/// a test. An export from such a session writes only what `dr-export` says
|
|
/// about itself, and in particular invents no capture date.
|
|
pub fn source_metadata(&self) -> Option<&dr_decode::Metadata> {
|
|
self.source_meta.as_ref()
|
|
}
|
|
|
|
/// The displayed size, for sizing the viewport.
|
|
///
|
|
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
|
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
|
|
/// cropped image against the wrong shape.
|
|
pub fn source_size(&self) -> (u32, u32) {
|
|
let (w, h) = self.demosaiced.size();
|
|
self.graph.output_size(w, h)
|
|
}
|
|
|
|
/// How many shader pipelines have been compiled. Surfaced so the status
|
|
/// strip can show that slider movement is not recompiling.
|
|
pub fn compiled_pipelines(&self) -> usize {
|
|
self.adjust.cached_pipelines()
|
|
}
|
|
|
|
pub fn is_neutral(&self) -> bool {
|
|
self.graph.is_neutral()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6
|
|
/// Lift this session's edit onto the clipboard.
|
|
///
|
|
/// Captured at full scope — framing included — because the decision about
|
|
/// what travels is made when the preset is *applied*. Copying, then
|
|
/// changing one's mind about the crop, must not mean copying again.
|
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
|
/// The local adjustment stack, for writing this image's edit back.
|
|
///
|
|
/// Beside `copy_settings` rather than part of it: that returns a `Preset`,
|
|
/// which travels *between* photographs, and a mask must not — it is drawn
|
|
/// against one frame and describes nothing on another. The save path takes
|
|
/// both; the paste path takes only the preset.
|
|
pub fn masks(&self) -> &dr_pipeline::mask::MaskStack {
|
|
self.graph.masks()
|
|
}
|
|
|
|
pub fn copy_settings(&self) -> Preset {
|
|
Preset::capture(&self.graph)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6
|
|
/// Replace this session's edit within `scope`.
|
|
///
|
|
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
|
|
/// values the sliders are showing, and nothing here pushes them.
|
|
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
|
|
let rebake = preset.apply(&mut self.graph, scope);
|
|
// TRACES: FR-DEV-3f
|
|
// Before the step is recorded, so the history holds the photograph as
|
|
// it now renders — on the preset's stock, baked from the film sliders
|
|
// that just arrived with it.
|
|
self.pay_film_debt(&rebake);
|
|
// A paste is undoable, and is the action most in need of it: it
|
|
// replaces everything in scope at once, so getting it wrong costs more
|
|
// than any single control can.
|
|
self.history
|
|
.record(&self.graph, Edit::Action(labels::step::PASTE));
|
|
}
|
|
|
|
/// TRACES: FR-CAT-8
|
|
/// Load a stored edit, as read from this image's sidecar.
|
|
///
|
|
/// A replacement rather than an overlay — [`Version::apply`] resets first —
|
|
/// so a version that stores nothing opens the photograph at its defaults
|
|
/// rather than leaving the previous image's exposure standing. The file's
|
|
/// orientation survives it, since that was never an edit.
|
|
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
|
|
// TRACES: FR-DEV-3f
|
|
// The film, which `apply` cleared and could not restore: a sidecar
|
|
// names a stock, and turning a name into tables needs the profile
|
|
// database that `dr-pipeline` deliberately does not link. So it is
|
|
// re-baked here, after the parameters, because the bake reads the
|
|
// film's own exposure sliders and they have just arrived.
|
|
let rebake = version.apply(&mut self.graph);
|
|
self.pay_film_debt(&rebake);
|
|
// The stored edit becomes the floor rather than a step. It is not
|
|
// something the user did in this sitting, and an undo that reached
|
|
// behind it would discard a previous session's work in one press —
|
|
// then persist that on the way out, since saving is automatic.
|
|
self.history.reset(&self.graph);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::develop::test_support::*;
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// A profile switched off is a third answer, and has to read as one.
|
|
///
|
|
/// "No profile" sends a photographer looking for a lens the database does
|
|
/// not have. If the profile is sitting in the panel with its box unticked,
|
|
/// that is a different sentence, and the line has to say which.
|
|
///
|
|
/// Depends on the bundled database holding a common lens, exactly as
|
|
/// `dr_lens`'s own tests do — this is the only path that sets
|
|
/// `lens_profile_found`, and standing a profile up by hand would test the
|
|
/// formatting while skipping the lookup it is reporting on.
|
|
#[test]
|
|
fn the_lens_line_separates_a_declined_profile_from_a_missing_one() {
|
|
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");
|
|
|
|
const LENS: &str = "Canon EF 16-35mm f/2.8L USM";
|
|
session.set_source_metadata(dr_decode::Metadata {
|
|
lens: Some(LENS.into()),
|
|
focal_length: Some(20.0),
|
|
aperture: Some(2.8),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
|
|
|
|
session.graph.set_lens_profile_applied(false);
|
|
assert_eq!(session.lens_summary(), format!("{LENS} · profile off"));
|
|
|
|
session.graph.set_lens_profile_applied(true);
|
|
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
}
|