//! The develop session — capabilities in, rendered image out. //! //! This is the only place the UI touches the pipeline, and it does so through //! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls //! to build, and `set_param` to change one. It never names an operation, and //! it knows nothing about shaders. //! //! Whether a control is a slider or a switch follows from the parameter's //! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a //! new operation appears in the panel with no change here (FR-DEV-3c). use std::borrow::Cow; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use dr_decode::RawImage; use dr_gpu::{ AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram, HistogramPass, MaskPass, RawHistogram, RawHistogramPass, }; use dr_pipeline::mask::{MaskLayer, MaskSource}; use crate::segmentation::{self, Segmentation}; use dr_pipeline::ops::curve; use dr_pipeline::{ CropRect, Edit, EditGraph, History, OpCapability, OpId, ParamId, ParamKind, Presentation, Preset, Scope, Unit, WidgetKind, }; use crate::labels; use crate::ParamRow; /// A loaded image plus its edit state. /// Longest edge the model and the masks work at. /// /// ~1.3 MP at 3:2. Large enough that an outline is within a pixel or two of /// where it belongs, small enough that a distance transform over it is a few /// milliseconds and its field a few megabytes. const SEGMENT_PROXY_EDGE: u32 = 1600; /// Which photograph a piece of background work was started for. /// /// Minted per session, never reused, and carried by the work rather than /// looked up when it finishes. A segmentation takes most of a second, so the /// user can be two frames further on by the time one lands, and the answer to /// "is this still wanted" has to be decided from what the work *was* rather /// than from what happens to be open. /// /// The alternative — a counter beside the session slot, bumped on every open — /// is written from four places in `lib.rs` and would apply one photograph's /// subjects to another the first time somebody added a fifth and forgot. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SessionId(u64); impl SessionId { pub(crate) fn next() -> Self { static NEXT: AtomicU64 = AtomicU64::new(1); Self(NEXT.fetch_add(1, Ordering::Relaxed)) } } /// Says that nobody is waiting for a job's answer any more. /// /// Not a cancellation in the sense of stopping the work: the model is one /// opaque call of about half a second and `ort` offers no way in. This is /// checked at the seams there are — before the job starts, and again between /// the proxy readback and the inference — so a job abandoned while the user /// was still paging usually costs nothing, and one abandoned mid-inference /// costs only the run it was already committed to. /// /// What it buys in every case is that the next photograph's segmentation is /// the only one anybody is waiting on. #[derive(Debug, Clone, Default)] pub struct Abandon(Arc); impl Abandon { pub fn now(&self) { self.0.store(true, Ordering::Relaxed); } pub fn asked(&self) -> bool { self.0.load(Ordering::Relaxed) } } /// What a finished [`SegmentationJob`] hands back. /// /// The rasteriser travels with the subjects because it is needed the instant /// they arrive and nowhere before. Building it is a shader compile — 23 ms on /// a desktop, and compiling shaders is among the slowest things a mobile /// driver does — so building it on adoption put a dropped frame on the one /// redraw the user is waiting for. Here it is on the thread that was waiting /// anyway. /// /// `None` where the device has no mask rasteriser at all: the session keeps /// the photograph and loses local adjustments, which is the same bargain the /// histogram makes. pub struct Segmented { seg: Segmentation, masks: Option, } /// TRACES: FR-DEV-3 /// A segmentation lifted out of the session that asked for it. /// /// [`DevelopSession`] cannot go to a worker. Not because of what it holds — /// the device, the source texture and the passes are all `Send` — but because /// it lives behind one `Rc>>` that every callback in the /// window reaches through, and the window has to keep reaching through it /// while the work runs. Handing the session over would freeze the interface /// exactly as thoroughly as blocking on it did. /// /// So the work takes a copy of the two things it needs. The device is `Arc`s, /// the source is shared rather than copied, and the answer comes back as plain /// data. pub struct SegmentationJob { ctx: GpuContext, source: Arc, session: SessionId, abandon: Abandon, /// TRACES: FR-CULL-10 /// Confirmed faces in this photograph, **normalised to the long edge** of /// the EXIF-upright image. /// /// Carried rather than looked up, because the job runs on a thread with no /// catalog in reach — the same reason it carries the pixels. Normalised /// rather than in pixels because the proxy size is only settled inside /// `run`. names: Vec, /// TRACES: FR-CULL-10 /// The file's EXIF turn alone, which is the space `names` is expressed in. /// /// **Deliberately not [`SegmentationJob::orientation`].** Faces are found /// on the thumbnail, which is stood up by the EXIF tag and knows nothing /// about the photographer's later turns; the segmentation below composes /// both. Using the composed one here would turn the faces twice on any /// photograph the user has rotated, and the failure would be silent — /// names simply landing on nobody. exif_orientation: dr_types::Orientation, /// TRACES: FR-DEV-3h /// How the sensor's pixels have to be turned to be the photograph. /// /// The file's EXIF tag and the photographer's own turns, composed into /// one permutation by `Framing::effective_orientation`. Carried rather /// than read from the session for the reason everything else here is: the /// job runs on a worker and the session stays behind. /// /// A *snapshot*, so turning the photograph while a run is in flight /// leaves that run answering the question it was asked. The next press /// takes the new one, and the signature says the two are different runs. orientation: dr_types::Orientation, } impl SegmentationJob { /// The photograph this was started for. pub fn session(&self) -> SessionId { self.session } /// The handle that tells this job its answer is no longer wanted. pub fn abandon(&self) -> Abandon { self.abandon.clone() } /// TRACES: FR-DEV-3 /// Find the subjects. **Blocking, and roughly two thirds of a second.** /// /// `Ok(None)` means abandoned rather than found-nothing: an image with no /// recognisable subject in it still comes back as `Ok(Some(_))` with an /// empty instance list, and the panel says so. /// /// There is deliberately no `&mut DevelopSession` in scope here. That is /// the whole point of the split — a caller cannot accidentally hold the /// session across the half second, because it was never given one. pub fn run(&self, options: &segmentation::Options) -> Result, String> { if self.abandon.asked() { return Ok(None); } // Timed and logged because this is the feature's largest cost and the // split between the two halves decides where any further work goes. A // number from the device it actually runs on beats an estimate from // the desktop. let started = std::time::Instant::now(); let (rgb, rw, rh) = self.neutral_proxy(SEGMENT_PROXY_EDGE)?; let proxied = started.elapsed(); // The one seam inside the run. Past here the model owns the thread // until it is done. if self.abandon.asked() { return Ok(None); } let mut seg = segmentation::compute(&self.ctx, &rgb, rw, rh, self.orientation, options)?; // TRACES: FR-CULL-10 // Put names on the people the segmenter found. // // `compute` stands the frame up to detect and lays the instances back // down, so `bbox` is in the sensor's space. The faces came off the // thumbnail and are in the EXIF-upright one. Two different spaces, and // on a portrait photograph they are a quarter turn apart — so the // faces are turned down to meet the instances, through the same // `Orientation` map every other consumer uses rather than a second // copy of the arithmetic. // // The catalog normalises a face to the image's **long edge**, where // `ShownRect` is normalised per axis; the conversions either side of // the turn are that difference and nothing more. if !self.names.is_empty() { let long_edge = rw.max(rh) as f32; let (dw, dh) = self.exif_orientation.oriented_size(rw as u32, rh as u32); let (dw, dh) = (dw as f32, dh as f32); let boxes: Vec> = self .names .iter() .map(|(x, y, w, h, name)| { let shown = dr_types::ShownRect { x: x * long_edge / dw, y: y * long_edge / dh, width: w * long_edge / dw, height: h * long_edge / dh, }; let stored = self.exif_orientation.into_stored_rect(shown); dr_face::NamedFace { bbox: ( stored.x * rw as f32, stored.y * rh as f32, (stored.x + stored.width) * rw as f32, (stored.y + stored.height) * rh as f32, ), name, } }) .collect(); let named = seg.apply_names(&boxes); if named > 0 { log::info!("named {named} segmented region(s) from known faces"); } } log::info!( "segmented {rw}×{rh}: {} subject(s), proxy {:.0} ms, total {:.0} ms", seg.instances().len(), proxied.as_secs_f32() * 1000.0, started.elapsed().as_secs_f32() * 1000.0, ); let masks = MaskPass::new(&self.ctx) .inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}")) .ok(); Ok(Some(Segmented { seg, masks })) } /// Render the *unedited* image to a CPU buffer at proxy size. /// /// The model reads the photograph as captured, not as edited: the /// segmentation must survive an exposure change, or every slider would /// invalidate the masks that depend on it (docs/segmentation.md §3). /// /// **Still the sensor's orientation, deliberately.** Standing the picture /// up is what `segmentation::compute` does to the buffer this returns, /// and it is done there rather than here because a mask has to come back /// in this space: the generated shader samples the mask array at `uv_src`, /// after the framing map. Rendering an upright proxy would put every mask /// a quarter turn away from the subject it was drawn around — a wrong /// mask rather than a weak one, and nothing would announce it. /// /// A throwaway [`AdjustPass`] with a neutral graph rather than the /// session's own — which this could not reach from here in any case, and /// must not: reusing it would overwrite the frame the histogram reads and /// leave the view showing an unedited image until the next redraw. /// /// This is `export_pixels`, which is ungated: an export is not the display /// round-trip AC-8 forbids, and neither is this. fn neutral_proxy(&self, max_edge: u32) -> Result<(Vec, usize, usize), String> { let (sw, sh) = self.source.size(); let scale = (max_edge as f32 / sw.max(sh) as f32).min(1.0); let (w, h) = ( ((sw as f32 * scale) as u32).max(1), ((sh as f32 * scale) as u32).max(1), ); let neutral = EditGraph::default_chain(); let mut pass = AdjustPass::new(&self.ctx); pass.render(&self.source, &neutral.compose(), w, h) .map_err(|e| format!("could not render the segmentation proxy: {e}"))?; let (rgba, pw, ph) = pass .export_pixels() .map_err(|e| format!("could not read the segmentation proxy: {e}"))?; // Straight to float RGB, dropping alpha. The values stay display- // encoded because that is what the model was trained on — one of the // few places in this codebase where not linearising is correct. let rgb = rgba .chunks_exact(4) .flat_map(|p| { [ p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0, ] }) .collect(); Ok((rgb, pw as usize, ph as usize)) } } /// Longest edge the refine crop is rendered at. /// /// Matches [`dr_segment::semantic::INPUT_EDGE`] rather than exceeding it: the /// model's own input is still fixed at 640x640, so rendering the crop larger /// only gets downsampled again inside the model's letterbox. The resolution /// win is entirely from *what fills the window* — a padded crop around one /// subject rather than the whole frame — not from feeding the model more /// pixels than it has ever read. const REFINE_EDGE: u32 = 640; /// How much of the box's own size is added on each side before cropping. /// /// Context for the model to place the subject's edge against, and slack for /// a box that under-ran the subject slightly on the first pass. Not so much /// that a second subject standing nearby gets pulled into the same window. const REFINE_PADDING: f32 = 0.25; /// TRACES: FR-DEV-3 /// A refine pass lifted out of the session that asked for it. /// /// The same split as [`SegmentationJob`], for the same reason — see its /// docs. This one answers for a single already-detected instance rather than /// the whole frame: it re-runs the model over a padded crop of just that /// subject's box, so the subject reaches the model at its own size instead /// of squeezed into the model's fixed window alongside everything else in /// the photograph. pub struct RefineJob { ctx: GpuContext, source: Arc, session: SessionId, abandon: Abandon, /// Which instance this answers for. A snapshot of what it needs from the /// segmentation, taken when the job was built — the same reason /// `SegmentationJob` carries a proxy render rather than the session. index: usize, class_name: Arc, bbox: (f32, f32, f32, f32), proxy: (usize, usize), /// The same permutation, for the same reason — see /// [`SegmentationJob::orientation`]. A refine pass that read the crop /// sideways would hand back a worse mask than the one it was asked to /// improve, on the subject the photographer had just pointed at. orientation: dr_types::Orientation, } impl RefineJob { pub fn session(&self) -> SessionId { self.session } pub fn abandon(&self) -> Abandon { self.abandon.clone() } /// TRACES: FR-DEV-3 /// Re-detect this one subject at higher effective resolution. **Blocking.** /// /// `Ok(None)` covers both "abandoned" and "the model found nothing of the /// same class in the crop" — a photographer pressing refine on a subject /// that the padded window no longer contains is a possible outcome, not /// a bug, and the caller treats it as "kept what was there" either way. pub fn run(&self) -> Result, String> { if self.abandon.asked() { return Ok(None); } let (px0, py0, px1, py1) = self.bbox; let (pw, ph) = (self.proxy.0 as f32, self.proxy.1 as f32); if pw <= 0.0 || ph <= 0.0 { return Ok(None); } let (bw, bh) = ((px1 - px0).max(1.0), (py1 - py0).max(1.0)); let (padx, pady) = (bw * REFINE_PADDING, bh * REFINE_PADDING); let x0 = (px0 - padx).max(0.0); let y0 = (py0 - pady).max(0.0); let x1 = (px1 + padx).min(pw); let y1 = (py1 + pady).min(ph); if x1 <= x0 || y1 <= y0 { return Ok(None); } let (cw_px, ch_px) = (x1 - x0, y1 - y0); let view = CropRect { x: x0 / pw, y: y0 / ph, width: cw_px / pw, height: ch_px / ph, }; // Aspect-matched render target, capped against blowing a tiny box up // absurdly far past what the source ever had to offer. let scale = (REFINE_EDGE as f32 / cw_px.max(ch_px)).min(4.0); let (tw, th) = ( (cw_px * scale).round().max(1.0) as u32, (ch_px * scale).round().max(1.0) as u32, ); if self.abandon.asked() { return Ok(None); } let (rgb, rw, rh) = self.render_view(view, tw, th)?; if self.abandon.asked() { return Ok(None); } // Stood up before the model reads it and laid back down after, the // same way the whole-frame pass does it — `segmentation::upright` is // the one place that permutation is written. A crop rendered in // sensor space is exactly as sideways as the frame it came from. let (upright, uw, uh) = segmentation::upright(&rgb, rw, rh, self.orientation); let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?; let options = dr_segment::SemanticOptions { tiling: dr_segment::Tiling::Whole, ..dr_segment::SemanticOptions::default() }; let found = model .detect(&upright, uw, uh, &options) .map_err(|e| e.to_string())?; // The crop was built around one subject, so the right answer among // whatever the model found in it is the same class closest to the // window's centre — not merely the highest score, which a second, // unrelated instance caught in the padding could win. // // Measured in the upright frame, which is where the model's boxes are. // The centre is the centre either way; the distances are not, once the // window is not square. let (cx, cy) = (uw as f32 * 0.5, uh as f32 * 0.5); let best = found .into_iter() .filter(|i| i.class_name.as_ref() == self.class_name.as_ref()) .min_by(|a, b| centre_distance(a, cx, cy).total_cmp(¢re_distance(b, cx, cy))); let Some(instance) = best else { return Ok(None); }; // Back into the crop's own sensor-space pixels, so everything below // this line measures in the space `self.bbox` and `self.proxy` are in. let (crop_mask, crop_bbox) = segmentation::lay_down(&instance.mask, instance.bbox, uw, uh, self.orientation); // Downsampled back onto the shared proxy grid like every other // instance's mask is, but built from a sharper source than the // whole-frame pass ever saw for this subject. let mask = paste_into_proxy(&crop_mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px)); let bbox = ( x0 + crop_bbox.0 / scale, y0 + crop_bbox.1 / scale, x0 + crop_bbox.2 / scale, y0 + crop_bbox.3 / scale, ); Ok(Some(RefinedInstance { index: self.index, summary: segmentation::InstanceSummary { class_name: instance.class_name, score: instance.score, mask, bbox, }, })) } /// Render the *unedited* image, showing only `view`, at `(width, height)`. /// /// The same neutral, as-captured render [`SegmentationJob::neutral_proxy`] /// uses — a refine pass must read the same kind of pixels the first pass /// did, or a subject would gain or lose an edge depending on which pass /// found it. `view` is framing's ephemeral viewport (`Framing::set_view`), /// the mechanism the on-screen zoom already uses to render a region at /// more than proxy resolution — not the crop tool's own persisted /// rectangle, and nothing here touches that. fn render_view( &self, view: CropRect, width: u32, height: u32, ) -> Result<(Vec, usize, usize), String> { let mut neutral = EditGraph::default_chain(); neutral.framing_mut().set_view(view); let mut pass = AdjustPass::new(&self.ctx); pass.render(&self.source, &neutral.compose(), width, height) .map_err(|e| format!("could not render the refine crop: {e}"))?; let (rgba, pw, ph) = pass .export_pixels() .map_err(|e| format!("could not read the refine crop: {e}"))?; let rgb = rgba .chunks_exact(4) .flat_map(|p| { [ p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0, ] }) .collect(); Ok((rgb, pw as usize, ph as usize)) } } /// What a finished [`RefineJob`] hands back: a replacement for one instance /// in the segmentation it was run against. pub struct RefinedInstance { index: usize, summary: segmentation::InstanceSummary, } fn centre_distance(instance: &dr_segment::Instance, cx: f32, cy: f32) -> f32 { let (x0, y0, x1, y1) = instance.bbox; let (ix, iy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5); ((ix - cx).powi(2) + (iy - cy).powi(2)).sqrt() } /// Sample a crop-local mask back onto its footprint in the shared proxy grid. /// /// Bilinear, the same as every other resampling in this mask pipeline /// (`dr_segment::semantic`'s own prototype sampling, the letterbox that feeds /// it) — correct whether the crop was rendered denser than the proxy (the /// common case this feature exists for) or coarser than it (a subject large /// enough that refining it buys little, which still renders a sensible if /// unremarkable answer rather than a distorted one). fn paste_into_proxy( crop_mask: &[f32], crop_w: usize, crop_h: usize, proxy: (usize, usize), origin_px: (f32, f32), extent_px: (f32, f32), ) -> Vec { let (pw, ph) = proxy; let mut out = vec![0u8; pw * ph]; let (ew, eh) = extent_px; if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 { return out; } let (ox, oy) = origin_px; let px0 = ox.floor().max(0.0) as usize; let py0 = oy.floor().max(0.0) as usize; let px1 = ((ox + ew).ceil() as usize).min(pw); let py1 = ((oy + eh).ceil() as usize).min(ph); for py in py0..py1 { let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32; if gy < 0.0 || gy >= crop_h as f32 { continue; } for px in px0..px1 { let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32; if gx < 0.0 || gx >= crop_w as f32 { continue; } let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy); out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8; } } out } fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 { let (fx0, fy0) = (x.floor(), y.floor()); let (fx, fy) = (x - fx0, y - fy0); let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize; let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize; let x1 = (x0 + 1).min(w - 1); let y1 = (y0 + 1).min(h - 1); let at = |x: usize, y: usize| mask[y * w + x]; let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx; let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx; top * (1.0 - fy) + bot * fy } /// TRACES: FR-DEV-3 /// A shape the crop rectangle is held to while it is dragged. /// /// A photographer cropping for a print, a phone wallpaper or a 16:9 frame is /// not choosing four edges — they are choosing one edge and a known shape, and /// a free crop makes them do the arithmetic by eye on every drag. This is the /// lock that removes it. /// /// **The ratio is of output pixels, not of the rect's own numbers.** The rect /// is stored in fractions of a frame that is not square, so `CropRect` needs /// the frame's size to hold a shape; see [`CropRect::with_aspect`], which is /// where that conversion is done and explained. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum CropAspect { /// Any shape. The handles move independently, as they always have. #[default] Free, /// Whatever the frame already is, so a crop trims without reshaping. /// /// Not the same as `Fixed(3, 2)` even on a 3:2 camera: it follows the /// frame, so it stays right on the next photograph from another body and /// after a quarter turn. Original, /// A named ratio of `w:h`, before the portrait switch is applied. Fixed(u32, u32), } impl CropAspect { /// The ratios the panel offers, in the order it draws them. /// /// Short on purpose. These sit as chips in a column narrow enough for a /// tablet, and every ratio a photographer reaches for repeatedly is here: /// the frame's own shape, the square, the two classic camera ratios, the /// large-format one that most print papers follow, and video's. pub const CHOICES: [Self; 6] = [ Self::Free, Self::Original, Self::Fixed(1, 1), Self::Fixed(3, 2), Self::Fixed(4, 3), Self::Fixed(16, 9), ]; /// The chip's text. pub fn label(self) -> String { match self { Self::Free => "Free".to_string(), Self::Original => "Original".to_string(), Self::Fixed(w, h) => format!("{w}:{h}"), } } /// Whether this choice has a portrait form at all. /// /// A square does not, and neither does `Free`. The switch is disabled /// rather than hidden for those, so the row does not change shape as the /// chips are tried. pub fn has_orientation(self) -> bool { !matches!(self, Self::Free | Self::Fixed(1, 1)) } /// TRACES: FR-DEV-3 /// Whether a quarter turn of the frame has to flip the orientation switch /// to leave this ratio describing the same shape. /// /// A quarter turn carries the crop with it — that is what makes turning a /// photograph keep its composition — so a rect locked to 16:9 comes out of /// the turn at 9:16, and the switch has to agree or the next drag would /// snap the crop back and undo the turn's effect on it. /// /// `Original` is deliberately *not* included, and getting that wrong flips /// it twice. It is resolved against the framed size every time it is /// asked for, and a quarter turn swaps that frame's axes — so it has /// already turned by the time anything asks. pub fn turns_with_the_frame(self) -> bool { matches!(self, Self::Fixed(w, h) if w != h) } /// Width over height in output pixels, or `None` where nothing is locked. /// /// `frame` is the framed size the crop is measured against — the turned /// frame, not the sensor — which is what makes `Original` follow a quarter /// turn instead of becoming a portrait crop on a landscape photograph. pub fn ratio(self, frame: (u32, u32), portrait: bool) -> Option { let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32); let landscape = match self { Self::Free => return None, Self::Original => fw / fh, Self::Fixed(w, h) => w.max(1) as f32 / h.max(1) as f32, }; Some(if portrait && self.has_orientation() { 1.0 / landscape } else { landscape }) } } pub struct DevelopSession { /// This session's name, for work that outlives the frame it started on. 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. 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. 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`. 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". 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. ctx: GpuContext, 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. history: History, demosaiced: Arc, 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. 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. 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. 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. 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. 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. segmentation: Option, /// Rasterises the mask layers. Built lazily for the same reason. masks: Option, /// One signed distance field per active subject layer, on the GPU. 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. 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. 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. 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. 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. 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. selected_spot: Option, /// Whether to draw the false-coloured region overlay. show_overlay: bool, /// TRACES: FR-DEV-19c /// How the selected layer's mask is being shown, or `None` for not at all. /// /// 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`). /// /// The *style* is stored and not the layer, because the layer is always /// the selected one: a reveal that stayed pointed at a mask nobody was /// working on would be describing the wrong thing every time the selection /// moved, and there is no gesture that wants it. reveal_style: Option, /// 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). 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. 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. auto_crop: Option<(CropRect, CropRect)>, 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 { 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 { let source = DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?; Ok(Self::with_source(ctx, source, orientation)) } 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, 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: None, active_tab: None, curve_channel: 0, display_space: dr_types::ColourSpace::Srgb, auto_crop: None, } } /// The controls the interface should show. /// /// Built entirely from the capability list. The `kind` string chooses the /// widget; nothing switches on a parameter's identity. pub fn rows(&self) -> Vec { let caps = self.scoped_capabilities(); match self.active_tab { Some(attribute) => rows_filtered( &caps, |op| op.attributes.contains(&attribute), self.curve_channel, ), None => rows_filtered(&caps, |_| true, self.curve_channel), } } /// The capability list the panel is currently describing. /// /// A selected mask layer takes over the panel, so every control the global /// chain offers is offered on a layer too — including operations added /// later, which need no work to become local. fn scoped_capabilities(&self) -> Vec { match self.active_layer() { Some(layer) => layer.capabilities(), None => self.graph.capabilities(), } } /// The attributes worth offering as tabs, in declaration order. /// /// **Derived from the chain, never listed here.** The groups are whatever /// the operations say they are about, so a new operation joins the right /// tab by declaring its nature and this file goes on naming none of them /// (FR-DEV-3a). An attribute nothing carries is left out rather than /// offered as a tab that opens onto nothing. /// /// Compose is excluded: its one operation prefers an on-canvas widget and /// is skipped by the row builder, so a Compose tab would be empty of rows /// while `ComposePanel` holds the real controls. pub fn tabs(&self) -> Vec<(dr_pipeline::Attribute, String)> { use dr_pipeline::Attribute; let caps = self.scoped_capabilities(); Attribute::ALL .into_iter() .filter(|a| *a != Attribute::Compose) .filter(|a| { caps.iter().any(|c| { c.attributes.contains(a) && !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty() }) }) .map(|a| (a, crate::labels::resolve(a.label().0))) .collect() } /// Which tab is selected, as an index into [`Self::tabs`]. `-1` is "all". pub fn active_tab(&self) -> i32 { let Some(active) = self.active_tab else { return -1; }; self.tabs() .iter() .position(|(a, _)| *a == active) .map_or(-1, |i| i as i32) } /// TRACES: FR-DEV-3f /// Whether the film stock belongs in the group currently on screen. /// /// The stock is not a parameter, so it is not a [`ParamRow`] and the tab /// filter that hides every other control never reached it: the picker was /// drawn above the rows in *every* group, so "Kodachrome" sat at the top of /// Light, of Colour and of Detail alike. Three places it does not belong, /// and the one it does was no more prominent than the rest. /// /// Answered here rather than in the panel because it is a question about /// the operation — what is this control *about* — and the panel is not /// allowed to know. It asks the descriptor, so a stock that were ever /// re-declared as something other than an effect would move on its own. pub fn film_in_group(&self) -> bool { let Some(active) = self.active_tab else { // "All" shows everything, the stock included. return true; }; self.graph .capabilities() .iter() .find(|c| c.id == dr_pipeline::ops::film_sim::ID) .is_some_and(|c| c.attributes.contains(&active)) } /// Select a tab by its index in [`Self::tabs`], or `-1` for all. pub fn set_active_tab(&mut self, index: i32) { self.active_tab = usize::try_from(index) .ok() .and_then(|i| self.tabs().get(i).map(|(a, _)| *a)); } /// The selection's representative layer, for anything that can only show /// one answer — which tab is open, what value a slider currently reads. /// /// The first id selected, not "the" active layer: with more than one /// selected there is no single truth to show, and the panel has to pick /// something. Whichever layer this is, [`Self::set_param`] and /// [`Self::reset_op`] still write to every selected layer, not just this /// one — a slider shows one number and applies it everywhere selected. fn active_layer(&self) -> Option<&MaskLayer> { let id = self.active_masks.first()?; self.graph.masks().get(id) } /// Every selected layer, mutably — what a batched slider or reset walks. fn active_layers_mut(&mut self) -> impl Iterator { let selected = self.active_masks.clone(); self.graph .masks_mut() .layers_mut() .iter_mut() .filter(move |l| selected.contains(&l.id)) } } // The empty nested models, each a single shared identity. // // **`ModelRc` compares by identity, not by contents**, and `sync_rows` decides // which controls to invalidate by comparing each freshly built row against the // one on screen. A brand-new empty model per row per call therefore makes every // row differ from *itself* on every parameter event, and the panel rewrites all // of them. // // That is not merely wasteful — it breaks dragging. An operation with several // parameters renders them through a repeater whose model is read off the // group's head row; rewriting that row re-evaluates the repeater, rebuilding // its items and destroying the `TouchArea` that holds the gesture. The slider // takes the press, jumps once, then goes dead under the finger. Only // multi-parameter operations show it, because a lone parameter has no inner // repeater to rebuild — which is exactly how it hid: exposure and contrast drag // perfectly while temperature and tint do not. // // Most rows carry neither points nor choices, so the empty case is the common // one and it costs nothing to make it a constant. /// The empty points model, shared by every row that is not a curve. fn no_points() -> slint::ModelRc { thread_local! { static EMPTY: slint::ModelRc = slint::ModelRc::new(slint::VecModel::from(Vec::::new())); } EMPTY.with(Clone::clone) } /// The empty choices model, shared by every row that is not an enum. fn no_choices() -> slint::ModelRc { thread_local! { static EMPTY: slint::ModelRc = slint::ModelRc::new(slint::VecModel::from(Vec::::new())); } EMPTY.with(Clone::clone) } /// The choices model for one enum parameter, built once per variant list. /// /// Memoised for exactly the reason [`no_choices`] is shared: `ModelRc` compares /// by *identity*, so building a fresh one each call makes the row differ from /// itself on every parameter event. `sync_rows` would then replace the row — /// destroying the elements built from it, including whichever `TouchArea` is /// holding the current gesture — and the enum's own control would fight every /// slider drag elsewhere in the panel. /// /// Curve rows solve the same problem the other way, by writing new values /// through the existing model. That is not available here: a variant list is /// fixed at compile time, so the model never needs updating and can simply be /// the same one every time. /// /// Keyed on the labels rather than the slice's address, because they are /// resolved through the UI's catalogue and two operations offering the same /// choices should share one model. fn choices_model(labels: &[slint::SharedString]) -> slint::ModelRc { use std::cell::RefCell; use std::collections::HashMap; thread_local! { static CACHE: RefCell>> = RefCell::new(HashMap::new()); } let key = labels.join("\u{1f}"); CACHE.with(|cache| { cache .borrow_mut() .entry(key) .or_insert_with(|| slint::ModelRc::new(slint::VecModel::from(labels.to_vec()))) .clone() }) } /// Whether this frontend has an implementation of `widget` **anywhere**. /// /// "Anywhere" is doing real work: a widget may be drawn in the panel, as the /// tone curve is, or hosted on the canvas, as the crop is. Both count as /// implemented, and the difference is settled afterwards by /// [`WidgetKind::is_on_canvas`] rather than by two separate lists that could /// disagree about the same kind. /// /// A kind answering `false` here is not an error — the operation's parameters /// are ordinary scalars, so it falls back to sliders and stays fully editable /// (ARCH §4.3a). pub(crate) fn supported(widget: WidgetKind) -> bool { match widget { // Drawn in the panel. WidgetKind::ToneCurve => true, // Hosted on the canvas: the overlay is drawn over the photograph and // the panel contributes `ComposePanel`, the affordance that turns it // on. WidgetKind::CropOverlay => true, // TRACES: FR-DEV-3 // Hosted on the canvas too — a click on the photograph — with the // affordance that arms it in the group's own heading. See // `samples_the_canvas` for why this one leaves its sliders standing // where the crop takes them away. WidgetKind::WhitePoint => true, // Not implemented. Listed rather than caught by a wildcard so the next // kind added to the core surfaces here as a compile error. WidgetKind::ColourWheel | WidgetKind::GradientHandle | WidgetKind::BrushMask => false, } } /// TRACES: FR-DEV-3a | FR-UI-7 /// Whether an on-canvas widget *reads* the photograph rather than replacing /// its parameters with handles. /// /// **This is the distinction that stopped the picker eating its own sliders.** /// [`WidgetKind::is_on_canvas`] says where a widget is manipulated, and the /// panel had been treating that as also meaning "and so the panel draws /// nothing for it". For a crop that is right: four edge fractions and an angle /// are not controls anybody drags in a list, and the whole reason the crop is /// on the photograph is that they are unusable anywhere else. /// /// An eyedropper is the other thing. It *writes* temperature and tint — they /// remain exactly the controls a photographer reaches for afterwards, because /// a sampled neutral is a starting point and warming a portrait past it is the /// next move, not a mistake. Taking the sliders away to make room for the /// picker would be trading a control for a control. /// /// So the panel draws the group as usual and puts the affordance that arms the /// canvas in its heading. FR-DEV-3's "temperature/tint, **and** picker" is one /// word doing a lot of work, and this is the word. fn samples_the_canvas(widget: WidgetKind) -> bool { match widget { WidgetKind::WhitePoint => true, // Dragged rather than sampled: the parameters *are* the handles. WidgetKind::CropOverlay | WidgetKind::GradientHandle | WidgetKind::BrushMask => false, // Not on the canvas at all, so nothing asks. Listed rather than // wildcarded for the reason `supported` lists its own. WidgetKind::ToneCurve | WidgetKind::ColourWheel => false, } } /// The panel model for a set of capabilities. /// /// Free-standing rather than a method, and that is the point: it needs no GPU, /// no decoded image and no session, so the whole descriptor-to-panel path can /// be exercised against a hand-built capability list. That is what the /// FR-DEV-3c acceptance test asks for — an operation the frontend has never /// heard of appearing in a generated panel — and it cannot be asserted at all /// if generating a row requires a device. /// /// `#[cfg(test)]` since the panel began passing the selected curve down: the /// session always has one to pass, and a wrapper that quietly picked the first /// would be a second answer to a question the session already answers. #[cfg(test)] pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec { rows_filtered(caps, |_| true, 0) } /// The panel model for the capabilities `keep` accepts. /// /// **`op_index` counts over every capability, not over the kept ones.** It is /// how a row routes back to the core, so filtering must not renumber it — a /// row that survived a filter has to still name the operation it came from. /// `group_head` is the opposite: a position within the *emitted* rows, because /// the panel walks back to it through the model it was given. /// /// Getting that backwards is how a slider ends up driving a different /// operation, which is the kind of fault that looks like a rendering bug. /// /// `curve_channel` is which subject a multi-subject widget is showing — the /// tone curve's four curves are one plot with a selector over it. It is passed /// in rather than read from anywhere because this function is deliberately /// free-standing: the descriptor-to-panel path has to be exercisable against a /// hand-built capability list with no session behind it. pub(crate) fn rows_filtered( caps: &[OpCapability], keep: impl Fn(&OpCapability) -> bool, curve_channel: usize, ) -> Vec { let mut rows = Vec::new(); for (op_index, op) in caps.iter().enumerate() { if !keep(op) { continue; } // Where this operation's rows begin. The panel groups by walking // back to it, so it has to be taken before any row is pushed. let group_head = rows.len(); // TRACES: FR-DEV-3 // Whether this group's heading carries the affordance that arms an // on-canvas sampler. Set below, from what the operation asked for and // nothing else — the panel never learns which operation it is. let mut group_samples = false; // An operation may ask for one widget spanning several // parameters. Honouring it is optional — dropping this block // renders the same parameters as ordinary sliders, and the edit // still works — which is exactly why the hint is a hint. if let Some(presentation) = &op.presentation { // **The widget registry, and the only one.** // // `choose` walks the operation's preference list and hands back // the first entry this frontend implements (ARCH §4.3a). A kind // it does not implement falls through to sliders — the designed // behaviour, not a gap, since every parameter is an individually // addressable scalar. if let Some(widget) = presentation.choose(supported) { // **Yielded to the canvas, and this is what replaced naming // framing.** // // This loop used to open with `if op.id == framing::ID { continue }` // and a paragraph explaining that a crop is dragged on the // photograph rather than typed into four boxes. All of that is // true and none of it was this file's to know: it is a fact // about the operation, and it now arrives as one. Any stage // preferring an on-canvas widget is skipped here on the same // terms, with nothing named. // // Skipped rather than rendered as an affordance row, because // the affordance is `ComposePanel` — a bespoke control for a // known stage, which is a thing the interface is entitled to // build (ARCH §4.3a draws the line at the *generated* panel // naming stages, not at the interface having hand-made // widgets). // // TRACES: FR-DEV-3 // **Unless the canvas is *reading* rather than driving.** An // eyedropper writes temperature and tint and leaves them as // the controls they were, so its group is drawn in full and // only the affordance moves to the heading. See // `samples_the_canvas` for the whole of that argument. group_samples = samples_the_canvas(widget); if widget.is_on_canvas() && !group_samples { continue; } // The `match` is exhaustive on purpose. Adding a `WidgetKind` // to the core stops this compiling until someone has decided, // here, whether the panel draws it. let row = match widget { WidgetKind::ToneCurve => { curve_row(op_index, group_head, op, presentation, curve_channel) } // Canvas-hosted kinds returned above, except a // sampler, which falls through to its own sliders; the // rest are not implemented and reached sliders via // `choose`. WidgetKind::ColourWheel | WidgetKind::CropOverlay | WidgetKind::GradientHandle | WidgetKind::BrushMask | WidgetKind::WhitePoint => None, }; if let Some(row) = row { rows.push(row); continue; } } } // Whether anything in this operation has been touched, aggregated // before the rows are built so every row of the group can carry // the same answer — the panel's heading is one of them and cannot // see the others. // // Derived here rather than asked of the core: a group is a // composition this side invented, so whether one is modified is // this side's question to answer (ARCH §4.3a). let group_modified = op.params.iter().any(|p| p.value != p.default); let group_len = op.params.len() as i32; // The aspect the previous row belonged to, so a run can be told // from its continuation. Reset per operation: two operations that // happened to facet on the same key are still two groups. let mut previous_aspect: Option<&str> = None; for param_index in presentation_order(&op.params) { let p = &op.params[param_index]; // Empty for every kind but `Enum`, which is what the panel // keys on to build a segmented control rather than a slider. let mut choices: Vec = Vec::new(); let (kind, min, max, precision, unit) = match &p.kind { ParamKind::Scalar { min, max, unit, precision, .. } => ( "scalar", *min, *max, i32::from(*precision), unit_suffix(*unit), ), ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""), // The value is a variant index, so the range is the list's // own bounds and the precision is whole numbers. Labels are // resolved here, against this crate's catalogue, because // the core deals in localisation keys only (NFR-A11Y-1). ParamKind::Enum { variants } => { choices = variants .iter() .map(|v| labels::resolve(v.0).into()) .collect(); ("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "") } }; // A faceted parameter is named by its *subject* — the band — // because its aspect is already written above the run it sits // in. Unfaceted parameters keep their own label, which is // every operation but the mixer. // // Except when the parameter is the operation's only one. The panel // draws no heading over a group of one, on the argument that a // lone control names itself — and that holds only while the // parameter is named after what it does. Three operations declare // a single parameter called `amount`, which is the name // `ops/README.md` tells an author to reach for first, and they // arrived in the panel as three consecutive sliders all labelled // "Amount" with nothing to tell them apart. // // So a lone parameter is titled by its operation. That is the name // the missing heading would have carried, and for the operations // whose one parameter already shares the operation's name it reads // exactly as it did before. let param_label = match &p.facet { Some(f) => labels::resolve(f.subject.0), None if op.params.len() == 1 => labels::resolve(op.label.0), None => labels::resolve(p.label.0), }; let aspect = p.facet.as_ref().map(|f| f.aspect.0); let starts_facet = aspect.is_some() && aspect != previous_aspect; previous_aspect = aspect; rows.push(ParamRow { op_index: op_index as i32, param_index: param_index as i32, op_label: labels::resolve(op.label.0).into(), param_label: param_label.into(), facet_label: aspect.map(labels::resolve).unwrap_or_default().into(), starts_facet, // -1 rather than an `Option`, which a Slint struct cannot // carry: 0° is red, so no value in range can stand for // "no swatch". swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0), group_head: group_head as i32, group_len, group_modified, group_samples, kind: kind.into(), value: p.value, default_value: p.default, minimum: min, maximum: max, precision, unit: unit.into(), // Only curve rows carry points. points: no_points(), // The shared empty model unless this row really has choices — // see `no_choices` for why the identity matters. choices: if choices.is_empty() { no_choices() } else { choices_model(&choices) }, }); } } rows } /// One run of a curve widget's parameters: the points of a single curve. /// /// A widget may span several curves — the tone curve is one plot over a master /// curve and three colour channels — and it says so the way the colour mixer /// says it has twelve bands: by faceting each parameter with the *subject* it /// acts on. Consecutive parameters sharing a subject are one curve. struct CurveRun { /// The subject's localisation key, or `None` where the widget's parameters /// carry no facet at all and are therefore a single unnamed curve. subject: Option<&'static str>, /// Where this run's points begin in the operation's parameter list. What /// a drag routes back through, so it must be a position in `op.params` /// and not in the presentation's list. base: usize, /// How many coordinates it holds. len: usize, } /// TRACES: FR-DEV-3a /// The curves a curve widget spans, in the order the operation declares them. /// /// **This is the whole of the panel's knowledge of colour channels: none.** It /// groups by whatever subject the parameters carry, so an operation offering a /// master curve and three channels gets a four-way selector, one offering a /// single unfaceted curve gets no selector at all, and one that grows a fifth /// curve tomorrow needs no change here. /// /// Returns `None` where the parameters do not look like point coordinates — /// an odd count, a run that is not contiguous in the capability list — in /// which case the caller falls back to sliders rather than drawing a widget /// over a layout it has guessed at. fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option> { // Points are x/y pairs, so an odd count means the operation and this code // disagree about the layout. if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) { log::warn!("{}: curve widget needs an even parameter count", op.id); return None; } let mut runs: Vec = Vec::new(); for id in &presentation.params { // The widget addresses points by offset from the first of its run, so // a run has to be contiguous in the capability list. let at = op.params.iter().position(|p| p.id == *id)?; let subject = op.params[at].facet.as_ref().map(|f| f.subject.0); match runs.last_mut() { Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1, _ => runs.push(CurveRun { subject, base: at, len: 1, }), } } if runs.iter().any(|r| !r.len.is_multiple_of(2)) { log::warn!("{}: a curve's points are not contiguous", op.id); return None; } Some(runs) } /// One row standing for a whole curve. /// /// `channel` picks which of the widget's curves is plotted; it is clamped /// rather than validated, because the selection is interface state that /// outlives a change of photograph and the new image's operation may have /// fewer curves than the old one's. /// /// Returns `None` if the operation's parameters do not look like point /// coordinates, in which case the caller falls back to sliders rather than /// rendering a broken widget. fn curve_row( op_index: usize, group_head: usize, op: &OpCapability, presentation: &Presentation, channel: usize, ) -> Option { let runs = curve_runs(op, presentation)?; let run = runs.get(channel.min(runs.len().saturating_sub(1)))?; let points: Vec = op.params[run.base..run.base + run.len] .iter() .map(|p| p.value) .collect(); Some(ParamRow { op_index: op_index as i32, // The first point parameter *of the curve on show*; the widget offsets // from here, so switching curve is what re-points the drag. param_index: run.base as i32, op_label: labels::resolve(op.label.0).into(), param_label: String::new().into(), // A widget spanning a whole operation is not a row in anyone's // grid, so it heads no run and carries no swatch. facet_label: String::new().into(), starts_facet: false, swatch_hue: -1.0, group_head: group_head as i32, // One widget standing for every parameter of the operation, so // the group it heads is itself and nothing else. group_len: 1, group_modified: op.params.iter().any(|p| p.value != p.default), // A curve is drawn, not sampled. Its own affordance is the plot. group_samples: false, kind: "curve".into(), value: 0.0, default_value: 0.0, minimum: 0.0, maximum: 1.0, precision: 4, unit: String::new().into(), points: slint::ModelRc::new(slint::VecModel::from(points)), // A curve is not a choice between named alternatives. The curves it // can switch between are named on the panel rather than on the row — // see `DevelopSession::curve_channels` for why they cannot ride here. choices: no_choices(), }) } impl DevelopSession { /// TRACES: FR-DEV-3 /// The names of the curves the widget can switch between. /// /// Empty where there is only one, which is also the answer for a frontend /// with no curve at all: a selector over a single choice is a row of /// nothing. /// /// **Derived from the facets, so nothing here names a colour channel.** /// The operation says its forty points are one control applied to four /// subjects and publishes a localisation key for each; this resolves the /// keys and hands over four words. An operation that grew a fifth curve /// would appear here on its own. /// /// A panel property rather than a field on the curve's `ParamRow`, and the /// reason is Slint's: a row's models are compared by identity, so a fresh /// list of names built on every parameter event would make the row look /// changed every time, and rewriting a row rebuilds the repeater item /// underneath it — destroying the `TouchArea` holding the drag in /// progress. The same hazard `rows`'s in-place point update exists to /// avoid. Nothing in this list is a drag target, so up here it is safe to /// replace wholesale, exactly as [`Self::curve_samples`] is. pub fn curve_channels(&self) -> Vec { for op in &self.scoped_capabilities() { let Some(presentation) = &op.presentation else { continue; }; if presentation.choose(supported) != Some(WidgetKind::ToneCurve) { continue; } let Some(runs) = curve_runs(op, presentation) else { continue; }; if runs.len() < 2 { continue; } return runs .iter() .map(|r| r.subject.map(labels::resolve).unwrap_or_default()) .collect(); } Vec::new() } /// Which curve the widget is plotting, as an index into /// [`Self::curve_channels`]. pub fn curve_channel(&self) -> i32 { self.curve_channel as i32 } /// Plot a different one of the operation's curves. /// /// Out-of-range indices are ignored rather than clamped: the only thing /// that can send one is a stale interface event, and quietly moving the /// selection somewhere the user did not point is worse than doing nothing. pub fn set_curve_channel(&mut self, index: i32) { let Ok(index) = usize::try_from(index) else { return; }; if index < self.curve_channels().len() { self.curve_channel = index; } } /// The plotted curve's shape, sampled for drawing. /// /// Evaluated with `dr_pipeline`'s own spline, so the line the user drags /// is the line the shader applies. The alternative — reading the curve /// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a /// polyline. /// /// The line drawn is the *selected* curve's own shape, not the composition /// of it with the master. Two curves overlaid on one grid is a plot of two /// things, and the one being dragged has to be the one whose points are /// under the pointer. pub fn curve_samples(&self) -> Vec { const SAMPLES: usize = 96; // The selection is an index over the subjects the panel found, which // for this operation is its channel order. Clamped rather than // trusted: a selection made on one photograph outlives the change to // the next. let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)]; let mut xs = [0.0f32; curve::POINTS]; let mut ys = [0.0f32; curve::POINTS]; let mut found = false; for cap in self.graph.capabilities() { if cap.id != curve::ID { continue; } found = true; // By id rather than by position, so which curve is plotted is // decided by naming it and not by arithmetic over the parameter // list. let value = |id| { cap.params .iter() .find(|p| p.id == id) .map_or(0.0, |p| p.value) }; for i in 0..curve::POINTS { xs[i] = value(curve::coordinate(channel, i, curve::Axis::X)); ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y)); } } if !found { return Vec::new(); } // Sorted the same way the operation sorts before handing points to // the shader, or a dragged-past point would draw differently from // how it renders. sort_with_gap(&mut xs); (0..SAMPLES) .map(|i| { let x = i as f32 / (SAMPLES - 1) as f32; curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0) }) .collect() } /// Return every parameter of one operation to its default. /// /// What both a section's reset and a curve's reset do — a curve is one /// widget spanning all of its operation's parameters, so "reset this /// curve" and "reset this operation" were always the same action. Nothing /// here is curve-shaped; it walks whatever parameters the operation /// declares. pub fn reset_op(&mut self, op_index: i32) { let caps = self.scoped_capabilities(); let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else { return; }; if !self.active_masks.is_empty() { let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect(); let id = cap.id.0; for layer in self.active_layers_mut() { for &(param, default) in ¶ms { layer.set_param(id, param, default); } } self.history .record(&self.graph, Edit::Action(labels::step::RESET_OP)); return; } for p in &cap.params { self.graph.set_param(cap.id, p.id, p.default); } // One step, though it moved every parameter the operation has: the // user pressed one button. self.history .record(&self.graph, Edit::Action(labels::step::RESET_OP)); } /// Reset a curve, which is to reset its operation. /// /// Kept as its own name because the call site is a curve widget's own /// double-click, and reading `reset_curve` there says why it resets ten /// parameters at once rather than the one that was clicked. pub fn reset_curve(&mut self, op_index: i32) { self.reset_op(op_index); } /// Apply a change from the interface. /// /// Indices are positions in [`Self::rows`]; the mapping back to ids stays /// on this side of the boundary. pub fn set_param(&mut self, op_index: i32, param_index: i32, value: f32) { let Some((op, param)) = self.lookup(op_index, param_index) else { log::warn!("control at ({op_index}, {param_index}) has no parameter"); return; }; if !self.active_masks.is_empty() { // Every selected layer is set to the same absolute value the // slider now shows, not offset by however far each one already // was from it — the slider has one position, and "apply this // reading to all of them" is the reading a photographer gets // from watching it move. for layer in self.active_layers_mut() { layer.set_param(op.0, param, value); } // Coalesced the same way a global drag is: a slider dragged across // masked layers is still one gesture and must undo as one. let edit = Edit::for_param(&self.graph, op, param); self.history.record(&self.graph, edit); return; } self.graph.set_param(op, param, value); let edit = Edit::for_param(&self.graph, op, param); self.history.record(&self.graph, edit); } /// Return one parameter to its default. pub fn reset_param(&mut self, op_index: i32, param_index: i32) { let Some((op, param)) = self.lookup(op_index, param_index) else { return; }; let default = self .graph .capabilities() .iter() .find(|c| c.id == op) .and_then(|c| c.params.iter().find(|p| p.id == param)) .map(|p| p.default) .unwrap_or(0.0); self.graph.set_param(op, param, default); self.history .record(&self.graph, Edit::Action(labels::step::RESET_PARAM)); } pub fn reset_all(&mut self) { self.graph.reset(); self.history .record(&self.graph, Edit::Action(labels::step::RESET_ALL)); } /// Rasterise the current mask stack, if there is one. /// /// Returns `None` for a stack with no active layers, which is the common /// case and the one that must cost nothing: the adjust pass then binds its /// own placeholder and the generated shader has no layer block to read it /// with. /// Render `shader` at `w`×`h` with this edit's masks bound. /// /// **Every path that produces pixels must come through here.** The /// generated shader always declares the mask binding and always emits a /// layer block for each active layer; binding the empty placeholder /// instead multiplies every one of them by zero. That is not an error and /// logs nothing — the local adjustments simply are not there. Exports and /// thumbnails both did exactly that. /// /// The mask array is rasterised in source space at proxy size and sampled /// through the framing map, so one array is correct at every output size: /// a 256px thumbnail and a 24 MP export bind the same texture. /// /// **And the detail stage with it.** The neighbourhood operations — noise /// reduction, capture sharpening, and the rest of FR-DEV-3's kernels — /// cannot be fused into the single dispatch, so an edit using one composes /// a fused pass that hands on *linear* values and a chain of passes that /// finishes the job (see `dr_pipeline::detail`). Those two halves must be /// composed from one graph and dispatched together, or the fused shader's /// storage format does not match the texture bound to it; going through /// `render_detailed` here is what makes that true of every path at once. /// It falls through to the plain render when the chain is empty, which is /// almost every edit, so this costs nothing to the frames that do not /// need it. /// /// `space` has to be the space `shader` was composed for. It is the last /// pass of the detail chain that performs the output transform when there /// is one, so the two would otherwise be free to disagree about which /// primaries the file is in — and the result would be a correctly /// labelled file with the wrong colours in it (FR-EXP-2). fn render_with_masks( &mut self, shader: &dr_pipeline::operation::ComposedShader, w: u32, h: u32, space: dr_types::ColourSpace, ) -> Result<(), String> { let ctx = self.ctx.clone(); self.ensure_subject_fields(&ctx); let masks = self .rasterise_masks() .then(|| self.masks.as_ref().and_then(|p| p.array())) .flatten(); // The neighbourhood stage, composed at the size actually being drawn. // // It has to be composed *per render* rather than cached with the edit, // because a kernel is the one thing in this pipeline that is not // scale-free: a sharpening radius is stated in source pixels and the // develop view renders at whatever the viewport needs (FR-DSP-1), so // the conversion is different for the canvas, the thumbnail and the // export. `render_scale` works the ratio out from the framing, so a // crop and a zoom are already accounted for, and zooming to 1:1 // restores an exact preview with no second render path to maintain. // // Empty for every edit with no active neighbourhood operation — which // is almost all of them — and `render_detailed` then falls straight // through to the single masked dispatch this used to call. let detail = self .graph .compose_detail_for(self.demosaiced.size(), (w, h), space); // Detail passes read what the colour pass wrote, so the key they are // cached against is the colour key: moving a sharpening slider re-runs // this stage and not the fused one (FR-DEV-3d). let colour_key = self .graph .invalidation() .through(dr_pipeline::Affects::Colour); self.adjust .render_detailed(&self.demosaiced, shader, w, h, masks, &detail, colour_key) .map(|_| ()) .map_err(|e| e.to_string()) } /// Returns whether the array is now valid for the current stack. /// /// Split from reading the array back because the render below needs /// `self.adjust` mutably while holding `self.masks` immutably. Those are /// disjoint fields and the borrow checker will allow it — but only when /// each is reached directly rather than through a method taking `self`. /// What the uploaded distance fields depend on. /// /// The instance each layer names, and the morphology that *rebuilds* a /// field rather than offsetting it. Nothing else: see `subject_key`. fn subject_signature(&self) -> u64 { use dr_pipeline::mask::{MaskSource, Morphology}; let mut h: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |v: u64| { for byte in v.to_le_bytes() { h ^= byte as u64; h = h.wrapping_mul(0x1000_0000_01b3); } }; // TRACES: FR-DEV-19c // The reveal is in the key because it is in the *sequence*: revealing // a layer with no adjustment on it gives that layer a slot, which // renumbers every field after it. Leaving it out is the bug where // clicking a subject shows the mask of whichever layer happened to be // beneath it. let reveal = self.reveal(); mix(reveal.as_ref().map_or(0, |r| { let mut h: u64 = 1; for b in r.layer.as_bytes() { h = h.wrapping_mul(31).wrapping_add(*b as u64); } h })); for layer in self.graph.masks().rendered(reveal.as_ref()) { match &layer.base().source { MaskSource::Subject { index, .. } => { mix(1); mix(*index as u64); if layer.base().morphology.is_compound() { mix(match layer.base().morphology { Morphology::Close => 2, Morphology::Open => 3, _ => 0, }); mix(layer.base().morph_radius.to_bits() as u64); } } // Hashed by *name*, and `4` rather than `1` so a category // named the same as an instance index could never collide with // it. The field has to be rebuilt when either changes. MaskSource::Category { name, .. } => { mix(4); for b in name.as_bytes() { mix(*b as u64); } // Only the compound operations change the field itself. if layer.base().morphology.is_compound() { mix(match layer.base().morphology { Morphology::Close => 2, Morphology::Open => 3, _ => 0, }); mix(layer.base().morph_radius.to_bits() as u64); } // The refine control changes which pixels are in the mask // at all, so it changes the coverage the field is measured // from — unlike a feather, which is read off a field that // is already correct. Omitting it here is the bug where // the slider moves and nothing happens until some other // control happens to invalidate the cache. mix(layer.base().refine.to_bits() as u64); } _ => mix(0), } } h } /// One layer's coverage: what the model says now, or what the sidecar /// remembered it saying. /// /// **The model first, always.** It is the live answer, it is the only one /// that can respond to the refine control, and a run in this sitting is by /// definition newer than anything a file was holding. /// /// The fallback is the point of the stored raster. A photograph reopened, /// and a batch export from the grid — which opens a session, applies a /// version and never runs a model at all — have no segmentation to ask, so /// before this they resolved every subject and category layer to nothing /// and wrote out a file missing the local adjustments, with a line in the /// log as the only sign. See [`dr_pipeline::coverage`]. /// /// `None` for every other source: a gradient and a brush are rasterised /// from their own geometry and have no coverage to fetch, and the caller /// gives them a placeholder field so the slot indices still line up. fn layer_coverage<'a>( &'a self, layer: &'a dr_pipeline::mask::MaskLayer, width: usize, height: usize, ) -> Option> { use dr_pipeline::mask::MaskSource; let live = self .segmentation .as_ref() .and_then(|seg| match &layer.base().source { MaskSource::Category { name, .. } => { seg.category_mask_at(name, layer.base().refine) } MaskSource::Subject { index, .. } => { seg.instance_mask(*index as usize).map(Cow::Borrowed) } _ => None, }); if live.is_some() { return live; } match layer.base().source { // Resampled where it was written against a different proxy edge; // in the ordinary case the sizes match and this is the decode. MaskSource::Subject { .. } | MaskSource::Category { .. } => layer .base() .coverage .as_ref() .map(|stored| Cow::Owned(stored.decode_at(width, height))), _ => None, } } /// TRACES: FR-CAT-8 | FR-DEV-3 /// The mask stack as it should be written to a sidecar. /// /// The stack the graph holds, with each model layer's coverage brought up /// to what the segmentation now says it is. That raster is what lets the /// *next* opening of this photograph render the layer without a model run /// — the whole of [`dr_pipeline::coverage`]'s reason to exist. /// /// # Why here, and not where the field is built /// /// `ensure_subject_fields` is the tempting place: it is the one funnel /// every coverage passes through, so recording it there would catch every /// route automatically. But it runs on a *drag* — dilating a mask with a /// compound morphology rebuilds the field every frame — and encoding a /// megapixel raster per frame is exactly the kind of work NFR-P5 is about. /// /// Saving happens when the photograph stops being the open one, once, and /// already costs a network round trip. So the encoding is done here, where /// nothing is waiting on it, and the session's own rendering goes on /// reading the model directly. /// /// Returns the stack by value rather than mutating: the caller is /// serialising, not editing, and a graph that quietly gained a field on /// the way past would be a mutation nobody asked for and undo would not /// know about. pub fn masks_for_storage(&self) -> dr_pipeline::mask::MaskStack { use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS}; use dr_pipeline::mask::MaskSource; let mut stack = self.graph.masks().clone(); let Some(seg) = self.segmentation.as_ref() else { // No model has run this sitting, so whatever the layers arrived // holding is still the best answer anyone has. Handing the stack // back untouched is what stops a photograph that was opened, // glanced at and closed from losing the coverage its own sidecar // gave it. return stack; }; let (pw, ph) = seg.proxy_size(); for layer in stack.layers_mut() { let values = match &layer.base().source { MaskSource::Category { name, .. } => { seg.category_mask_at(name, layer.base().refine) } MaskSource::Subject { index, .. } => { seg.instance_mask(*index as usize).map(Cow::Borrowed) } // Nothing else has a model behind it. Left alone rather than // cleared, so a hand-written file's key survives a round trip // even though nothing samples it. _ => continue, }; // The layer names something this run does not contain — a stale // index, a category the scene model no longer offers. Keeping what // was stored is right: it is a mask that was once correct, and the // panel is already telling the user the layer is stale. let Some(values) = values else { continue }; // `None` from the encoder means "will not fit in a sidecar", and // the stored raster is then cleared rather than left standing. It // would describe the layer at some earlier refine, and a mask of // the wrong shape presented as authoritative is worse than the // honest state, which is that this one needs the model run. layer.base_mut().coverage = Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new); } stack } /// Rebuild the distance fields if anything they depend on moved. fn ensure_subject_fields(&mut self, ctx: &GpuContext) { let key = self.subject_signature(); if key == self.subject_key && self.subjects.is_some() { return; } // The proxy the fields are measured in: the segmentation's own where // one has been run, and otherwise the size the mask array is // rasterised at. `mask_raster_size` derives that from the photograph // rather than from a segmentation for exactly this case, and the two // are the same number by construction — see there. let (pw, ph) = match self.segmentation.as_ref() { Some(seg) => seg.proxy_size(), None => { let (w, h) = self.mask_raster_size(); (w as usize, h as usize) } }; // In `rendered()` order, because that is the order the rasteriser // walks and the order it indexes these by — the revealed layer // included, which is why the reveal is asked for here and folded into // the key above. let reveal = self.reveal(); let mut fields: Vec> = Vec::new(); for layer in self.graph.masks().rendered(reveal.as_ref()) { let field = match self.layer_coverage(layer, pw, ph) { Some(coverage) => { dr_segment::Shaped::build( &coverage, pw, ph, 128, morphology_for(layer.base().morphology), // Radii are fractions of the shorter edge; the field // is in proxy pixels. layer.base().morph_radius * pw.min(ph) as f32, ) .distance } // Full size, never `unwrap_or_default`: an empty vec is a // wrong-sized field, `SubjectMasks::upload` rejects the whole // batch on one, and every other layer in the stack then loses // its mask too. One stale name should cost one layer, not all // of them. // // It is also the placeholder a gradient or a brush gets, so // the slot indices line up with `active()` whatever mix of // sources the stack holds. None => vec![-1.0; pw * ph], }; fields.push(field); } if fields.is_empty() { self.subjects = None; self.subject_key = key; return; } let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect(); self.subjects = dr_gpu::SubjectMasks::upload(ctx, &refs, pw as u32, ph as u32) .inspect_err(|e| log::warn!("could not upload the subject fields: {e}")) .ok(); self.subject_key = key; } fn rasterise_masks(&mut self) -> bool { // TRACES: FR-DEV-19c // A stack that changes no pixel is normally not worth a pass — except // when one of its layers is being looked at, which is exactly the // state a fresh selection is in. Asking `rendered_count` rather than // `is_neutral` is what makes "click a category, see its mask" work at // all: before it, the array was never rasterised, the slice the reveal // samples held whatever was last in it, and the answer was a blank // photograph. let reveal = self.reveal(); if self.graph.masks().rendered_count(reveal.as_ref()) == 0 { return false; } // **Source space, at the segmentation's proxy size** — not the // viewport's. The generated shader samples this after the framing map, // so a mask drawn here stays on the photograph through a zoom, a pan // and a crop. Rasterising at viewport size, as this first did, pinned // the mask to the screen instead: zooming slid the picture underneath // one that stayed put. // // It also means the array does not reallocate when the window // resizes, and does not need redrawing when the view moves. let (pw, ph) = self.mask_raster_size(); let subjects = self.subjects.as_ref(); // TRACES: FR-DEV-10 // A refcount, taken before the rasteriser is borrowed mutably. A range // layer measures the photograph itself, so the pass needs the source // as well as the stack — and it is the same texture every other pass // reads, not a copy made for masking. let source = self.demosaiced.clone(); // **Built here, not in `segment`.** A gradient needs no segmentation — // a graduated filter over a sky never had to know what a sky is — but // the rasteriser was only ever constructed on the way out of one, so // adding a gradient to a photograph nobody had segmented produced an // array that was never rasterised and a layer that drew nothing at // all. Silently: the generated shader still emits the layer's block // and the empty placeholder multiplies it by zero, which is the same // failure exports and thumbnails had. // // The shader compile this costs is paid once, on the first frame after // the first mask is added — a button press, not a frame anyone is // dragging through. `is_neutral` above is what keeps it off the path // of every photograph that has no local adjustment at all. if self.masks.is_none() { let ctx = self.ctx.clone(); self.masks = dr_gpu::MaskPass::new(&ctx) .inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}")) .ok(); } let Some(pass) = self.masks.as_mut() else { return false; }; // No label field: region masks were the watershed's, and nothing // produces one any more. A stored layer that still names regions is // skipped by the rasteriser rather than drawn wrong. pass.render_revealing( self.graph.masks(), None, subjects, Some(source.as_ref()), pw, ph, reveal.as_ref(), ) .inspect_err(|e| log::warn!("mask rasterisation failed: {e}")) .is_ok() } /// The size the mask array is rasterised at, in source space. /// /// **A property of the photograph, not of the segmentation.** The two come /// out the same because both are the source scaled to `SEGMENT_PROXY_EDGE`, /// and they have to: a subject layer's distance field is built at the /// segmentation's proxy and sampled against this array, so the two sizes /// agreeing is a requirement rather than a coincidence. Reading the size /// *off* the segmentation is what made it look like a dependency, and made /// a gradient — which indexes nothing — wait for a model to run. /// /// The aspect must be the source's either way. A gradient's geometry is /// measured against the frame's own proportions, so a square array over a /// 3:2 photograph would stretch every circle it drew. fn mask_raster_size(&self) -> (u32, u32) { if let Some(seg) = self.segmentation.as_ref() { let (pw, ph) = seg.proxy_size(); return (pw as u32, ph as u32); } let (sw, sh) = self.demosaiced.size(); let scale = (SEGMENT_PROXY_EDGE as f32 / sw.max(sh).max(1) as f32).min(1.0); ( ((sw as f32 * scale) as u32).max(1), ((sh as f32 * scale) as u32).max(1), ) } // ---------------------------------------------------------------------- // Segmentation (S15, docs/segmentation.md) // ---------------------------------------------------------------------- /// This session's name, carried by any work started against it. pub fn id(&self) -> SessionId { self.id } /// TRACES: FR-DEV-3 /// Everything a segmentation needs, so it can be run somewhere else. /// /// Taking the job is cheap — two `Arc` bumps and a texture handle — and /// nothing about the session is borrowed past the call, which is what /// lets the window go on drawing while the answer is being found. pub fn segmentation_job(&self) -> SegmentationJob { SegmentationJob { ctx: self.ctx.clone(), source: self.demosaiced.clone(), session: self.id, abandon: Abandon::default(), names: self.face_names.clone(), exif_orientation: self.orientation, orientation: self.graph.framing().effective_orientation(), } } /// TRACES: FR-CULL-10 | FR-DEV-3 /// The confirmed faces in this photograph, for naming segmented regions. /// /// Set once when the image opens, because that is the only moment the /// catalog and the image id are both in reach — the develop session /// deliberately knows nothing about either, and every segmentation run /// after this point picks the names up for free. /// /// Boxes are normalised to the long edge, as the catalog stores them, so /// they survive whatever proxy size a run settles on. pub fn set_face_names(&mut self, names: Vec) { self.face_names = names; } /// TRACES: FR-DEV-3 /// Take on a segmentation found elsewhere. /// /// The caller is responsible for checking that this result was computed /// for *this* session — see [`SegmentationJob::session`]. Nothing here can /// tell one photograph's subjects from another's, and a mismatch is /// silent: the masks would rasterise, the overlay would draw, and the /// outlines would simply follow a subject that is not in the picture. pub fn adopt_segmentation(&mut self, found: Segmented) { // Kept rather than replaced where there is one already: a second // segmentation of the same photograph would otherwise throw away a // working rasteriser for an identical one. self.masks = self.masks.take().or(found.masks); // The fields themselves are built per *layer*, on demand — there are // none yet, and building one per detected object would transform // several megapixels for masks the user may never make. self.segmentation = Some(found.seg); self.subjects = None; self.subject_key = 0; } /// TRACES: FR-DEV-3 /// Everything a refine pass needs for one already-detected instance, so /// it can be run somewhere else — see [`Self::segmentation_job`] for why /// the split exists. /// /// `None` where there is nothing to refine: no segmentation yet, or an /// index the panel offered a button for a moment ago but the segmentation /// underneath has since changed. pub fn refine_job(&self, index: usize) -> Option { let seg = self.segmentation.as_ref()?; let instance = seg.instances().get(index)?; Some(RefineJob { ctx: self.ctx.clone(), source: self.demosaiced.clone(), session: self.id, abandon: Abandon::default(), index, class_name: instance.class_name.clone(), bbox: instance.bbox, proxy: seg.proxy_size(), orientation: self.graph.framing().effective_orientation(), }) } /// Take on a refined instance found elsewhere. /// /// Same caution as [`Self::adopt_segmentation`]: the caller checks the /// job's session before handing back its answer, not this. Every mask /// layer pointing at this instance's index reads it fresh next redraw — /// `subject_key` is reset because the pixels changed under an index /// `subject_signature` has no way to know changed, unlike a morphology /// slider it does track. pub fn adopt_refined(&mut self, refined: RefinedInstance) { if let Some(seg) = self.segmentation.as_mut() { seg.replace_instance(refined.index, refined.summary); } self.subjects = None; self.subject_key = 0; } /// The mask layer's original detection index, for the refine button — /// `None` for a layer that is not a subject at all (a gradient has no /// instance to re-detect). pub fn subject_instance_index(&self, id: &str) -> Option { match &self.graph.masks().get(id)?.base().source { MaskSource::Subject { index, .. } => Some(*index as usize), _ => None, } } /// Find the subjects and take them on, blocking until both are done. /// /// Test-only, and deliberately: a session-shaped blocking call is exactly /// the shape that put two thirds of a second on the UI thread in the first /// place, and leaving it public would invite the next caller to reach for /// it. A test has nothing else to be doing. #[cfg(test)] fn segment(&mut self, options: &segmentation::Options) -> Result<(), String> { if let Some(found) = self.segmentation_job().run(options)? { self.adopt_segmentation(found); } Ok(()) } pub fn has_segmentation(&self) -> bool { self.segmentation.is_some() } /// The subjects the model recognised, as `(label, confidence)`. /// /// Confidence is shown rather than hidden because the detector is offered /// as a shortcut, not as an authority: a 0.42 "dog" is worth listing and /// worth flagging, and a list that presented it identically to a 0.95 one /// would make the tool look wrong when the guess was merely weak. pub fn detected_subjects(&self) -> Vec<(String, f32)> { self.segmentation .as_ref() .map(|s| { s.instances() .iter() .map(|i| (i.class_name.to_string(), i.score)) .collect() }) .unwrap_or_default() } // ---------------------------------------------------------------------- // Seeing the mask (FR-DEV-19c) // ---------------------------------------------------------------------- /// TRACES: FR-DEV-19c /// What the canvas should draw over the photograph, if anything. /// /// **Only when exactly one layer is selected**, on the same rule the part /// list and the brush follow: a mask is one thing, and drawing the union /// of three because three rows were control-clicked would answer a /// question nobody asked. `None` there rather than picking the first, so /// the reveal disappearing is itself the signal that the selection is not /// what it needs to be. /// /// Rebuilt per call rather than kept in step with the selection, because /// it is two fields and a clone of one id — cheaper than the invalidation /// a cached copy would need every time a layer is added, removed, renamed /// or reordered. pub(crate) fn reveal(&self) -> Option { let style = self.reveal_style?; let [id] = self.active_masks.as_slice() else { return None; }; Some(dr_pipeline::mask::Reveal { layer: id.clone(), style, }) } /// How the selected layer's mask is being shown, as an index into /// [`dr_pipeline::mask::RevealStyle::ALL`], or `0` for not at all. /// /// An index because the panel offers it as a strip of chips and an index /// is what a strip of chips reports. The enum stays the thing that is /// stored, so a fourth style is a variant and a label rather than a number /// two files have to agree on. pub fn mask_view(&self) -> usize { use dr_pipeline::mask::RevealStyle; self.reveal_style .and_then(|s| RevealStyle::ALL.iter().position(|&a| a == s)) .map_or(0, |i| i + 1) } /// Show the selected layer's mask, or stop. /// /// Takes no history step and marks nothing dirty: this is how the /// photograph is being *looked at*, not an edit to it. pub fn set_mask_view(&mut self, view: usize) { use dr_pipeline::mask::RevealStyle; self.reveal_style = view .checked_sub(1) .and_then(|i| RevealStyle::ALL.get(i).copied()); } /// TRACES: FR-DEV-19c /// Show the mask of a layer that has just been made. /// /// **Making a mask is asking what it selected**, and for a subject or a /// category that question has no other answer: the model's outline is not /// derivable from anything on screen, the layer carries no adjustment yet, /// and the list it was chosen from says "architecture 23%" and nothing /// about *which* 23%. So the mask appears with the layer rather than /// waiting to be asked for a second time. /// /// Only from the resting position, and only on an explicit action — the /// same rule arming a brush follows. Somebody who has switched to the /// alpha or the outline keeps it, and this never re-arms in the background; /// the trap `Masking.overlay-hidden` documents is an automatic reveal that /// undoes a switch a photographer turned off, and every caller of this is /// a press that asked for a new mask. fn show_new_mask(&mut self) { if self.reveal_style.is_none() { self.reveal_style = Some(dr_pipeline::mask::RevealStyle::Tint); } } // ---------------------------------------------------------------------- // The region overlay // ---------------------------------------------------------------------- pub fn overlay_enabled(&self) -> bool { self.show_overlay } pub fn set_overlay(&mut self, on: bool) { self.show_overlay = on; } /// The part of the overlay the view is currently showing, in overlay /// pixels: `(x, y, width, height)`. /// /// The overlay is a **source-space** picture, and the canvas beside it /// shows whatever the crop, the zoom and the pan selected out of that same /// space. Drawn whole, it stays the size of the frame while the photograph /// moves underneath — which is exactly the fault this exists to fix. /// /// Reported as a clip rectangle rather than resampled here: the compositor /// crops and scales a texture for nothing, where doing it on the CPU would /// mean rebuilding a megapixel image on every frame of a drag. /// /// **Known gap.** A quarter turn or a flip permutes the axes, and a clip /// rectangle cannot express that — the straightening angle is handled /// alongside this, but a quarter-turned frame shows the overlay unturned. /// Fixing it properly means running the overlay through the same shader /// prologue the image goes through, which is the right answer and a larger /// one than this. pub fn overlay_clip(&self) -> (i32, i32, i32, i32) { let Some(seg) = self.segmentation.as_ref() else { return (0, 0, 0, 0); }; // **Shown pixels, matching `overlay_image`.** The crop and the // viewport are fractions of the photograph as the user sees it — the // prologue maps an output pixel through `crop_rect` *before* it // unturns the frame — so measuring them against the sensor's width // and height puts the clip on the wrong axis the moment the two // differ. That is the same confusion as the overlay itself had, one // layer down, and it is silent for exactly the images where it is // wrong: a landscape frame has nothing to notice. let (sw, sh) = seg.proxy_size(); let (w, h) = self .graph .framing() .effective_orientation() .oriented_size(sw as u32, sh as u32); let rect = self.graph.framing().visible_rect(); // Rounded outward, so half a pixel of rounding never shows as a strip // of missing overlay along an edge. let x = (rect.x * w as f32).floor().max(0.0) as i32; let y = (rect.y * h as f32).floor().max(0.0) as i32; let right = ((rect.x + rect.width) * w as f32).ceil().min(w as f32) as i32; let bottom = ((rect.y + rect.height) * h as f32).ceil().min(h as f32) as i32; (x, y, (right - x).max(1), (bottom - y).max(1)) } /// TRACES: FR-DEV-3 /// A false-coloured picture of what a click can select, for the canvas. /// /// Returned as a CPU image rather than a texture, and deliberately: it is /// regenerated only when the segmentation changes, it is proxy-sized /// rather than viewport-sized, and the compositor scales and clips it for /// free. Putting it on the GPU would buy nothing and add a second texture /// to keep in step with the view. /// /// `None` when the overlay is off or nothing has been segmented, so the /// caller can bind this straight to an image source. pub fn overlay_image(&self) -> Option { if !self.show_overlay { return None; } let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba(); // TRACES: FR-DEV-3h // **Turned the right way up before it is drawn.** Instance masks live // in sensor space, because the generated shader samples them after // the framing map (`uv_src`) — but this is not sampled by that shader. // It is a flat image handed to the compositor to lay over a // photograph that *has* been through the framing map, so it has to // arrive in the same space the photograph is in. // // Without this the outlines are drawn in the sensor's orientation over // an upright picture: on a portrait frame the colour sits nowhere near // the subject, which reads as the detector having failed rather than // as the overlay being turned. Nothing announces it, and it is // invisible on landscape frames, which is most of them. let (rgba, w, h) = self .graph .framing() .effective_orientation() .into_shown(&rgba, w, h, 4); let buffer = slint::SharedPixelBuffer::::clone_from_slice(&rgba, w, h); Some(slint::Image::from_rgba8(buffer)) } // ---------------------------------------------------------------------- // Mask layers // ---------------------------------------------------------------------- /// The layers, as `(id, name, enabled, is_active_selection)`. pub fn mask_layers(&self) -> Vec<(String, String, bool, bool)> { self.graph .masks() .layers() .iter() .map(|l| { ( l.id.clone(), l.display_name().to_string(), l.enabled, self.active_masks.iter().any(|a| a == &l.id), ) }) .collect() } /// What kind of mask a layer is — "regions", "linear", "radial". pub fn mask_kind(&self, id: &str) -> &'static str { self.part_of(id).map_or("", |p| p.source.kind()) } pub fn mask_inverted(&self, id: &str) -> bool { self.graph.masks().get(id).is_some_and(|l| l.invert) } pub fn mask_opacity(&self, id: &str) -> f32 { self.graph.masks().get(id).map_or(1.0, |l| l.opacity) } /// Whether a layer has any adjustment on it yet. /// /// Distinct from `is_active`, which also asks whether the layer is enabled /// and visible. The panel wants specifically "you have made a selection /// and not yet done anything with it", because that state looks identical /// to a broken mask and is the most likely thing a first-time user hits. pub fn mask_is_adjusted(&self, id: &str) -> bool { self.graph .masks() .get(id) .is_some_and(|l| l.active_ops().next().is_some()) } /// The panel's representative selection — see [`Self::active_layer`] for /// what "representative" means once more than one layer is selected. pub fn active_mask(&self) -> Option<&str> { self.active_masks.first().map(String::as_str) } /// Every selected layer's id, in selection order. pub fn active_masks(&self) -> &[String] { &self.active_masks } /// TRACES: FR-DEV-3 | FR-UI-3 /// The selected gradient's handles, in fractions of the shown image. /// /// Empty unless **exactly one** gradient layer is selected. Dragging a /// shared handle for several gradients at once has no single geometry to /// move — each one's centre, angle and extent differ — so multi-select /// simply offers no handles rather than moving one layer's shape while /// silently leaving the others behind. /// /// Recomputed on every redraw rather than cached, because the answer /// changes with the *view* and not only with the mask: a pan moves every /// handle and touches no geometry. Four handles through an affine map is /// not work worth caching, and a cache keyed on the wrong thing is how a /// handle comes to sit where the mask used to be. pub fn gradient_handles(&self) -> Vec { if self.active_masks.len() != 1 { return Vec::new(); } let Some(layer) = self.active_layer() else { return Vec::new(); }; let (sw, sh) = self.demosaiced.size(); crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh)) } /// Drag one handle of the selected gradient, from `press` to `now`, both /// in fractions of the shown image. /// /// `origin` is the geometry the gesture started from — see /// [`crate::gradient::drag`] for why a drag is applied to that rather than /// accumulated. Returns it, so the caller can hold it for the rest of the /// gesture; `None` when there is no gradient selected to drag. pub fn drag_gradient_handle( &mut self, role: crate::HandleRole, origin: Option<&MaskSource>, press: (f32, f32), now: (f32, f32), ) -> Option { if self.active_masks.len() != 1 { return None; } let (sw, sh) = self.demosaiced.size(); let framing = *self.graph.framing(); let id = self.active_masks.first()?.clone(); let start = match origin { Some(s) => s.clone(), None => self.graph.masks().get(&id)?.base().source.clone(), }; let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh)); self.graph.masks_mut().get_mut(&id)?.base_mut().source = moved; // **Nothing recorded here.** A drag delivers a pointer event a frame, // and a history step per frame would make undo walk a gesture back // pixel by pixel. `Edit` coalesces by operation id and a mask's shape // is not an operation, so there is no key to coalesce under — the // honest answer is to record once, on release. Some(start) } /// A handle drag finished: one history step for the whole gesture. /// /// Called on the pointer's release rather than on each move, which is what /// makes a drag one decision in the undo stack however many frames it took. pub fn commit_gradient_drag(&mut self) { self.history .record(&self.graph, Edit::Action(labels::step::MASK_MOVED)); } // --- editing a mask by hand (FR-DEV-19) -------------------------------- /// TRACES: FR-DEV-19a /// Which part of `id` the edge controls act on. /// /// The selected part when this is the layer being edited, and the base /// otherwise — because a panel that is not showing a layer's parts has not /// offered anybody a way to choose one, and answering with a part they /// cannot see would make the same slider mean different things depending /// on what was selected a moment ago. fn shaped_part(&self, id: &str) -> usize { match self.active_masks.as_slice() { [only] if only == id => self.active_part, _ => 0, } } /// The part of `id` the edge controls read. fn part_of(&self, id: &str) -> Option<&dr_pipeline::mask::MaskPart> { let index = self.shaped_part(id); self.graph.masks().get(id)?.part(index) } /// The same, to write through. fn part_of_mut(&mut self, id: &str) -> Option<&mut dr_pipeline::mask::MaskPart> { let index = self.shaped_part(id); self.graph.masks_mut().get_mut(id)?.part_mut(index) } /// Which part of the selected layer the tools point at. pub fn active_part(&self) -> usize { self.active_part } /// Point the tools at one part, or at the base when the index is past the /// end — which is what a part being removed under the selection leaves. pub fn set_active_part(&mut self, index: usize) { let parts = self.active_layer().map_or(1, |l| l.parts().len()); self.active_part = if index < parts { index } else { 0 }; } /// The parts of a layer: id, what to call it, and how it joins. /// /// The join of the first is meaningless — there is nothing before it to /// join to — and the panel shows it as the selection the layer *is* /// rather than as a row with a chip that does nothing. pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize)> { let Some(layer) = self.graph.masks().get(id) else { return Vec::new(); }; layer .parts() .iter() .map(|p| { let join = dr_pipeline::mask::Join::ALL .iter() .position(|&j| j == p.join) .unwrap_or(0); (p.id.clone(), p.source.kind().to_string(), join) }) .collect() } /// TRACES: FR-DEV-19a /// Join a fresh painted part to a layer, returning its index. /// /// Painted, because that is the correction a photographer reaches for /// first and the only source that needs nothing found for it. The other /// sources arrive when a part can carry its own distance field. pub fn add_mask_part(&mut self, id: &str, join: usize) -> Option { use dr_pipeline::mask::{Join, MaskPart}; let &join = Join::ALL.get(join)?; let layer = self.graph.masks_mut().get_mut(id)?; let part_id = layer.next_part_id(); if !layer.push_part(MaskPart::painted(part_id, join)) { return None; } let index = layer.parts().len() - 1; self.active_part = index; self.history .record(&self.graph, Edit::Action(labels::step::MASK_PART_ADDED)); Some(index) } /// Take a part back out of a layer. The base is not removable — removing /// the selection a layer *is* is removing the layer. pub fn remove_mask_part(&mut self, id: &str, index: usize) { let Some(layer) = self.graph.masks_mut().get_mut(id) else { return; }; if layer.remove_part(index).is_none() { return; } self.set_active_part(self.active_part.min(index.saturating_sub(1))); self.history .record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED)); } /// Change how a part joins: added to the mask, or taken out of it. pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) { use dr_pipeline::mask::Join; let Some(&join) = Join::ALL.get(join) else { return; }; let Some(layer) = self.graph.masks_mut().get_mut(id) else { return; }; // The first part joins nothing, so saying how it joins would be a // control that moves and changes no pixel. if index == 0 { return; } let Some(part) = layer.part_mut(index) else { return; }; part.join = join; self.history .record(&self.graph, Edit::Action(labels::step::MASK_JOINED)); } /// The brush: radius, hardness, flow. pub fn brush(&self) -> (f32, f32, f32) { self.brush } /// Set the brush. Radius is a fraction of the frame's shorter edge, so it /// means the same thing on the phone and on the desktop and at any zoom. pub fn set_brush(&mut self, radius: f32, hardness: f32, flow: f32) { self.brush = ( radius.clamp(0.002, 0.5), hardness.clamp(0.0, 1.0), flow.clamp(0.01, 1.0), ); } /// How many stroke points the selected layer may still record. /// /// Asked by the panel so that a mask approaching its budget can say so /// before a gesture is refused mid-stroke — which is the moment the /// refusal is least explicable. pub fn mask_room(&self) -> usize { self.active_layer().map_or(0, |l| l.room()) } /// TRACES: FR-DEV-19b /// Begin a stroke at a point in fractions of the shown image. /// /// **Paints into the active part, or joins one if that part cannot hold a /// stroke.** Pressing Paint on a mask the model made is the ordinary way /// this is reached, and it must not answer with a refusal explaining that /// a subject is not a brush: the correction the photographer is about to /// make *is* a new part, so it is made. /// /// Returns whether a stroke was started. `false` means the layer is full /// or there is nothing selected, and the caller should not send moves. pub fn begin_mask_stroke(&mut self, x: f32, y: f32, erase: bool) -> bool { let Some(id) = self.active_masks.first().cloned() else { return false; }; if self.active_masks.len() != 1 { // Several layers share the slider drags; a stroke has one target // and guessing which of three it is would be worse than refusing. return false; } let paintable = self .graph .masks() .get(&id) .and_then(|l| l.part(self.active_part)) .is_some_and(|p| matches!(p.source, MaskSource::Brush { .. })); if !paintable { use dr_pipeline::mask::Join; let join = if erase { Join::Subtract } else { Join::Union }; let position = Join::ALL.iter().position(|&j| j == join).unwrap_or(0); if self.add_mask_part(&id, position).is_none() { return false; } } let part = self.active_part; let (radius, hardness, flow) = self.brush; // An erase stroke inside a part that subtracts would take away from // what the part removes, which reads backwards. In a subtracting part // the brush's two modes are already the right way round. let subtracting = self .graph .masks() .get(&id) .and_then(|l| l.part(part)) .is_some_and(|p| p.join == dr_pipeline::mask::Join::Subtract); let erase = erase && !subtracting; let Some(layer) = self.graph.masks_mut().get_mut(&id) else { return false; }; if !layer.begin_stroke(part, erase, radius, hardness, flow) { return false; } self.painting = Some((id, part)); self.extend_mask_stroke(x, y); true } /// Carry the stroke to another point, in fractions of the shown image. /// /// The point is mapped into normalised **source** coordinates on the way /// in, through the same framing map the shader applies — so a stroke stays /// on the thing it was painted on through a zoom, a pan, a crop and a /// straighten, and lands in an export at any size where it was drawn. pub fn extend_mask_stroke(&mut self, x: f32, y: f32) { let Some((id, part)) = self.painting.clone() else { return; }; let (sw, sh) = self.demosaiced.size(); let (sx, sy) = self.graph.framing().source_at((x, y), sw, sh); if let Some(layer) = self.graph.masks_mut().get_mut(&id) { layer.extend_stroke(part, sx, sy); } } /// Finish the stroke: one history step for the whole gesture. /// /// One step, on release, for the reason a handle drag records once — a /// stroke is a decision, and undo that walked it back dab by dab would /// make taking a mark back cost as many presses as making it did. pub fn end_mask_stroke(&mut self) { let Some((id, part)) = self.painting.take() else { return; }; let erased = self .graph .masks() .get(&id) .and_then(|l| l.part(part)) .and_then(|p| p.strokes().last()) .is_some_and(|s| s.erase); if let Some(layer) = self.graph.masks_mut().get_mut(&id) { layer.end_stroke(part); } let step = if erased { labels::step::MASK_ERASED } else { labels::step::MASK_PAINTED }; self.history.record(&self.graph, Edit::Action(step)); } /// Abandon a stroke that turned out to be something else — a pinch, or a /// gesture the window cancelled. Nothing is recorded, because nothing /// happened as far as the photographer is concerned. pub fn cancel_mask_stroke(&mut self) { let Some((id, part)) = self.painting.take() else { return; }; if let Some(layer) = self.graph.masks_mut().get_mut(&id) { layer.drop_last_stroke(part); } } // --- repairs (FR-DEV-8) ------------------------------------------------ /// TRACES: FR-DEV-8 /// Cover what is at `(x, y)`, in fractions of the shown image. /// /// The click arrives in *output* coordinates — where the photograph /// currently sits on screen — and a repair is stored against the /// photograph, so it goes through `Framing::source_at`: the same map the /// shader applies, run backwards. Anything less would put the repair where /// the pointer was rather than where the mark is, and the two agree only at /// fit-to-window with no crop. /// /// Returns the new repair's id, or `None` when the set is full. Selecting /// it is deliberate: the control that changes its size is in the column, /// and a photographer who has just placed a spot too small should find that /// control already pointed at it. pub fn place_spot(&mut self, x: f32, y: f32) -> Option { let (sw, sh) = self.demosaiced.size(); let centre = self.graph.framing().source_at((x, y), sw, sh); // Outside the photograph entirely — the letterbox margin, or a drag // that ended off the edge. Placing a repair there would put a disc // somewhere the user cannot see and cannot pick up again. if !(0.0..=1.0).contains(¢re.0) || !(0.0..=1.0).contains(¢re.1) { return None; } let aspect = sw.max(1) as f32 / sh.max(1) as f32; let radius = dr_pipeline::spot::DEFAULT_RADIUS; let offset = dr_pipeline::Spot::default_offset(centre, radius, aspect); let id = self .graph .spots_mut() .place(dr_pipeline::Spot::new(centre, offset, radius))?; self.selected_spot = Some(id.clone()); self.history .record(&self.graph, Edit::Action(labels::step::SPOT_PLACED)); Some(id) } /// TRACES: FR-DEV-8 | FR-UI-3 /// Every repair as a circle on the shown image, plus the source circle of /// the selected one. /// /// # Why only the selected repair shows its source /// /// A dusty sky carries a dozen repairs. Two dozen circles with nothing /// saying which source belongs to which disc is not more information, it is /// less — and there is no room on a phone for a connector between each /// pair. The selection is what disambiguates them, which is also why a /// press on a repair selects it before the drag begins. /// /// Recomputed per redraw rather than cached, for the reason /// [`Self::gradient_handles`] gives: the answer changes with the *view*, /// and a pan moves every circle while touching no edit. pub fn spot_handles(&self) -> Vec { let (sw, sh) = self.demosaiced.size(); let framing = self.graph.framing(); let aspect = sw.max(1) as f32 / sh.max(1) as f32; // The shown image's own shape, which is not the source's once the frame // has been cropped or turned. A radius is reported against its height, // so this is what converts the x half of the mapped offset. let (ow, oh) = self.graph.output_size(sw, sh); let shown_aspect = ow.max(1) as f32 / oh.max(1) as f32; let mut handles = Vec::new(); for spot in self.graph.spots().spots() { let selected = self.selected_spot.as_deref() == Some(spot.id.as_str()); let centre = framing.output_at(spot.centre, sw, sh); // The radius, mapped rather than scaled: a point one radius above // the centre goes through the same map, and the distance between // the two answers is the radius as drawn. The x half is multiplied // by the shown aspect because the two axes are normalised by // different lengths, and a circle measured in mixed units is an // ellipse. let rim = framing.output_at((spot.centre.0, spot.centre.1 + spot.radius), sw, sh); let radius = ((rim.0 - centre.0) * shown_aspect).hypot(rim.1 - centre.1); handles.push(crate::SpotHandle { id: spot.id.clone().into(), role: crate::SpotRole::Destination, x: centre.0, y: centre.1, radius, selected, enabled: spot.enabled, }); if selected { let source = framing.output_at(spot.source(aspect), sw, sh); handles.push(crate::SpotHandle { id: spot.id.clone().into(), role: crate::SpotRole::Source, x: source.0, y: source.1, radius, selected: true, enabled: spot.enabled, }); } } handles } /// TRACES: FR-DEV-8 /// Drag one circle of one repair, from `press` to `now`, both in fractions /// of the shown image. /// /// Dragging the disc moves the whole repair and carries its source along — /// what a photographer means by nudging a spot. Dragging the source moves /// the source alone, which is the override FR-DEV-8 asks for over the /// automatic placement. /// /// `origin` is the repair as it stood when the gesture began; the caller /// holds it for the duration and hands it back, so a drag is applied to /// that rather than accumulated frame by frame — the rule /// [`Self::drag_gradient_handle`] states, for the same reasons. pub fn drag_spot( &mut self, id: &str, role: crate::SpotRole, origin: Option<&dr_pipeline::Spot>, press: (f32, f32), now: (f32, f32), ) -> Option { let (sw, sh) = self.demosaiced.size(); let framing = *self.graph.framing(); let aspect = sw.max(1) as f32 / sh.max(1) as f32; let start = match origin { Some(spot) => spot.clone(), None => self.graph.spots().get(id)?.clone(), }; // The displacement in source coordinates. Affine, so a movement is a // movement: the map may be run on the two endpoints and subtracted, // which is what makes a drag on a rotated photograph move the repair in // the direction the finger went. let from = framing.source_at(press, sw, sh); let to = framing.source_at(now, sw, sh); let moved = (to.0 - from.0, to.1 - from.1); let spot = self.graph.spots_mut().get_mut(id)?; match role { crate::SpotRole::Destination => { spot.set_centre((start.centre.0 + moved.0, start.centre.1 + moved.1)); } // In frame units, because that is what an offset is stored in — and // the x half of a normalised displacement is short by the aspect. crate::SpotRole::Source => { spot.set_offset((start.offset.0 + moved.0 * aspect, start.offset.1 + moved.1)); } } // Nothing recorded here: a drag delivers a pointer event a frame, and // one history step apiece would make undo walk the gesture back pixel // by pixel. Recorded once, on release. Some(start) } /// A repair's drag finished: one history step for the whole gesture. pub fn commit_spot_drag(&mut self) { self.history .record(&self.graph, Edit::Action(labels::step::SPOT_MOVED)); } /// Which repair the column is describing. pub fn selected_spot(&self) -> Option<&dr_pipeline::Spot> { let id = self.selected_spot.as_deref()?; self.graph.spots().get(id) } pub fn selected_spot_id(&self) -> Option<&str> { self.selected_spot.as_deref() } /// Choose a repair, or `None` to describe none. /// /// An id the graph no longer holds selects nothing rather than being kept: /// the circle that offered it is stale by the time the press lands, and a /// selection pointing at a deleted repair would leave the column describing /// something that is not on the photograph. pub fn select_spot(&mut self, id: Option<&str>) { self.selected_spot = id .filter(|id| self.graph.spots().get(id).is_some()) .map(str::to_string); } /// TRACES: FR-DEV-8 /// Take a repair off the photograph, returning whether one went. pub fn remove_spot(&mut self, id: &str) -> bool { if self.graph.spots_mut().remove(id).is_none() { return false; } if self.selected_spot.as_deref() == Some(id) { self.selected_spot = None; } self.history .record(&self.graph, Edit::Action(labels::step::SPOT_REMOVED)); true } /// TRACES: FR-DEV-8 /// Change one of the selected repair's settings. /// /// Recorded as a named [`Edit::Action`] rather than under a parameter key: /// a repair is not an operation and has no `OpId` to name or coalesce by, /// so a slider drag over it records a step per movement unless the caller /// debounces. `SliderRow` fires once per completed gesture, /// which is what makes that acceptable here and is why this is the one /// panel in the application built from that row rather than from a live /// track. pub fn set_selected_spot(&mut self, change: F) -> bool where F: FnOnce(&mut dr_pipeline::Spot), { let Some(id) = self.selected_spot.clone() else { return false; }; let Some(spot) = self.graph.spots_mut().get_mut(&id) else { return false; }; change(spot); self.history .record(&self.graph, Edit::Action(labels::step::SPOT)); true } /// How many repairs this photograph carries. pub fn spot_count(&self) -> usize { self.graph.spots().len() } /// The selected layer's mask rule, for a caller that has to remember what /// a gesture started from. pub fn active_mask_source(&self) -> Option { self.active_layer().map(|l| l.base().source.clone()) } /// Select exactly one layer for editing, or `None` to return the panel to /// the global chain. Replaces whatever was selected before, including a /// multi-selection — the ordinary, unmodified click. /// Selecting a layer points the tools at its base again. /// /// Without this, selecting a two-part mask after a three-part one would /// leave the brush aimed at a part that is not there — or, worse, at a /// different part than the one highlighted in the panel. pub fn set_active_mask(&mut self, id: Option<&str>) { self.active_part = 0; self.active_masks = id .filter(|id| self.graph.masks().get(id).is_some()) .map(|id| vec![id.to_string()]) .unwrap_or_default(); } /// Add or remove one layer from the selection, keeping the rest — the /// modifier-click that builds a multi-selection. /// /// A layer id the graph no longer has is dropped rather than toggled in: /// the row that offered it is stale by the time the click lands, and /// selecting a ghost would make every subsequent batched edit silently /// skip it (`active_layers_mut` filters by membership, not existence). pub fn toggle_active_mask(&mut self, id: &str) { if self.graph.masks().get(id).is_none() { return; } match self.active_masks.iter().position(|a| a == id) { Some(i) => { self.active_masks.remove(i); } None => self.active_masks.push(id.to_string()), } } /// Mask the object under a normalised image point. /// /// Clicking the photograph is how a local adjustment begins, so this /// creates the layer — requiring "add layer" first would be a step with no /// decision in it. /// /// Returns the layer that now holds the selection. pub fn select_region_at(&mut self, x: f32, y: f32) -> Option { let index = self.segmentation.as_ref()?.instance_at(x, y)?; self.add_subject_mask(index) } /// Add a layer covering one photographic category. /// /// The counterpart to [`Self::add_subject_mask`], and it takes a *name* /// rather than an index for the reason `MaskSource::Category` stores one: /// the descriptor grouping ADE20K's classes is editable, so an index would /// silently repoint every stored layer the first time a category was /// added to it. /// /// The layer is named after the category, because "sky" is a better name /// for a layer than "Mask 3" and the user can rename it anyway. pub fn add_category_mask(&mut self, name: &str) -> Option { use dr_pipeline::mask::MaskSource; let seg = self.segmentation.as_ref()?; let signature = seg.signature(); // Refuse a category this run did not produce rather than creating a // layer that renders empty: an empty mask looks like a broken // adjustment, where a button that does nothing at least says so. seg.category_mask(name)?; let id = self.graph.masks().next_id(); let mut layer = MaskLayer::new( id.clone(), MaskSource::Category { signature, name: name.to_string(), }, ); layer.name = name.to_string(); // Refined from the start, by as much as this photograph will bear. // // A category's edges are twenty proxy pixels wide before this runs, so // the unrefined mask is the wrong default for the common case — a // photographer adding a sky mask wants the sky, not the sky plus every // chimney in it. Zero is still one drag away, and it is exactly the // model's own weighting when they get there. // // **Asked of the frame rather than taken from a constant**, and that // is the correction rather than a refinement of the idea. The constant // was `STRICTNESS_DEFAULT`, fitted on a synthetic sky; on real // photographs it removes three quarters to all of `architecture`, // `ground` and `vegetation`, so clicking a category produced an empty // mask — the failure that reads as the feature not working at all, // because the adjustment moves and no pixel changes. The measurements // are on `dr_segment::Refinement::gentle`, which is what picks the // number now. // // Set here rather than in `MaskLayer::new` because the number belongs // to `dr_segment` and `dr-pipeline` does not depend on it — see // `dr_pipeline::mask::MAX_REFINE`. layer.base_mut().refine = self .segmentation .as_ref() .map_or(dr_segment::STRICTNESS_OFF, |seg| { seg.category_default_refine(name) }); if !self.graph.masks_mut().push(layer) { return None; } self.active_masks = vec![id.clone()]; self.show_new_mask(); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// What the scene model found in this frame, largest category first. /// /// Empty when no scene model was available, which is an ordinary state — /// see `segmentation::scene_categories`. pub fn categories(&self) -> &[segmentation::CategorySummary] { self.segmentation.as_ref().map_or(&[], |s| s.categories()) } /// Add a layer selecting one detected subject. /// /// The instance's own coverage is the mask, rather than the watershed /// regions it overlaps. Snapping to regions was the original design and /// it is not currently worth doing: the hierarchy those ids index into /// collapses on a photograph (docs/segmentation.md §15), so snapping /// would trade the model's approximately-right outline for a /// confidently-wrong one. pub fn add_subject_mask(&mut self, index: usize) -> Option { let seg = self.segmentation.as_ref()?; let instance = seg.instances().get(index)?; let signature = seg.signature(); let name = instance.class_name.to_string(); let score = instance.score; let id = self.graph.masks().next_id(); let mut layer = MaskLayer::new( id.clone(), MaskSource::Subject { signature, index: index as u32, class: name.clone(), score, }, ); layer.name = name; if !self.graph.masks_mut().push(layer) { return None; } self.active_masks = vec![id.clone()]; self.show_new_mask(); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// Add a gradient layer, which needs no segmentation. pub fn add_gradient_mask(&mut self, radial: bool) -> Option { let id = self.graph.masks().next_id(); let source = if radial { MaskSource::Radial { centre: (0.5, 0.5), radii: (0.35, 0.35), angle: 0.0, feather: 0.5, } } else { MaskSource::Linear { centre: (0.5, 0.5), angle: std::f32::consts::FRAC_PI_2, width: 0.3, } }; if !self .graph .masks_mut() .push(MaskLayer::new(id.clone(), source)) { return None; } self.active_masks = vec![id.clone()]; self.show_new_mask(); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// TRACES: FR-DEV-19b /// Add a mask that is nothing but hand-painted, and select it. /// /// **The one route to a brush that starts from nothing.** Everything else /// in this file makes a layer out of a selection — a gradient, a band, a /// subject, a category — and painting was reachable only by making one of /// those first and then joining a painted part to it. So the answer to /// "brush a correction onto this corner of the sky" was "add a radial /// gradient you do not want, then paint into it", which is not an answer. /// /// The layer covers nothing until a stroke lands in it, which is exactly /// what [`dr_pipeline::mask::MaskPart::covers`] is about: it is not active, /// it costs no slice, and an invert on it would not take the adjustment /// global. What the panel shows meanwhile is a row with the tools armed /// over it — see `masks_ui`, which arms them. pub fn add_brush_mask(&mut self) -> Option { let id = self.graph.masks().next_id(); if !self .graph .masks_mut() .push(MaskLayer::new(id.clone(), MaskSource::brush())) { return None; } self.active_masks = vec![id.clone()]; self.active_part = 0; self.show_new_mask(); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// TRACES: FR-DEV-10 /// Add a mask that selects by a range of the photograph's own values. /// /// Needs no segmentation, like a gradient, and for a stronger reason: a /// band is a question about the picture rather than about what is in it. /// `chromatic` picks the colour range over the brightness one. pub fn add_range_mask(&mut self, chromatic: bool) -> Option { let id = self.graph.masks().next_id(); let source = if chromatic { MaskSource::skin_tones() } else { MaskSource::highlights() }; if !self .graph .masks_mut() .push(MaskLayer::new(id.clone(), source)) { return None; } self.active_masks = vec![id.clone()]; self.show_new_mask(); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// TRACES: FR-DEV-10 /// Move a range layer's band — its two bounds and the fade at each edge. /// /// All three at once, because they are one control: dragging the lower /// bound past the upper swaps them (see `MaskSource::luminance_range`), /// and a setter per field would have to decide that question three times /// with only a third of the answer each time. /// /// A no-op on a layer that is not a range, rather than a panic: the panel /// asks first, and a callback that arrives against a layer the user has /// since replaced is ordinary rather than a fault. pub fn set_mask_band(&mut self, id: &str, lo: f32, hi: f32, softness: f32) { let Some(layer) = self.part_of_mut(id) else { return; }; // Built into a temporary first: the arc is read out of the layer and // the whole source is written back over it, and the two cannot be the // same statement. let rebuilt = match &layer.source { MaskSource::Luminance { .. } => MaskSource::luminance_range(lo, hi, softness), MaskSource::Colour { hue, hue_width, .. } => { MaskSource::colour_range(*hue, *hue_width, lo, hi, softness) } _ => return, }; layer.source = rebuilt; // `Control`, not `Action`: a band is dragged, and a drag is one // decision however many values it passes through. self.history .record(&self.graph, Edit::Control(labels::step::MASK_RANGE)); } /// TRACES: FR-DEV-10 /// Move a colour range's arc — its centre hue and its half-width. pub fn set_mask_hue(&mut self, id: &str, hue: f32, width: f32) { let Some(layer) = self.part_of_mut(id) else { return; }; // A temporary, for the reason `set_mask_band` gives. let rebuilt = match &layer.source { MaskSource::Colour { chroma_lo, chroma_hi, softness, .. } => MaskSource::colour_range(hue, width, *chroma_lo, *chroma_hi, *softness), // Only a colour range has an arc. A brightness one reaching here // is a stale callback against a layer the user has replaced, // which is ordinary rather than a fault. _ => return, }; layer.source = rebuilt; self.history .record(&self.graph, Edit::Control(labels::step::MASK_RANGE)); } /// TRACES: FR-DEV-10 /// Whether the band controls apply to this layer. pub fn mask_is_ranged(&self, id: &str) -> bool { self.part_of(id).is_some_and(|p| p.source.is_range()) } /// TRACES: FR-DEV-10 /// Whether this layer's band is over colour rather than over brightness. pub fn mask_is_chromatic(&self, id: &str) -> bool { self.part_of(id) .is_some_and(|p| matches!(p.source, MaskSource::Colour { .. })) } /// TRACES: FR-DEV-10 /// This layer's band: lower bound, upper bound, softness. /// /// Tone positions for a luminance layer and chroma for a colour one — /// the same two questions asked of different measurements, which is why /// one panel control drives both. Zeroes for a layer that has no band, /// which the panel never draws. pub fn mask_band(&self, id: &str) -> (f32, f32, f32) { match self.part_of(id).map(|p| &p.source) { Some(MaskSource::Luminance { lo, hi, softness }) => (*lo, *hi, *softness), Some(MaskSource::Colour { chroma_lo, chroma_hi, softness, .. }) => (*chroma_lo, *chroma_hi, *softness), _ => (0.0, 0.0, 0.0), } } /// TRACES: FR-DEV-10 /// A colour range's arc: centre hue and half-width, both in turns. pub fn mask_hue(&self, id: &str) -> (f32, f32) { match self.part_of(id).map(|p| &p.source) { Some(MaskSource::Colour { hue, hue_width, .. }) => (*hue, *hue_width), _ => (0.0, 0.0), } } pub fn remove_mask(&mut self, id: &str) { if self.graph.masks_mut().remove(id).is_some() { self.active_masks.retain(|a| a != id); self.history .record(&self.graph, Edit::Action(labels::step::MASK_REMOVED)); } } pub fn set_mask_enabled(&mut self, id: &str, enabled: bool) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.enabled = enabled; self.history .record(&self.graph, Edit::Action(labels::step::MASK_TOGGLED)); } } pub fn set_mask_invert(&mut self, id: &str, invert: bool) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.invert = invert; self.history .record(&self.graph, Edit::Action(labels::step::MASK_INVERTED)); } } /// Edge transition half-width, in fractions of the shorter edge. pub fn set_mask_feather(&mut self, id: &str, feather: f32) { if let Some(part) = self.part_of_mut(id) { part.feather = feather.clamp(0.0, 1.0); self.history .record(&self.graph, Edit::Control(labels::step::MASK_FEATHER)); } } /// How strictly this layer's category is cut back to the pixels whose /// colour agrees with it. /// /// Unlike the feather, this changes the mask's *shape*, so the distance /// field has to be rebuilt — the same class of cost as a close or an open /// (`dr_segment::Morphology::needs_recompute`), and the reason it is in /// `subject_signature`. It still runs no model: the evidence was fitted /// during segmentation and this is a smoothstep over it. pub fn set_mask_refine(&mut self, id: &str, refine: f32) { if let Some(part) = self.part_of_mut(id) { part.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE); self.history .record(&self.graph, Edit::Control(labels::step::MASK_REFINE)); } } pub fn set_mask_falloff(&mut self, id: &str, index: usize) { use dr_pipeline::mask::Falloff; let Some(&falloff) = Falloff::ALL.get(index) else { return; }; if let Some(part) = self.part_of_mut(id) { part.falloff = falloff; self.history .record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF)); } } pub fn set_mask_morphology(&mut self, id: &str, index: usize) { use dr_pipeline::mask::Morphology; let Some(&morphology) = Morphology::ALL.get(index) else { return; }; if let Some(part) = self.part_of_mut(id) { part.morphology = morphology; // Picking an operation with no amount set would appear to do // nothing, and the user would reasonably conclude it is broken. if morphology != Morphology::None && part.morph_radius <= 0.0 { part.morph_radius = 0.006; } self.history .record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY)); } } pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) { if let Some(part) = self.part_of_mut(id) { part.morph_radius = radius.clamp(0.0, 1.0); self.history .record(&self.graph, Edit::Control(labels::step::MASK_MORPH)); } } /// Which falloff a layer uses, as an index into `Falloff::ALL`. pub fn mask_falloff(&self, id: &str) -> usize { use dr_pipeline::mask::Falloff; self.part_of(id).map_or(0, |p| { Falloff::ALL .iter() .position(|&f| f == p.falloff) .unwrap_or(0) }) } pub fn mask_morphology(&self, id: &str) -> usize { use dr_pipeline::mask::Morphology; self.part_of(id).map_or(0, |p| { Morphology::ALL .iter() .position(|&m| m == p.morphology) .unwrap_or(0) }) } pub fn mask_feather(&self, id: &str) -> f32 { self.part_of(id).map_or(0.0, |p| p.feather) } pub fn mask_morph_radius(&self, id: &str) -> f32 { self.part_of(id).map_or(0.0, |p| p.morph_radius) } pub fn mask_refine(&self, id: &str) -> f32 { self.part_of(id).map_or(0.0, |p| p.refine) } /// Whether this layer has a refinement to act on. /// /// Two conditions, and both are needed. The source must be a category — /// nothing else has a colour model fitted for it — and the segmentation /// must actually have fitted one, which it cannot for a category that is /// everywhere thinner than the model's own resolution or that fills the /// whole frame. /// /// Asked by the panel before it draws the slider, because a control that /// moves and does nothing is worse than an absent one. pub fn mask_is_refinable(&self, id: &str) -> bool { use dr_pipeline::mask::MaskSource; let Some(layer) = self.graph.masks().get(id) else { return false; }; let index = self.shaped_part(id); let Some(part) = layer.part(index) else { return false; }; let MaskSource::Category { name, .. } = &part.source else { return false; }; self.segmentation .as_ref() .is_some_and(|seg| seg.category_is_refinable(name)) } /// Whether the edge controls apply to this layer. /// /// Only sources that go through the distance field. A gradient carries its /// own falloff in its geometry, so offering a second one would be two /// controls fighting over the same edge. /// /// A range (FR-DEV-10) is excluded on stronger grounds than the gradient. /// Feather, falloff and morphology are every one of them a function of the /// *signed distance from a boundary*, and a range mask has no boundary: it /// is a weighting over the photograph's values, soft everywhere the values /// are, sharp everywhere they are. There is no outline to grow, shrink or /// cross. Its edge is the softness of its own band, which is in the band's /// units rather than in pixels — see `MaskSource::Luminance`. pub fn mask_is_shapeable(&self, id: &str) -> bool { use dr_pipeline::mask::MaskSource; self.part_of(id).is_some_and(|p| { matches!( p.source, MaskSource::Subject { .. } | MaskSource::Category { .. } | MaskSource::Regions { .. } ) }) } pub fn set_mask_opacity(&mut self, id: &str, opacity: f32) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.opacity = opacity.clamp(0.0, 1.0); // `Op` rather than `Discrete`: opacity is dragged, and a drag is // one decision however many values it passes through. `Discrete` // would put every intermediate position on the undo stack. self.history .record(&self.graph, Edit::Control(labels::step::MASK_OPACITY)); } } /// Whether a layer's region ids belong to a segmentation other than the /// one currently loaded — a mask restored from a sidecar written under /// different tuning. pub fn mask_is_stale(&self, id: &str) -> bool { let Some(layer) = self.graph.masks().get(id) else { return false; }; match self.segmentation.as_ref() { Some(seg) => layer.is_stale(seg.signature()), // Nothing loaded to compare against, and nothing to report: the // layer renders from the raster its sidecar stored (see // `layer_coverage`). It was already not *stale* before that — the // word invites the user to recompute a selection that is fine — // and now it is not unrenderable either. None => false, } } fn lookup(&self, op_index: i32, param_index: i32) -> Option<(OpId, ParamId)> { // Rows are emitted in capability order, so the flat index is the sum // of preceding parameter counts. Taken from whichever scope `rows` // last described — the indices the interface is holding are positions // in *that* list, and reading the global chain while a layer is // selected would map a slider onto a different operation. // The *unfiltered* scoped list, because `op_index` counts over every // capability — see `rows_filtered`. Indexing a filtered list here is // how a slider would drive the wrong operation once a tab is chosen. let caps = self.scoped_capabilities(); let op = caps.get(usize::try_from(op_index).ok()?)?; let param = op.params.get(usize::try_from(param_index).ok()?)?; Some((op.id, param.id)) } /// TRACES: FR-DSP-8 /// Encode the canvas for a different display from now on. /// /// Returns whether anything changed, so a caller polling for window moves /// can redraw only when the answer is genuinely different — the poll runs /// far more often than a monitor is changed, and a redraw per poll would /// undo the point of rendering on demand. /// /// Nothing is invalidated here and nothing needs to be. The next /// [`Self::render`] composes against the new space, the pipeline cache /// distinguishes the two shaders by the structure hash the space enters, /// and the mask array — rasterised in source space, sampled through the /// framing — is unaffected because a colour space is not a geometry. pub fn set_display_space(&mut self, space: dr_types::ColourSpace) -> bool { let changed = self.display_space != space; self.display_space = space; changed } /// The space the canvas is currently being encoded into. pub fn display_space(&self) -> dr_types::ColourSpace { self.display_space } /// TRACES: FR-DSP-1 | AC-8 /// Render at the requested display size and hand back a Slint image. /// /// Renders at *viewport* resolution rather than sensor resolution, which /// is what keeps slider interaction inside the frame budget on a 24 MP /// file (FR-DSP-1). /// /// **The image is the texture, not a copy of it.** This used to end in a /// `read_output` into a `SharedPixelBuffer` — the GPU→CPU→GPU round-trip /// ARCH §6.1 forbids and AC-8 asserts against, measured at ~7 ms at 4K /// against a 0.28 ms compute pass. Spike S1 replaced it with /// `slint::Image::try_from`, which wraps the texture where it already is. /// The `clone` below is a refcount on the wgpu handle, not on the pixels. /// /// This only works because the compositor is drawing with the same device /// the pass wrote with; see `shared_gpu` in the crate root for how that is /// arranged, and note that nothing here can detect it having gone wrong — /// a texture from a foreign device is a runtime fault on a real screen, /// which is why the arrangement is made once at startup and never again. pub fn render(&mut self, width: u32, height: u32) -> Result { // Fit the render to the viewport while preserving aspect, so the // pass does no work on pixels the view will letterbox away. // // Fitted against the *framed* size, not the sensor's: a crop changes // the aspect ratio, and fitting the uncropped shape would letterbox // to the wrong box and render the crop squashed. let (sw, sh) = self.demosaiced.size(); let (fw, fh) = self.graph.output_size(sw, sh); let (w, h) = fit(fw, fh, width.max(1), height.max(1)); // TRACES: FR-DSP-8 | FR-DSP-6 // **Composed for the display that is showing this canvas**, not for // sRGB. This is the whole of FR-DSP-8's second half arriving at the // pipeline: a display change is a *recomposition* and nothing more, // because the output space was always a parameter of composition and // always entered the structure hash. Moving the window to a P3 panel // therefore costs one shader compile and no pipeline change at all. let space = self.display_space; // TRACES: FR-DEV-19c // **The one composition that may show a mask.** Every other caller of // the graph — `render_the_file`, the thumbnail, `sample_as_shot` — // goes through `compose_for`, which cannot ask for a reveal, so no // exported file can carry one. let shader = self.graph.compose_revealing(space, self.reveal().as_ref()); // Rasterise the masks first: the shader addresses array slices by // index, so the array has to describe *this* stack before it is bound. // // The same `space` to both, necessarily: where a detail stage exists // it is the *last* pass that performs the output transform, and two // halves composed for different spaces would encode the frame twice // or not at all. self.render_with_masks(&shader, w, h, space)?; let texture = self.adjust.output().ok_or("nothing was rendered")?; // The import is fallible on format and usage only, and both are fixed // in `AdjustPass`'s texture descriptor — so a failure here is a // descriptor that drifted, not anything the caller did. Say that, // rather than surfacing "InvalidUsage" to a photographer. #[cfg(not(target_os = "android"))] { slint::Image::try_from(texture.clone()) .map_err(|e| format!("the render target is not importable by the compositor: {e}")) } // Android draws with Skia over OpenGL and cannot sample a // `wgpu::Texture`, so the frame comes back through memory. See // `crate::shared_gpu`'s Android arm for why that is the trade on this // platform. The pass still runs on the GPU; only this last hop does not. #[cfg(target_os = "android")] { let _ = texture; let (rgba, w, h) = self .adjust .export_pixels() .map_err(|e| format!("reading the rendered frame back: {e}"))?; let mut buf = slint::SharedPixelBuffer::::new(w, h); let wanted = (w as usize) * (h as usize) * 4; let src = &rgba[..wanted.min(rgba.len())]; buf.make_mut_bytes()[..src.len()].copy_from_slice(src); Ok(slint::Image::from_rgba8(buf)) } } /// TRACES: FR-DSP-7 /// Count the frame that is currently on the canvas. /// /// **Reads the frame [`Self::render`] last produced rather than rendering /// its own.** The histogram has to describe what the photographer is /// looking at, and rendering a second time to count it would both cost a /// second pass and open the possibility of the two disagreeing. /// /// That the frame is the *displayed* one has two consequences worth being /// explicit about. It is in the output colour space, which is what /// FR-DSP-7 asks for — the levels counted are the levels the display will /// show, so a clipped bin means a highlight that is actually gone rather /// than one the transform might still recover. Since FR-DSP-8 that is the /// space of *this display* rather than sRGB, which makes the reading more /// truthful and not less: a highlight that survives on a wide-gamut panel /// and clips on the laptop's screen genuinely is two different facts, and /// the histogram now reports whichever one the photographer is looking at. And when the view is zoomed /// or cropped it describes the visible region, not the whole file: a /// photographer inspecting a highlight at 4× is asking about *that* /// highlight, and a histogram of the parts of the frame off screen would /// be answering a question nobody asked. /// /// `None` where nothing has been rendered yet, or where the device could /// not build the reduction. pub fn histogram(&self) -> Option { let pass = self.histogram.as_ref()?; let frame = self.adjust.output()?; pass.compute(frame) .inspect_err(|e| log::warn!("histogram failed: {e}")) .ok() } /// TRACES: FR-CULL-3 /// Whether there is sensor data behind this session at all. /// /// False for the JPEG path, where [`DemosaicedImage::from_rgba8`] built /// the source from an already-rendered image. There is no white level in /// such a file and so no scale to measure headroom against: the honest /// answer for one is that the raw instrument has nothing to say, which is /// a different statement from a device that could not build the pass, and /// the panel says the two differently. pub fn has_sensor_data(&self) -> bool { !self.demosaiced.is_non_linear() } /// TRACES: FR-CULL-3 /// Count the sensor data this photograph was demosaiced from. /// /// **This is the other histogram, not a variant of the one above**, and /// the two answer questions that a culling decision needs kept apart. /// [`Self::histogram`] counts the frame on the canvas, after white /// balance, the camera matrix, the base curve, the tone curve and the /// output transform: a clipped bin there is a highlight that is gone as /// the image currently stands. This counts the demosaiced scene-linear /// texture, before any of that, on an axis of stops below sensor /// saturation — so a clipped bin here is a highlight that is gone *in the /// file*, and no edit will bring it back. FR-CULL-3 exists because the /// tools that offer the second reading do not develop, and the ones that /// develop offer only the first — "no shipping tool combines both". /// /// **It describes the whole frame, not the visible region**, which is the /// opposite of what [`Self::histogram`] does and deliberate. A crop and a /// zoom change what is on screen; neither changes what the sensor /// recorded, and the question this answers — how much latitude does this /// exposure have — is asked of the capture rather than of the view. /// /// Computed once and cached, for the reason `raw_counts` gives. /// /// `None` where the file carries no sensor data, or where the device could /// not build the reduction. The caller distinguishes those with /// [`Self::has_sensor_data`]. pub fn raw_histogram(&mut self) -> Option { if self.raw_counts.is_none() { if !self.has_sensor_data() { return None; } // Scoped so the shared borrow of the pass and of the source ends // before the cache is written, rather than relying on the reader // to see that the two field paths are disjoint. let counted = { let pass = self.raw_histogram.as_ref()?; pass.compute(self.demosaiced.texture()) .inspect_err(|e| log::warn!("the raw histogram failed: {e}")) .ok() }; self.raw_counts = counted; } self.raw_counts.clone() } /// TRACES: FR-CULL-3 /// Whether this device could build the focus-peaking overlay. /// /// Asked by the interface so that it can say the overlay is unavailable /// rather than offer a switch that does nothing. The same courtesy the /// histogram is not paid, and should be: a control that silently does /// nothing is worse than one that is visibly absent. pub fn peaking_available(&self) -> bool { self.peak.is_some() } /// TRACES: FR-CULL-3 /// What the overlay is set to, or `None` when it is off. pub fn peaking(&self) -> Option { self.peaking } /// TRACES: FR-CULL-3 /// Switch the overlay on with these settings, or off. /// /// Asking for peaking on a device that could not build the pass leaves it /// off, so that [`Self::peaking`] never claims something is being drawn /// that is not. Switching off drops the overlay textures rather than /// merely stopping drawing them: a resident overlay from the last frame is /// one interface bug away from being laid over the next photograph. pub fn set_peaking(&mut self, settings: Option) { self.peaking = settings.filter(|_| self.peak.is_some()); if self.peaking.is_none() { if let Some(pass) = self.peak.as_mut() { pass.clear(); } } } /// TRACES: FR-CULL-3 | NFR-P14 /// Mark the in-focus regions of the frame that is currently on the canvas. /// /// **Reads the frame [`Self::render`] last produced**, exactly as /// [`Self::histogram`] does and for the same reason: the overlay has to /// describe what the photographer is looking at, and rendering a second /// time to measure it would cost a pass and admit the possibility of the /// two disagreeing about the picture. /// /// That the frame is the displayed one is what makes the marks land where /// the eye is. It is at viewport resolution, cropped and zoomed as the /// view is, and — the point of FR-CULL-3 — descended from sensor data /// through the demosaic rather than from the camera's embedded JPEG, whose /// in-body sharpening this would otherwise be measuring at least as much /// as the lens. /// /// **Call this only after a settled render.** See /// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame /// cannot be measured for sharpness. /// /// `None` where nothing has been rendered, where peaking is off, or where /// the device could not build the pass. pub fn focus_overlay(&mut self) -> Option { let settings = self.peaking?; // Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`, // and holding a shared borrow of `self.adjust` across the mutable // borrow of `self.peak` would cost a `Self { .. }` destructure to say // something the clone says in one word. let frame = self.adjust.output()?.clone(); let pass = self.peak.as_mut()?; let overlay = pass .render(&frame, settings) .inspect_err(|e| log::warn!("focus peaking failed: {e}")) .ok()? .clone(); #[cfg(not(target_os = "android"))] { // A layer over the canvas rather than a tint in it, so nothing // here reaches the histogram or an export — see `FocusPeakPass` // for the whole of that argument. slint::Image::try_from(overlay) .inspect_err(|e| log::warn!("the focus overlay is not importable: {e}")) .ok() } // Android draws with Skia over OpenGL and cannot sample a // `wgpu::Texture`, so the overlay follows the frame it belongs to back // through memory (technical-debt.md TD-1). The measurement still // happens on the GPU; only this last hop does not. #[cfg(target_os = "android")] { let _ = overlay; let (rgba, w, h) = pass .read_overlay() .inspect_err(|e| log::warn!("reading the focus overlay back: {e}")) .ok()?; let mut buf = slint::SharedPixelBuffer::::new(w, h); let wanted = (w as usize) * (h as usize) * 4; let src = &rgba[..wanted.min(rgba.len())]; buf.make_mut_bytes()[..src.len()].copy_from_slice(src); Some(slint::Image::from_rgba8(buf)) } } /// Render the *whole* frame for the crop overlay to be drawn over. /// /// Crop mode cannot use [`Self::render`]: that applies the crop, so the /// area being cropped away would not be on screen and there would be /// nothing to drag the handles across. This renders as though the crop /// were full, and the interface draws the rect and greys the surround. /// /// Zoom is suspended too. Panning a zoomed view while also dragging crop /// handles is two conflicting meanings for one drag, and the handles are /// placed against the whole frame in any case. /// /// Returns the image together with the size it was rendered at, since the /// overlay has to place its rect against exactly those pixels. pub fn render_uncropped( &mut self, width: u32, height: u32, ) -> Result<(slint::Image, u32, u32), String> { let saved_crop = self.graph.crop(); let saved_view = self.graph.framing().view(); self.graph.set_crop(CropRect::default()); self.graph.framing_mut().set_view(CropRect::default()); let result = self.render(width, height); // Restored whatever happened: leaving the graph cropped-to-full on a // render error would silently discard the user's crop. self.graph.set_crop(saved_crop); self.graph.framing_mut().set_view(saved_view); let image = result?; let (sw, sh) = self.demosaiced.size(); // The uncropped frame still turns with the quarter turns, so the // overlay's box comes from the framing rather than the sensor. let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh); let (rw, rh) = fit(fw, fh, width.max(1), height.max(1)); Ok((image, rw, rh)) } /// TRACES: FR-DEV-7 /// Render the photograph as the file has it, with every adjustment off. /// /// **What a held comparison shows, and it is not a history state.** /// FR-DEV-7 asks for the current edit against the unedited original, and /// the only thing the interface had was [`Self::go_to_history`] — which /// *changes* the edit rather than previewing against it. A photographer /// who suspects they have overcooked a frame therefore had to undo, look, /// and redo, which puts two real steps on the stack at exactly the moment /// they are least sure of what they are doing. /// /// This puts none there. It borrows the graph for the length of one /// render and hands it back: the same shape [`Self::render_uncropped`] /// uses for the crop and [`Self::render_the_file`] uses for the zoom, and /// for the same reason — the graph is the one description of the /// photograph, so a second rendering of it is a suspension rather than a /// copy. Nothing is recorded, nothing is marked modified, and a caller /// asking whether the image differs from its defaults gets the same /// answer before and after. /// /// **The framing stays on**, and that is a decision rather than an /// oversight. A held before/after is a question about tone and colour — /// "have I pushed this too far" — and re-cropping the canvas under the /// photographer's thumb would move the very detail they are comparing. /// It would also make the view meaningless: the zoom is a rectangle of the /// *framed* image, so dropping the crop at 4× would show a different part /// of the photograph rather than the same part unedited. What the crop /// took away is compared in Compose, which already shows the whole frame. /// /// Restored whatever happens, for the reason `render_uncropped` restores /// its crop: leaving the graph stripped after a failed render would /// discard the entire edit, silently. pub fn render_original(&mut self, width: u32, height: u32) -> Result { let saved = self.graph.state(); self.strip_adjustments(); let rendered = self.render(width, height); let debt = self.graph.set_state(&saved); self.pay_film_debt(&debt); rendered } /// TRACES: FR-DEV-7 /// Take everything off the graph except the shape of the frame. /// /// Four removals rather than [`EditGraph::reset`], which would take the /// framing with it. The empty preset applied at /// [`Scope::adjustments`] — the scope that is defined as "all of it but /// the crop" — clears every parameter the photographer can move, and the /// three things that are not parameters go by hand: local adjustments, /// repairs, and the film stock. A local adjustment is as much an edit as /// the slider that drives it, so an "original" still wearing its masks /// would be answering a different question. /// /// **Only ever inside a suspension.** This leaves the graph describing a /// photograph nobody asked for, so every caller restores from a /// [`EditGraph::state`] taken first. fn strip_adjustments(&mut self) { Preset::default().apply(&mut self.graph, Scope::adjustments()); *self.graph.masks_mut() = dr_pipeline::mask::MaskStack::new(); *self.graph.spots_mut() = dr_pipeline::SpotSet::new(); // Through the session rather than the graph: the baked tables live on // the adjust pass as well, and clearing one without the other is the // silent disagreement `set_film` exists to prevent. self.set_film(None); } /// TRACES: FR-DEV-3 | FR-DEV-5 /// Set the white balance from a point on the photograph. /// /// `x` and `y` are fractions of the *visible* image — the coordinates a /// click on the canvas arrives in — so a photographer inspecting a /// highlight at 4× samples the pixel they are actually looking at. /// /// **Nothing here knows it is white balance.** The colour goes to /// [`dr_pipeline::neutral`], which finds the operation that asked to be /// driven by a pixel and inverts its declared response; this side supplies /// the pixel and the undo step and nothing else. That is FR-DEV-3a's line /// in its awkward case: a picker genuinely needs to know how far a hundred /// units of temperature move red against blue, and that number is declared /// in the node's own file, so the interface must not be the thing that /// holds a second copy of it. /// /// **One step per sample**, and no step at all for a sample that could not /// be used — a point in the deep shadows has no balance in it to correct. /// `Edit::Action` never coalesces, so two clicks are two decisions /// however quickly they follow each other, which is what a photographer /// trying a wall and then a cloud expects to be able to undo one at a /// time. /// /// Returns whether the photograph moved. pub fn sample_neutral(&mut self, x: f32, y: f32) -> bool { let Some(sample) = self.sample_as_shot(x, y) else { return false; }; if !dr_pipeline::neutral::neutralise(&mut self.graph, sample) { return false; } self.history .record(&self.graph, Edit::Action(labels::step::SAMPLED_NEUTRAL)); true } /// The colour at a point with every adjustment taken off, in linear RGB. /// /// **Measured before the chain rather than off the screen**, and that is /// the difference between a picker that converges and one that chases /// itself. The frame on the canvas has already been through the white /// balance being solved for, the tone curve, the contrast and whatever /// else is on; neutralising *that* pixel would be correcting a correction, /// and the second sample of the same wall would land somewhere else. With /// the adjustments stripped the value read is the colour as the file has /// it, which is the domain `neutralise` is defined over. /// /// The framing stays on, exactly as it does for [`Self::render_original`] /// and for the same reason: `x` and `y` are fractions of what is on /// screen, and a probe rendered without the crop and the zoom would be /// answering about a different part of the photograph. /// /// **Rendered small on purpose.** A 192px probe of the visible region /// averages a small neighbourhood into each of its pixels, which is what /// every eyedropper does deliberately: a single photosite off a noisy /// shadow is a worse answer than the patch around it, and the photographer /// is pointing at a grey card rather than at a pixel. It is also two /// dispatches' worth of work on a click. /// /// This overwrites the frame the adjust pass is holding, so the caller /// must redraw — which the callback that samples does anyway, since the /// picture has just changed. fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> { /// Long edge of the probe render. See the note above on why it is /// small rather than large. const PROBE_EDGE: u32 = 192; // sRGB regardless of the display: this is a measurement, not something // anybody looks at, and decoding it needs a transfer function known // here. Composing for a wide-gamut panel would put the reading in a // space the arithmetic below does not undo. let space = dr_types::ColourSpace::Srgb; let saved = self.graph.state(); self.strip_adjustments(); let (sw, sh) = self.demosaiced.size(); let (fw, fh) = self.graph.output_size(sw, sh); let (w, h) = fit(fw, fh, PROBE_EDGE, PROBE_EDGE); let shader = self.graph.compose_for(space); let probe = self .render_with_masks(&shader, w, h, space) .and_then(|()| self.adjust.export_pixels().map_err(|e| e.to_string())); // Restored whatever happened, for the reason every other suspension // here restores: leaving the graph stripped after a failed probe would // discard the edit silently. let debt = self.graph.set_state(&saved); self.pay_film_debt(&debt); let (rgba, pw, ph) = probe .inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}")) .ok()?; let (pw, ph) = (pw as usize, ph as usize); let px = ((x.clamp(0.0, 1.0) * pw as f32) as usize).min(pw.saturating_sub(1)); let py = ((y.clamp(0.0, 1.0) * ph as f32) as usize).min(ph.saturating_sub(1)); let at = (py * pw + px) * 4; let pixel = rgba.get(at..at + 3)?; // The probe was encoded for the screen; the solve is multiplicative // and only means anything in linear light (ARCH §5.2). Undone with // the space's own transfer function rather than a second copy of the // curve written out here. let transfer = space.transfer(); Some([ transfer.decode(f32::from(pixel[0]) / 255.0), transfer.decode(f32::from(pixel[1]) / 255.0), transfer.decode(f32::from(pixel[2]) / 255.0), ]) } /// TRACES: FR-PLAT-AND-5 | NFR-RES-1 /// Give back the GPU memory this session is holding only to be fast. /// /// The edit is untouched: the graph and its history are CPU-side by /// design (ARCH §6.1), so the photograph, the undo stack and the viewport /// all survive and the next frame simply costs what the first one did. /// /// # What is not released, and what it is waiting on /// /// The demosaiced source is the largest single allocation a session holds /// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is /// deliberately kept. Dropping it would need the session to be able to /// rebuild itself from the file, and rebuilding a session from a durable /// record is FR-PLAT-AND-3, which is not built. Freeing it now would not /// be an eviction; it would be closing the photograph without telling /// anyone. Likewise the subject distance fields and the segmentation map: /// each is guarded by a key recording what it was built from, and freeing /// one without invalidating its key is the failure `AdjustPass` documents /// under `colour_key`. /// /// So this is the part of the GPU tier that can be given back and asked /// for again with no other machinery, which is exactly as far as an /// eviction should go. pub fn release_gpu_caches(&mut self) { self.adjust.release_caches(); } /// 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. fn apply_lens_profile(&mut self, meta: &dr_decode::Metadata) { let found = Self::profile_for(meta); self.lens_profile_found = found.is_some(); self.graph.set_lens_profile(found); } /// The profile for one shot, converted into the pipeline's own types. /// /// **The conversion lives here because nowhere else can see both sides.** /// `dr-lens` carries the Lensfun database and `dr-pipeline` carries the /// maths, and the coefficient structs are deliberately duplicated so that /// the dependency between them does not exist (ARCH §6.5a). This function /// is the seam, and it is a `match` on three optionals. /// /// Every field is taken independently. The database routinely knows a /// lens's distortion and not its vignetting, or covers only part of a /// zoom's range, and a partial profile is worth applying — discarding it /// because one field is missing would turn a good correction into none. fn profile_for(meta: &dr_decode::Metadata) -> Option { // 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) } /// TRACES: FR-EXP-9 /// Render at full resolution and hand back the pixels, for an export. /// /// **Not the frame on screen.** [`Self::render`] deliberately renders at /// viewport size, which is what keeps a slider inside the frame budget on /// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft, /// screen-sized file. This renders the framed output size instead, so the /// export is the full-quality path FR-EXP-9 requires. /// /// This reads pixels back and [`Self::render`] does not, and that is the /// whole distinction AC-8 draws: a file is made of bytes on the CPU and /// there is no path to one that avoids the transfer, whereas a frame on /// screen had no business making the trip. See `AdjustPass::export_pixels` /// for the longer version. /// /// Leaves one of the pass's two targets at full resolution; it is dropped /// and reallocated on the second display render after this, since the /// other target still holds a viewport-sized texture and comes up first. /// Cheaper than keeping a second pass alive for the exports a session /// rarely performs. /// /// `space` is the output colour space the file will claim. It is chosen /// here rather than at encode time because the conversion happens in the /// shader, before the clip to 0..1 — by the time pixels reach the encoder /// they are in exactly one space, and the only honest thing left to do is /// label them. Asking for the wrong one is a typed error rather than a /// mislabelled file (FR-EXP-2). pub fn render_for_export( &mut self, space: dr_types::ColourSpace, ) -> Result { let (sw, sh) = self.demosaiced.size(); let (w, h) = self.graph.output_size(sw, sh); let (pixels, rw, rh) = self.render_the_file(w, h, space)?; dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string()) } /// TRACES: FR-EXP-9 | FR-CAT-9 /// Render the *photograph*, with the viewport suspended, and read it back. /// /// **The one thing separating a file from a frame on screen**, and the /// reason both file-producing paths go through here rather than composing /// for themselves. [`Framing::view`](../dr_pipeline/framing/struct.Framing.html#method.view) /// is not an edit — it is kept out of the sidecar, out of `is_active` and /// out of `output_size` precisely so that zooming cannot change what the /// file becomes. But it is folded into `visible_rect`, which is the rect /// the fused shader's prologue samples, so a path that composes the graph /// and renders it inherits the zoom whether or not it wanted it. Exporting /// at 4:1 wrote the middle of the frame magnified to fill the file, at the /// full output size, with the detail kernels scaled four times over — /// silently, since every dimension the old guard checked still held. /// /// Suspended rather than refused: an export is a thing the photographer /// asks for *while* inspecting a highlight at 4×, and demanding they zoom /// out first would be answering a question nobody asked. /// /// Restored whatever happens, for the reason [`Self::render_uncropped`] /// restores it: leaving the graph un-zoomed after a failed export would /// throw away where the photographer was looking. fn render_the_file( &mut self, w: u32, h: u32, space: dr_types::ColourSpace, ) -> Result<(Vec, u32, u32), String> { let saved_view = self.graph.framing().view(); self.graph.framing_mut().set_view(CropRect::default()); // Composed *inside* the suspension: the view reaches the shader as a // uniform baked at composition, so composing before this point would // restore the framing and export the zoom anyway. let shader = self.graph.compose_for(space); let rendered = self.render_with_masks(&shader, w, h, space); self.graph.framing_mut().set_view(saved_view); rendered?; self.adjust.export_pixels().map_err(|e| e.to_string()) } /// TRACES: FR-CAT-9 /// Render this edit small, for the grid's thumbnail. /// /// **The framed output, not the sensor.** `output_size` is what a crop, a /// quarter turn, a flip and a straighten all act on, so a thumbnail taken /// from the raw frame would show the grid a photograph the user no longer /// has — the right pixels in the wrong shape, still the wrong way up. This /// is the same path [`Self::render_for_export`] takes, at a size the store /// wants instead of at full resolution. /// /// Always sRGB: this is going into a JPEG in a thumbnail shard that syncs /// between devices and is drawn as a cell, not a file the user is /// finishing. The wider spaces exist for export and mean nothing here. /// /// Returns width, height and RGBA8. /// TRACES: FR-DEV-3f /// The stocks this build can offer, "no film" first. /// /// First rather than last so that index zero is the neutral choice: a /// photograph that has never been put on film selects it without anyone /// inventing a sentinel, and `reset` means what it means everywhere else. /// /// Only what goes in a camera. A print paper is a stock in the database /// and is chosen *for* a negative rather than instead of one, and so is a /// cine projection print film — which is coated on film, and is why the /// filter asks about the stage rather than the support. pub fn film_choices() -> Vec<(Option<&'static str>, String)> { let mut out = vec![(None, "None".to_string())]; out.extend(dr_film::camera_stocks().map(|p| (Some(p.stock.as_str()), p.name.clone()))); out } /// TRACES: FR-DEV-3f /// Develop on a named stock, printed or scanned. /// /// Baking is milliseconds and happens here rather than being cached, /// because the tables depend on the exposure parameters as well as the /// stock: they are what the enlarger was set to, and a cache keyed on the /// name alone would hand back somebody else's print. /// /// A name this build has no profile for clears the film and says so. That /// is the sync case — a sidecar written on a device with a stock this one /// lacks — and rendering it as *some other* film would be worse than /// rendering it plainly. /// TRACES: FR-DEV-3f | FR-DEV-5 /// Choose a stock **as the photographer just did**, and record the step. /// /// Separate from [`Self::choose_film`] because that call has two very /// different callers. Picking Portra from the list is an edit and belongs /// in the history; the same call made while *restoring* an edit — opening /// a photograph, or stepping to a history row that names a stock — is the /// second half of putting a state back, and recording it would push a step /// for the undo the photographer had just asked for. /// /// Choosing a stock was not undoable at all before this existed: the pick /// went straight to `choose_film`, which nothing on the history's path /// ever sees. pub fn pick_film(&mut self, stock: Option<&str>, print: bool) { self.choose_film(stock, print); self.history .record(&self.graph, Edit::Action(labels::step::FILM)); } pub fn choose_film(&mut self, stock: Option<&str>, print: bool) { let Some(stock) = stock else { self.set_film(None); return; }; let Some(profile) = dr_film::find(stock) else { log::warn!("no film profile named {stock}; developing without one"); self.set_film(None); return; }; // Only a negative has a paper. Asking to print a reversal stock is not // an error to report, it is a request that has no meaning — so it is // quietly the same as not asking. let paper = if print { dr_film::default_print(profile) } else { None }; // TRACES: FR-DEV-3f // Grain, at the scale this photograph is being sampled at. // // A digital frame has no film format, so simulating one means choosing // what it *would have been* — 35 mm, because that is the format every // published granularity figure and every intuition about how grainy a // stock looks comes from. The sensor's width in pixels then says how // much film one pixel covers, and the grain model needs nothing else // to be correct at any zoom. // TRACES: FR-DEV-3f // The frame this is being simulated on, against the pixels it is being // rendered to: together they are the enlargement, and the enlargement // is what decides how grainy the result looks. A crystal is a fixed // size in micrometres — the same emulsion on a sheet averages far more // of them into each pixel than it does on 35 mm. let format = dr_film::Format::from_index( self.graph .param( dr_pipeline::ops::film_sim::ID, dr_pipeline::ops::film_sim::FORMAT, ) .unwrap_or(0.0) .max(0.0) as usize, ); let (source_width, _) = self.demosaiced.size(); let pixel_size_um = format.width_um() / source_width.max(1) as f32; let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um); let baked = dr_film::bake(&dr_film::Recipe { film: profile, print: paper, exposure_ev: self .graph .param( dr_pipeline::ops::film_sim::ID, dr_pipeline::ops::film_sim::EXPOSURE, ) .unwrap_or(0.0), push_stops: self .graph .param( dr_pipeline::ops::film_sim::ID, dr_pipeline::ops::film_sim::PUSH, ) .unwrap_or(0.0), print_exposure_ev: self .graph .param( dr_pipeline::ops::film_sim::ID, dr_pipeline::ops::film_sim::PRINT_EXPOSURE, ) .unwrap_or(0.0), }); self.set_film(Some(dr_pipeline::graph::Film { stock: profile.stock.clone(), print: paper.map(|p| p.stock.clone()), tables: dr_pipeline::ops::FilmTables { exposure_matrix: baked.exposure_matrix, curves: baked.curves, curve_log_min: baked.curve_log_min, curve_log_max: baked.curve_log_max, lut: baked.lut, density_max: baked.density_max, lut_size: baked.lut_size, grain_particles: grain.particles, grain_density_max: grain.density_max, grain_uniformity: grain.uniformity, }, })); } /// The stock and paper currently chosen, by id. pub fn film(&self) -> Option<(&str, bool)> { self.graph .film() .map(|f| (f.stock.as_str(), f.print.is_some())) } /// Re-bake if `op_index` names the film, and do nothing otherwise. /// /// `op_index` counts over [`Self::scoped_capabilities`] — the same list /// [`Self::lookup`] resolves a slider through — so that is the only list to /// ask. An earlier version also indexed `rows()`, which is one entry per /// *parameter* and filtered by the active tab: past its end the check /// short-circuited, the tables were never rebuilt, and the film's own /// sliders moved nothing at all. /// /// The test is here rather than at the call site so the callback in /// `lib.rs` goes on naming no operation, which is the rule the whole panel /// is built on (ARCH §4.3a). pub fn rebake_film_if_affected(&mut self, op_index: i32) { let is_film = usize::try_from(op_index) .ok() .and_then(|i| self.scoped_capabilities().get(i).map(|c| c.id)) .is_some_and(|id| id == dr_pipeline::ops::film_sim::ID); if is_film { self.rebake_film(); } } /// The stock, the paper, and how far it was developed. /// /// Push rides with the other two through every path that re-bakes, because /// it is the same kind of fact: a decision about the material rather than /// an adjustment to the picture it produced. pub fn rebake_film(&mut self) { if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) { self.choose_film(Some(&stock), print); } } /// TRACES: FR-DEV-3f /// Choose the film stock this session renders through, or clear it. /// /// One call, because two places have to agree and they fail *silently* /// apart. The graph decides whether the generated shader reads the film /// textures at all; the pass decides what is bound to them. A graph /// carrying a stock with a pass that is not carrying one samples the 1x1 /// placeholders, which is a black frame and an error message from nobody. /// /// Nothing downstream needs to know the order, so it is fixed here: the /// pass first, so that the textures are resident before any shader /// composed from the graph can be dispatched against them. pub fn set_film(&mut self, film: Option) { self.adjust.set_film(film.as_ref().map(|f| &f.tables)); self.graph.set_film(film); } pub fn render_thumbnail(&mut self, edge: u32) -> Result<(u32, u32, Vec), String> { let (sw, sh) = self.demosaiced.size(); let (fw, fh) = self.graph.output_size(sw, sh); let (w, h) = fit(fw, fh, edge.max(1), edge.max(1)); let (pixels, rw, rh) = self.render_the_file(w, h, dr_types::ColourSpace::Srgb)?; Ok((rw, rh, pixels)) } /// The sensor's own dimensions, before framing. /// /// What a crop overlay needs: its handles are placed against the full /// frame, since that is what the user is selecting *from*. pub fn sensor_size(&self) -> (u32, u32) { self.demosaiced.size() } /// Whether one source pixel now covers more than one screen pixel. /// /// The question the interface asks to decide how the canvas is *filtered*, /// not how it is rendered. Below 1:1 there are more source pixels than /// screen pixels and smoothing is what stops the image aliasing; past it /// there is no more detail to show, and smoothing only invents values /// between real ones — at which point a photographer inspecting focus or /// noise wants to see the pixels, not a blur of them. /// /// Measured against the visible region rather than the zoom factor alone, /// because the two differ: a 24 MP file in a 1200px viewport is still /// showing five sensor pixels per screen pixel at 4×, while a small JPEG is /// already magnified at 1×. pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool { let (sw, sh) = self.demosaiced.size(); let (fw, fh) = self.graph.output_size(sw, sh); let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1)); // How many source pixels lie behind the render target: the framed // image narrowed to the region the view selects. The target keeps its // size while that region shrinks, which is what raises the ratio. let view = self.graph.framing().view(); let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON)); let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON)); // Strictly greater, with a margin: at exactly 1:1 either filter gives // the same answer, and flipping mode on a rounding error would make the // canvas visibly change character mid-scroll. f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001 } /// Set the crop rectangle, in fractions of the source. pub fn set_crop(&mut self, rect: CropRect) { self.graph.set_crop(rect); // Keyed on the operation, not on a parameter: one drag of one handle // moves the origin and the extent together. self.history .record(&self.graph, Edit::Op(dr_pipeline::framing::ID)); } /// TRACES: FR-DEV-3 /// Set the crop rectangle, held to `aspect` about `anchor`. /// /// The frame size the ratio needs is this session's own, so the caller /// passes a shape rather than a rectangle and never has to know what a /// quarter turn did to the frame's dimensions. /// /// `anchor` is the point of the rect that must not move, in the rect's own /// `0..1` coordinates — the corner *opposite* the handle being dragged, so /// that shaping the rect onto the ratio pushes the held corner and leaves /// the far one where the user put it. pub fn set_crop_locked( &mut self, rect: CropRect, aspect: CropAspect, portrait: bool, anchor: (f32, f32), ) { let frame = self.framed_size(); let rect = match aspect.ratio(frame, portrait) { Some(r) => rect.with_aspect(frame.0, frame.1, r, anchor), None => rect, }; self.set_crop(rect); } pub fn crop(&self) -> CropRect { self.graph.crop() } /// TRACES: FR-DEV-3 /// The whole frame the crop is measured against, in output pixels. /// /// The *framed* size, not the sensor's: quarter turns swap the axes, and a /// ratio resolved against the sensor would come out on its side the moment /// a portrait photograph was turned upright. The crop is excluded because /// this is the shape being selected *from*. pub fn framed_size(&self) -> (u32, u32) { let (sw, sh) = self.demosaiced.size(); self.graph.framing().output_size_uncropped(sw, sh) } /// Rotate by quarter turns, wrapping. The rotate-left/right buttons. /// /// The crop travels with the frame rather than staying where it was on /// screen. A crop is a decision about *this part of the photograph*, and /// leaving the rect in place while the image turns under it would move the /// selection onto a different part of the picture — so the rect is turned /// by the same quarter and the composition survives the rotation. pub fn rotate_quarters(&mut self, turns: i32) { let crop = self.graph.crop(); if !crop.is_full() { self.graph.set_crop(rotate_crop(crop, turns)); } self.graph.rotate_quarters(turns); self.history .record(&self.graph, Edit::Action(labels::step::ROTATE)); } /// Straightening, in degrees. Positive turns the image clockwise. pub fn angle(&self) -> f32 { self.graph.framing().angle() } /// Quarter turns clockwise, 0..=3 — for the panel's readout. pub fn quarter_turns(&self) -> u8 { self.graph.framing().quarter_turns() } pub fn flips(&self) -> (bool, bool) { self.graph.framing().flips() } /// Mirror horizontally, about the frame's vertical centre line. pub fn toggle_flip_h(&mut self) { let (h, _) = self.graph.framing().flips(); self.graph.set_param( dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, f32::from(u8::from(!h)), ); self.history .record(&self.graph, Edit::Action(labels::step::FLIP_H)); } pub fn toggle_flip_v(&mut self) { let (_, v) = self.graph.framing().flips(); self.graph.set_param( dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_V, f32::from(u8::from(!v)), ); self.history .record(&self.graph, Edit::Action(labels::step::FLIP_V)); } /// TRACES: FR-DEV-3 /// The crop the user chose, as distinct from the one currently applied. /// /// The remembered intent, but only while it is still credible: if the /// graph no longer holds what the auto-crop wrote, something else has set /// the crop since — a handle, a ratio, a sidecar, a paste, an undo — and /// that new rectangle *is* the intent. See [`Self::auto_crop`]. fn intended_crop(&self) -> CropRect { match self.auto_crop { Some((applied, intended)) if applied == self.graph.crop() => intended, _ => self.graph.crop(), } } /// TRACES: FR-DEV-3 /// Fit the crop to the area the straightening angle leaves defined. /// /// Turning a rectangle inside its own bounds exposes its corners: there is /// no source pixel out there and the shader renders it black. Nothing in /// the render prevents it — a free angle deliberately does *not* change the /// output size, so that straightening a horizon leaves the frame where the /// user put it — which is correct for the drag and leaves black wedges in /// the corners of the finished photograph. /// /// This is the correction, and it runs when the gesture **finishes**. /// Applied continuously it would fight the drag, shrinking the crop on /// every frame of the slider. /// /// **It grows as well as shrinks.** The crop is recomputed from /// [`Self::intended_crop`] rather than from itself, so straightening /// further in takes more away and straightening back out gives it back, /// stopping at the rectangle the user actually chose. Deriving it from the /// applied crop instead — the obvious way, and how this first shipped — /// ratchets: every angle the slider rested at takes its cut and none of /// them is ever returned, so coming back to zero leaves a crop that /// nothing on screen explains. /// /// The crop keeps its own shape — so a locked ratio survives — and keeps /// the side of the frame it was on; see [`CropRect::fitted_into`] for why /// it is not simply replaced by the inscribed rectangle. pub fn auto_crop_to_angle(&mut self) { let (sw, sh) = self.demosaiced.size(); // At zero this is the whole frame, and fitting into it is the identity // — which is what returns an over-corrected crop to its full size. // There is deliberately no early exit for the upright case: that exit // is precisely what would strand the crop small. let bound = self.graph.framing().max_inscribed_crop(sw, sh); let intended = self.intended_crop(); let want = intended.fitted_into(bound); if want != self.graph.crop() { self.graph.set_crop(want); self.history .record(&self.graph, Edit::Op(dr_pipeline::framing::ID)); } // Recorded even when nothing moved: the pairing is what tells the next // call that this rectangle is a correction rather than a choice. self.auto_crop = Some((want, intended)); } /// Set the straightening angle, in degrees. pub fn set_angle(&mut self, degrees: f32) { self.graph.set_param( dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, degrees, ); self.history.record( &self.graph, Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE), ); } /// Whether the framing currently changes the image — what lights the /// section's modified dot and enables its reset. /// /// Asks whether it *edits*, not whether it is active: a zoomed view makes /// the framing active without changing the photograph, and a section that /// claimed an edit because the user scrolled would be lying. pub fn framing_edits_image(&self) -> bool { self.graph.framing().edits_image() } /// Return crop, straightening, rotation and flips to neutral, leaving /// every colour adjustment alone. /// /// The zoom is deliberately preserved: it is a viewing state, and resetting /// the framing is an edit, so throwing away where the user was looking /// would be an unrelated second effect. pub fn reset_framing(&mut self) { let view = self.graph.framing().view(); self.graph.framing_mut().reset(); self.graph.framing_mut().set_view(view); self.history .record(&self.graph, Edit::Action(labels::step::RESET_FRAMING)); } /// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×. pub fn zoom(&self) -> f32 { let v = self.graph.framing().view(); if v.width <= 0.0 { 1.0 } else { 1.0 / v.width } } pub fn is_zoomed(&self) -> bool { self.graph.framing().is_zoomed() } /// Zoom about a point, given in fractions of the *visible* area. /// /// Anchoring matters: zooming about the pointer keeps whatever is under /// it stationary, which is what makes a scroll-wheel zoom feel like it is /// magnifying the photograph rather than sliding it around. /// /// `factor` multiplies the current zoom — above 1 moves in. pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) { const MAX_ZOOM: f32 = 16.0; let view = self.graph.framing().view(); let current = if view.width > 0.0 { 1.0 / view.width } else { 1.0 }; let target = (current * factor).clamp(1.0, MAX_ZOOM); // Snapped so scrolling back out reliably reaches "fit" rather than // stopping a fraction short and leaving the image imperceptibly // panned. let target = if (target - 1.0).abs() < 0.01 { 1.0 } else { target }; let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0); // The point under the cursor, in framed coordinates, must land back // under the cursor afterwards. let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width; let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height; self.set_view_clamped( anchor_x - at_x.clamp(0.0, 1.0) * extent, anchor_y - at_y.clamp(0.0, 1.0) * extent, extent, ); } /// Pan by a fraction of the *visible* area — what a drag reports. pub fn pan_by(&mut self, dx: f32, dy: f32) { let view = self.graph.framing().view(); self.set_view_clamped( view.x + dx * view.width, view.y + dy * view.height, view.width, ); } /// Back to fitting the whole frame. pub fn reset_zoom(&mut self) { self.graph.framing_mut().set_view(CropRect::default()); } /// TRACES: FR-UI-4 | FR-DSP-1 /// The zoom that puts one source pixel under one screen pixel. /// /// **Derived from the file and the viewport rather than fixed at some /// multiple**, because 1:1 is not a number: a 60 MP frame in a 1200px /// viewport needs about 7× before its pixels are its own, and a /// screen-sized JPEG needs none at all. The same arithmetic /// [`Self::magnifies_source`] uses to decide how to *filter* the canvas, /// asked in the other direction — which is what keeps the readout the /// canvas shows and the zoom this lands on from disagreeing about what /// 100% means. /// /// Never below 1.0: fitting is as far out as the view goes, so a /// photograph already smaller than the viewport is at 1:1 the moment it /// is fitted. pub fn one_to_one_zoom(&self, viewport_w: u32, viewport_h: u32) -> f32 { let (sw, sh) = self.demosaiced.size(); let (fw, fh) = self.graph.output_size(sw, sh); let (rw, _) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1)); if rw == 0 { return 1.0; } (fw as f32 / rw as f32).max(1.0) } /// TRACES: FR-UI-4 /// Where the view is centred, in fractions of the framed image. /// /// The form the inspection point is *remembered* in, and it has to be /// this one: the point is carried to the next photograph, and fractions /// of the frame are the only coordinates two different files share. pub fn inspection_point(&self) -> (f32, f32) { let v = self.graph.framing().view(); (v.x + v.width / 2.0, v.y + v.height / 2.0) } /// TRACES: FR-UI-4 /// Put the view at 1:1, centred on a point in fractions of the framed /// image. /// /// Separate from [`Self::toggle_inspection`] because the two callers are /// not the same person: the toggle is a photographer pressing something, /// and this is the next photograph arriving under the magnifier the last /// one was left under. pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) { let extent = (1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0); self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent); } /// TRACES: FR-UI-4 | FR-DEV-3 /// Toggle between fitting the frame and inspecting it at 1:1. /// /// **Why 1:1 and not "zoom in a bit".** Noise reduction and capture /// sharpening are judgements about individual pixels, and at a fitted /// view several source pixels are averaged into each screen pixel — so /// the frame looks cleaner and softer than it is, and the photographer /// corrects for a softness the display invented. Over-sharpening is the /// documented result. There is exactly one magnification at which those /// two controls are telling the truth, and this is the gesture that /// reaches it without anyone reading a percentage. /// /// `at_x`/`at_y` are fractions of the *visible* area — the same /// coordinates [`Self::zoom_about`] takes, because they come from the /// same pointer over the same box. /// /// Returns the point now under inspection in fractions of the framed /// image, or `None` where the view has gone back to fit. That is the /// answer *after* clamping, so a point near an edge is remembered where /// the view actually landed rather than where the finger was — otherwise /// the next photograph would be inspected somewhere the previous one /// never showed. /// /// Leaves the history alone, and must: this changes no pixel of the file. /// See [`Self::framing_edits_image`] for the same distinction drawn from /// the other side. pub fn toggle_inspection( &mut self, at_x: f32, at_y: f32, viewport_w: u32, viewport_h: u32, ) -> Option<(f32, f32)> { // Out from *any* zoom, not only from 1:1. The gesture means "show me // the whole photograph again", and a scroll wheel that stopped at // 173% must not leave the toggle inert. if self.is_zoomed() { self.reset_zoom(); return None; } let view = self.graph.framing().view(); let x = view.x + at_x.clamp(0.0, 1.0) * view.width; let y = view.y + at_y.clamp(0.0, 1.0) * view.height; self.inspect_at(x, y, viewport_w, viewport_h); Some(self.inspection_point()) } /// Place a square view of `extent`, keeping it inside the frame. /// /// Clamped rather than allowed to run off the edge: panning past the /// boundary would show undefined area beside the photograph, which reads /// as a rendering fault rather than as the end of the image. fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) { let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0); let max = 1.0 - extent; self.graph.framing_mut().set_view(CropRect { x: x.clamp(0.0, max.max(0.0)), y: y.clamp(0.0, max.max(0.0)), width: extent, height: extent, }); } /// The largest centred crop that, at the current straightening angle, /// contains no undefined area. What a "straighten and fill" action /// applies. pub fn max_inscribed_crop(&self) -> CropRect { let (w, h) = self.demosaiced.size(); self.graph.framing().max_inscribed_crop(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) { preset.apply(&mut self.graph, scope); // 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); } /// TRACES: FR-DEV-3f | FR-DEV-5 /// Pay what a restored edit owes the picture. /// /// `dr-pipeline` restores a stock's *name* and clears its tables, because /// baking needs the profile database it does not link (ARCH §6.5a). This /// side of the seam has it, so this is where the photograph gets its film /// back. /// /// Both outcomes go through [`Self::set_film`], and the empty one is not /// a no-op: `set_state` cleared the *graph*, and the adjust pass would go /// on holding textures that nothing will sample. That is the two halves /// disagreeing, which is the failure `set_film` exists to make /// impossible — and it is silent in this direction, which is worse. /// /// Baked unconditionally rather than only when the stock changed: the /// tables come from the film node's own exposure sliders as well as from /// the stock, and restoring an edit replaces those sliders too. A bake is /// milliseconds and this happens on a keypress, so the cheap correct rule /// beats the clever one. fn pay_film_debt(&mut self, rebake: &dr_pipeline::FilmRebake) { match rebake.wanted() { Some(film) => { let stock = film.stock.clone(); let print = film.print.is_some(); self.choose_film(Some(&stock), print); } None => self.set_film(None), } } /// TRACES: FR-DEV-5 /// [`Self::pay_film_debt`] for a history step, when the step went /// anywhere. /// /// The guard is the whole difference between the two: a step that found /// nowhere to go left the graph alone, and clearing the film because /// undo hit the floor would take the picture's stock off it. fn settle(&mut self, step: &dr_pipeline::Step) { if let dr_pipeline::Step::Took(rebake) = step { self.pay_film_debt(rebake); } } /// TRACES: FR-DEV-5 /// Step the edit back one, returning whether anything moved. /// /// The panel must be rebuilt from [`Self::rows`] afterwards, for the same /// reason a paste must: this moves values the controls are showing and /// nothing here pushes them. pub fn undo(&mut self) -> bool { let step = self.history.undo(&mut self.graph); self.settle(&step); step.moved() } /// TRACES: FR-DEV-5 /// Step the edit forward one, returning whether anything moved. pub fn redo(&mut self) -> bool { let step = self.history.redo(&mut self.graph); self.settle(&step); step.moved() } pub fn can_undo(&self) -> bool { self.history.can_undo() } pub fn can_redo(&self) -> bool { self.history.can_redo() } /// TRACES: FR-DEV-5 | FR-DEV-7 /// Every step this photograph has been through, newest first. /// /// Newest first because the list is consulted to take back something just /// done, not browsed chronologically — the order `dr_catalog::trash` /// settled on for the same question. It also keeps the interesting end /// against the heading, so a stack sixty-four deep does not put the step /// the photographer is looking for at the bottom of a long scroll. /// /// The reversal happens here rather than in the core, which returns the /// stack in stack order and stamps each row with its own index — so /// nothing on this side does arithmetic to turn a row back into a step. pub fn history_rows(&self) -> Vec { let mut rows: Vec<_> = self .history .entries(&self.graph) .into_iter() .map(|entry| crate::HistoryRow { index: entry.index as i32, label: labels::resolve(entry.label.0).into(), current: entry.current, // Everything past the mark is a future the photographer // stepped out of. Still listed, because it is still reachable // by redo and hiding it would make redo arrive somewhere the // panel never mentioned — but drawn as the branch it is. undone: entry.index > self.history.cursor(), }) .collect(); rows.reverse(); rows } /// TRACES: FR-DEV-5 | FR-DEV-7 /// Step straight to one row of [`Self::history_rows`]. /// /// Takes the row's own `index`, not its position in that list. /// TRACES: FR-DEV-5 /// A number that changes exactly when [`Self::history_rows`] would. /// /// The panel is rebuilt off this rather than every redraw: a drag ends in /// a redraw per frame and changes no row, and pushing a fresh model makes /// the toolkit tear down and recreate every one of them. pub fn history_revision(&self) -> u64 { self.history.revision() } pub fn go_to_history(&mut self, index: i32) -> bool { let Ok(index) = usize::try_from(index) else { return false; }; let step = self.history.go_to(&mut self.graph, index); self.settle(&step); step.moved() } /// TRACES: FR-DEV-5 /// What undo would take back, and what redo would put back. /// /// Named on the buttons rather than left to the bare verb. "Undo" asks the /// photographer to remember what they last did, which after a run of small /// adjustments is exactly what they have stopped tracking — and it is the /// moment they are least willing to press a button and find out. /// /// Empty when there is nowhere to go, so the caller falls back to the verb /// alone rather than printing a label for a disabled control. pub fn undo_label(&self) -> String { // Undo takes back the step the graph is *standing on*, so the row to // name is the current one — not the one it will land on. self.step_name(self.history.cursor(), self.history.can_undo()) } /// TRACES: FR-DEV-5 pub fn redo_label(&self) -> String { self.step_name(self.history.cursor() + 1, self.history.can_redo()) } fn step_name(&self, index: usize, offered: bool) -> String { if !offered { return String::new(); } self.history .entries(&self.graph) .into_iter() .find(|e| e.index == index) .map(|e| labels::resolve(e.label.0)) .unwrap_or_default() } } /// Re-express a crop rect after the frame it is measured against turns. /// /// The crop lives in fractions of the *framed* image — the one the quarter /// turns have already produced — so turning the frame another quarter leaves /// the rect describing the wrong region unless it turns with it. Without this, /// rotating a portrait crop on a landscape photograph slides the selection /// onto a different part of the picture, which reads as the rotation having /// moved the image rather than the frame. /// /// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the /// extents; anticlockwise is the same map run the other way. Applied /// `turns.rem_euclid(4)` times so the caller's wrapping and this agree. fn rotate_crop(rect: CropRect, turns: i32) -> CropRect { let mut r = rect; for _ in 0..turns.rem_euclid(4) { r = CropRect { x: 1.0 - r.y - r.height, y: r.x, width: r.height, height: r.width, }; } r.normalised() } /// Sort ascending and force a minimum separation. /// /// Mirrors what the curve operation does before handing points to the /// shader. Duplicated rather than shared because the operation keeps it /// private, and the consequence of drift is only a drawn line that lags the /// rendered one by a pixel — not a wrong image. fn sort_with_gap(xs: &mut [f32]) { const MIN_GAP: f32 = 0.001; for i in 1..xs.len() { let mut j = i; while j > 0 && xs[j - 1] > xs[j] { xs.swap(j - 1, j); j -= 1; } } for i in 1..xs.len() { if xs[i] - xs[i - 1] < MIN_GAP { xs[i] = xs[i - 1] + MIN_GAP; } } } /// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect. /// /// Rendering to the letterboxed size rather than the full viewport avoids /// shading pixels the view will not show, which at a 3:2 image in a 16:9 /// window is a fifth of them. fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) { if sw == 0 || sh == 0 { return (max_w, max_h); } let scale = (max_w as f32 / sw as f32).min(max_h as f32 / sh as f32); // Never upscale past the source: there is no detail to recover, and a // 1:1 render is cheaper. let scale = scale.min(1.0); ( ((sw as f32 * scale).round() as u32).max(1), ((sh as f32 * scale).round() as u32).max(1), ) } /// The order an operation's parameters are shown in. /// /// Declaration order, unless the operation facets them — in which case /// parameters sharing an aspect are brought together, so the panel names /// each run once instead of repeating "Hue / Saturation / Luminance" /// twelve times over. The colour mixer declares band by band, which is the /// order the shader wants; a photographer works channel by channel. /// /// **This is presentation, and so it lives here** (ARCH §4.3a). The core /// says which aspect a parameter belongs to; deciding that an aspect is /// worth stacking rows by is the panel's composition to make, exactly as /// grouping by operation is. Routing is unaffected — `param_index` stays /// the position in the capability list however the rows are stacked. /// /// A stable sort by the aspect's first appearance, so an operation with no /// facets comes back untouched, and one that mixes plain parameters with /// faceted ones keeps the plain ones first and in order. fn presentation_order(params: &[dr_pipeline::ParamCapability]) -> Vec { let mut aspects: Vec<&str> = Vec::new(); let rank: Vec = params .iter() .map(|p| match &p.facet { None => 0, Some(f) => { let at = aspects.iter().position(|a| *a == f.aspect.0); // First appearance defines the run's place, so the panel's // sections come out in the order the operation introduced // them rather than alphabetically. 1 + at.unwrap_or_else(|| { aspects.push(f.aspect.0); aspects.len() - 1 }) } }) .collect(); let mut order: Vec = (0..params.len()).collect(); order.sort_by_key(|i| rank[*i]); order } /// Suffix shown after a value. Comes from the descriptor's declared unit, so /// this function needs no knowledge of which parameter it is formatting. fn unit_suffix(unit: Unit) -> &'static str { match unit { Unit::None => "", Unit::Stops => " EV", Unit::Kelvin => " K", Unit::Percent => "%", } } /// `dr_pipeline`'s morphology, as `dr_segment` names it. /// /// Two enums for one idea, and deliberately: `dr-pipeline` describes the /// *edit* and `dr-segment` implements the *transform*, and neither depends on /// the other. The crossing is this function, which the compiler makes /// exhaustive on both sides. fn morphology_for(m: dr_pipeline::mask::Morphology) -> dr_segment::Morphology { use dr_pipeline::mask::Morphology as Edit; use dr_segment::Morphology as Transform; match m { Edit::None => Transform::None, Edit::Dilate => Transform::Dilate, Edit::Erode => Transform::Erode, Edit::Close => Transform::Close, Edit::Open => Transform::Open, } } #[cfg(test)] mod tests { use super::*; use dr_pipeline::EditGraph; // --- the crop ratio lock --------------------------------------------- #[test] fn a_locked_ratio_is_resolved_in_output_pixels() { // 3:2 means three pixels across to two down, whatever shape the frame // it is being cut out of happens to be. let landscape = CropAspect::Fixed(3, 2); assert_eq!(landscape.ratio((6000, 4000), false), Some(1.5)); assert_eq!(landscape.ratio((4000, 6000), false), Some(1.5)); // Stood on its short edge. assert_eq!(landscape.ratio((6000, 4000), true), Some(2.0 / 3.0)); } #[test] fn the_frames_own_ratio_follows_the_frame() { // What separates `Original` from naming the same numbers: it is right // on the next photograph from another body, and after a quarter turn. let a = CropAspect::Original; assert_eq!(a.ratio((6000, 4000), false), Some(1.5)); assert_eq!(a.ratio((4000, 6000), false), Some(2.0 / 3.0)); assert_eq!(a.ratio((5000, 5000), false), Some(1.0)); } #[test] fn free_locks_nothing() { assert_eq!(CropAspect::Free.ratio((6000, 4000), false), None); assert_eq!(CropAspect::Free.ratio((6000, 4000), true), None); assert!(!CropAspect::Free.has_orientation()); } #[test] fn a_square_has_no_second_orientation() { // Turning it would be a control that visibly does nothing, so the // switch is disabled and the flag is ignored either way. let square = CropAspect::Fixed(1, 1); assert!(!square.has_orientation()); assert_eq!(square.ratio((6000, 4000), true), Some(1.0)); assert_eq!(square.ratio((6000, 4000), false), Some(1.0)); } #[test] fn only_a_named_ratio_has_to_be_turned_with_the_frame() { // The distinction that stops `Original` being flipped twice: a quarter // turn swaps the frame's axes, so a ratio resolved *against* the frame // has already turned by the time anything asks it. assert!(CropAspect::Fixed(16, 9).turns_with_the_frame()); assert!(CropAspect::Fixed(3, 2).turns_with_the_frame()); assert!(!CropAspect::Original.turns_with_the_frame()); assert!(!CropAspect::Free.turns_with_the_frame()); assert!(!CropAspect::Fixed(1, 1).turns_with_the_frame()); } #[test] fn a_quarter_turn_leaves_a_locked_crop_the_shape_it_already_was() { // The whole reason the switch is flipped on a quarter turn. A crop // locked to 16:9 is carried through the turn by `rotate_crop`, coming // out at 9:16 of a frame whose axes have also swapped — so the lock // must now read as portrait, or the next drag would snap the crop back // upright and undo what the turn did to the composition. let (fw, fh) = (6000u32, 4000u32); let aspect = CropAspect::Fixed(16, 9); let before = CropRect::default().with_aspect( fw, fh, aspect.ratio((fw, fh), false).unwrap(), (0.5, 0.5), ); let after = rotate_crop(before, 1); let (tw, th) = (fh, fw); let got = (after.width * tw as f32) / (after.height * th as f32); let want = aspect.ratio((tw, th), true).unwrap(); assert!( (got / want - 1.0).abs() < 1e-3, "turned crop is {got}, the flipped lock says {want}" ); } #[test] fn every_offered_ratio_has_a_name_and_a_place() { // The chips are drawn from this list, so a duplicate would light two // at once and an empty label would draw a blank button. let mut seen = Vec::new(); for a in CropAspect::CHOICES { assert!(!a.label().is_empty(), "{a:?} has no label"); assert!(!seen.contains(&a), "{a:?} is offered twice"); seen.push(a); } assert_eq!( CropAspect::CHOICES[0], CropAspect::Free, "free is the default" ); assert_eq!(CropAspect::default(), CropAspect::Free); } /// TRACES: FR-DSP-1 | AC-8 /// Copy a displayed frame back to the CPU, for assertions and nothing else. /// /// The library has no such function on purpose: S1 removed the display /// readback, and AC-8 is the assertion that it stayed removed. A test that /// wants to look at the pixels therefore has to do the copy itself, which /// is exactly the right shape — the round-trip lives in the test binary /// and cannot be reached from a shipping one. /// /// Doubles as the proof: this only compiles because the image *is* a wgpu /// texture. Hand it a `SharedPixelBuffer`-backed image and it panics. fn read_back(ctx: &GpuContext, image: &slint::Image) -> Vec { let texture = image .to_wgpu_29_texture() .expect("the develop canvas must be a GPU texture, not a pixel buffer"); let (w, h) = (texture.width(), texture.height()); // Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT. let unpadded = w * 4; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let padded = unpadded.div_ceil(align) * align; let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor { label: Some("test-readback"), size: u64::from(padded * h), usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); let mut enc = ctx.device.create_command_encoder(&Default::default()); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: &texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(h), }, }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, ); ctx.queue.submit(Some(enc.finish())); let slice = buf.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); ctx.device .poll(wgpu::PollType::wait_indefinitely()) .expect("poll"); rx.recv().expect("map").expect("map"); let data = slice.get_mapped_range(); let mut out = Vec::with_capacity((unpadded * h) as usize); for row in 0..h { let start = (row * padded) as usize; out.extend_from_slice(&data[start..start + unpadded as usize]); } drop(data); buf.unmap(); out } // ---------------------------------------------------------------------- // Segmentation off the UI thread // ---------------------------------------------------------------------- /// The property the whole arrangement rests on. /// /// If someone puts an `Rc`, a `Cell` or a raw pipeline handle into /// `SegmentationJob`, this stops compiling — which is the only warning /// there would be, since the call site in `masks_ui` would then fail with /// a lifetime error a long way from the cause. #[test] fn a_job_and_its_answer_can_cross_a_thread() { fn is_send() {} is_send::(); is_send::(); is_send::(); } /// Two sessions over the same file are still two photographs as far as a /// late result is concerned, because opening one twice is opening it /// twice. #[test] fn every_session_has_its_own_identity() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); let open = || { DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session") }; let (a, b) = (open(), open()); assert_ne!(a.id(), b.id()); assert_eq!(a.id(), a.id(), "and stable within one session"); } #[test] fn a_job_carries_the_session_it_was_taken_from() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session"); assert_eq!(session.segmentation_job().session(), session.id()); } /// Abandoning before the run reaches the proxy must cost nothing at all — /// this is the case that fires when the user pages on while a job is still /// waiting for a thread. #[test] fn an_abandoned_job_does_no_work() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session"); let job = session.segmentation_job(); job.abandon().now(); let started = std::time::Instant::now(); let out = job.run(&crate::segmentation::Options::default()); assert!( matches!(out, Ok(None)), "abandoned is not an error and not an empty answer: {:?}", out.map(|o| o.is_some()) ); assert!( started.elapsed() < std::time::Duration::from_millis(50), "it returned without loading the model" ); } /// A finished segmentation is adopted whole, and the session says so. #[test] fn adopting_a_result_gives_the_session_its_subjects() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) .expect("session"); assert!(!session.has_segmentation()); let job = session.segmentation_job(); let Ok(Some(found)) = job.run(&crate::segmentation::Options::default()) else { eprintln!("no model; skipping"); return; }; session.adopt_segmentation(found); assert!(session.has_segmentation()); } // ---------------------------------------------------------------------- // The overlay's clip rectangle // ---------------------------------------------------------------------- // // The overlay is a source-space picture and the canvas shows whatever the // crop, the zoom and the pan selected out of that space. Drawn whole it // stays frame-sized while the photograph moves underneath, which is what // these pin down. /// A session with a segmentation, so the clip has a proxy to measure /// against. /// /// The model finds nothing in flat grey, and that is fine: the clip is /// computed from the framing and the proxy size, neither of which depends /// on what was detected. fn segmented_session(ctx: &GpuContext) -> Option { let rgba: Vec = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) .expect("session"); session .segment(&crate::segmentation::Options::default()) .ok()?; Some(session) } /// One device for the whole test binary. /// /// This opened a *new* `GpuContext` per test, and `cargo test` runs tests /// on as many threads as there are cores — so a full run asked the driver /// to bring up a dozen Vulkan devices at once and the binary died with /// SIGSEGV. Serially it passed, which is what made it look like flakiness /// rather than a bug in the harness. /// /// A `GpuContext` is an `Arc` and an `Arc`, so sharing one /// is a refcount rather than a copy, and wgpu is explicit that both are /// safe to use from several threads. Nothing here mutates the context; the /// per-test state is in the passes and the sessions built on top of it. /// /// `OnceLock` rather than `lazy_static`: the initialiser runs once however /// many threads arrive together, and the losers block until it is done — /// which is precisely the property that was missing. fn headless() -> Option { static SHARED: std::sync::OnceLock> = std::sync::OnceLock::new(); SHARED .get_or_init(|| pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()) .clone() } /// Every attribute the chain actually carries must reach the tab strip. /// /// The strip is generated, so an attribute with operations behind it and /// no tab in front of it is unreachable — the controls exist, are in the /// shader, and cannot be filtered to. That is exactly how `Optics` sat /// invisible for as long as it had no operations, and the failure looks /// identical from the outside whether the cause is an empty category or a /// broken filter. #[test] fn every_attribute_with_operations_gets_a_tab() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); let session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) .expect("session"); let tabs: Vec = session.tabs().into_iter().map(|(a, _)| a).collect(); for attribute in dr_pipeline::Attribute::ALL { // Compose is deliberately absent: its one stage prefers an // on-canvas widget, so a Compose tab would open onto nothing while // `ComposePanel` holds the real controls. if attribute == dr_pipeline::Attribute::Compose { continue; } let carried = dr_pipeline::EditGraph::default_chain() .capabilities() .iter() .any(|c| c.attributes.contains(&attribute) && !c.params.is_empty()); assert_eq!( tabs.contains(&attribute), carried, "{attribute:?}: carried by the chain = {carried}, has a tab = {}", tabs.contains(&attribute) ); } } /// 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" ); } /// TRACES: FR-DEV-3 /// A gradient needs no segmentation, and until now it silently got no mask. /// /// The rasteriser was built on the way out of `segment`, so a gradient /// added to a photograph nobody had segmented had nothing to draw it — and /// the failure was invisible from every side. The generated shader still /// emits the layer's block, the empty placeholder multiplies it by zero, /// and the result is a well-formed frame with the local adjustment simply /// absent. No error, no warning, and nothing on screen to tell it apart /// from a mask the user had placed badly. /// /// A graduated filter over a sky never had to know what a sky is, so the /// dependency was wrong as well as silent. #[test] fn a_gradient_renders_on_a_photograph_nobody_has_segmented() { let Some(ctx) = headless() else { return }; // Mid grey, so a brightening layer is unambiguous either way. let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); assert!( !session.has_segmentation(), "the point of the test is that there is none" ); let before = read_back(&ctx, &session.render(64, 64).expect("render")); // A radial over the middle, brightened hard. Addressed by index, so // this names no operation (FR-DEV-3a). session.add_gradient_mask(true).expect("a radial"); let row = session.rows()[0].clone(); session.set_param(row.op_index, row.param_index, row.maximum); let after = read_back(&ctx, &session.render(64, 64).expect("render")); let centre = |px: &[u8]| px[((32 * 64 + 32) * 4) as usize]; assert!( centre(&after) > centre(&before) + 20, "the middle of the frame must brighten: {} against {}", centre(&after), centre(&before) ); // And only the middle: a mask that failed to rasterise the other way — // covering everything — would pass the assertion above. let corner = |px: &[u8]| px[0]; assert_eq!( corner(&after), corner(&before), "the corner is outside the radial and must not move" ); } // ---------------------------------------------------------------------- // Stored coverage (dr_pipeline::coverage) // ---------------------------------------------------------------------- // // A subject or category layer is stored in the sidecar as identity — which // run, which instance, which category — and identity resolves to pixels // only while that run is in memory. So reopening an edited photograph // dropped every model-backed local adjustment, and a batch export, which // never runs a model at all, could not have them at any point. Both // failures were silent: the shader still emitted the layer's block, the // placeholder multiplied it by zero, and the result was a well-formed // frame with the adjustment simply absent. /// A stored edit holding one subject layer over the left half of the /// frame, as a session that *had* run the model would have written it. /// /// `stored` is the whole variable: with the raster, this is a sidecar /// written by a build that persists coverage; without it, one written /// before that existed. Everything else about the two is identical, which /// is what makes the pair of tests below a measurement rather than an /// assertion about two different edits. fn version_with_a_subject(proxy: (u32, u32), stored: bool) -> dr_pipeline::Version { use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS}; let (pw, ph) = (proxy.0 as usize, proxy.1 as usize); let mut values = vec![0u8; pw * ph]; for y in 0..ph { for x in 0..pw / 2 { values[y * pw + x] = 255; } } let mut layer = MaskLayer::new( "m1", MaskSource::Subject { signature: 0xfeed, index: 0, class: "dog".into(), score: 0.9, }, ); layer.set_param("exposure", ParamId("exposure"), 2.0); if stored { layer.base_mut().coverage = Some(Arc::new( Coverage::encode(&values, pw, ph, RENDERED_LEVELS).expect("a half frame encodes"), )); } let mut graph = EditGraph::default_chain(); graph.masks_mut().push(layer); dr_pipeline::Version::from_graph("default", "Default", &graph) } /// A flat grey session with nothing segmented, and its render. fn grey_session(ctx: &GpuContext) -> (DevelopSession, Vec) { let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); assert!( !session.has_segmentation(), "the premise: no model has been run" ); let before = read_back(ctx, &session.render(64, 64).expect("render")); (session, before) } /// TRACES: FR-UI-4 /// The inspection zoom is 1:1 for *this* file in *this* viewport. /// /// The number is the whole point. A magnifier that lands on some fixed /// multiple tells the photographer nothing about whether they are looking /// at the file's own pixels, and that is the only question noise reduction /// and capture sharpening can honestly be judged by. #[test] fn inspecting_lands_on_one_source_pixel_per_screen_pixel() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); // Sixty-four source pixels fitted into thirty-two is one screen pixel // per two of the file's, so 1:1 is 2×. let one_to_one = session.one_to_one_zoom(32, 32); assert!( (one_to_one - 2.0).abs() < 1e-3, "a 64px frame in a 32px viewport is 2× at 1:1, not {one_to_one}" ); assert!( session.toggle_inspection(0.5, 0.5, 32, 32).is_some(), "the first toggle goes in" ); assert!( (session.zoom() - one_to_one).abs() < 1e-3, "the view should have landed on 1:1, not {}", session.zoom() ); } /// TRACES: FR-UI-4 /// The second press goes back to fit — from any zoom, not only from 1:1. /// /// A scroll wheel that stopped at 173% must not leave the toggle inert: /// the gesture means "show me the whole photograph again". #[test] fn the_inspection_toggle_returns_to_fit_from_any_zoom() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); session.zoom_about(3.0, 0.5, 0.5); assert!(session.is_zoomed(), "the premise"); assert_eq!( session.toggle_inspection(0.5, 0.5, 32, 32), None, "toggling out reports no inspection point" ); assert!(!session.is_zoomed()); } /// TRACES: FR-UI-4 | FR-DEV-5 /// Inspecting is a way of looking, and leaves no trace on the photograph. /// /// The failure this guards is quiet and expensive: a zoom that recorded a /// step would put a viewport rectangle on the undo stack and into the /// sidecar, and the photograph would then open on another device cropped /// to wherever somebody once looked. #[test] fn inspecting_writes_nothing_the_file_would_remember() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); assert!(!session.can_undo(), "the premise: nothing has been done"); session.toggle_inspection(0.25, 0.75, 32, 32); assert!(!session.can_undo(), "a zoom is not a step to take back"); assert!(session.is_neutral(), "and it is not an edit either"); assert!(!session.framing_edits_image()); } /// TRACES: FR-DEV-3 | FR-DEV-5 /// Sampling something that is already neutral corrects nothing, and says /// so by leaving the stack alone. /// /// The failure this guards is a picker that lands a ten-thousandth off /// zero: the photograph would come back marked modified, an undo step /// would appear for a correction of nothing, and the sidecar would gain a /// temperature the photographer never chose. The solve rounds to the /// precision the control is drawn at, which is what makes "no correction" /// representable at all. #[test] fn sampling_a_grey_that_is_already_grey_leaves_the_photograph_alone() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); let steps = session.history_rows().len(); assert!( session.sample_neutral(0.5, 0.5), "a flat grey frame is a usable sample" ); assert!( session.is_neutral(), "there was nothing to correct, so nothing was corrected" ); assert_eq!( session.history_rows().len(), steps, "and a correction of nothing is not a step" ); } /// TRACES: FR-DEV-7 | FR-DEV-5 /// A held comparison hands the edit straight back. /// /// This is the whole difference between comparing and the /// undo-look-redo that had to stand in for it. Two steps on the stack, at /// the moment a photographer is least sure of what they are doing, was /// the price of looking — and this asserts the price is now nothing: /// the same parameters, the same history, still modified. #[test] fn showing_the_original_leaves_the_edit_exactly_as_it_was() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); // The first control that actually moves the picture — addressed by // index, so this still names no operation (FR-DEV-3a). // // Deliberately not row zero. `EditGraph::capabilities` puts the lens // corrections first, matching where they sit in the shader, and those // carry profile coefficients rather than parameters: with no profile // loaded, driving one to its maximum leaves the graph neutral. Taking // the first row blindly made the premise below fail for a reason that // has nothing to do with what is being asserted. let rows = session.rows(); let row = rows .iter() .find(|row| { session.set_param(row.op_index, row.param_index, row.maximum); !session.is_neutral() }) .expect("some control in the panel moves the picture") .clone(); let _ = row; let edit = session.copy_settings(); let steps = session.history_rows().len(); assert!(!session.is_neutral(), "the premise: there is an edit"); session .render_original(64, 64) .expect("render the original"); assert_eq!( session.copy_settings(), edit, "every parameter comes back where it was" ); assert_eq!( session.history_rows().len(), steps, "looking is not a step to take back" ); assert!( !session.is_neutral(), "and the photograph is still modified" ); } /// TRACES: FR-DEV-3 | FR-CAT-8 /// Reopening an edited photograph renders its subject mask, with no model. /// /// This is the failure the stored raster exists for. `apply_version` is /// exactly what opening a photograph from the library does with the /// sidecar it fetched, and until the coverage went into the file the layer /// resolved to nothing every time. #[test] fn a_stored_subject_mask_renders_with_no_model_run() { let Some(ctx) = headless() else { return }; let (mut session, before) = grey_session(&ctx); let proxy = session.mask_raster_size(); session.apply_version(&version_with_a_subject(proxy, true)); let after = read_back(&ctx, &session.render(64, 64).expect("render")); let at = |px: &[u8], x: usize, y: usize| px[(y * 64 + x) * 4]; assert!( at(&after, 16, 32) > at(&before, 16, 32) + 20, "the covered half must brighten: {} against {}", at(&after, 16, 32), at(&before, 16, 32) ); // And only that half. A mask that failed the other way — covering // everything — would pass the assertion above and is the louder bug. assert_eq!( at(&after, 56, 32), at(&before, 56, 32), "the uncovered half must not move" ); } /// The control for the test above: the same edit with the raster left out /// is the behaviour every build had before this, which is no mask at all. /// /// Worth pinning down in both directions. It is what a sidecar written by /// an older build looks like, and reading one must go on being harmless — /// the layer needs the model run, exactly as it always did, rather than /// rendering as an empty mask or as the whole frame. #[test] fn a_subject_mask_with_no_stored_coverage_still_needs_the_model() { let Some(ctx) = headless() else { return }; let (mut session, before) = grey_session(&ctx); let proxy = session.mask_raster_size(); session.apply_version(&version_with_a_subject(proxy, false)); let after = read_back(&ctx, &session.render(64, 64).expect("render")); assert_eq!( after, before, "with no coverage the layer contributes nothing" ); } /// TRACES: FR-EXP-9 | FR-CAT-8 /// A batch export renders the stored mask too. /// /// `export::render_from_library` fetches the RAW and the sidecar, opens a /// session, applies the version and calls `render_for_export` — and it /// runs no model on the way, so before the coverage was stored a batch /// wrote out files with the photographer's local adjustments missing, over /// a log warning nobody was reading. These two lines are that path with /// the fetch taken out. #[test] fn an_export_renders_a_stored_subject_mask() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); let plain = session .render_for_export(dr_types::ColourSpace::Srgb) .expect("export"); let proxy = session.mask_raster_size(); session.apply_version(&version_with_a_subject(proxy, true)); let masked = session .render_for_export(dr_types::ColourSpace::Srgb) .expect("export"); let at = |f: &dr_export::Frame, x: usize, y: usize| f.rgba[(y * 64 + x) * 4]; assert!( at(&masked, 16, 32) > at(&plain, 16, 32) + 20, "the exported file must carry the local adjustment: {} against {}", at(&masked, 16, 32), at(&plain, 16, 32) ); assert_eq!( at(&masked, 56, 32), at(&plain, 56, 32), "and only where the mask covers" ); } /// What a session hands the sidecar writer when no model has run. /// /// Untouched, and that is the whole of it: a photograph opened, looked at /// and closed must not have the coverage its own sidecar gave it stripped /// out on the way past. Saving is automatic, so a stack that lost the /// raster here would lose it on disk within seconds of being opened. #[test] fn saving_without_a_model_keeps_the_coverage_that_was_loaded() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); let proxy = session.mask_raster_size(); session.apply_version(&version_with_a_subject(proxy, true)); let stored = session.masks_for_storage(); assert_eq!(stored.len(), 1); assert!( stored.layers()[0].base().coverage.is_some(), "the raster the sidecar gave us must go back to the sidecar" ); } /// A session holding a segmentation with one instance over the left half. /// /// Built rather than detected. What these tests need is coverage of a /// *known* shape, so that "the stored raster renders what the model's did" /// is a comparison rather than a hope — and asking a real run what it /// happened to find in a synthetic frame would make the assertion depend /// on the weights. `segmented_session` above is the one that runs a model, /// and it goes on doing so. fn session_with_a_left_half_subject(ctx: &GpuContext) -> DevelopSession { let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); let (pw, ph) = session.mask_raster_size(); let (pw, ph) = (pw as usize, ph as usize); let mut mask = vec![0u8; pw * ph]; for y in 0..ph { for x in 0..pw / 2 { mask[y * pw + x] = 255; } } session.segmentation = Some(segmentation::Segmentation::for_test( vec![segmentation::InstanceSummary { class_name: "dog".into(), score: 0.9, mask, bbox: (0.0, 0.0, (pw / 2) as f32, ph as f32), }], Vec::new(), 0xfeed, (pw, ph), )); session.subjects = None; session.subject_key = 0; session } /// TRACES: FR-DEV-3 | FR-CAT-8 /// The whole claim, end to end: what is stored renders what was rendered. /// /// A session with a model's coverage in hand draws the mask; the stack it /// hands the sidecar writer goes through the file and into a session with /// no model at all; and the two frames must be the same. Anything weaker /// — that the coverage is present, that it round-trips as bytes — would /// still pass if the raster came back at the wrong scale, upside down, or /// a threshold out. #[test] fn a_stored_mask_renders_exactly_what_the_model_rendered() { let Some(ctx) = headless() else { return }; let mut live = session_with_a_left_half_subject(&ctx); let id = live.add_subject_mask(0).expect("a subject layer"); // TRACES: FR-DEV-19c // Making a mask shows it (`show_new_mask`), and this test is about the // pixels the *edit* produces. Put away here rather than left on, and // the asymmetry is the point rather than an inconvenience: the reveal // is how somebody is looking at a photograph, so a session that has // just made a layer legitimately draws a frame that a session which // read the same layer out of a file does not. Both of those are // correct, and only one of them is what a stored raster has to // reproduce. live.set_mask_view(0); live.graph .masks_mut() .get_mut(&id) .expect("the layer") .set_param("exposure", ParamId("exposure"), 2.0); let with_model = read_back(&ctx, &live.render(64, 64).expect("render")); // Through the sidecar, the way `presets::save_open_edit` does it: the // graph's own parameters, with the stack that carries the coverage // substituted for the one the graph is holding. let mut version = dr_pipeline::Version::from_graph("default", "Default", &live.graph); version.masks = live.masks_for_storage(); let mut sidecar = dr_pipeline::Sidecar::new(); sidecar.put(version); let text = sidecar.to_text(); let parsed = dr_pipeline::Sidecar::parse(&text).expect("reparse"); let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut reopened = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); assert!( !reopened.has_segmentation(), "the point: nothing has run a model in this session" ); reopened.apply_version(parsed.default_version().expect("a version")); // TRACES: FR-DEV-19c // And a sidecar carries no viewing state: a photograph reopened is not // reopened with its masks tinted red. Checked here because this is the // one test that puts an edit through a file and renders both ends, so // it is where the property would first go wrong. assert_eq!( reopened.mask_view(), 0, "restoring an edit must not turn an overlay on" ); let from_the_file = read_back(&ctx, &reopened.render(64, 64).expect("render")); assert_eq!( from_the_file, with_model, "the reopened frame must be the frame the model produced" ); // And that both are actually a mask rather than both being nothing. let at = |px: &[u8], x: usize| px[(32 * 64 + x) * 4]; assert!( at(&with_model, 16) > at(&with_model, 56) + 20, "the premise: the live render really is masked ({} against {})", at(&with_model, 16), at(&with_model, 56) ); } /// And what a session hands over when a model *has* run: the coverage /// folded in, at the proxy the distance field is measured in. /// /// The encoding happens here rather than where the field is built because /// that runs on a drag — see `masks_for_storage`. #[test] fn saving_after_a_model_run_folds_the_coverage_in() { let Some(ctx) = headless() else { return }; let mut session = session_with_a_left_half_subject(&ctx); let id = session.add_subject_mask(0).expect("a subject layer"); let stored = session.masks_for_storage(); let coverage = stored .get(&id) .expect("the layer is in the stack") .base() .coverage .as_ref() .expect("a subject the model found has coverage to store"); let (pw, ph) = session.mask_raster_size(); assert_eq!( (coverage.width(), coverage.height()), (pw as usize, ph as usize) ); let decoded = coverage.decode(); assert_eq!(decoded[32 * pw as usize + 8], 255, "inside the subject"); assert_eq!(decoded[32 * pw as usize + 56], 0, "outside it"); assert!( coverage.encoded_len() < 512, "a half-frame mask is a handful of runs, not {} bytes", coverage.encoded_len() ); } /// A layer whose coverage came from the file must still take the model's /// once a model runs — the live answer is the only one that responds to /// the refine control, and a run in this sitting is newer than a file. #[test] fn a_model_run_takes_precedence_over_what_was_stored() { let Some(ctx) = headless() else { return }; let mut session = session_with_a_left_half_subject(&ctx); // Stored coverage saying the *right* half, against a segmentation // saying the left. let (pw, ph) = session.mask_raster_size(); let (pw, ph) = (pw as usize, ph as usize); let mut mirrored = vec![0u8; pw * ph]; for y in 0..ph { for x in pw / 2..pw { mirrored[y * pw + x] = 255; } } let id = session.add_subject_mask(0).expect("a subject layer"); { let layer = session.graph.masks_mut().get_mut(&id).expect("the layer"); layer.set_param("exposure", ParamId("exposure"), 2.0); layer.base_mut().coverage = Some(Arc::new( dr_pipeline::Coverage::encode( &mirrored, pw, ph, dr_pipeline::coverage::RENDERED_LEVELS, ) .expect("encode"), )); } let frame = read_back(&ctx, &session.render(64, 64).expect("render")); let at = |x: usize| frame[(32 * 64 + x) * 4]; assert!( at(16) > at(56) + 20, "the model's left half must win over the file's right half: \ {} against {}", at(16), at(56) ); } /// TRACES: FR-DEV-3 /// Multi-select: one slider, applied to every selected layer. /// /// `toggle_active_mask` builds the selection a control-click makes, and /// `set_param`/`reset_op` are what a drag and a reset call — this pins /// down that both fan out to every layer in it rather than only the /// first, which is the whole point of selecting more than one. #[test] fn a_slider_moved_with_two_layers_selected_moves_both() { 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"); let a = session.add_gradient_mask(true).expect("first gradient"); let b = session.add_gradient_mask(false).expect("second gradient"); // Adding `b` selected it alone — build the multi-selection a // control-click would, starting from that single-layer state. session.toggle_active_mask(&a); assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice()); assert!( !session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b), "neither layer has been touched yet" ); let row = session.rows()[0].clone(); session.set_param(row.op_index, row.param_index, row.maximum); assert!( session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b), "one slider, both layers selected, both layers must show the edit" ); // And a reset walks the same set. session.reset_op(row.op_index); assert!( !session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b), "resetting with both selected must clear both, not just the one \ the panel happens to read values from" ); } /// TRACES: FR-DEV-3 /// A refine crop's mask, pasted back onto the proxy grid it replaces, /// must land exactly where the crop was — not shifted by the origin, not /// scaled onto the wrong footprint. #[test] fn a_refined_mask_pastes_back_at_the_crops_own_position() { // A crop entirely on (1.0), covering proxy pixels 2..6 in x and // 2..6 in y of an 8x8 proxy — everywhere inside that box must read // back as fully covered, everywhere outside as untouched. let crop = vec![1.0f32; 4 * 4]; let mask = paste_into_proxy(&crop, 4, 4, (8, 8), (2.0, 2.0), (4.0, 4.0)); assert_eq!(mask[2 * 8 + 2], 255, "top-left corner of the box"); assert_eq!(mask[5 * 8 + 5], 255, "bottom-right corner of the box"); assert_eq!(mask[0], 0, "outside the box, untouched"); assert_eq!(mask[7 * 8 + 7], 0, "outside the box, untouched"); } #[test] fn bilinear_sample_averages_its_four_neighbours() { // Two rows, black then white: the exact midpoint reads as grey. let mask = [0.0f32, 0.0, 1.0, 1.0]; let v = bilinear_sample(&mask, 2, 2, 0.5, 0.5); assert!( (v - 0.5).abs() < 1e-6, "expected the midpoint grey, got {v}" ); } /// A control-click twice — once to add, once to remove — is a no-op on /// the selection, which is the sanity check for `toggle_active_mask` /// itself before trusting anything built on it. #[test] fn toggling_a_layer_twice_returns_to_the_starting_selection() { 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"); let a = session.add_gradient_mask(true).expect("gradient"); assert_eq!(session.active_masks(), [a.clone()].as_slice()); session.toggle_active_mask(&a); assert!(session.active_masks().is_empty(), "removed by the toggle"); session.toggle_active_mask(&a); assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back"); } /// TRACES: FR-DEV-3 /// The mask array and the segmentation proxy are the same size on purpose. /// /// They have to be: a subject layer's distance field is built at the /// segmentation's proxy resolution and sampled against the array, so if the /// two ever diverged a subject mask would be drawn at the wrong scale — /// a mask that is confidently in the wrong place, which is worse than none. /// /// It used to hold because the array's size was *read off* the /// segmentation, which also made a gradient wait for a model it does not /// use. Deriving both from the photograph keeps the agreement and drops the /// dependency, and this is what stops the agreement being an accident. #[test] fn the_mask_array_is_the_size_the_segmentation_will_use() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..100 * 100) .flat_map(|_| [128u8, 128, 128, 255]) .collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) .expect("session"); let before = session.mask_raster_size(); if session .segment(&crate::segmentation::Options::default()) .is_err() { eprintln!("no model; skipping"); return; } assert_eq!( before, session.mask_raster_size(), "a mask rasterised before the model ran must not move when it does" ); } #[test] fn an_unzoomed_overlay_shows_the_whole_frame() { let Some(ctx) = headless() else { return }; let Some(session) = segmented_session(&ctx) else { eprintln!("no model; skipping"); return; }; let (x, y, w, h) = session.overlay_clip(); assert_eq!((x, y), (0, 0)); assert!(w > 1 && h > 1, "the whole proxy: {w}x{h}"); } /// The bug this exists for: zooming must narrow the clip, or the overlay /// keeps showing the whole picture at frame size while the canvas shows a /// detail of it. #[test] fn zooming_narrows_the_overlay_to_what_is_visible() { let Some(ctx) = headless() else { return }; let Some(mut session) = segmented_session(&ctx) else { eprintln!("no model; skipping"); return; }; let (_, _, full_w, full_h) = session.overlay_clip(); session.zoom_about(4.0, 0.5, 0.5); let (_, _, zoomed_w, zoomed_h) = session.overlay_clip(); assert!( zoomed_w < full_w && zoomed_h < full_h, "zoomed in, the overlay should show less: {zoomed_w}x{zoomed_h} \ against {full_w}x{full_h}" ); } #[test] fn panning_moves_the_overlay_with_the_photograph() { let Some(ctx) = headless() else { return }; let Some(mut session) = segmented_session(&ctx) else { eprintln!("no model; skipping"); return; }; session.zoom_about(4.0, 0.5, 0.5); let (before_x, _, _, _) = session.overlay_clip(); session.pan_by(0.3, 0.0); let (after_x, _, _, _) = session.overlay_clip(); assert!( after_x > before_x, "panning right moves the visible window right: {before_x} then {after_x}" ); } #[test] fn cropping_narrows_the_overlay_too() { let Some(ctx) = headless() else { return }; let Some(mut session) = segmented_session(&ctx) else { eprintln!("no model; skipping"); return; }; let (_, _, full_w, _) = session.overlay_clip(); session.set_crop(dr_pipeline::CropRect { x: 0.25, y: 0.25, width: 0.5, height: 0.5, }); let (x, y, w, _) = session.overlay_clip(); assert!(w < full_w, "a half-width crop shows half the overlay"); assert!(x > 0 && y > 0, "and it starts inside the frame"); } /// Nothing segmented means no overlay, and no rectangle a caller might /// divide by. #[test] fn no_segmentation_means_no_clip() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session"); assert_eq!(session.overlay_clip(), (0, 0, 0, 0)); } /// TRACES: FR-DSP-1 | AC-8 #[test] fn the_displayed_frame_is_a_texture_and_not_a_pixel_buffer() { // The acceptance criterion itself, asserted from the side that would // notice it regressing. `to_rgba8` returning `Some` would mean the // frame had come back through system memory to be looked at, which is // the ~7 ms per frame at 4K that ARCH §6.1 forbids; `to_wgpu_29_texture` // returning `Some` means the compositor got the texture where it lay. // // Note this passes without a display: the import is a wrapper, and it // is the *compositor* adopting the device that needs a screen. What // cannot be proved here is that the picture arrives; what can be // proved is that no copy was made on the way. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let rgba = vec![128u8; 32 * 32 * 4]; let mut session = DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) .expect("session"); let frame = session.render(32, 32).expect("render"); assert!( frame.to_rgba8().is_none(), "the canvas has CPU pixels, so something copied them there" ); let texture = frame .to_wgpu_29_texture() .expect("the canvas is neither a texture nor a pixel buffer"); assert_eq!((texture.width(), texture.height()), (32, 32)); } /// TRACES: FR-DSP-1 | AC-8 #[test] fn consecutive_frames_look_different_to_the_property_system() { // The catch that comes free with handing over a texture instead of a // buffer. Slint repaints when the image property *changes*, and it // decides that with `PartialEq` — which for two images over one // `wgpu::Texture` says "unchanged". A pass that reused a single target // would therefore render every slider move correctly and show none of // them. // // `AdjustPass` alternates between two targets to prevent it. This // asserts the consequence in the terms Slint actually uses, so it // would still catch the regression if the mechanism were replaced. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let rgba = vec![128u8; 32 * 32 * 4]; let mut session = DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) .expect("session"); let first = session.render(32, 32).expect("first render"); let second = session.render(32, 32).expect("second render"); assert_ne!( first, second, "the canvas property would not change, so the frame would never be shown" ); } /// The whole scroll-to-zoom path, end to end, in the order the user drives /// it: show the image fitted, *then* turn the wheel. /// /// The lower layers each had zoom tests and each passed while this was /// broken, because every one of them set a view before its first render. /// That ordering hid the bug — a neutral framing compiles a prologue that /// never reads the crop rect, and while zoom was absent from the structure /// hash that pipeline stayed cached once zoomed. The session reported the /// new zoom, the uniforms carried the new view, and the pixels never moved. /// /// So this asserts on the rendered pixels rather than on `zoom()`: the /// symptom was precisely that the state was right and the image was not. #[test] fn zooming_after_a_fitted_render_changes_the_pixels() { let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; // A gradient, so any change in the sampled region moves the pixels. let (w, h) = (64u32, 64u32); let mut rgba = Vec::with_capacity((w * h * 4) as usize); for y in 0..h { for x in 0..w { rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]); } } let mut session = DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL) .expect("session"); let fitted = session.render(64, 64).expect("fitted render"); session.zoom_about(4.0, 0.5, 0.5); assert!(session.is_zoomed(), "the session did not register the zoom"); let zoomed = session.render(64, 64).expect("zoomed render"); // Both images are still readable here because consecutive frames go to // alternating textures; see `AdjustPass::targets`. Holding two frames // at once would be meaningless against a single reused target. let before = read_back(&ctx, &fitted); let after = read_back(&ctx, &zoomed); let differing = before .iter() .zip(after.iter()) .filter(|(a, b)| a != b) .count(); assert!( differing > 0, "zooming 4x after a fitted render produced identical pixels — the \ view reached the session but not the shader" ); } #[test] fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() { // What decides whether the canvas is filtered. The distinction this // guards is the reason the interface cannot answer it from `zoom()` // alone: the same 4x on a large source is still showing more source // pixels than screen pixels, while on a small one it is already // inventing values between them. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; // Bigger than the viewport it is shown in: `fit` scales it down, so // every screen pixel still has several source pixels behind it. let big = vec![128u8; (800 * 800 * 4) as usize]; let mut session = DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL) .expect("session"); assert!( !session.magnifies_source(200, 200), "a downscaled image is not magnified" ); session.zoom_about(2.0, 0.5, 0.5); assert!( !session.magnifies_source(200, 200), "2x on a 4x-downscaled source is still below 1:1" ); session.zoom_about(8.0, 0.5, 0.5); assert!( session.magnifies_source(200, 200), "16x on a 4x-downscaled source magnifies and must not be filtered" ); // Smaller than the viewport: `fit` refuses to upscale, so the render is // 1:1 and unzoomed is exactly the boundary — not past it. let small = vec![128u8; (100 * 100 * 4) as usize]; let mut session = DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL) .expect("session"); assert!( !session.magnifies_source(800, 800), "1:1 is the boundary, not past it — filtering must not flip on a \ rounding error" ); session.zoom_about(2.0, 0.5, 0.5); assert!( session.magnifies_source(800, 800), "any zoom past a 1:1 render magnifies" ); } #[test] fn every_capability_becomes_exactly_one_row() { // The UI shows what the pipeline offers — no more, and nothing // dropped. Asserted against the chain rather than a literal count, // so operations can be added without editing this, and so the test // actually checks the correspondence rather than restating a number. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let expected: usize = caps.iter().map(|c| c.params.len()).sum(); assert!(expected > 0, "the chain must expose some parameters"); // Every (operation, parameter) pair must be reachable as a distinct // row index; a collision would route two sliders to one parameter. let mut seen = std::collections::HashSet::new(); for (oi, cap) in caps.iter().enumerate() { for (pi, _) in cap.params.iter().enumerate() { assert!(seen.insert((oi, pi)), "duplicate row index"); } } assert_eq!(seen.len(), expected); } #[test] fn each_operation_becomes_exactly_one_group() { // The panel draws one section per group, and derives the boundary // from `group_head` rather than from a flag the core supplies. Two // heads for one operation would draw its heading twice; none would // swallow the operation into the section above it. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); // A row heads its group exactly when its own index equals its // `group_head` — the same test `adjust.slint` makes. let mut heads = 0; for (i, row) in rows_of(&caps).iter().enumerate() { if row.0 == i { heads += 1; } } // Every operation but framing, which has its own panel. let generated = caps .iter() .filter(|c| c.id != dr_pipeline::framing::ID) .count(); assert_eq!(heads, generated); } #[test] fn regenerating_the_rows_leaves_unchanged_ones_equal() { // **This is a dragging test wearing a data disguise.** // // `sync_rows` rewrites exactly the rows that compare unequal, and a // rewritten row re-evaluates the repeater that a multi-parameter // operation renders its parameters through — which rebuilds the items // and destroys the `TouchArea` mid-gesture. So a row that differs from // itself between two identical calls is a slider that takes the press, // jumps once and then dies under the finger. // // It is asserted here rather than left to the eye because the failure // is invisible in a still: every value is right, the panel looks // perfect, and only a live drag on a *grouped* parameter shows it. // `ModelRc` compares by identity, so any new model-valued field // reintroduces this the moment it is built fresh per call. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let first = rows_from(&caps); let second = rows_from(&caps); assert_eq!(first.len(), second.len()); for (i, (a, b)) in first.iter().zip(second.iter()).enumerate() { // A curve row is the one legitimate exception: its `points` model // carries live coordinates, so it genuinely is rebuilt each call // and `sync_rows` writes the values through the existing model // instead of swapping it. Every other row must be stable here, at // the source, rather than relying on a caller to repair it. if a.kind == "curve" { continue; } assert!( a == b, "row {i} ({}) differs from itself across two identical builds, \ so every parameter event would rewrite it and break dragging", a.param_label ); } } #[test] fn a_grouped_parameter_survives_a_neighbours_change() { // The reported bug, at the level it actually occurred. Moving // temperature flips `group_modified` on *both* of white balance's // rows — that much is correct and intended. What must not happen is // the untouched rows of *other* operations also coming back unequal, // because rewriting a group's head row is what rebuilds the repeater // holding the live drag. let mut graph = EditGraph::default_chain(); let before = rows_from(&graph.capabilities()); // Move the first parameter of the first multi-parameter operation, // named by shape rather than by id so this keeps testing the property // when the chain changes. let caps = graph.capabilities(); let group = caps .iter() .find(|c| c.params.len() > 1 && c.presentation.is_none()) .expect("some operation has several plain parameters"); let target = &group.params[0]; graph.set_param(group.id, target.id, target.default + 1.0); let after = rows_from(&graph.capabilities()); assert_eq!(before.len(), after.len()); // Curve rows excluded for the reason given in the test above: their // points model is rebuilt by design and repaired in `sync_rows`. let changed: Vec<&str> = before .iter() .zip(after.iter()) .filter(|(a, b)| a != b && a.kind != "curve") .map(|(a, _)| a.param_label.as_str()) .collect(); // Its own group, and nothing beyond it. assert_eq!( changed.len(), group.params.len(), "moving one parameter should dirty only its own group's rows, \ but these came back changed: {changed:?}" ); } /// TRACES: FR-DEV-3 | FR-DEV-3a /// A canvas *sampler* adds an affordance; it does not take the sliders. /// /// The distinction the panel had been missing. `is_on_canvas` was read as /// "and so the panel draws nothing", which is right for a crop and wrong /// for an eyedropper — FR-DEV-3 asks for "temperature/tint, **and** /// picker", and a picker that ate the two sliders would have traded one /// control for another. Asserted against the chain rather than a literal, /// so it keeps testing the property when the declaration moves. #[test] fn a_sampled_operation_keeps_the_sliders_it_writes() { let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let (index, cap) = caps .iter() .enumerate() .find(|(_, c)| { c.presentation .as_ref() .is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint)) }) .expect("some operation asks to be driven by a pixel"); let rows = rows_from(&caps); let mine: Vec<_> = rows.iter().filter(|r| r.op_index == index as i32).collect(); assert_eq!( mine.len(), cap.params.len(), "every parameter of a sampled operation still has its own control" ); assert!( mine.iter().all(|r| r.group_samples), "and the group's heading carries the picker" ); assert!( rows.iter() .filter(|r| r.op_index != index as i32) .all(|r| !r.group_samples), "no other group claims one" ); } #[test] fn framing_is_not_generated_as_sliders() { // `ComposePanel` presents crop, rotation, flips and straightening as // the gestures they are. If the generic path emitted them too the // sidebar would carry both — including four "Crop Left/Top/Width/ // Height" sliders no one can compose a photograph with. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let framing = caps .iter() .position(|c| c.id == dr_pipeline::framing::ID) .expect("the chain must still expose framing — the panel reads it"); assert!(!caps[framing].params.is_empty()); // Checked against the real generator, and by *routing* rather than by // counting: a row carries the capability index it writes back to, so // "no row belongs to framing" is the property directly, and it cannot // be satisfied accidentally by two miscounts cancelling out. let rows = rows_from(&caps); assert!( rows.iter().all(|r| r.op_index as usize != framing), "framing parameters leaked into the generated panel" ); // Every other operation still arrives, so the skip is specific rather // than the panel having quietly stopped generating. assert!(rows.len() > caps.len() - 1); } #[test] fn a_stage_is_yielded_to_the_canvas_by_what_it_declares_not_by_its_name() { // The property that replaced `if op.id == framing::ID`. An invented // stage preferring an on-canvas widget must be skipped on exactly the // same terms — if this needs a name added anywhere to pass, the // special case has grown back. use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; let param = |id: &'static str| ParamCapability { id: ParamId(id), label: LocalizedKey("param.invented"), kind: ParamKind::Scalar { min: 0.0, max: 1.0, scale: dr_pipeline::Scale::Linear, unit: Unit::None, precision: 2, }, default: 0.0, value: 0.0, facet: None, }; let on_canvas = OpCapability { id: OpId("invented_mask"), label: LocalizedKey("op.invented_mask"), active: false, presentation: Some(Presentation { // Prefers a gradient handle; this frontend has none, so it // falls back to the next entry, which the canvas does host. widgets: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay], demand: WidgetDemand { two_dimensional: true, precise_pointing: false, }, params: vec![ParamId("a"), ParamId("b")], }), params: vec![param("a"), param("b")], attributes: vec![dr_pipeline::Attribute::Tone], }; assert!(rows_from(&[on_canvas]).is_empty()); } #[test] fn a_group_spans_exactly_its_operations_rows() { // `group_len` is how many rows the section reaches forward over. Too // few silently drops controls off the bottom of a section; too many // reads past the model and renders a neighbouring operation's // parameters under the wrong heading. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let rows = rows_of(&caps); for (i, row) in rows.iter().enumerate() { let (head, len) = *row; assert!(head <= i, "row {i} claims a head after itself"); assert!( head + len <= rows.len(), "group at {head} reaches past the model" ); // Every row the group spans must agree it belongs to that group. for (offset, spanned) in rows[head..head + len].iter().enumerate() { let span = head + offset; assert_eq!(spanned.0, head, "row {span} disagrees about its group"); } } } #[test] fn a_group_is_modified_when_any_of_its_parameters_is() { // The dot on a collapsed section is the only thing saying an edit is // hidden inside it, and it is derived here rather than asked of the // core (ARCH §4.3a). let mut graph = EditGraph::default_chain(); let caps = graph.capabilities(); // A fresh chain is at its defaults, so nothing is modified. assert!( caps.iter() .all(|c| c.params.iter().all(|p| p.value == p.default)), "a fresh chain must start neutral" ); // Move one parameter of one operation off its default; only that // operation's group may light up. let (op_id, param_id, default) = caps .iter() .find_map(|c| { c.params .iter() .find(|p| matches!(p.kind, ParamKind::Scalar { .. })) .map(|p| (c.id, p.id, p.default)) }) .expect("the chain has a scalar parameter"); graph.set_param(op_id, param_id, default + 1.0); let caps = graph.capabilities(); let modified: Vec = caps .iter() .map(|c| c.params.iter().any(|p| p.value != p.default)) .collect(); assert_eq!( modified.iter().filter(|m| **m).count(), 1, "one edit must mark exactly one group" ); // And it goes out again when the value returns. graph.set_param(op_id, param_id, default); assert!( graph .capabilities() .iter() .all(|c| c.params.iter().all(|p| p.value == p.default)), "returning a value to its default must clear the group" ); } /// `(group_head, group_len)` per row, flattened as /// [`DevelopSession::rows`] flattens — without needing a GPU to build a /// session. /// /// A widget hint only collapses an operation to one row when it is /// *honoured*; `rows` falls back to sliders otherwise, and mirroring that /// here is what keeps the test honest when a hint stops applying. /// TRACES: FR-DEV-3a /// A declared switch reaches the panel as a switch. /// /// `ParamKind::Bool` was in the core's closed enum, was mapped to the row /// kind `"bool"` here, and had no control behind it in `adjust.slint` — /// which meant a parameter declaring itself a switch was flattened into a /// row that drew nothing at all. Nothing shipped had one until the lens /// profile did, so the gap cost nothing and was invisible. /// /// This asserts the Rust half; the Slint half is the `if kind == "bool"` /// branch in the control registry, which this cannot reach. #[test] fn a_switch_becomes_a_switch_row() { use dr_pipeline::{LocalizedKey, ParamCapability}; let switched = OpCapability { id: OpId("invented_switch"), label: LocalizedKey("op.invented_switch"), active: true, presentation: None, params: vec![ParamCapability { id: ParamId("engaged"), label: LocalizedKey("param.invented_switch.engaged"), kind: ParamKind::Bool, // On by default, which is the shape a correction the file // itself asked for takes: off is the edit. default: 1.0, value: 0.0, facet: None, }], attributes: vec![dr_pipeline::Attribute::Optics], }; let rows = rows_from(&[switched]); assert_eq!(rows.len(), 1); assert_eq!(rows[0].kind, "bool"); assert_eq!(rows[0].value, 0.0); assert_eq!(rows[0].default_value, 1.0); // A lone parameter is titled by its operation, so the box carries the // name the withheld heading would have. assert_eq!( rows[0].param_label, labels::resolve("op.invented_switch").as_str() ); assert!( rows[0].group_modified, "a switch turned off differs from its default like any other \ parameter, and the group's marker has to say so" ); } /// TRACES: FR-DEV-3c /// An operation this file has never heard of, appearing in the panel. /// /// The acceptance test requirements.md names for FR-DEV-3c: "a test /// operation added to the registry appears in a generated panel with no /// frontend change". Built as a capability rather than a real node so it /// costs the pipeline nothing — what is being asserted is the mapping from /// descriptor to control, and that mapping does not care whether a shader /// exists behind it. #[test] fn an_operation_the_frontend_has_never_heard_of_gets_controls() { use dr_pipeline::{LocalizedKey, ParamCapability}; let invented = OpCapability { id: OpId("invented"), label: LocalizedKey("op.invented"), active: false, presentation: None, params: vec![ ParamCapability { id: ParamId("strength"), label: LocalizedKey("param.invented.strength"), kind: ParamKind::Scalar { min: -100.0, max: 100.0, scale: dr_pipeline::Scale::Linear, unit: Unit::Percent, precision: 0, }, default: 0.0, value: 25.0, facet: None, }, ParamCapability { id: ParamId("method"), label: LocalizedKey("param.invented.method"), kind: ParamKind::Enum { variants: vec![ LocalizedKey("param.invented.method.fast"), LocalizedKey("param.invented.method.exact"), ], }, default: 0.0, value: 1.0, facet: None, }, ], attributes: vec![dr_pipeline::Attribute::Tone], }; let rows = rows_from(&[invented]); assert_eq!(rows.len(), 2, "each parameter should become one row"); // The scalar becomes a slider carrying its declared range and unit. assert_eq!(rows[0].kind, "scalar"); assert_eq!(rows[0].minimum, -100.0); assert_eq!(rows[0].maximum, 100.0); assert_eq!(rows[0].value, 25.0); // The enum becomes a choice, with its range spanning the variant // indices and the variant names resolved for drawing. Nothing in this // file names the operation or either parameter to make that happen. assert_eq!(rows[1].kind, "enum"); assert_eq!(rows[1].minimum, 0.0); assert_eq!(rows[1].maximum, 1.0); assert_eq!(rows[1].precision, 0); assert_eq!(slint::Model::row_count(&rows[1].choices), 2); // The value is the selected index, which is what the segmented control // reads — an enum needs no separate selection field. assert_eq!(rows[1].value, 1.0); } #[test] fn an_unimplemented_widget_falls_back_to_sliders_rather_than_vanishing() { // ARCH §4.3a: falling off the end of the preference list is not an // error. An operation asking only for a widget this frontend does not // draw must still yield one control per parameter, or declaring a // preference would be a way to make an edit unreachable. use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; let wheel = OpCapability { id: OpId("grading"), label: LocalizedKey("op.grading"), active: false, presentation: Some(Presentation { widgets: vec![WidgetKind::ColourWheel], demand: WidgetDemand { two_dimensional: true, precise_pointing: false, }, params: vec![ParamId("hue"), ParamId("strength")], }), params: vec![ ParamCapability { id: ParamId("hue"), label: LocalizedKey("param.grading.hue"), kind: ParamKind::Scalar { min: 0.0, max: 360.0, scale: dr_pipeline::Scale::Linear, unit: Unit::None, precision: 0, }, default: 0.0, value: 0.0, facet: None, }, ParamCapability { id: ParamId("strength"), label: LocalizedKey("param.grading.strength"), kind: ParamKind::Scalar { min: 0.0, max: 1.0, scale: dr_pipeline::Scale::Linear, unit: Unit::None, precision: 2, }, default: 0.0, value: 0.0, facet: None, }, ], attributes: vec![dr_pipeline::Attribute::Tone], }; assert!(!supported(WidgetKind::ColourWheel), "precondition"); let rows = rows_from(&[wheel]); assert_eq!(rows.len(), 2, "both parameters must remain reachable"); assert!(rows.iter().all(|r| r.kind == "scalar")); } /// Each generated row's `(group_head, group_len)`. /// /// Taken from the real generator rather than re-derived. This used to be a /// hand-written simulation of `rows_from` — it walked the capabilities and /// reproduced the grouping rules, including a copy of the framing skip — /// which meant the tests below asserted against a second implementation /// that had to be kept in step with the first by hand. It was not: giving /// framing a presentation changed the real panel and the simulation /// disagreed, which is how a passing test suite would have hidden the /// change entirely. fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> { rows_from(caps) .iter() .map(|r| (r.group_head as usize, r.group_len as usize)) .collect() } #[test] fn four_quarter_turns_return_a_crop_where_it_started() { // The property that makes rotation safe to repeat: a user who turns // past the orientation they wanted and keeps going must arrive back at // the crop they had, not at a slowly drifting one. let start = CropRect { x: 0.1, y: 0.2, width: 0.3, height: 0.4, }; let mut r = start; for _ in 0..4 { r = rotate_crop(r, 1); } assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x); assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y); assert!((r.width - start.width).abs() < 1e-5); assert!((r.height - start.height).abs() < 1e-5); } #[test] fn a_quarter_turn_exchanges_a_crops_extents() { // A portrait selection on a landscape frame must come out landscape. // Were the extents left alone, the rect would keep its old shape while // the frame changed to the other one, and the crop would spill off the // photograph. let r = rotate_crop( CropRect { x: 0.0, y: 0.0, width: 0.25, height: 1.0, }, 1, ); assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width); assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height); } #[test] fn rotating_a_crop_keeps_it_inside_the_frame() { // Whatever the angle and wherever the rect, the result must still be a // rect the pipeline can render: outside the unit square it would // sample undefined area, and degenerate it is a zero-sized texture. for turns in -5..=5 { for rect in [ CropRect { x: 0.0, y: 0.0, width: 1.0, height: 1.0, }, CropRect { x: 0.7, y: 0.8, width: 0.3, height: 0.2, }, CropRect { x: 0.0, y: 0.45, width: 0.02, height: 0.02, }, ] { let r = rotate_crop(rect, turns); assert!( r.x >= 0.0 && r.y >= 0.0, "{turns} turns of {rect:?} gave {r:?}" ); assert!( r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5, "{turns} turns of {rect:?} left the frame: {r:?}" ); assert!( r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT, "{turns} turns of {rect:?} went degenerate: {r:?}" ); } } } #[test] fn opposite_quarter_turns_cancel() { // The rotate-left and rotate-right buttons must undo one another, or // correcting an over-rotation would land somewhere new each time. let start = CropRect { x: 0.15, y: 0.05, width: 0.5, height: 0.25, }; let there_and_back = rotate_crop(rotate_crop(start, 1), -1); assert!((there_and_back.x - start.x).abs() < 1e-5); assert!((there_and_back.y - start.y).abs() < 1e-5); assert!((there_and_back.width - start.width).abs() < 1e-5); assert!((there_and_back.height - start.height).abs() < 1e-5); } #[test] fn a_full_crop_survives_rotation_as_a_full_crop() { // The common case: rotating an uncropped photograph must not quietly // introduce a crop, which would shrink the exported image. assert!(rotate_crop(CropRect::default(), 1).is_full()); assert!(rotate_crop(CropRect::default(), -3).is_full()); } #[test] fn an_operation_without_facets_keeps_its_declared_order() { // Every operation but the mixer. Reordering one of these would move // Highlights below Shadows for no reason anybody could see in the // code, so the stable sort has to be a no-op when nothing is faceted. let graph = EditGraph::default_chain(); for cap in graph.capabilities() { if cap.params.iter().any(|p| p.facet.is_some()) { continue; } let order = presentation_order(&cap.params); assert_eq!( order, (0..cap.params.len()).collect::>(), "{} was reordered", cap.id ); } } #[test] fn faceted_parameters_are_stacked_one_run_per_aspect() { // The panel names a run once and then draws its rows. That only works // if a run is *contiguous*: the mixer declares band by band — red hue, // red sat, red lum, orange hue — so shown in declaration order every // single row would begin a new run, and the panel would draw // thirty-six headings over thirty-six sliders. let graph = EditGraph::default_chain(); let cap = graph .capabilities() .into_iter() .find(|c| c.params.iter().any(|p| p.facet.is_some())) .expect("the chain has a faceted operation"); let mut seen: Vec<&str> = Vec::new(); let mut previous: Option<&str> = None; for i in presentation_order(&cap.params) { let aspect = cap.params[i] .facet .as_ref() .expect("this operation facets every parameter") .aspect .0; if previous != Some(aspect) { assert!( !seen.contains(&aspect), "{aspect} is split into two runs — a heading would be \ drawn over each half" ); seen.push(aspect); previous = Some(aspect); } } assert!(seen.len() > 1, "the fixture must have several aspects"); } #[test] fn reordering_rows_does_not_move_where_a_change_is_routed() { // The rows are stacked for reading; `param_index` still addresses the // capability list. Were the two confused, dragging a band's Hue would // silently write to whichever parameter happened to sit at that // position — an edit landing on the wrong control, which reads as the // renderer being broken rather than the panel. let graph = EditGraph::default_chain(); let cap = graph .capabilities() .into_iter() .find(|c| c.params.iter().any(|p| p.facet.is_some())) .expect("the chain has a faceted operation"); let mut order = presentation_order(&cap.params); order.sort_unstable(); assert_eq!( order, (0..cap.params.len()).collect::>(), "the order must be a permutation: every parameter reachable from \ exactly one row, and every row addressing a parameter that exists" ); } #[test] fn every_faceted_parameter_resolves_to_a_band_name() { // The bug this closes: `labels.rs` had no `param.mixer.*` entries, so // all thirty-six keys fell through to a derived label that yields the // bare channel name — twelve rows reading "Hue" with nothing saying // which band. A row identified only by a swatch depends on this // resolving, since the name is what a screen reader speaks and what // anyone who cannot separate two squares by eye has to go on. let graph = EditGraph::default_chain(); for cap in graph.capabilities() { for p in &cap.params { let Some(facet) = &p.facet else { continue }; let subject = labels::resolve(facet.subject.0); let aspect = labels::resolve(facet.aspect.0); assert!(!subject.is_empty(), "{} has no subject name", p.id); assert!(!aspect.is_empty(), "{} has no aspect name", p.id); // Not the channel name repeated: that is exactly the failure // the catalogue entries were added to fix. assert_ne!(subject, aspect, "{} is named after its channel", p.id); } } } #[test] fn unit_suffixes_come_from_the_descriptor() { assert_eq!(unit_suffix(Unit::Stops), " EV"); assert_eq!(unit_suffix(Unit::None), ""); } #[test] fn fitting_preserves_aspect_ratio() { // A 3:2 image in a 16:9 window must letterbox, not stretch. let (w, h) = fit(6000, 4000, 1600, 900); assert_eq!(h, 900); assert!( ((w as f32 / h as f32) - 1.5).abs() < 0.01, "got {w}x{h}, aspect {}", w as f32 / h as f32 ); } #[test] fn fitting_never_upscales_past_the_source() { // Rendering a 400px image into a 4K window at 4K shades 25x the // pixels for no additional detail. let (w, h) = fit(400, 300, 3840, 2160); assert_eq!((w, h), (400, 300)); } #[test] fn fitting_handles_a_degenerate_source() { let (w, h) = fit(0, 0, 800, 600); assert_eq!((w, h), (800, 600)); } #[test] fn fitting_is_bounded_by_the_narrow_axis() { // A tall window on a wide image must be limited by width. let (w, h) = fit(4000, 1000, 800, 4000); assert_eq!(w, 800); assert_eq!(h, 200); } #[test] fn the_curve_collapses_to_a_single_row() { // Every point parameter — all four curves' worth — must appear as one // curve control, not as forty sliders. Otherwise the widget and the // sliders both render and the panel shows the same values twice. let graph = EditGraph::default_chain(); let curve_cap = graph .capabilities() .into_iter() .find(|c| c.id == curve::ID) .expect("the chain includes a tone curve"); assert_eq!( curve_cap.params.len(), curve::CHANNELS * curve::POINTS * 2, "a master curve and one per colour channel" ); let presentation = curve_cap .presentation .as_ref() .expect("the curve declares a widget"); // Asked the way the panel asks it: the first preference this frontend // implements, not a fixed single kind. assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve)); // Every parameter is owned by the widget, so none is left over to be // rendered as a stray slider. assert_eq!(presentation.params.len(), curve_cap.params.len()); } #[test] fn curve_point_parameters_are_contiguous() { // The widget addresses points by offset from the first. Were they // interleaved with anything else, dragging a point would write to // the wrong parameter. let graph = EditGraph::default_chain(); let cap = graph .capabilities() .into_iter() .find(|c| c.id == curve::ID) .expect("tone curve present"); let presentation = cap.presentation.as_ref().expect("declares a widget"); let base = cap .params .iter() .position(|p| p.id == presentation.params[0]) .expect("first point is a parameter"); for (i, id) in presentation.params.iter().enumerate() { assert_eq!( cap.params[base + i].id, *id, "point parameter {i} is out of order" ); } } /// The panel's whole knowledge of colour channels, asserted to be none. /// /// It groups the widget's parameters by the subject the *operation* put on /// them and finds four curves; nothing below says "red", and an operation /// that grew a fifth curve would arrive here on its own. #[test] fn a_curve_widget_offers_one_run_per_subject() { let graph = EditGraph::default_chain(); let cap = graph .capabilities() .into_iter() .find(|c| c.id == curve::ID) .expect("tone curve present"); let presentation = cap.presentation.as_ref().expect("declares a widget"); let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation"); assert_eq!(runs.len(), curve::CHANNELS); for (i, run) in runs.iter().enumerate() { assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points"); assert_eq!(run.base, i * curve::POINTS * 2); assert!(run.subject.is_some(), "run {i} is unnamed"); } } /// TRACES: FR-DEV-3 /// A lone parameter is titled by its operation, so several cannot collide. /// /// The panel draws no heading over a group of one, on the argument that a /// lone control names itself. Three operations declare a single parameter /// called `amount` — the name `ops/README.md` tells an author to reach for /// first — and they reached the Detail group as three consecutive sliders /// all reading "Amount", which is a panel a photographer cannot use. /// /// Asserted over the real chain rather than a fixture, because the failure /// was a property of what is actually declared: a fixture would have to be /// written to reproduce it and would then only prove itself. /// TRACES: FR-DEV-3f /// The film stock is offered in its own group and in "All", nowhere else. /// /// It is not a parameter, so it is not a row, so the filter that hides /// every other control when a group is chosen never saw it: the picker sat /// at the top of Light, of Colour and of Detail alike. Three places it does /// not belong, and the one it does no more prominent than the rest. /// /// Asserted against whatever the operation actually declares rather than /// against a named group, so a stock re-declared as something else moves /// here on its own and this test still describes the rule. #[test] fn the_film_stock_is_offered_only_where_it_belongs() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); let film = session .graph .capabilities() .into_iter() .find(|c| c.id == dr_pipeline::ops::film_sim::ID) .expect("the film stock is in the chain"); session.set_active_tab(-1); assert!( session.film_in_group(), "\"All\" hides nothing, so the stock is offered there" ); for (i, (attribute, label)) in session.tabs().into_iter().enumerate() { session.set_active_tab(i as i32); let belongs = film.attributes.contains(&attribute); assert_eq!( session.film_in_group(), belongs, "the stock is offered in {label} but the operation does not claim it" ); } } #[test] fn a_lone_parameter_is_named_after_its_operation() { let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let rows = rows_filtered(&caps, |_| true, 0); for (i, cap) in caps.iter().enumerate() { if cap.params.len() != 1 || cap.presentation.is_some() { continue; } let Some(row) = rows.iter().find(|r| r.op_index as usize == i) else { continue; }; assert_eq!( row.param_label, labels::resolve(cap.label.0).as_str(), "a lone parameter must carry its operation's name, or the \ heading the panel withheld takes the name with it" ); } // And the specific collision that started this: no two rows drawn // without a heading may read the same. let bare: Vec<_> = rows .iter() .filter(|r| r.group_len == 1) .map(|r| r.param_label.to_string()) .collect(); let mut unique = bare.clone(); unique.sort(); unique.dedup(); assert_eq!( bare.len(), unique.len(), "two headingless rows share a label: {bare:?}" ); } #[test] fn switching_curve_repoints_the_row() { use slint::Model as _; // What a drag routes through. The row's `param_index` is the base of // the curve *on show*, so picking a different one must move it — if it // did not, dragging a point on the red curve would write to the // master's. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let curve_at = caps .iter() .position(|c| c.id == curve::ID) .expect("tone curve present"); let mut bases = Vec::new(); for channel in 0..curve::CHANNELS { let rows = rows_filtered(&caps, |_| true, channel); let row = rows .iter() .find(|r| r.op_index as usize == curve_at) .expect("the curve has a row"); assert_eq!(row.kind, "curve"); assert_eq!( row.points.row_count(), curve::POINTS * 2, "one curve's points, not all four curves'" ); bases.push(row.param_index); } assert_eq!( bases, (0..curve::CHANNELS) .map(|i| (i * curve::POINTS * 2) as i32) .collect::>() ); } #[test] fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() { // The selection outlives the photograph it was made on, and the next // image's operation may offer fewer curves. Clamping keeps a plot on // the grid; the alternative is a curve row that vanishes, which reads // as the tone curve having disappeared from the panel. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let rows = rows_filtered(&caps, |_| true, 99); let row = rows .iter() .find(|r| r.kind == "curve") .expect("the curve still has a row"); assert_eq!( row.param_index, ((curve::CHANNELS - 1) * curve::POINTS * 2) as i32 ); } #[test] fn an_operation_whose_points_are_unfaceted_is_one_curve() { // A curve widget that spans a single unnamed curve — which is what // this operation was before the channels arrived, and what any other // node declaring a `tone_curve` widget over ten scalars would be. // It must draw, and it must offer no choice. use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; use slint::Model as _; static IDS: [ParamId; 4] = [ ParamId("p0_x"), ParamId("p0_y"), ParamId("p1_x"), ParamId("p1_y"), ]; let param = |id: ParamId| ParamCapability { id, label: LocalizedKey("param.point"), kind: ParamKind::Scalar { min: 0.0, max: 1.0, scale: dr_pipeline::Scale::Linear, unit: Unit::None, precision: 4, }, default: 0.0, value: 0.0, facet: None, }; let plain = OpCapability { id: OpId("invented_curve"), label: LocalizedKey("op.invented_curve"), active: false, presentation: Some(Presentation { widgets: vec![WidgetKind::ToneCurve], demand: WidgetDemand { two_dimensional: true, precise_pointing: true, }, params: IDS.to_vec(), }), params: IDS.iter().map(|id| param(*id)).collect(), attributes: vec![dr_pipeline::Attribute::Tone], }; let presentation = plain.presentation.as_ref().expect("declares a widget"); let runs = curve_runs(&plain, presentation).expect("curve-shaped"); assert_eq!(runs.len(), 1, "one unnamed curve"); assert_eq!(runs[0].subject, None); let rows = rows_from(&[plain]); assert_eq!(rows.len(), 1); assert_eq!(rows[0].kind, "curve"); assert_eq!(rows[0].points.row_count(), IDS.len()); } #[test] fn curve_samples_start_on_the_diagonal() { // A fresh curve is the identity, so the drawn line must be the 45° // diagonal — anything else means the widget opens showing a shape // the image does not have. let mut xs = [0.0f32; curve::POINTS]; let mut ys = [0.0f32; curve::POINTS]; for i in 0..curve::POINTS { let t = i as f32 / (curve::POINTS - 1) as f32; xs[i] = t; ys[i] = t; } for i in 0..=20 { let x = i as f32 / 20.0; let y = curve::evaluate(&xs, &ys, x); assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}"); } } #[test] fn sorting_enforces_a_minimum_gap() { // Two points dragged onto each other would divide by zero in the // spline; the drawn curve must survive it exactly as the shader does. let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5]; sort_with_gap(&mut xs); for i in 1..xs.len() { assert!(xs[i] > xs[i - 1], "not separated: {xs:?}"); } } #[test] fn sorting_orders_reversed_points() { let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1]; sort_with_gap(&mut xs); for i in 1..xs.len() { assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}"); } } /// A frame black on the left half and white on the right, at `size` /// square. Both ends of the histogram are occupied and both clipping /// counters are non-zero, and cropping to one half leaves exactly one of /// them so. fn split_frame(size: u32) -> Vec { let mut rgba = Vec::with_capacity((size * size * 4) as usize); for _ in 0..size { for x in 0..size { let v = if x < size / 2 { 0u8 } else { 255 }; rgba.extend_from_slice(&[v, v, v, 255]); } } rgba } /// TRACES: FR-DSP-7 #[test] fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() { // The wiring, end to end and against exact numbers: a 64x64 frame that // is half black and half white must come back as 2048 pixels at level // 0, 2048 at 255, and both clipping counters at 2048. // // Asserted at the session rather than at the pass because the mistake // this catches is not arithmetic — `dr_gpu` has its own tests for that // — it is counting the *wrong texture*. Reading a stale target, or the // demosaiced source instead of the adjusted output, produces a // perfectly well-formed histogram of an image the photographer is not // looking at, which is the one failure mode that cannot be seen. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let rgba = split_frame(64); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); session.render(64, 64).expect("render"); let hist = session.histogram().expect("a rendered session must count"); assert_eq!(hist.pixels(), 64 * 64); assert_eq!(hist.red()[0], 2048, "the black half"); assert_eq!(hist.red()[255], 2048, "the white half"); assert_eq!(hist.clipped_shadows(), 2048); assert_eq!(hist.clipped_highlights(), 2048); } /// TRACES: FR-DSP-7 #[test] fn the_histogram_follows_the_edit_rather_than_the_file() { // The property that makes it *live*. A histogram computed once from the // source would pass the test above and be useless — the whole reason // FR-DSP-7 exists is to show what an adjustment is doing, so cropping // away the white half must leave a histogram with no white in it and // no highlight clipping to report. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let rgba = split_frame(64); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); session.set_crop(CropRect { x: 0.0, y: 0.0, width: 0.5, height: 1.0, }); session.render(64, 64).expect("render"); let hist = session.histogram().expect("histogram"); assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame"); assert_eq!(hist.red()[0], 32 * 64); assert_eq!(hist.red()[255], 0, "the white half was cropped away"); assert_eq!(hist.clipped_highlights(), 0); assert_eq!(hist.clipped_shadows(), 32 * 64); } /// A flat Bayer frame whose every photosite normalises to `level`. /// /// Black at zero and a power-of-two white level, so the normalisation is /// exact and the value the raw histogram sees is the one this asked for /// rather than one rounded by two divisions. fn flat_raw(size: u32, level: f32) -> RawImage { const WHITE: u16 = 16384; let sample = (level * f32::from(WHITE)).round() as u16; RawImage { width: size, height: size, data: vec![sample; (size * size) as usize], cfa_pattern: dr_decode::CfaPattern::Rggb, black_level: [0; 4], white_level: WHITE, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: None, base_curve: dr_decode::BaseCurve::IDENTITY, crop: dr_decode::CropRect { x: 0, y: 0, width: size, height: size, }, } } /// TRACES: FR-CULL-3 #[test] fn the_raw_histogram_describes_the_file_and_not_the_view() { // **The property that makes it a second instrument rather than a // second rendering of the first**, and the one every other test here // would pass without. The display histogram beside it deliberately // follows the edit and the visible region — that is what FR-DSP-7 // asks of it. This must do neither: cropping away half the photograph // changes what is on the canvas and changes nothing about what the // sensor recorded, and a culler asking how much latitude an exposure // has is asking about the capture. // // Reading the adjusted output by mistake would pass a plausible-looking // plot back — which is exactly why this asserts the *denominator* and // the bin, not merely that something was counted. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; // 0.234253 is the centre of the bin 33 sixteenths below saturation — // mid-bin on purpose, so the assertion is about the reduction rather // than about how this machine's `log2` rounds an exact tie. let raw = flat_raw(16, 3838.0 / 16384.0); let mut session = DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); let before = session.raw_histogram().expect("a raw session must count"); assert_eq!(before.pixels(), 16 * 16); assert_eq!( before.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1 - 33], 16 * 16, "a flat frame two stops down did not land in one bin" ); assert_eq!(before.saturated(), 0, "nothing here is at the white level"); assert_eq!(before.at_black(), 0); session.set_crop(CropRect { x: 0.0, y: 0.0, width: 0.5, height: 1.0, }); session.render(16, 16).expect("render"); // The display histogram followed the crop, as it is supposed to. // Asserted as an inequality rather than an exact figure: how a half // crop of a 16px frame rounds to a viewport is `AdjustPass`'s // business and has its own tests, and pinning it here would make this // test fail for a reason it is not about. let shown = session.histogram().expect("histogram"); assert!( shown.pixels() < 16 * 16, "the crop did not reach the display histogram, so this proves nothing" ); // The raw one did not. let after = session.raw_histogram().expect("raw histogram"); assert_eq!( after, before, "the raw reading followed the crop, so it is measuring the render" ); } /// TRACES: FR-CULL-3 #[test] fn a_blown_frame_reads_as_clipped_in_the_raw_domain() { // The other end, and the reason the requirement exists. Every // photosite at the white level is a photograph with no highlight // headroom left in the file — no edit recovers it — and the instrument // has to say so in the same terms whatever the develop chain currently // makes of it. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let raw = flat_raw(16, 1.0); let mut session = DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); let hist = session.raw_histogram().expect("raw histogram"); assert_eq!(hist.pixels(), 16 * 16); assert_eq!(hist.saturated(), 16 * 16); assert_eq!(hist.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1], 16 * 16); } /// TRACES: FR-CULL-3 #[test] fn a_file_with_no_sensor_data_has_no_raw_reading_rather_than_a_wrong_one() { // The JPEG path. Its source texture is gamma-encoded and carries no // white level, so there is no scale to measure headroom against — and // counting it anyway would produce a confident plot of a quantity that // does not exist, which is the failure mode an instrument must not // have. The panel says there is nothing to say. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let rgba = split_frame(16); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session"); assert!(!session.has_sensor_data()); assert!(session.raw_histogram().is_none()); } #[test] fn routing_indices_map_back_to_the_right_parameter() { // A wrong index would silently move the wrong slider's value, which // is exactly the kind of bug that looks like a rendering fault. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); for (oi, op) in caps.iter().enumerate() { for (pi, p) in op.params.iter().enumerate() { assert_eq!(caps[oi].params[pi].id, p.id); assert_eq!(caps[oi].id, op.id); } } } // ---------------------------------------------------------------------- // Group tabs (ARCH §4.3a, FR-DEV-3a) // ---------------------------------------------------------------------- fn tabbed_session(ctx: &GpuContext) -> DevelopSession { let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); DevelopSession::open_rgb(ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session") } #[test] fn the_tabs_come_from_the_chain_not_from_a_list() { let Some(ctx) = headless() else { return }; let session = tabbed_session(&ctx); let names: Vec = session.tabs().into_iter().map(|(_, n)| n).collect(); assert!(names.contains(&"Light".to_string()), "got {names:?}"); assert!(names.contains(&"Colour".to_string()), "got {names:?}"); // Nothing offers a group with no rows behind it. for (attribute, name) in session.tabs() { let mut probe = tabbed_session(&ctx); let index = probe .tabs() .iter() .position(|(a, _)| *a == attribute) .expect("just listed"); probe.set_active_tab(index as i32); assert!(!probe.rows().is_empty(), "tab {name} opens onto nothing"); } } #[test] fn choosing_a_tab_narrows_the_panel() { let Some(ctx) = headless() else { return }; let mut session = tabbed_session(&ctx); let all = session.rows().len(); session.set_active_tab(0); let narrowed = session.rows().len(); assert!(narrowed > 0, "a tab must show something"); assert!( narrowed < all, "and less than everything: {narrowed} of {all}" ); } /// The trap: `op_index` counts over *every* capability, so a row that /// survived a filter must still route to the operation it came from. If it /// renumbered, a slider would drive a different operation once a tab was /// chosen. #[test] fn a_filtered_row_still_drives_its_own_operation() { let Some(ctx) = headless() else { return }; let mut session = tabbed_session(&ctx); // Find a colour row while unfiltered, and remember where it points. let colour = session .tabs() .iter() .position(|(_, n)| n == "Colour") .expect("the chain has colour operations"); session.set_active_tab(colour as i32); let row = session.rows().into_iter().next().expect("a row"); let (op, param) = (row.op_index, row.param_index); session.set_param(op, param, 0.5); let after = session .rows() .into_iter() .find(|r| r.op_index == op && r.param_index == param) .expect("the row survived"); assert!( (after.value - 0.5).abs() < 1e-5, "the value landed on the row that asked for it, not another" ); } #[test] fn all_is_reachable_again() { let Some(ctx) = headless() else { return }; let mut session = tabbed_session(&ctx); let all = session.rows().len(); session.set_active_tab(0); assert!(session.rows().len() < all); session.set_active_tab(-1); assert_eq!(session.rows().len(), all, "-1 means everything"); assert_eq!(session.active_tab(), -1); } /// An out-of-range index is navigation nonsense, not an edit; it must not /// leave the panel showing nothing. #[test] fn a_nonsense_tab_falls_back_to_everything() { let Some(ctx) = headless() else { return }; let mut session = tabbed_session(&ctx); let all = session.rows().len(); session.set_active_tab(99); assert_eq!(session.rows().len(), all); } // ---------------------------------------------------------------------- // Per-display colour (FR-DSP-8) // ---------------------------------------------------------------------- /// TRACES: FR-DSP-8 | FR-DSP-6 /// The canvas is encoded for the display, not always for sRGB. /// /// This is the assertion the requirement is actually about. Before it, /// `render` composed `ColourSpace::Srgb` unconditionally, and a second /// monitor with a different profile got sRGB pixels *labelled* as its own /// space by the compositor — the silent wrongness FR-DSP-8 calls a /// correctness defect. If someone re-hardcodes the space, these pixels /// stop differing and this fails. /// /// A saturated red is the probe deliberately: it sits near the edge of /// sRGB's gamut, so re-encoding it into a wider one moves it a long way, /// where a mid grey would move by almost nothing in any of the four and /// the test would pass on a broken build. #[test] fn the_canvas_is_encoded_for_the_display_showing_it() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..64 * 64).flat_map(|_| [230u8, 20, 20, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); assert_eq!( session.display_space(), dr_types::ColourSpace::Srgb, "a session starts on the fallback, so nothing changes for a \ desktop whose display server will not say otherwise" ); let on_srgb = read_back(&ctx, &session.render(64, 64).expect("render")); assert!(session.set_display_space(dr_types::ColourSpace::AdobeRgb)); let on_wide = read_back(&ctx, &session.render(64, 64).expect("render")); assert_ne!( on_srgb, on_wide, "the same edit rendered for two displays produced the same pixels" ); // And back again, because a photographer dragging a window between // two monitors expects the first one to look as it did rather than to // accumulate a transform. assert!(session.set_display_space(dr_types::ColourSpace::Srgb)); let returned = read_back(&ctx, &session.render(64, 64).expect("render")); assert_eq!(on_srgb, returned); } /// TRACES: FR-DSP-8 /// A move that changes nothing reports nothing, so nothing is redrawn. /// /// The window's position is polled twice a second and two displays often /// share a profile. A setter that reported a change every time it was /// called would turn that poll into a redraw loop. #[test] fn setting_the_same_display_space_twice_is_not_a_change() { 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"); assert!(session.set_display_space(dr_types::ColourSpace::DisplayP3)); assert!(!session.set_display_space(dr_types::ColourSpace::DisplayP3)); assert_eq!(session.display_space(), dr_types::ColourSpace::DisplayP3); } /// TRACES: FR-DSP-8 | FR-EXP-2 /// The display's space is the *canvas's*, and reaches nothing else. /// /// A thumbnail goes into a shard that syncs between devices and an export /// claims the space the export dialogue asked for. Letting the monitor in /// front of the photographer decide either would write a file whose /// profile describes the desk it was made at. #[test] fn a_wide_gamut_monitor_does_not_reach_the_thumbnail_or_the_export() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..32 * 32).flat_map(|_| [230u8, 20, 20, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) .expect("session"); let thumb_before = session.render_thumbnail(16).expect("thumbnail"); let export_before = session .render_for_export(dr_types::ColourSpace::Srgb) .expect("export") .rgba; session.set_display_space(dr_types::ColourSpace::ProPhoto); assert_eq!( session.render_thumbnail(16).expect("thumbnail"), thumb_before, "the grid's thumbnail followed the monitor" ); assert_eq!( session .render_for_export(dr_types::ColourSpace::Srgb) .expect("export") .rgba, export_before, "an sRGB export followed the monitor" ); } }