A 22927×8966 Lightroom panorama opened as its embedded preview with develop withheld, because no texture could hold it. DevelopSession now opens a linear DNG past PROXY_EDGE (8192) on a box-reduced copy, and keeps the full resolution on the CPU. The canvas at fit, the thumbnail, the histograms and the masks work from the copy; a render finer than it — the canvas zoomed in, a tile of the export — samples a window cut from the full resolution, kept while the view stays inside it. The export renders in halo-grown tiles of 4096 and assembles them. On the panorama: decode 1.6 s, open 210 ms, canvas 37 ms, a zoomed window 130 ms, the full-size export 5.9 s.
848 lines
40 KiB
Rust
848 lines
40 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>,
|
||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||
/// The photograph at full resolution, when it is too large to hold in one
|
||
/// texture. `demosaiced` is then a reduced copy of it, which is all the
|
||
/// canvas needs at fit and all the histograms, masks and probes ever
|
||
/// read; a render that wants more detail than the copy has — the canvas
|
||
/// zoomed in, a tile of the export — cuts a window from this instead
|
||
/// (see `source_for`). `None` for every photograph that fits, which is
|
||
/// nearly all of them.
|
||
pub(super) full: Option<Arc<RawImage>>,
|
||
/// The last window cut from `full`: the region it covers in normalised
|
||
/// source coordinates, the reduction it was cut at, and the texture. Kept
|
||
/// so panning a zoomed canvas does not re-upload what is already there.
|
||
pub(super) window: Option<(CropRect, u32, 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,
|
||
}
|
||
|
||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||
/// The longest edge a photograph is developed at whole; one larger is
|
||
/// developed from a reduced copy and full-resolution windows.
|
||
///
|
||
/// Below the device's own limit on purpose. A 16384-texel texture of
|
||
/// half floats is two gigabytes, and the canvas never shows more than a few
|
||
/// thousand pixels of it; 8192 is four times a 4K long edge, the same bound
|
||
/// the JPEG path fits a film scan to.
|
||
pub const PROXY_EDGE: u32 = 8192;
|
||
|
||
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))
|
||
}
|
||
|
||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||
/// Open a decoded photograph, whatever its size.
|
||
///
|
||
/// A photograph that fits the device goes through [`Self::open`] as it
|
||
/// always did. A linear DNG larger than [`PROXY_EDGE`] — a stitched
|
||
/// panorama — is instead held at full resolution on the CPU and opened
|
||
/// on a copy reduced to fit, from which the canvas, the histograms and
|
||
/// the masks work; the full resolution is cut into windows only where a
|
||
/// render needs it. Without this the file opened as its embedded preview
|
||
/// with develop withheld.
|
||
pub fn open_owned(
|
||
ctx: &GpuContext,
|
||
raw: RawImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let (w, h) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||
let edge = PROXY_EDGE.min(DemosaicedImage::max_dimension(ctx));
|
||
if raw.samples_per_pixel != 3 || w.max(h) <= edge {
|
||
return Self::open(ctx, &raw, orientation);
|
||
}
|
||
let reduce = w.max(h).div_ceil(edge);
|
||
log::info!("{w}×{h} is larger than one texture; developing from a 1/{reduce} copy");
|
||
let proxy = DemosaicedImage::linear_rgb16_window(ctx, &raw, [0, 0, w, h], reduce)
|
||
.map_err(|e| e.to_string())?;
|
||
let mut session = Self::with_source(ctx, proxy, orientation);
|
||
session.full = Some(Arc::new(raw));
|
||
Ok(session)
|
||
}
|
||
|
||
/// 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),
|
||
full: None,
|
||
window: None,
|
||
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"
|
||
);
|
||
}
|
||
}
|