//! 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, /// 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, /// 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, /// 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, /// 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, /// 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, pub(super) demosaiced: Arc, /// 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>, /// 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)>, 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, /// 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, /// 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, /// 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, /// 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, /// 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, /// Rasterises the mask layers. Built lazily for the same reason. pub(super) masks: Option, /// One signed distance field per active subject layer, on the GPU. pub(super) subjects: Option, /// 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, /// 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, /// 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, /// 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, /// 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 { 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 { 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 { 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 { // 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 = (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 = (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 = (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" ); } }