Files
DarkRoom/ui/dr-ui/src/develop/session.rs
T
dtourolle 90c0695c05 Let a preset name its film, and let a look reach only what it names
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.
2026-09-26 13:44:32 -04:00

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"
);
}
}